mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
added credentials
This commit is contained in:
parent
f218d472fa
commit
59f209d754
22 changed files with 741 additions and 0 deletions
27
modules/admin/ui/views/password-admin-view.tsx
Normal file
27
modules/admin/ui/views/password-admin-view.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { getCredentials } from "@/modules/shared/queries/credentials"
|
||||
import { CredentialsTable } from "@/modules/shared/ui/components/credentials-table"
|
||||
import { CredentialDialog } from "@/modules/shared/ui/components/credential-dialog"
|
||||
import { createAdminClient } from "@/lib/supabase/admin"
|
||||
|
||||
const PasswordAdminViewPage = async ({ clientProfileId }: { clientProfileId: string }) => {
|
||||
const supabase = createAdminClient()
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("client_id")
|
||||
.eq("id", clientProfileId)
|
||||
.single()
|
||||
|
||||
const credentials = profile?.client_id ? await getCredentials(profile.client_id) : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<h1 className="text-3xl font-semibold">Passwörter</h1>
|
||||
<CredentialDialog profileId={clientProfileId} />
|
||||
</div>
|
||||
<CredentialsTable profileId={clientProfileId} credentials={credentials} isAdmin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordAdminViewPage
|
||||
30
modules/client/ui/views/password-client-view.tsx
Normal file
30
modules/client/ui/views/password-client-view.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { verifySession } from "@/lib/supabase/dal"
|
||||
import { createAdminClient } from "@/lib/supabase/admin"
|
||||
import { getCredentials } from "@/modules/shared/queries/credentials"
|
||||
import { CredentialsTable } from "@/modules/shared/ui/components/credentials-table"
|
||||
import { CredentialDialog } from "@/modules/shared/ui/components/credential-dialog"
|
||||
|
||||
const PasswordClientViewPage = async () => {
|
||||
const session = await verifySession()
|
||||
|
||||
const supabase = createAdminClient()
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("client_id")
|
||||
.eq("id", session.userId)
|
||||
.single()
|
||||
|
||||
const credentials = profile?.client_id ? await getCredentials(profile.client_id) : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<h1 className="text-3xl font-semibold">Passwörter</h1>
|
||||
<CredentialDialog profileId={session.userId} />
|
||||
</div>
|
||||
<CredentialsTable profileId={session.userId} credentials={credentials} isAdmin={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordClientViewPage
|
||||
153
modules/shared/actions/credentials.ts
Normal file
153
modules/shared/actions/credentials.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use server";
|
||||
|
||||
import * as z from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
|
||||
async function resolveClientId(profileId: string) {
|
||||
const supabase = createAdminClient();
|
||||
const { data: profile, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("client_id")
|
||||
.eq("id", profileId)
|
||||
.single();
|
||||
|
||||
if (error || !profile?.client_id) return null;
|
||||
return profile.client_id as string;
|
||||
}
|
||||
|
||||
const CredentialSchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
service: z.string().min(1, { error: "Dienst ist erforderlich." }),
|
||||
username: z.string().min(1, { error: "Benutzername ist erforderlich." }),
|
||||
password: z.string().min(1, { error: "Passwort ist erforderlich." }),
|
||||
});
|
||||
|
||||
export type CredentialState = { error: string } | { success: true } | undefined;
|
||||
|
||||
export async function createCredential(
|
||||
_prevState: CredentialState,
|
||||
formData: FormData
|
||||
): Promise<CredentialState> {
|
||||
const session = await verifySession();
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
|
||||
if (!session.isAdmin && session.userId !== profileId) {
|
||||
return { error: "Keine Berechtigung." };
|
||||
}
|
||||
|
||||
const validatedFields = CredentialSchema.safeParse({
|
||||
profileId,
|
||||
service: formData.get("service"),
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||
const firstError = Object.values(fieldErrors).flat()[0];
|
||||
return { error: firstError ?? "Bitte alle Felder ausfüllen." };
|
||||
}
|
||||
|
||||
const clientId = await resolveClientId(profileId);
|
||||
if (!clientId) {
|
||||
return { error: "Client hat noch keine Stammdaten hinterlegt." };
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.rpc("create_credential", {
|
||||
p_client_id: clientId,
|
||||
p_service: validatedFields.data.service,
|
||||
p_username: validatedFields.data.username,
|
||||
p_password: validatedFields.data.password,
|
||||
});
|
||||
|
||||
if (error) return { error: "Eintrag konnte nicht gespeichert werden." };
|
||||
|
||||
revalidatePath("/client/passwords");
|
||||
revalidatePath(`/admin/clients/${profileId}/passwords`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const UpdateCredentialSchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
credentialId: z.string().uuid(),
|
||||
service: z.string().min(1, { error: "Dienst ist erforderlich." }),
|
||||
username: z.string().min(1, { error: "Benutzername ist erforderlich." }),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function updateCredential(
|
||||
_prevState: CredentialState,
|
||||
formData: FormData
|
||||
): Promise<CredentialState> {
|
||||
const session = await verifySession();
|
||||
const profileId = String(formData.get("profileId") ?? "");
|
||||
|
||||
if (!session.isAdmin && session.userId !== profileId) {
|
||||
return { error: "Keine Berechtigung." };
|
||||
}
|
||||
|
||||
const password = String(formData.get("password") ?? "").trim();
|
||||
|
||||
const validatedFields = UpdateCredentialSchema.safeParse({
|
||||
profileId,
|
||||
credentialId: formData.get("credentialId"),
|
||||
service: formData.get("service"),
|
||||
username: formData.get("username"),
|
||||
password: password === "" ? undefined : password,
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||
const firstError = Object.values(fieldErrors).flat()[0];
|
||||
return { error: firstError ?? "Bitte alle Felder ausfüllen." };
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.rpc("update_credential", {
|
||||
p_id: validatedFields.data.credentialId,
|
||||
p_service: validatedFields.data.service,
|
||||
p_username: validatedFields.data.username,
|
||||
p_password: validatedFields.data.password ?? null,
|
||||
});
|
||||
|
||||
if (error) return { error: "Eintrag konnte nicht aktualisiert werden." };
|
||||
|
||||
revalidatePath("/client/passwords");
|
||||
revalidatePath(`/admin/clients/${profileId}/passwords`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function revealCredential(credentialId: string): Promise<{ password: string } | { error: string }> {
|
||||
await verifySession();
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase.rpc("reveal_credential", { p_id: credentialId });
|
||||
|
||||
if (error || typeof data !== "string") {
|
||||
return { error: "Passwort konnte nicht abgerufen werden." };
|
||||
}
|
||||
|
||||
return { password: data };
|
||||
}
|
||||
|
||||
export async function deleteCredential(profileId: string, credentialId: string): Promise<CredentialState> {
|
||||
const session = await verifySession();
|
||||
|
||||
if (!session.isAdmin) {
|
||||
return { error: "Keine Berechtigung." };
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.from("credentials").delete().eq("id", credentialId);
|
||||
|
||||
if (error) return { error: "Eintrag konnte nicht gelöscht werden." };
|
||||
|
||||
revalidatePath(`/admin/clients/${profileId}/passwords`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
24
modules/shared/data/services-data.ts
Normal file
24
modules/shared/data/services-data.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export type Service = {
|
||||
id: string;
|
||||
label: string;
|
||||
logo: string;
|
||||
};
|
||||
|
||||
export const SERVICES: Service[] = [
|
||||
{ id: "vercel", label: "Vercel", logo: "/logos/vercel.svg" },
|
||||
{ id: "hostinger", label: "Hostinger", logo: "/logos/hostinger.svg" },
|
||||
{ id: "hostinger-webmail", label: "Hostinger Webmail", logo: "/logos/hostinger.svg" },
|
||||
{ id: "supabase", label: "Supabase", logo: "/logos/supabase.svg" },
|
||||
{ id: "github", label: "GitHub", logo: "/logos/github.svg" },
|
||||
{ id: "shopify", label: "Shopify", logo: "/logos/shopify.svg" },
|
||||
{ id: "wordpress", label: "Wordpress", logo: "/logos/wordpress.svg" },
|
||||
{ id: "google-workspace", label: "Google Workspace", logo: "/logos/google.svg" },
|
||||
{ id: "google-ads", label: "Google Ads", logo: "/logos/google-ads.svg" },
|
||||
{ id: "sanity", label: "Sanity", logo: "/logos/sanity.svg" },
|
||||
{ id: "stripe", label: "Stripe", logo: "/logos/stripe.svg" },
|
||||
{ id: "other", label: "Sonstiges", logo: "" },
|
||||
];
|
||||
|
||||
export function getServiceById(id: string): Service {
|
||||
return SERVICES.find((service) => service.id === id) ?? SERVICES[SERVICES.length - 1];
|
||||
}
|
||||
16
modules/shared/queries/credentials.ts
Normal file
16
modules/shared/queries/credentials.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import "server-only"
|
||||
import { createAdminClient } from "@/lib/supabase/admin"
|
||||
|
||||
export async function getCredentials(clientId: string) {
|
||||
const supabase = createAdminClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("credentials")
|
||||
.select("id, service, username, created_at, updated_at")
|
||||
.eq("client_id", clientId)
|
||||
.order("service")
|
||||
|
||||
if (error) throw new Error("Passwörter konnten nicht geladen werden.")
|
||||
|
||||
return data
|
||||
}
|
||||
167
modules/shared/ui/components/credential-dialog.tsx
Normal file
167
modules/shared/ui/components/credential-dialog.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use client"
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { createCredential, updateCredential } from "@/modules/shared/actions/credentials";
|
||||
import { SERVICES } from "@/modules/shared/data/services-data";
|
||||
import { ServiceLogo } from "@/modules/shared/ui/components/service-logo";
|
||||
|
||||
type Credential = {
|
||||
id: string;
|
||||
service: string;
|
||||
username: string;
|
||||
} | null;
|
||||
|
||||
const selectClassName =
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
function CredentialDialogContent({
|
||||
profileId,
|
||||
credential,
|
||||
onDone,
|
||||
}: {
|
||||
profileId: string;
|
||||
credential: Credential;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const isEdit = Boolean(credential);
|
||||
const action = isEdit ? updateCredential : createCredential;
|
||||
const [serviceId, setServiceId] = useState(
|
||||
credential?.service ?? SERVICES[0].id
|
||||
);
|
||||
|
||||
async function actionWithDoneCheck(_prevState: Awaited<ReturnType<typeof action>>, formData: FormData) {
|
||||
const result = await action(_prevState, formData);
|
||||
if (result && "success" in result && result.success) {
|
||||
onDone();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const [state, formAction, pending] = useActionState(actionWithDoneCheck, undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>{isEdit ? "Eintrag bearbeiten" : "Neuer Eintrag"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="profileId" value={profileId} />
|
||||
{isEdit && <input type="hidden" name="credentialId" value={credential!.id} />}
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="service">
|
||||
Dienst <span className="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border border-input bg-background">
|
||||
<ServiceLogo serviceId={serviceId} size={20} className="size-5 object-contain" />
|
||||
</div>
|
||||
<select
|
||||
id="service"
|
||||
name="service"
|
||||
value={serviceId}
|
||||
onChange={(event) => setServiceId(event.target.value)}
|
||||
required
|
||||
className={selectClassName}
|
||||
>
|
||||
{SERVICES.map((service) => (
|
||||
<option key={service.id} value={service.id}>
|
||||
{service.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">
|
||||
Benutzername <span className="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
defaultValue={credential?.username ?? ""}
|
||||
required
|
||||
className="focus-visible:ring-0"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">
|
||||
Passwort {!isEdit && <span className="text-destructive">*</span>}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required={!isEdit}
|
||||
placeholder={isEdit ? "Leer lassen, um Passwort zu behalten" : undefined}
|
||||
className="focus-visible:ring-0"
|
||||
/>
|
||||
</Field>
|
||||
{state && "error" in state && (
|
||||
<p className="text-sm text-destructive">{state.error}</p>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Wird gespeichert..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CredentialDialog({
|
||||
profileId,
|
||||
credential,
|
||||
trigger,
|
||||
}: {
|
||||
profileId: string;
|
||||
credential?: Credential;
|
||||
trigger?: React.ReactElement;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [instanceKey, setInstanceKey] = useState(0);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setInstanceKey((key) => key + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger
|
||||
render={trigger ?? <Button variant="outline" className="cursor-pointer" />}
|
||||
>
|
||||
{!trigger && (
|
||||
<>
|
||||
<PlusIcon className="size-4" />
|
||||
Neuer Eintrag
|
||||
</>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<CredentialDialogContent
|
||||
key={instanceKey}
|
||||
profileId={profileId}
|
||||
credential={credential ?? null}
|
||||
onDone={() => handleOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
139
modules/shared/ui/components/credentials-table.tsx
Normal file
139
modules/shared/ui/components/credentials-table.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EyeIcon, EyeOffIcon, PencilIcon } from "lucide-react";
|
||||
import { revealCredential } from "@/modules/shared/actions/credentials";
|
||||
import { getServiceById } from "@/modules/shared/data/services-data";
|
||||
import { CredentialDialog } from "./credential-dialog";
|
||||
import { DeleteCredentialDialog } from "./delete-credential-dialog";
|
||||
import { ServiceLogo } from "./service-logo";
|
||||
|
||||
type Credential = {
|
||||
id: string;
|
||||
service: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function RevealButton({ credentialId }: { credentialId: string }) {
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleToggle() {
|
||||
if (password !== null) {
|
||||
setPassword(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
const result = await revealCredential(credentialId);
|
||||
setPending(false);
|
||||
|
||||
if ("password" in result) {
|
||||
setPassword(result.password);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-32 font-mono text-small">
|
||||
{password ?? "••••••••"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleToggle}
|
||||
disabled={pending}
|
||||
aria-label={password !== null ? "Passwort verbergen" : "Passwort anzeigen"}
|
||||
>
|
||||
{password !== null ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CredentialsTable({
|
||||
profileId,
|
||||
credentials,
|
||||
isAdmin,
|
||||
}: {
|
||||
profileId: string;
|
||||
credentials: Credential[];
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
if (credentials.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
|
||||
Noch keine Passwörter hinterlegt.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Dienst</TableHead>
|
||||
<TableHead>Benutzername</TableHead>
|
||||
<TableHead>Passwort</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{credentials.map((credential) => {
|
||||
const service = getServiceById(credential.service);
|
||||
|
||||
return (
|
||||
<TableRow key={credential.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||
<ServiceLogo serviceId={credential.service} size={18} className="size-4.5 object-contain" />
|
||||
</div>
|
||||
<span className="font-medium">{service.label}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{credential.username}</TableCell>
|
||||
<TableCell>
|
||||
<RevealButton credentialId={credential.id} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<CredentialDialog
|
||||
profileId={profileId}
|
||||
credential={credential}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Bearbeiten">
|
||||
<PencilIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<DeleteCredentialDialog
|
||||
profileId={profileId}
|
||||
credentialId={credential.id}
|
||||
service={service.label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
modules/shared/ui/components/delete-credential-dialog.tsx
Normal file
68
modules/shared/ui/components/delete-credential-dialog.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { deleteCredential } from "@/modules/shared/actions/credentials";
|
||||
|
||||
export function DeleteCredentialDialog({
|
||||
profileId,
|
||||
credentialId,
|
||||
service,
|
||||
}: {
|
||||
profileId: string;
|
||||
credentialId: string;
|
||||
service: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleDelete() {
|
||||
setPending(true);
|
||||
const result = await deleteCredential(profileId, credentialId);
|
||||
setPending(false);
|
||||
|
||||
if (result && "success" in result) {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" aria-label="Löschen" />}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>Eintrag löschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Eintrag <span className="font-medium text-foreground">{service}</span> wirklich löschen?
|
||||
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
||||
{pending ? "Wird gelöscht..." : "Löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
35
modules/shared/ui/components/service-logo.tsx
Normal file
35
modules/shared/ui/components/service-logo.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { ServerIcon } from "lucide-react";
|
||||
import { getServiceById } from "@/modules/shared/data/services-data";
|
||||
|
||||
export function ServiceLogo({
|
||||
serviceId,
|
||||
size = 20,
|
||||
className,
|
||||
}: {
|
||||
serviceId: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const service = getServiceById(serviceId);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!service.logo || failed) {
|
||||
return <ServerIcon className={className ?? "size-4.5 text-muted-foreground"} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={service.logo}
|
||||
alt={service.label}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className ?? "object-contain"}
|
||||
style={{ width: size, height: size }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue