since3-portal/modules/admin/actions/internal-credentials.ts
2026-10-11 18:22:32 +02:00

173 lines
No EOL
6.3 KiB
TypeScript

"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<InternalCredentialState> {
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<InternalCredentialState> {
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<InternalCredentialState> {
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 }
}