added credentials

This commit is contained in:
Rajpreet0 2026-09-28 20:17:33 +02:00
parent f218d472fa
commit 59f209d754
22 changed files with 741 additions and 0 deletions

View 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

View 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

View 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 };
}

View 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];
}

View 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
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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)}
/>
);
}