diff --git a/app/admin/clients/[id]/passwords/page.tsx b/app/admin/clients/[id]/passwords/page.tsx new file mode 100644 index 0000000..6fff790 --- /dev/null +++ b/app/admin/clients/[id]/passwords/page.tsx @@ -0,0 +1,10 @@ +import PasswordAdminViewPage from "@/modules/admin/ui/views/password-admin-view" + +type Params = Promise<{id: string}> + +const PasswordAdminPage = async ({ params } : { params: Params }) => { + const { id } = await params + return +} + +export default PasswordAdminPage \ No newline at end of file diff --git a/app/client/passwords/page.tsx b/app/client/passwords/page.tsx new file mode 100644 index 0000000..5f688f2 --- /dev/null +++ b/app/client/passwords/page.tsx @@ -0,0 +1,7 @@ +import PasswordClientViewPage from "@/modules/client/ui/views/password-client-view" + +const PasswordPage = () => { + return +} + +export default PasswordPage \ No newline at end of file diff --git a/lib/supabase/migrations/20260929120000_allow_client_write_credentials.sql b/lib/supabase/migrations/20260929120000_allow_client_write_credentials.sql new file mode 100644 index 0000000..b0f305b --- /dev/null +++ b/lib/supabase/migrations/20260929120000_allow_client_write_credentials.sql @@ -0,0 +1,39 @@ +-- Clients dürfen eigene Credentials anlegen und bearbeiten (nicht löschen). +-- Löschen bleibt exklusiv Admin (siehe RLS-Policy credentials_admin_delete). + +create or replace function public.create_credential( + p_client_id uuid, p_service text, p_username text, p_password text +) returns uuid language plpgsql security definer set search_path = '' as $$ +declare v_secret uuid; v_id uuid; +begin + if not (public.is_admin() or p_client_id = public.current_client_id()) then + raise exception 'forbidden' using errcode = '42501'; + end if; + v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service); + insert into public.credentials (client_id, service, username, vault_secret_id, updated_by) + values (p_client_id, p_service, p_username, v_secret, auth.uid()) + returning id into v_id; + return v_id; +end $$; + +-- p_password = NULL -> Passwort bleibt unverändert +create or replace function public.update_credential( + p_id uuid, p_service text, p_username text, p_password text default null +) returns void language plpgsql security definer set search_path = '' as $$ +declare v_secret uuid; v_client uuid; +begin + select client_id, vault_secret_id into v_client, v_secret + from public.credentials where id = p_id; + if not found then raise exception 'credential not found'; end if; + if not (public.is_admin() or v_client = public.current_client_id()) then + raise exception 'forbidden' using errcode = '42501'; + end if; + + update public.credentials + set service = p_service, username = p_username, updated_by = auth.uid() + where id = p_id; + + if p_password is not null and p_password <> '' then + perform vault.update_secret(v_secret, p_password); + end if; +end $$; diff --git a/modules/admin/ui/views/password-admin-view.tsx b/modules/admin/ui/views/password-admin-view.tsx new file mode 100644 index 0000000..e6ac5ec --- /dev/null +++ b/modules/admin/ui/views/password-admin-view.tsx @@ -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 ( +
+
+

Passwörter

+ +
+ +
+ ) +} + +export default PasswordAdminViewPage diff --git a/modules/client/ui/views/password-client-view.tsx b/modules/client/ui/views/password-client-view.tsx new file mode 100644 index 0000000..ce9cb2e --- /dev/null +++ b/modules/client/ui/views/password-client-view.tsx @@ -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 ( +
+
+

Passwörter

+ +
+ +
+ ) +} + +export default PasswordClientViewPage diff --git a/modules/shared/actions/credentials.ts b/modules/shared/actions/credentials.ts new file mode 100644 index 0000000..e09a8a6 --- /dev/null +++ b/modules/shared/actions/credentials.ts @@ -0,0 +1,154 @@ +"use server"; + +import * as z from "zod"; +import { revalidatePath } from "next/cache"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { createClient } from "@/lib/supabase/server"; +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 { + 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 = await createClient(); + 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 { + 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 = await createClient(); + 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 = await createClient(); + 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 { + 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 }; +} diff --git a/modules/shared/data/services-data.ts b/modules/shared/data/services-data.ts new file mode 100644 index 0000000..01eaa27 --- /dev/null +++ b/modules/shared/data/services-data.ts @@ -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]; +} diff --git a/modules/shared/queries/credentials.ts b/modules/shared/queries/credentials.ts new file mode 100644 index 0000000..020feed --- /dev/null +++ b/modules/shared/queries/credentials.ts @@ -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 +} diff --git a/modules/shared/ui/components/credential-dialog.tsx b/modules/shared/ui/components/credential-dialog.tsx new file mode 100644 index 0000000..3e0da63 --- /dev/null +++ b/modules/shared/ui/components/credential-dialog.tsx @@ -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>, 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 ( + <> + + {isEdit ? "Eintrag bearbeiten" : "Neuer Eintrag"} + +
+ + {isEdit && } + + + + Dienst * + +
+
+ +
+ +
+
+ + + Benutzername * + + + + + + Passwort {!isEdit && *} + + + + {state && "error" in state && ( +

{state.error}

+ )} +
+ + + +
+ + ); +} + +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 ( + + } + > + {!trigger && ( + <> + + Neuer Eintrag + + )} + + + handleOpenChange(false)} + /> + + + ); +} diff --git a/modules/shared/ui/components/credentials-table.tsx b/modules/shared/ui/components/credentials-table.tsx new file mode 100644 index 0000000..83c6d75 --- /dev/null +++ b/modules/shared/ui/components/credentials-table.tsx @@ -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(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 ( +
+ + {password ?? "••••••••"} + + +
+ ); +} + +export function CredentialsTable({ + profileId, + credentials, + isAdmin, +}: { + profileId: string; + credentials: Credential[]; + isAdmin: boolean; +}) { + if (credentials.length === 0) { + return ( +

+ Noch keine Passwörter hinterlegt. +

+ ); + } + + return ( +
+ + + + Dienst + Benutzername + Passwort + + + + + {credentials.map((credential) => { + const service = getServiceById(credential.service); + + return ( + + +
+
+ +
+ {service.label} +
+
+ {credential.username} + + + + +
+ + + + } + /> + {isAdmin && ( + + )} +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/modules/shared/ui/components/delete-credential-dialog.tsx b/modules/shared/ui/components/delete-credential-dialog.tsx new file mode 100644 index 0000000..cf1fc27 --- /dev/null +++ b/modules/shared/ui/components/delete-credential-dialog.tsx @@ -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 ( + + } + > + + + + + Eintrag löschen + + Möchtest du den Eintrag {service} wirklich löschen? + Diese Aktion kann nicht rückgängig gemacht werden. + + + + + + + + + ); +} diff --git a/modules/shared/ui/components/service-logo.tsx b/modules/shared/ui/components/service-logo.tsx new file mode 100644 index 0000000..b5c7b35 --- /dev/null +++ b/modules/shared/ui/components/service-logo.tsx @@ -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 ; + } + + return ( + {service.label} setFailed(true)} + /> + ); +} diff --git a/public/logos/github.svg b/public/logos/github.svg new file mode 100644 index 0000000..18e9450 --- /dev/null +++ b/public/logos/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/google-ads.svg b/public/logos/google-ads.svg new file mode 100644 index 0000000..51f6834 --- /dev/null +++ b/public/logos/google-ads.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/public/logos/google.svg b/public/logos/google.svg new file mode 100644 index 0000000..765a10f --- /dev/null +++ b/public/logos/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/hostinger.svg b/public/logos/hostinger.svg new file mode 100644 index 0000000..b72c7c5 --- /dev/null +++ b/public/logos/hostinger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/sanity.svg b/public/logos/sanity.svg new file mode 100644 index 0000000..995c14f --- /dev/null +++ b/public/logos/sanity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/shopify.svg b/public/logos/shopify.svg new file mode 100644 index 0000000..5072635 --- /dev/null +++ b/public/logos/shopify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/stripe.svg b/public/logos/stripe.svg new file mode 100644 index 0000000..ad3787f --- /dev/null +++ b/public/logos/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/supabase.svg b/public/logos/supabase.svg new file mode 100644 index 0000000..6c01b94 --- /dev/null +++ b/public/logos/supabase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/vercel.svg b/public/logos/vercel.svg new file mode 100644 index 0000000..beb0195 --- /dev/null +++ b/public/logos/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/wordpress.svg b/public/logos/wordpress.svg new file mode 100644 index 0000000..d919a8e --- /dev/null +++ b/public/logos/wordpress.svg @@ -0,0 +1 @@ + \ No newline at end of file