"use server" import { SERVICES } from "@/modules/shared/data/services-data" import * as z from "zod" import { BILLING_CYCLES, type BillingCycle, type Plan } from "../lib/internal-costs"; import { verifySession } from "@/lib/supabase/dal"; import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; const PATH = "/admin/internal-accounts" const BaseSchema = z.object({ service: z.string().refine((id) => SERVICES.some((service) => service.id === id), { error: "Unbekannter Dienst." }), title: z.string().trim().max(120, { error: "Bezeichnung ist zu lang." }), username: z.string().trim().min(1, { error: "Benutzername ist erforderlich." }).max(200), url: z .string() .trim() .max(500) .refine((value) => value === "" || /^https?:\/\//i.test(value), { error: "Login-Adresse bitte mit https:// angeben." }), }); const CreateSchema = BaseSchema.extend({ password: z.string().min(1, { error: "Passwort ist erforderlich." }) }) const UpdateSchema = BaseSchema.extend({ id: z.string().uuid(), password: z.string().optional(), }) // "12,99", "1.234,50" oder "12.99" → Zahl const toNumber = (value: string) => Number(value.includes(",") ? value.replace(/\./g, "").replace(",", ".") : value) const CostSchema = z.discriminatedUnion( "plan", [ z.object({ plan: z.literal("free") }), z.object({ plan: z.literal("paid"), price: z .string() .trim() .min(1, { error: "Bitte einen Preis angeben." }) .transform(toNumber) .refine((value) => Number.isFinite(value) && value >= 0 && value < 100_000_000, { error: "Bitte einen gültigen Preis angeben, z. B. 12,99.", }), billingCycle: z.enum(BILLING_CYCLES, { error: "Bitte die Abrechnung wählen." }), }), ], { error: "Bitte kostenlos oder bezahlt wählen." } ) const IdSchema = z.string().uuid() export type InternalCredentialState = { error: string } | { success: true } | undefined async function isAdmin() { const session = await verifySession() return session.isAdmin } function readFields(formData: FormData) { return { service: formData.get("service"), title: formData.get("title") ?? "", username: formData.get("username"), url: formData.get("url") ?? "", } } type CostParams = { p_plan: Plan; p_price: number | null; p_billing_cycle: BillingCycle | null } // Kosten als RPC-Parameter; kostenlos = kein Preis und keine Abrechnung. // Expliziter Rückgabetyp, damit `"error" in cost` sauber eingrenzt. function readCost(formData: FormData): CostParams | { error: string } { const parsed = CostSchema.safeParse({ plan: formData.get("plan"), price: formData.get("price") ?? "", billingCycle: formData.get("billingCycle"), }) if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte die Kosten prüfen." } return parsed.data.plan === "paid" ? { p_plan: "paid", p_price: Math.round(parsed.data.price * 100) / 100, p_billing_cycle: parsed.data.billingCycle } : { p_plan: "free", p_price: null, p_billing_cycle: null } } export async function createInternalCredential( _prevState: InternalCredentialState, formData: FormData ): Promise { if (!(await isAdmin())) return { error: "Keine Berechtigung." } const parsed = CreateSchema.safeParse({ ...readFields(formData), password: formData.get("password") }) if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte alle Pflichtfelder ausfüllen." } const cost = readCost(formData) if ("error" in cost) return cost const { service, title, username, url, password } = parsed.data const supabase = await createClient() const { error } = await supabase.rpc("create_internal_credential", { p_service: service, p_title: title, p_username: username, p_url: url, ...cost, p_password: password, }) if (error) return { error: "Eintrag konnte nicht gespeichert werden." } revalidatePath(PATH) return { success: true } } export async function updateInternalCredential( _prevState: InternalCredentialState, formData: FormData ): Promise { if (!(await isAdmin())) return { error: "Keine Berechtigung." } // Leeres Passwortfeld = Passwort bleibt, wie es ist const password = String(formData.get("password") ?? "") const parsed = UpdateSchema.safeParse({ ...readFields(formData), id: formData.get("id"), password: password === "" ? undefined : password, }) if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte alle Pflichtfelder ausfüllen." } const cost = readCost(formData) if ("error" in cost) return cost const supabase = await createClient() const { error } = await supabase.rpc("update_internal_credential", { p_id: parsed.data.id, p_service: parsed.data.service, p_title: parsed.data.title, p_username: parsed.data.username, p_url: parsed.data.url, ...cost, p_password: parsed.data.password ?? null, }) if (error) return { error: "Eintrag konnte nicht aktualisiert werden." } revalidatePath(PATH) return { success: true } } export async function revealInternalCredential(id: string): Promise<{ password: string } | { error: string }> { if (!(await isAdmin()) || !IdSchema.safeParse(id).success) return { error: "Keine Berechtigung." } const supabase = await createClient() const { data, error } = await supabase.rpc("reveal_internal_credential", { p_id: id }) if (error || typeof data !== "string") return { error: "Passwort konnte nicht abgerufen werden." } return { password: data } } export async function deleteInternalCredential(id: string): Promise { if (!(await isAdmin())) return { error: "Keine Berechtigung." } if (!IdSchema.safeParse(id).success) return { error: "Ungültiger Eintrag." } const supabase = await createClient() const { error } = await supabase.from("internal_credentials").delete().eq("id", id) if (error) return { error: "Eintrag konnte nicht gelöscht werden." } revalidatePath(PATH) return { success: true } }