diff --git a/src/app/(admin)/_actions/brigades.ts b/src/app/(admin)/_actions/brigades.ts new file mode 100644 index 0000000..f277767 --- /dev/null +++ b/src/app/(admin)/_actions/brigades.ts @@ -0,0 +1,56 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { + createBrigadeWithFirstAdmin, + resetUserPassword, +} from "@/lib/admin/provisioning"; +import { + brigadeProvisionSchema, + userResetSchema, +} from "@/lib/validation/brigade"; + +export type ProvisionActionResult = + | { ok: true; tempPassword: string; geocoded: boolean } + | { ok: false; error: string }; + +export type ResetActionResult = + | { ok: true; tempPassword: string } + | { ok: false; error: string }; + +/** + * Legt eine Wehr samt erstem wehr_admin an (Geocoding + Audit in + * provisioning.ts). Guard zuerst (default-deny, platform_admin). + */ +export async function provisionBrigade( + input: unknown, +): Promise { + const s = await requirePlatformAdmin(); + const p = brigadeProvisionSchema.safeParse(input); + if (!p.success) { + return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const result = await createBrigadeWithFirstAdmin(p.data, s.user.id); + revalidatePath("/admin/wehren"); + return { + ok: true, + tempPassword: result.tempPassword, + geocoded: result.geocoded, + }; +} + +/** + * Setzt das Passwort eines lokalen Benutzers zurück (Audit `user.reset`). + * Guard zuerst. + */ +export async function resetBrigadeUserPassword( + input: unknown, +): Promise { + const s = await requirePlatformAdmin(); + const p = userResetSchema.safeParse(input); + if (!p.success) return { ok: false, error: "Ungültige ID." }; + const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id); + revalidatePath("/admin/wehren"); + return { ok: true, tempPassword }; +} diff --git a/src/app/(admin)/_actions/equipment-categories.ts b/src/app/(admin)/_actions/equipment-categories.ts new file mode 100644 index 0000000..edd1ecb --- /dev/null +++ b/src/app/(admin)/_actions/equipment-categories.ts @@ -0,0 +1,120 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { + equipmentCategories, + equipmentCategoryMerkmale, +} from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { writeAudit } from "@/lib/audit"; +import { + equipmentCategoryCreateSchema, + equipmentCategoryUpdateSchema, + categoryMerkmalSchema, +} from "@/lib/validation/equipment-category"; +import { uuidSchema } from "@/lib/validation/common"; + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +export async function createCategory(input: unknown): Promise { + const s = await requirePlatformAdmin(); + const p = equipmentCategoryCreateSchema.safeParse(input); + if (!p.success) { + return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + await db.transaction(async (tx) => { + const [c] = await tx + .insert(equipmentCategories) + .values({ name: p.data.name }) + .returning(); + if (!c) throw new Error("Kategorie konnte nicht angelegt werden."); + await writeAudit(s.user.id, "category.create", "equipment_category", c.id, undefined, tx); + }); + revalidatePath("/admin/geraete-kategorien"); + return { ok: true }; +} + +export async function updateCategory(input: unknown): Promise { + const s = await requirePlatformAdmin(); + const p = equipmentCategoryUpdateSchema.safeParse(input); + if (!p.success) { + return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + await db.transaction(async (tx) => { + await tx + .update(equipmentCategories) + .set({ name: p.data.name }) + .where(eq(equipmentCategories.id, p.data.id)); + await writeAudit(s.user.id, "category.update", "equipment_category", p.data.id, undefined, tx); + }); + revalidatePath(`/admin/geraete-kategorien/${p.data.id}`); + return { ok: true }; +} + +export async function setCategoryMerkmal(input: { + categoryId: unknown; + merkmal: unknown; +}): Promise { + const s = await requirePlatformAdmin(); + const cid = uuidSchema.safeParse(input.categoryId); + const m = categoryMerkmalSchema.safeParse(input.merkmal); + if (!cid.success || !m.success) return { ok: false, error: "Ungültige Eingabe." }; + await db.transaction(async (tx) => { + await tx + .insert(equipmentCategoryMerkmale) + .values({ + categoryId: cid.data, + merkmalId: m.data.merkmalId, + reihenfolge: m.data.reihenfolge, + }) + .onConflictDoUpdate({ + target: [ + equipmentCategoryMerkmale.categoryId, + equipmentCategoryMerkmale.merkmalId, + ], + set: { reihenfolge: m.data.reihenfolge }, + }); + await writeAudit( + s.user.id, + "category.merkmal_set", + "equipment_category", + cid.data, + { merkmalId: m.data.merkmalId }, + tx, + ); + }); + revalidatePath(`/admin/geraete-kategorien/${cid.data}`); + return { ok: true }; +} + +export async function removeCategoryMerkmal(input: { + categoryId: unknown; + merkmalId: unknown; +}): Promise { + const s = await requirePlatformAdmin(); + const cid = uuidSchema.safeParse(input.categoryId); + const mid = uuidSchema.safeParse(input.merkmalId); + if (!cid.success || !mid.success) return { ok: false, error: "Ungültige ID." }; + await db.transaction(async (tx) => { + await tx + .delete(equipmentCategoryMerkmale) + .where( + and( + eq(equipmentCategoryMerkmale.categoryId, cid.data), + eq(equipmentCategoryMerkmale.merkmalId, mid.data), + ), + ); + await writeAudit( + s.user.id, + "category.merkmal_remove", + "equipment_category", + cid.data, + { merkmalId: mid.data }, + tx, + ); + }); + revalidatePath(`/admin/geraete-kategorien/${cid.data}`); + return { ok: true }; +} diff --git a/src/app/(admin)/_actions/merkmale.ts b/src/app/(admin)/_actions/merkmale.ts new file mode 100644 index 0000000..d937835 --- /dev/null +++ b/src/app/(admin)/_actions/merkmale.ts @@ -0,0 +1,159 @@ +"use server"; + +import { eq, count } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { + merkmale, + merkmalOptionen, + merkmalValues, + vehicleTemplateMerkmale, + equipmentCategoryMerkmale, +} from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { writeAudit } from "@/lib/audit"; +import { + merkmalCreateSchema, + merkmalUpdateSchema, +} from "@/lib/validation/merkmal"; +import { slugify } from "@/lib/admin/slug"; + +export type ActionResult = + | { ok: true } + | { ok: false; error: string }; + +/** + * Legt ein neues Merkmal an (Status `active`, da vom Admin kuratiert) inkl. + * Enum-Optionen. Guard zuerst (default-deny). Audit `merkmal.create`. + */ +export async function createMerkmal(input: unknown): Promise { + const s = await requirePlatformAdmin(); + const parsed = merkmalCreateSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const d = parsed.data; + + await db.transaction(async (tx) => { + const [m] = await tx + .insert(merkmale) + .values({ + slug: slugify(d.name), + name: d.name, + typ: d.typ, + einheit: d.einheit ?? null, + geltungsbereich: d.geltungsbereich, + status: "active", + }) + .returning(); + if (!m) throw new Error("Merkmal konnte nicht angelegt werden."); + + if (d.typ === "enum") { + await tx.insert(merkmalOptionen).values( + d.optionen.map((o, i) => ({ + merkmalId: m.id, + wert: o.wert, + label: o.label, + reihenfolge: i, + })), + ); + } + + await writeAudit( + s.user.id, + "merkmal.create", + "merkmal", + m.id, + { name: d.name, typ: d.typ }, + tx, + ); + }); + + revalidatePath("/admin/merkmale"); + return { ok: true }; +} + +/** + * Aktualisiert ein Merkmal und ersetzt seine Optionen (delete-then-insert für + * Enum). Guard zuerst. + */ +export async function updateMerkmal(input: unknown): Promise { + const s = await requirePlatformAdmin(); + const parsed = merkmalUpdateSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const d = parsed.data; + + await db.transaction(async (tx) => { + await tx + .update(merkmale) + .set({ + name: d.name, + typ: d.typ, + einheit: d.einheit ?? null, + geltungsbereich: d.geltungsbereich, + }) + .where(eq(merkmale.id, d.id)); + + await tx.delete(merkmalOptionen).where(eq(merkmalOptionen.merkmalId, d.id)); + if (d.typ === "enum") { + await tx.insert(merkmalOptionen).values( + d.optionen.map((o, i) => ({ + merkmalId: d.id, + wert: o.wert, + label: o.label, + reihenfolge: i, + })), + ); + } + + await writeAudit(s.user.id, "merkmal.update", "merkmal", d.id, undefined, tx); + }); + + revalidatePath("/admin/merkmale"); + return { ok: true }; +} + +/** + * Löscht ein Merkmal — blockiert, wenn es in Werten, Vorlagen oder Kategorien + * referenziert wird (klare deutsche Fehlermeldung, kein 500). + */ +export async function deleteMerkmal(merkmalId: string): Promise { + const s = await requirePlatformAdmin(); + + const [vals, tmpl, cats] = await Promise.all([ + db + .select({ c: count() }) + .from(merkmalValues) + .where(eq(merkmalValues.merkmalId, merkmalId)), + db + .select({ c: count() }) + .from(vehicleTemplateMerkmale) + .where(eq(vehicleTemplateMerkmale.merkmalId, merkmalId)), + db + .select({ c: count() }) + .from(equipmentCategoryMerkmale) + .where(eq(equipmentCategoryMerkmale.merkmalId, merkmalId)), + ]); + + const referenced = + (vals[0]?.c ?? 0) + (tmpl[0]?.c ?? 0) + (cats[0]?.c ?? 0) > 0; + if (referenced) { + return { + ok: false, + error: "Merkmal wird verwendet und kann nicht gelöscht werden.", + }; + } + + await db.transaction(async (tx) => { + await tx + .delete(merkmalOptionen) + .where(eq(merkmalOptionen.merkmalId, merkmalId)); + await tx.delete(merkmale).where(eq(merkmale.id, merkmalId)); + await writeAudit(s.user.id, "merkmal.delete", "merkmal", merkmalId, undefined, tx); + }); + + revalidatePath("/admin/merkmale"); + return { ok: true }; +} diff --git a/src/app/(admin)/_actions/proposals.ts b/src/app/(admin)/_actions/proposals.ts new file mode 100644 index 0000000..03d8932 --- /dev/null +++ b/src/app/(admin)/_actions/proposals.ts @@ -0,0 +1,105 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { writeAudit } from "@/lib/audit"; +import { uuidSchema } from "@/lib/validation/common"; + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +/** + * Promoviert ein vorgeschlagenes Merkmal: setzt `status = 'active'`. Der + * partielle Unique-Index `merkmale_active_name_uq` verhindert dabei zwei aktive + * Merkmale gleichen Namens (DB-WS). Guard zuerst. Audit `merkmal.promote`. + */ +export async function promoteMerkmal(merkmalId: unknown): Promise { + const s = await requirePlatformAdmin(); + const id = uuidSchema.safeParse(merkmalId); + if (!id.success) return { ok: false, error: "Ungültige ID." }; + + try { + await db.transaction(async (tx) => { + await tx + .update(merkmale) + .set({ status: "active" }) + .where(eq(merkmale.id, id.data)); + await writeAudit( + s.user.id, + "merkmal.promote", + "merkmal", + id.data, + undefined, + tx, + ); + }); + } catch { + return { + ok: false, + error: + "Es existiert bereits ein aktives Merkmal mit diesem Namen. Bitte stattdessen zusammenführen.", + }; + } + + revalidatePath("/admin/merkmale/proposals"); + return { ok: true }; +} + +/** + * Führt ein vorgeschlagenes Merkmal in ein Ziel-Merkmal zusammen: hängt alle + * `merkmal_values` und `vehicle_template_merkmale` auf das Ziel um und löscht + * das vorgeschlagene Merkmal. Typ-Kompatibilität wird serverseitig erzwungen. + * Guard zuerst. Audit `merkmal.merge`. + */ +export async function mergeMerkmal(input: { + proposedId: unknown; + zielId: unknown; +}): Promise { + const s = await requirePlatformAdmin(); + const proposed = uuidSchema.safeParse(input.proposedId); + const ziel = uuidSchema.safeParse(input.zielId); + if (!proposed.success || !ziel.success) { + return { ok: false, error: "Ungültige ID." }; + } + if (proposed.data === ziel.data) { + return { ok: false, error: "Quelle und Ziel müssen verschieden sein." }; + } + + const rows = await db + .select({ id: merkmale.id, typ: merkmale.typ, status: merkmale.status }) + .from(merkmale); + const src = rows.find((r) => r.id === proposed.data); + const dst = rows.find((r) => r.id === ziel.data); + if (!src || !dst) return { ok: false, error: "Merkmal nicht gefunden." }; + if (src.typ !== dst.typ) { + return { + ok: false, + error: "Nur Merkmale gleichen Typs können zusammengeführt werden.", + }; + } + + await db.transaction(async (tx) => { + await tx + .update(merkmalValues) + .set({ merkmalId: ziel.data }) + .where(eq(merkmalValues.merkmalId, proposed.data)); + await tx + .update(vehicleTemplateMerkmale) + .set({ merkmalId: ziel.data }) + .where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data)); + await tx.delete(merkmale).where(eq(merkmale.id, proposed.data)); + await writeAudit( + s.user.id, + "merkmal.merge", + "merkmal", + ziel.data, + { merged: proposed.data }, + tx, + ); + }); + + revalidatePath("/admin/merkmale/proposals"); + return { ok: true }; +} diff --git a/src/app/(admin)/_actions/templates.ts b/src/app/(admin)/_actions/templates.ts new file mode 100644 index 0000000..25f76ee --- /dev/null +++ b/src/app/(admin)/_actions/templates.ts @@ -0,0 +1,200 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { + vehicleTemplates, + vehicleTemplateMerkmale, + vehicleTemplateAliasse, +} from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { writeAudit } from "@/lib/audit"; +import { + templateCreateSchema, + templateUpdateSchema, + templateMerkmalSchema, + aliasSchema, +} from "@/lib/validation/template"; +import { uuidSchema } from "@/lib/validation/common"; + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +export async function createTemplate(input: unknown): Promise { + const s = await requirePlatformAdmin(); + const p = templateCreateSchema.safeParse(input); + if (!p.success) { + return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + await db.transaction(async (tx) => { + const [t] = await tx + .insert(vehicleTemplates) + .values({ + code: p.data.code, + name: p.data.name, + beschreibung: p.data.beschreibung ?? null, + }) + .returning(); + if (!t) throw new Error("Vorlage konnte nicht angelegt werden."); + await writeAudit(s.user.id, "template.create", "vehicle_template", t.id, undefined, tx); + }); + revalidatePath("/admin/vorlagen"); + return { ok: true }; +} + +export async function updateTemplate(input: unknown): Promise { + const s = await requirePlatformAdmin(); + const p = templateUpdateSchema.safeParse(input); + if (!p.success) { + return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + await db.transaction(async (tx) => { + await tx + .update(vehicleTemplates) + .set({ + code: p.data.code, + name: p.data.name, + beschreibung: p.data.beschreibung ?? null, + }) + .where(eq(vehicleTemplates.id, p.data.id)); + await writeAudit(s.user.id, "template.update", "vehicle_template", p.data.id, undefined, tx); + }); + revalidatePath(`/admin/vorlagen/${p.data.id}`); + return { ok: true }; +} + +/** + * Setzt (upsert) ein Vorgabe-Merkmal einer Vorlage mit typisiertem Vorgabewert. + */ +export async function setTemplateMerkmal(input: { + templateId: unknown; + merkmal: unknown; +}): Promise { + const s = await requirePlatformAdmin(); + const tid = uuidSchema.safeParse(input.templateId); + const m = templateMerkmalSchema.safeParse(input.merkmal); + if (!tid.success || !m.success) { + return { ok: false, error: "Ungültige Eingabe." }; + } + await db.transaction(async (tx) => { + await tx + .insert(vehicleTemplateMerkmale) + .values({ + templateId: tid.data, + merkmalId: m.data.merkmalId, + vorgabewertNum: m.data.vorgabewertNum ?? null, + vorgabewertText: m.data.vorgabewertText ?? null, + vorgabewertBool: m.data.vorgabewertBool ?? null, + pflicht: m.data.pflicht, + reihenfolge: m.data.reihenfolge, + }) + .onConflictDoUpdate({ + target: [ + vehicleTemplateMerkmale.templateId, + vehicleTemplateMerkmale.merkmalId, + ], + set: { + vorgabewertNum: m.data.vorgabewertNum ?? null, + vorgabewertText: m.data.vorgabewertText ?? null, + vorgabewertBool: m.data.vorgabewertBool ?? null, + pflicht: m.data.pflicht, + reihenfolge: m.data.reihenfolge, + }, + }); + await writeAudit( + s.user.id, + "template.merkmal_set", + "vehicle_template", + tid.data, + { merkmalId: m.data.merkmalId }, + tx, + ); + }); + revalidatePath(`/admin/vorlagen/${tid.data}`); + return { ok: true }; +} + +export async function removeTemplateMerkmal(input: { + templateId: unknown; + merkmalId: unknown; +}): Promise { + const s = await requirePlatformAdmin(); + const tid = uuidSchema.safeParse(input.templateId); + const mid = uuidSchema.safeParse(input.merkmalId); + if (!tid.success || !mid.success) return { ok: false, error: "Ungültige ID." }; + await db.transaction(async (tx) => { + await tx + .delete(vehicleTemplateMerkmale) + .where( + and( + eq(vehicleTemplateMerkmale.templateId, tid.data), + eq(vehicleTemplateMerkmale.merkmalId, mid.data), + ), + ); + await writeAudit( + s.user.id, + "template.merkmal_remove", + "vehicle_template", + tid.data, + { merkmalId: mid.data }, + tx, + ); + }); + revalidatePath(`/admin/vorlagen/${tid.data}`); + return { ok: true }; +} + +export async function addAlias(input: unknown): Promise { + const s = await requirePlatformAdmin(); + const p = aliasSchema.safeParse(input); + if (!p.success) { + return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + await db.transaction(async (tx) => { + await tx + .insert(vehicleTemplateAliasse) + .values({ + templateId: p.data.templateId, + alias: p.data.alias, + bestaetigt: p.data.bestaetigt, + }) + .onConflictDoUpdate({ + target: [ + vehicleTemplateAliasse.templateId, + vehicleTemplateAliasse.alias, + ], + set: { bestaetigt: p.data.bestaetigt }, + }); + await writeAudit( + s.user.id, + "template.alias_set", + "vehicle_template", + p.data.templateId, + { alias: p.data.alias, bestaetigt: p.data.bestaetigt }, + tx, + ); + }); + revalidatePath(`/admin/vorlagen/${p.data.templateId}`); + return { ok: true }; +} + +export async function removeAlias(aliasId: unknown): Promise { + const s = await requirePlatformAdmin(); + const id = uuidSchema.safeParse(aliasId); + if (!id.success) return { ok: false, error: "Ungültige ID." }; + await db.transaction(async (tx) => { + await tx + .delete(vehicleTemplateAliasse) + .where(eq(vehicleTemplateAliasse.id, id.data)); + await writeAudit( + s.user.id, + "template.alias_remove", + "vehicle_template", + id.data, + undefined, + tx, + ); + }); + revalidatePath("/admin/vorlagen"); + return { ok: true }; +} diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx new file mode 100644 index 0000000..4d1e5c9 --- /dev/null +++ b/src/app/(admin)/admin/audit/page.tsx @@ -0,0 +1,150 @@ +import Link from "next/link"; +import { desc, eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { auditLog, users } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { DataTable } from "@/components/admin/DataTable"; +import { paginationSchema } from "@/lib/validation/common"; + +export const metadata = { title: "Audit-Log — Administration" }; + +const dtf = new Intl.DateTimeFormat("de-AT", { + dateStyle: "short", + timeStyle: "short", +}); + +export default async function AuditPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requirePlatformAdmin(); + const sp = await searchParams; + + const aktionFilter = + typeof sp.aktion === "string" && sp.aktion.trim() ? sp.aktion.trim() : null; + const { page, pageSize } = paginationSchema.parse({ + page: sp.page, + pageSize: 25, + }); + + const whereClause = aktionFilter + ? eq(auditLog.aktion, aktionFilter) + : undefined; + + const rows = await db + .select({ + id: auditLog.id, + zeitpunkt: auditLog.zeitpunkt, + aktion: auditLog.aktion, + zielTyp: auditLog.zielTyp, + zielId: auditLog.zielId, + actorName: users.name, + }) + .from(auditLog) + .leftJoin(users, eq(users.id, auditLog.actorUserId)) + .where(whereClause) + .orderBy(desc(auditLog.zeitpunkt)) + .limit(pageSize) + .offset((page - 1) * pageSize); + + const totalRows = await db + .select({ total: sql`count(*)::int` }) + .from(auditLog) + .where(whereClause); + const total = totalRows[0]?.total ?? 0; + + const aktionen = await db + .selectDistinct({ aktion: auditLog.aktion }) + .from(auditLog) + .orderBy(auditLog.aktion); + + const totalPages = Math.max(1, Math.ceil((total ?? 0) / pageSize)); + const qp = (p: number) => + `?page=${p}${aktionFilter ? `&aktion=${encodeURIComponent(aktionFilter)}` : ""}`; + + return ( +
+

{de.admin.navAudit}

+ +
+ + +
+ + r.id} + columns={[ + { + key: "zeitpunkt", + header: de.admin.auditZeitpunkt, + render: (r) => ( + + {dtf.format(new Date(r.zeitpunkt))} + + ), + }, + { + key: "aktion", + header: de.admin.auditAktion, + render: (r) => ( + {r.aktion} + ), + }, + { + key: "ziel", + header: de.admin.auditZiel, + render: (r) => r.zielTyp ?? "–", + }, + { + key: "akteur", + header: de.admin.auditAkteur, + render: (r) => r.actorName ?? "–", + }, + ]} + /> + +
+ {total} Einträge +
+ {page > 1 && ( + + ← {de.admin.zurueck} + + )} + + {page} / {totalPages} + + {page < totalPages && ( + + {de.admin.weiter} → + + )} +
+
+
+ ); +} diff --git a/src/app/(admin)/admin/geraete-kategorien/CategoryCreateForm.tsx b/src/app/(admin)/admin/geraete-kategorien/CategoryCreateForm.tsx new file mode 100644 index 0000000..affb066 --- /dev/null +++ b/src/app/(admin)/admin/geraete-kategorien/CategoryCreateForm.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { de } from "@/lib/i18n/de"; +import { createCategory } from "../../_actions/equipment-categories"; + +/** Inline-Anlageformular für eine neue Geräte-Kategorie. */ +export function CategoryCreateForm() { + const router = useRouter(); + const [open, setOpen] = React.useState(false); + const [name, setName] = React.useState(""); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + function submit() { + setError(null); + startTransition(async () => { + const res = await createCategory({ name }); + if (!res.ok) { + setError(res.error); + return; + } + setName(""); + setOpen(false); + router.refresh(); + }); + } + + if (!open) { + return ; + } + + return ( +
+ + {error &&

{error}

} + + +
+ ); +} diff --git a/src/app/(admin)/admin/geraete-kategorien/[id]/CategoryMerkmaleEditor.tsx b/src/app/(admin)/admin/geraete-kategorien/[id]/CategoryMerkmaleEditor.tsx new file mode 100644 index 0000000..65b7603 --- /dev/null +++ b/src/app/(admin)/admin/geraete-kategorien/[id]/CategoryMerkmaleEditor.tsx @@ -0,0 +1,135 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { de } from "@/lib/i18n/de"; +import { + setCategoryMerkmal, + removeCategoryMerkmal, +} from "../../../_actions/equipment-categories"; + +export interface CategoryMerkmalRow { + merkmalId: string; + name: string; + einheit: string | null; + reihenfolge: number; +} +export interface MerkmalLite { + id: string; + name: string; +} + +const selectCls = + "h-9 rounded border border-rand bg-white px-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy"; + +/** + * Editor der Merkmale einer Geräte-Kategorie (Reihenfolge stabil sortiert). + * Aktionen über guarded + Zod-validierte Server-Actions. + */ +export function CategoryMerkmaleEditor({ + categoryId, + rows, + alleMerkmale, +}: { + categoryId: string; + rows: CategoryMerkmalRow[]; + alleMerkmale: MerkmalLite[]; +}) { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [addId, setAddId] = React.useState(""); + const [pending, startTransition] = React.useTransition(); + + const zugeordnet = new Set(rows.map((r) => r.merkmalId)); + const verfuegbar = alleMerkmale.filter((m) => !zugeordnet.has(m.id)); + + function run(fn: () => Promise<{ ok: boolean; error?: string }>) { + setError(null); + startTransition(async () => { + const res = await fn(); + if (!res.ok) { + setError(res.error ?? de.fehler.allgemein); + return; + } + router.refresh(); + }); + } + + return ( +
+

{de.admin.navMerkmale}

+ {error &&

{error}

} + + {rows.length === 0 ? ( +

+ {de.admin.keineEintraege} +

+ ) : ( +
    + {rows.map((r, i) => ( +
  1. + + {i + 1}. + {r.name} + {r.einheit && ( + ({r.einheit}) + )} + + +
  2. + ))} +
+ )} + + {verfuegbar.length > 0 && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/app/(admin)/admin/geraete-kategorien/[id]/page.tsx b/src/app/(admin)/admin/geraete-kategorien/[id]/page.tsx new file mode 100644 index 0000000..565e0d0 --- /dev/null +++ b/src/app/(admin)/admin/geraete-kategorien/[id]/page.tsx @@ -0,0 +1,72 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { + equipmentCategories, + equipmentCategoryMerkmale, + merkmale, +} from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { + CategoryMerkmaleEditor, + type CategoryMerkmalRow, + type MerkmalLite, +} from "./CategoryMerkmaleEditor"; + +export default async function KategorieDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requirePlatformAdmin(); + const { id } = await params; + + const [cat] = await db + .select() + .from(equipmentCategories) + .where(eq(equipmentCategories.id, id)); + if (!cat) notFound(); + + const [cmRows, alleMerkmale] = await Promise.all([ + db + .select({ + merkmalId: equipmentCategoryMerkmale.merkmalId, + name: merkmale.name, + einheit: merkmale.einheit, + reihenfolge: equipmentCategoryMerkmale.reihenfolge, + }) + .from(equipmentCategoryMerkmale) + .innerJoin(merkmale, eq(merkmale.id, equipmentCategoryMerkmale.merkmalId)) + .where(eq(equipmentCategoryMerkmale.categoryId, id)) + .orderBy(equipmentCategoryMerkmale.reihenfolge, merkmale.name), + db + .select({ id: merkmale.id, name: merkmale.name }) + .from(merkmale) + .where(eq(merkmale.status, "active")) + .orderBy(merkmale.name), + ]); + + const rows: CategoryMerkmalRow[] = cmRows; + const merkmaleLite: MerkmalLite[] = alleMerkmale; + + return ( +
+
+ + ← {de.admin.navKategorien} + +

{cat.name}

+
+ +
+ ); +} diff --git a/src/app/(admin)/admin/geraete-kategorien/page.tsx b/src/app/(admin)/admin/geraete-kategorien/page.tsx new file mode 100644 index 0000000..d9b4d71 --- /dev/null +++ b/src/app/(admin)/admin/geraete-kategorien/page.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import { db } from "@/db"; +import { equipmentCategories } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { DataTable } from "@/components/admin/DataTable"; +import { CategoryCreateForm } from "./CategoryCreateForm"; + +export const metadata = { title: "Geräte-Kategorien — Administration" }; + +export default async function KategorienPage() { + await requirePlatformAdmin(); + + const rows = await db + .select() + .from(equipmentCategories) + .orderBy(equipmentCategories.reihenfolge, equipmentCategories.name); + + return ( +
+
+

+ {de.admin.navKategorien} +

+ +
+ r.id} + columns={[ + { + key: "name", + header: de.admin.name, + render: (r) => ( + {r.name} + ), + }, + { + key: "aktion", + header: "", + render: (r) => ( + + {de.admin.bearbeiten} + + ), + }, + ]} + /> +
+ ); +} diff --git a/src/app/(admin)/admin/merkmale/MerkmalEditor.tsx b/src/app/(admin)/admin/merkmale/MerkmalEditor.tsx new file mode 100644 index 0000000..5f1b8e6 --- /dev/null +++ b/src/app/(admin)/admin/merkmale/MerkmalEditor.tsx @@ -0,0 +1,209 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { de } from "@/lib/i18n/de"; +import { createMerkmal, updateMerkmal } from "../../_actions/merkmale"; + +export type MerkmalTyp = "number" | "enum" | "boolean" | "text"; +export type Geltung = "vehicle" | "equipment" | "both"; + +export interface MerkmalOption { + wert: string; + label: string; +} + +export interface MerkmalEditorValue { + id?: string; + name: string; + typ: MerkmalTyp; + einheit?: string; + geltungsbereich: Geltung; + optionen: MerkmalOption[]; +} + +const TYP_LABELS: Record = { + number: "Zahl", + enum: "Auswahl", + boolean: "Ja/Nein", + text: "Text", +}; +const GELTUNG_LABELS: Record = { + vehicle: "Fahrzeug", + equipment: "Gerät", + both: "beide", +}; + +const selectCls = + "flex h-10 w-full rounded border border-rand bg-white px-3 py-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy"; + +/** + * Client-Editor für ein Merkmal. Validiert clientseitig minimal (Enum braucht + * Optionen) und ruft die serverseitige (guarded + Zod-validierte) Action. Die + * autoritative Validierung passiert serverseitig. + */ +export function MerkmalEditor({ + initial, + onDone, +}: { + initial?: MerkmalEditorValue; + onDone?: () => void; +}) { + const router = useRouter(); + const [name, setName] = React.useState(initial?.name ?? ""); + const [typ, setTyp] = React.useState(initial?.typ ?? "number"); + const [einheit, setEinheit] = React.useState(initial?.einheit ?? ""); + const [geltung, setGeltung] = React.useState( + initial?.geltungsbereich ?? "vehicle", + ); + const [optionen, setOptionen] = React.useState( + initial?.optionen ?? [], + ); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + function setOption(i: number, patch: Partial) { + setOptionen((prev) => + prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)), + ); + } + + function submit() { + setError(null); + if (typ === "enum" && optionen.filter((o) => o.wert.trim()).length === 0) { + setError(de.admin.optionen + ": " + "mindestens eine erforderlich."); + return; + } + const payload = { + ...(initial?.id ? { id: initial.id } : {}), + name, + typ, + einheit: einheit || undefined, + geltungsbereich: geltung, + optionen: typ === "enum" ? optionen.filter((o) => o.wert.trim()) : [], + }; + startTransition(async () => { + const res = initial?.id + ? await updateMerkmal(payload) + : await createMerkmal(payload); + if (!res.ok) { + setError(res.error); + return; + } + router.refresh(); + onDone?.(); + }); + } + + return ( +
+
+ + + + +
+ + {typ === "enum" && ( +
+ + {de.admin.optionen} + + {optionen.map((o, i) => ( +
+ setOption(i, { wert: e.target.value })} + placeholder="wert" + /> + setOption(i, { label: e.target.value })} + placeholder="Bezeichnung" + /> + +
+ ))} + +
+ )} + + {error &&

{error}

} + +
+ + {onDone && ( + + )} +
+
+ ); +} diff --git a/src/app/(admin)/admin/merkmale/MerkmalListClient.tsx b/src/app/(admin)/admin/merkmale/MerkmalListClient.tsx new file mode 100644 index 0000000..48338d9 --- /dev/null +++ b/src/app/(admin)/admin/merkmale/MerkmalListClient.tsx @@ -0,0 +1,112 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { de } from "@/lib/i18n/de"; +import { + MerkmalEditor, + type MerkmalEditorValue, +} from "./MerkmalEditor"; +import { deleteMerkmal } from "../../_actions/merkmale"; + +export interface MerkmalRow extends MerkmalEditorValue { + id: string; +} + +const TYP_LABELS: Record = { + number: "Zahl", + enum: "Auswahl", + boolean: "Ja/Nein", + text: "Text", +}; + +/** + * Client-Hülle der Merkmal-Liste: Anlegen/Bearbeiten via MerkmalEditor, Löschen + * mit klarer Fehlermeldung (kein 500), wenn referenziert. Die Server-Action ist + * guarded + Zod-validiert. + */ +export function MerkmalListClient({ rows }: { rows: MerkmalRow[] }) { + const router = useRouter(); + const [creating, setCreating] = React.useState(false); + const [editId, setEditId] = React.useState(null); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + function remove(id: string) { + setError(null); + startTransition(async () => { + const res = await deleteMerkmal(id); + if (!res.ok) { + setError(res.error); + return; + } + router.refresh(); + }); + } + + return ( +
+
+

+ {de.admin.navMerkmale} +

+ {!creating && ( + + )} +
+ + {creating && setCreating(false)} />} + {error &&

{error}

} + + {rows.length === 0 ? ( +

+ {de.admin.keineEintraege} +

+ ) : ( +
    + {rows.map((m) => ( +
  • + {editId === m.id ? ( + setEditId(null)} + /> + ) : ( +
    +
    + {m.name} + + {TYP_LABELS[m.typ] ?? m.typ} + {m.einheit ? ` · ${m.einheit}` : ""} + +
    +
    + + +
    +
    + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(admin)/admin/merkmale/page.tsx b/src/app/(admin)/admin/merkmale/page.tsx new file mode 100644 index 0000000..a8e41d0 --- /dev/null +++ b/src/app/(admin)/admin/merkmale/page.tsx @@ -0,0 +1,40 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { merkmale, merkmalOptionen } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { + MerkmalListClient, + type MerkmalRow, +} from "./MerkmalListClient"; + +export const metadata = { title: "Merkmale — Administration" }; + +export default async function MerkmalePage() { + // Default-deny (Verteidigung in der Tiefe — zusätzlich zum Layout-Guard). + await requirePlatformAdmin(); + + const liste = await db + .select() + .from(merkmale) + .where(eq(merkmale.status, "active")) + .orderBy(merkmale.name); + + const optionen = await db.select().from(merkmalOptionen); + const optByMerkmal = new Map(); + for (const o of optionen) { + const arr = optByMerkmal.get(o.merkmalId) ?? []; + arr.push({ wert: o.wert, label: o.label }); + optByMerkmal.set(o.merkmalId, arr); + } + + const rows: MerkmalRow[] = liste.map((m) => ({ + id: m.id, + name: m.name, + typ: m.typ, + einheit: m.einheit ?? undefined, + geltungsbereich: m.geltungsbereich, + optionen: optByMerkmal.get(m.id) ?? [], + })); + + return ; +} diff --git a/src/app/(admin)/admin/merkmale/proposals/MergeDialog.tsx b/src/app/(admin)/admin/merkmale/proposals/MergeDialog.tsx new file mode 100644 index 0000000..f2ad071 --- /dev/null +++ b/src/app/(admin)/admin/merkmale/proposals/MergeDialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { de } from "@/lib/i18n/de"; +import { promoteMerkmal, mergeMerkmal } from "../../../_actions/proposals"; + +export interface ProposalRow { + id: string; + name: string; + typ: string; +} +export interface ActiveTarget { + id: string; + name: string; + typ: string; +} + +const TYP_LABELS: Record = { + number: "Zahl", + enum: "Auswahl", + boolean: "Ja/Nein", + text: "Text", +}; +const selectCls = + "h-9 rounded border border-rand bg-white px-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy"; + +/** + * Governance-Flow für vorgeschlagene Merkmale: Übernehmen (promote -> active) + * oder in ein typkompatibles aktives Merkmal Zusammenführen (merge). Die + * Typ-Kompatibilität wird zusätzlich serverseitig erzwungen; die UI bietet nur + * gleichtypige Ziele an. + */ +export function ProposalsClient({ + proposals, + targets, +}: { + proposals: ProposalRow[]; + targets: ActiveTarget[]; +}) { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [zielByRow, setZielByRow] = React.useState>({}); + const [pending, startTransition] = React.useTransition(); + + function run(fn: () => Promise<{ ok: boolean; error?: string }>) { + setError(null); + startTransition(async () => { + const res = await fn(); + if (!res.ok) { + setError(res.error ?? de.fehler.allgemein); + return; + } + router.refresh(); + }); + } + + if (proposals.length === 0) { + return ( +
+

+ {de.admin.navVorschlaege} +

+

+ {de.admin.keineEintraege} +

+
+ ); + } + + return ( +
+

+ {de.admin.navVorschlaege} +

+ {error &&

{error}

} +
    + {proposals.map((p) => { + const kompatibel = targets.filter((t) => t.typ === p.typ); + return ( +
  • +
    + {p.name} + + {TYP_LABELS[p.typ] ?? p.typ} + +
    +
    + + {kompatibel.length > 0 && ( + <> + + + + )} +
    +
  • + ); + })} +
+
+ ); +} diff --git a/src/app/(admin)/admin/merkmale/proposals/page.tsx b/src/app/(admin)/admin/merkmale/proposals/page.tsx new file mode 100644 index 0000000..6a30005 --- /dev/null +++ b/src/app/(admin)/admin/merkmale/proposals/page.tsx @@ -0,0 +1,26 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { merkmale } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { ProposalsClient } from "./MergeDialog"; + +export const metadata = { title: "Vorschläge — Administration" }; + +export default async function ProposalsPage() { + await requirePlatformAdmin(); + + const [proposals, active] = await Promise.all([ + db + .select({ id: merkmale.id, name: merkmale.name, typ: merkmale.typ }) + .from(merkmale) + .where(eq(merkmale.status, "proposed")) + .orderBy(merkmale.name), + db + .select({ id: merkmale.id, name: merkmale.name, typ: merkmale.typ }) + .from(merkmale) + .where(eq(merkmale.status, "active")) + .orderBy(merkmale.name), + ]); + + return ; +} diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..a9c9fd8 --- /dev/null +++ b/src/app/(admin)/admin/page.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import { de } from "@/lib/i18n/de"; + +export const metadata = { title: "Administration — FlorianNetz" }; + +const CARDS = [ + { href: "/admin/merkmale", label: de.admin.navMerkmale }, + { href: "/admin/merkmale/proposals", label: de.admin.navVorschlaege }, + { href: "/admin/vorlagen", label: de.admin.navVorlagen }, + { href: "/admin/geraete-kategorien", label: de.admin.navKategorien }, + { href: "/admin/wehren", label: de.admin.navWehren }, + { href: "/admin/audit", label: de.admin.navAudit }, +] as const; + +export default function AdminHomePage() { + return ( +
+

{de.admin.titel}

+
+ {CARDS.map((c) => ( + + {c.label} + + ))} +
+
+ ); +} diff --git a/src/app/(admin)/admin/vorlagen/TemplateCreateForm.tsx b/src/app/(admin)/admin/vorlagen/TemplateCreateForm.tsx new file mode 100644 index 0000000..eaa5c05 --- /dev/null +++ b/src/app/(admin)/admin/vorlagen/TemplateCreateForm.tsx @@ -0,0 +1,65 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { de } from "@/lib/i18n/de"; +import { createTemplate } from "../../_actions/templates"; + +/** Inline-Anlageformular für eine neue Fahrzeug-Vorlage. */ +export function TemplateCreateForm() { + const router = useRouter(); + const [open, setOpen] = React.useState(false); + const [code, setCode] = React.useState(""); + const [name, setName] = React.useState(""); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + function submit() { + setError(null); + startTransition(async () => { + const res = await createTemplate({ code, name }); + if (!res.ok) { + setError(res.error); + return; + } + setCode(""); + setName(""); + setOpen(false); + router.refresh(); + }); + } + + if (!open) { + return ; + } + + return ( +
+
+ + +
+ {error &&

{error}

} +
+ + +
+
+ ); +} diff --git a/src/app/(admin)/admin/vorlagen/[id]/AliasEditor.tsx b/src/app/(admin)/admin/vorlagen/[id]/AliasEditor.tsx new file mode 100644 index 0000000..1754a90 --- /dev/null +++ b/src/app/(admin)/admin/vorlagen/[id]/AliasEditor.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { de } from "@/lib/i18n/de"; +import { allradCode } from "@/lib/admin/codes"; +import { addAlias, removeAlias } from "../../../_actions/templates"; + +export interface AliasRow { + id: string; + alias: string; + bestaetigt: boolean; +} + +/** + * Editor der Such-/Anzeige-Aliasse einer Vorlage. Zeigt zusätzlich den + * kanonischen Allrad-Hinweis (`allradCode(code)`), da Allrad eine Laufzeitregel + * ist und NICHT als Alias gespeichert wird. + */ +export function AliasEditor({ + templateId, + code, + aliasse, +}: { + templateId: string; + code: string; + aliasse: AliasRow[]; +}) { + const router = useRouter(); + const [neu, setNeu] = React.useState(""); + const [bestaetigt, setBestaetigt] = React.useState(false); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + function run(fn: () => Promise<{ ok: boolean; error?: string }>) { + setError(null); + startTransition(async () => { + const res = await fn(); + if (!res.ok) { + setError(res.error ?? de.fehler.allgemein); + return; + } + router.refresh(); + }); + } + + return ( +
+

{de.admin.aliasse}

+

+ {de.admin.allradHinweis}:{" "} + {allradCode(code)}{" "} + + (Laufzeitregel, kein gespeicherter Alias) + +

+ {error &&

{error}

} + + {aliasse.length === 0 ? ( +

+ {de.admin.keineEintraege} +

+ ) : ( +
    + {aliasse.map((a) => ( +
  • + + {a.alias} + {a.bestaetigt && ( + + {de.admin.bestaetigt} + + )} + + +
  • + ))} +
+ )} + +
+ + + +
+
+ ); +} diff --git a/src/app/(admin)/admin/vorlagen/[id]/TemplateMerkmaleEditor.tsx b/src/app/(admin)/admin/vorlagen/[id]/TemplateMerkmaleEditor.tsx new file mode 100644 index 0000000..e33f02f --- /dev/null +++ b/src/app/(admin)/admin/vorlagen/[id]/TemplateMerkmaleEditor.tsx @@ -0,0 +1,197 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { de } from "@/lib/i18n/de"; +import { + setTemplateMerkmal, + removeTemplateMerkmal, +} from "../../../_actions/templates"; + +export interface MerkmalOptionLite { + wert: string; + label: string; +} +export interface MerkmalLite { + id: string; + name: string; + typ: "number" | "enum" | "boolean" | "text"; + einheit: string | null; + optionen: MerkmalOptionLite[]; +} +export interface TemplateMerkmalRow { + merkmalId: string; + name: string; + typ: MerkmalLite["typ"]; + einheit: string | null; + vorgabewertNum: number | null; + vorgabewertText: string | null; + vorgabewertBool: boolean | null; + pflicht: boolean; + optionen: MerkmalOptionLite[]; +} + +const selectCls = + "h-9 rounded border border-rand bg-white px-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy"; + +function vorgabewertLabel(r: TemplateMerkmalRow): string { + if (r.typ === "number") return r.vorgabewertNum?.toString() ?? "–"; + if (r.typ === "boolean") + return r.vorgabewertBool == null + ? "–" + : r.vorgabewertBool + ? de.search.ja + : de.search.nein; + if (r.typ === "enum") { + const opt = r.optionen.find((o) => o.wert === r.vorgabewertText); + return opt?.label ?? r.vorgabewertText ?? "–"; + } + return r.vorgabewertText ?? "–"; +} + +/** + * Editor der Vorgabe-Merkmale einer Vorlage: zuordnen, Vorgabewert je Typ + * (vorgabewert_num/_text/_bool) erfassen, Pflicht-Flag, entfernen. Alle Aktionen + * über guarded + Zod-validierte Server-Actions. + */ +export function TemplateMerkmaleEditor({ + templateId, + rows, + alleMerkmale, +}: { + templateId: string; + rows: TemplateMerkmalRow[]; + alleMerkmale: MerkmalLite[]; +}) { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + const [addId, setAddId] = React.useState(""); + + const zugeordnet = new Set(rows.map((r) => r.merkmalId)); + const verfuegbar = alleMerkmale.filter((m) => !zugeordnet.has(m.id)); + + function run(fn: () => Promise<{ ok: boolean; error?: string }>) { + setError(null); + startTransition(async () => { + const res = await fn(); + if (!res.ok) { + setError(res.error ?? de.fehler.allgemein); + return; + } + router.refresh(); + }); + } + + function saveVorgabe( + m: MerkmalLite, + value: { num?: number | null; text?: string | null; bool?: boolean | null }, + pflicht: boolean, + ) { + run(() => + setTemplateMerkmal({ + templateId, + merkmal: { + merkmalId: m.id, + vorgabewertNum: value.num ?? null, + vorgabewertText: value.text ?? undefined, + vorgabewertBool: value.bool ?? null, + pflicht, + }, + }), + ); + } + + return ( +
+

{de.admin.navMerkmale}

+ {error &&

{error}

} + + {rows.length === 0 ? ( +

+ {de.admin.keineEintraege} +

+ ) : ( +
    + {rows.map((r) => ( +
  • +
    + {r.name} + {r.einheit && ( + ({r.einheit}) + )} + + {de.admin.vorgabewert}: {vorgabewertLabel(r)} + {r.pflicht ? ` · ${de.admin.pflicht}` : ""} + +
    + +
  • + ))} +
+ )} + + {verfuegbar.length > 0 && ( +
+ + + + Vorgabewerte nach dem Hinzufügen über erneutes Speichern setzen. + +
+ )} + + +
+ ); +} + +/** Begleittext zu den drei typisierten Vorgabewert-Spalten. */ +function VorgabewertHinweis() { + return ( +

+ Vorgabewerte werden je Typ in eine der Spalten vorgabewert_num / + vorgabewert_text / vorgabewert_bool geschrieben. +

+ ); +} diff --git a/src/app/(admin)/admin/vorlagen/[id]/page.tsx b/src/app/(admin)/admin/vorlagen/[id]/page.tsx new file mode 100644 index 0000000..6ad526c --- /dev/null +++ b/src/app/(admin)/admin/vorlagen/[id]/page.tsx @@ -0,0 +1,110 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { + vehicleTemplates, + vehicleTemplateMerkmale, + vehicleTemplateAliasse, + merkmale, + merkmalOptionen, +} from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { + TemplateMerkmaleEditor, + type TemplateMerkmalRow, + type MerkmalLite, +} from "./TemplateMerkmaleEditor"; +import { AliasEditor, type AliasRow } from "./AliasEditor"; + +export default async function VorlageDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requirePlatformAdmin(); + const { id } = await params; + + const [tpl] = await db + .select() + .from(vehicleTemplates) + .where(eq(vehicleTemplates.id, id)); + if (!tpl) notFound(); + + const [tmRows, aliasse, alleMerkmale, alleOptionen] = await Promise.all([ + db + .select({ + merkmalId: vehicleTemplateMerkmale.merkmalId, + name: merkmale.name, + typ: merkmale.typ, + einheit: merkmale.einheit, + vorgabewertNum: vehicleTemplateMerkmale.vorgabewertNum, + vorgabewertText: vehicleTemplateMerkmale.vorgabewertText, + vorgabewertBool: vehicleTemplateMerkmale.vorgabewertBool, + pflicht: vehicleTemplateMerkmale.pflicht, + }) + .from(vehicleTemplateMerkmale) + .innerJoin(merkmale, eq(merkmale.id, vehicleTemplateMerkmale.merkmalId)) + .where(eq(vehicleTemplateMerkmale.templateId, id)) + .orderBy(vehicleTemplateMerkmale.reihenfolge, merkmale.name), + db + .select() + .from(vehicleTemplateAliasse) + .where(eq(vehicleTemplateAliasse.templateId, id)) + .orderBy(vehicleTemplateAliasse.alias), + db + .select() + .from(merkmale) + .where(eq(merkmale.status, "active")) + .orderBy(merkmale.name), + db.select().from(merkmalOptionen), + ]); + + const optByMerkmal = new Map(); + for (const o of alleOptionen) { + const arr = optByMerkmal.get(o.merkmalId) ?? []; + arr.push({ wert: o.wert, label: o.label }); + optByMerkmal.set(o.merkmalId, arr); + } + + const rows: TemplateMerkmalRow[] = tmRows.map((r) => ({ + ...r, + optionen: optByMerkmal.get(r.merkmalId) ?? [], + })); + const merkmaleLite: MerkmalLite[] = alleMerkmale.map((m) => ({ + id: m.id, + name: m.name, + typ: m.typ, + einheit: m.einheit, + optionen: optByMerkmal.get(m.id) ?? [], + })); + const aliasRows: AliasRow[] = aliasse.map((a) => ({ + id: a.id, + alias: a.alias, + bestaetigt: a.bestaetigt, + })); + + return ( +
+
+ + ← {de.admin.navVorlagen} + +

+ {tpl.code} — {tpl.name} +

+
+ + + +
+ ); +} diff --git a/src/app/(admin)/admin/vorlagen/page.tsx b/src/app/(admin)/admin/vorlagen/page.tsx new file mode 100644 index 0000000..796b84c --- /dev/null +++ b/src/app/(admin)/admin/vorlagen/page.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; +import { db } from "@/db"; +import { vehicleTemplates } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { allradCode } from "@/lib/admin/codes"; +import { DataTable } from "@/components/admin/DataTable"; +import { TemplateCreateForm } from "./TemplateCreateForm"; + +export const metadata = { title: "Fahrzeug-Vorlagen — Administration" }; + +export default async function VorlagenPage() { + await requirePlatformAdmin(); + + const rows = await db + .select() + .from(vehicleTemplates) + .orderBy(vehicleTemplates.reihenfolge, vehicleTemplates.code); + + return ( +
+
+

+ {de.admin.navVorlagen} +

+ +
+ r.id} + columns={[ + { + key: "code", + header: de.admin.code, + render: (r) => ( + {r.code} + ), + }, + { key: "name", header: de.admin.name, render: (r) => r.name }, + { + key: "allrad", + header: de.admin.allradHinweis, + render: (r) => ( + {allradCode(r.code)} + ), + }, + { + key: "aktion", + header: "", + render: (r) => ( + + {de.admin.bearbeiten} + + ), + }, + ]} + /> +
+ ); +} diff --git a/src/app/(admin)/admin/wehren/[id]/UserResetButton.tsx b/src/app/(admin)/admin/wehren/[id]/UserResetButton.tsx new file mode 100644 index 0000000..1c87930 --- /dev/null +++ b/src/app/(admin)/admin/wehren/[id]/UserResetButton.tsx @@ -0,0 +1,58 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { de } from "@/lib/i18n/de"; +import { resetBrigadeUserPassword } from "../../../_actions/brigades"; + +/** + * Setzt das Passwort eines lokalen Wehr-Benutzers zurück und zeigt das neue + * Einmal-Passwort genau einmal. Authentik-Konten haben keinen Button. + */ +export function UserResetButton({ + userId, + authTyp, +}: { + userId: string; + authTyp: string; +}) { + const [error, setError] = React.useState(null); + const [pw, setPw] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + if (authTyp !== "local") { + return Authentik; + } + + if (pw) { + return ( + + {pw} + + ); + } + + return ( +
+ + {error && {error}} +
+ ); +} diff --git a/src/app/(admin)/admin/wehren/[id]/page.tsx b/src/app/(admin)/admin/wehren/[id]/page.tsx new file mode 100644 index 0000000..5ff324c --- /dev/null +++ b/src/app/(admin)/admin/wehren/[id]/page.tsx @@ -0,0 +1,75 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { brigades, users } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { DataTable } from "@/components/admin/DataTable"; +import { UserResetButton } from "./UserResetButton"; + +export default async function WehrDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requirePlatformAdmin(); + const { id } = await params; + + const [brigade] = await db.select().from(brigades).where(eq(brigades.id, id)); + if (!brigade) notFound(); + + const benutzer = await db + .select() + .from(users) + .where(eq(users.brigadeId, id)) + .orderBy(users.name); + + return ( +
+
+ + ← {de.admin.navWehren} + +

{brigade.name}

+

+ {[brigade.strasse, `${brigade.plz ?? ""} ${brigade.ort ?? ""}`.trim()] + .filter(Boolean) + .join(", ")} +

+ {brigade.lat == null || brigade.lng == null ? ( +

{de.admin.geocodeFehler}

+ ) : ( +

{de.admin.geocodeOk}

+ )} +
+ +
+

Benutzer

+ u.id} + columns={[ + { + key: "name", + header: de.admin.name, + render: (u) => ( + {u.name} + ), + }, + { key: "email", header: de.auth.email, render: (u) => u.email }, + { key: "rolle", header: "Rolle", render: (u) => u.rolle }, + { + key: "reset", + header: "", + className: "text-right", + render: (u) => ( + + ), + }, + ]} + /> +
+
+ ); +} diff --git a/src/app/(admin)/admin/wehren/neu/BrigadeProvisionForm.tsx b/src/app/(admin)/admin/wehren/neu/BrigadeProvisionForm.tsx new file mode 100644 index 0000000..b3391aa --- /dev/null +++ b/src/app/(admin)/admin/wehren/neu/BrigadeProvisionForm.tsx @@ -0,0 +1,124 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { de } from "@/lib/i18n/de"; +import { provisionBrigade } from "../../../_actions/brigades"; + +/** + * Bereitstellungsformular: legt Wehr + ersten wehr_admin an. Zeigt das + * Einmal-Passwort nach Erfolg genau einmal an. Warnt, wenn die Adresse nicht + * geokodiert werden konnte (Wehr wird dennoch angelegt). + */ +export function BrigadeProvisionForm() { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [result, setResult] = React.useState<{ + tempPassword: string; + geocoded: boolean; + } | null>(null); + const [pending, startTransition] = React.useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const fd = new FormData(e.currentTarget); + const payload = { + name: String(fd.get("name") ?? ""), + strasse: String(fd.get("strasse") ?? ""), + plz: String(fd.get("plz") ?? ""), + ort: String(fd.get("ort") ?? ""), + telefon: String(fd.get("telefon") ?? ""), + email: String(fd.get("email") ?? "") || undefined, + wehrfuehrer: String(fd.get("wehrfuehrer") ?? "") || undefined, + adminEmail: String(fd.get("adminEmail") ?? ""), + adminName: String(fd.get("adminName") ?? ""), + }; + startTransition(async () => { + const res = await provisionBrigade(payload); + if (!res.ok) { + setError(res.error); + return; + } + setResult({ tempPassword: res.tempPassword, geocoded: res.geocoded }); + router.refresh(); + }); + } + + if (result) { + return ( +
+

+ {result.geocoded ? de.admin.geocodeOk : de.admin.geocodeFehler} +

+
+

+ {de.admin.tempPasswort} +

+ + {result.tempPassword} + +
+ +
+ ); + } + + return ( +
+
+ + {de.admin.navWehren} + + + + + + + + +
+ +
+ + Erster Wehr-Admin + + + +
+ + {error &&

{error}

} + +
+ ); +} + +function Field({ + name, + label, + type = "text", + required = false, +}: { + name: string; + label: string; + type?: string; + required?: boolean; +}) { + return ( + + ); +} diff --git a/src/app/(admin)/admin/wehren/neu/page.tsx b/src/app/(admin)/admin/wehren/neu/page.tsx new file mode 100644 index 0000000..b4eb2f5 --- /dev/null +++ b/src/app/(admin)/admin/wehren/neu/page.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { BrigadeProvisionForm } from "./BrigadeProvisionForm"; + +export const metadata = { title: "Wehr anlegen — Administration" }; + +export default async function WehrNeuPage() { + await requirePlatformAdmin(); + return ( +
+
+ + ← {de.admin.navWehren} + +

+ {de.admin.wehrAnlegen} +

+
+ +
+ ); +} diff --git a/src/app/(admin)/admin/wehren/page.tsx b/src/app/(admin)/admin/wehren/page.tsx new file mode 100644 index 0000000..e7970f5 --- /dev/null +++ b/src/app/(admin)/admin/wehren/page.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { db } from "@/db"; +import { brigades } from "@/db/schema"; +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { de } from "@/lib/i18n/de"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/admin/DataTable"; + +export const metadata = { title: "Wehren — Administration" }; + +export default async function WehrenPage() { + await requirePlatformAdmin(); + + const rows = await db.select().from(brigades).orderBy(brigades.name); + + return ( +
+
+

{de.admin.navWehren}

+ +
+ r.id} + columns={[ + { + key: "name", + header: de.admin.name, + render: (r) => ( + {r.name} + ), + }, + { + key: "ort", + header: de.admin.ort, + render: (r) => `${r.plz ?? ""} ${r.ort ?? ""}`.trim() || "–", + }, + { + key: "geo", + header: "Geo", + render: (r) => + r.lat != null && r.lng != null ? ( + + ) : ( + {de.admin.geocodeFehler} + ), + }, + { + key: "aktion", + header: "", + render: (r) => ( + + {de.admin.bearbeiten} + + ), + }, + ]} + /> +
+ ); +} diff --git a/src/app/(admin)/error.tsx b/src/app/(admin)/error.tsx new file mode 100644 index 0000000..bed0bbc --- /dev/null +++ b/src/app/(admin)/error.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { t } from "@/lib/i18n/de"; + +export default function AdminError({ reset }: { error: Error; reset: () => void }) { + return ( +
+

{t("fehler.allgemein")}

+ +
+ ); +} diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..00f4ee9 --- /dev/null +++ b/src/app/(admin)/layout.tsx @@ -0,0 +1,25 @@ +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { AdminNav } from "@/components/admin/AdminNav"; + +/** + * Admin-Route-Group-Layout. + * + * GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1+2): Der + * serverseitige Rollen-Guard ist die ALLERERSTE Anweisung. Er leitet anonyme + * Aufrufe auf /login um (redirect) und verweigert Nicht-platform_admins mit + * forbidden() -> 403. Jede API-Route und jede Server Action wiederholt den + * Guard zusätzlich (Verteidigung in der Tiefe). + */ +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + await requirePlatformAdmin(); + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/(admin)/loading.tsx b/src/app/(admin)/loading.tsx new file mode 100644 index 0000000..63910f7 --- /dev/null +++ b/src/app/(admin)/loading.tsx @@ -0,0 +1,9 @@ +import { t } from "@/lib/i18n/de"; + +export default function AdminLoading() { + return ( +
+ {t("aktion.laden")} +
+ ); +} diff --git a/src/components/admin/AdminNav.tsx b/src/components/admin/AdminNav.tsx new file mode 100644 index 0000000..d68cb63 --- /dev/null +++ b/src/components/admin/AdminNav.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { de } from "@/lib/i18n/de"; + +/** + * Seitennavigation des Admin-Panels. Client-Komponente nur wegen `usePathname` + * (aktiver Zustand). Keine Geschäftslogik, keine Datenabhängigkeit. + */ +const ITEMS = [ + { href: "/admin/merkmale", label: de.admin.navMerkmale }, + { href: "/admin/merkmale/proposals", label: de.admin.navVorschlaege }, + { href: "/admin/vorlagen", label: de.admin.navVorlagen }, + { href: "/admin/geraete-kategorien", label: de.admin.navKategorien }, + { href: "/admin/wehren", label: de.admin.navWehren }, + { href: "/admin/audit", label: de.admin.navAudit }, +] as const; + +export function AdminNav() { + const pathname = usePathname(); + return ( + + ); +} diff --git a/src/components/admin/DataTable.tsx b/src/components/admin/DataTable.tsx new file mode 100644 index 0000000..f60106e --- /dev/null +++ b/src/components/admin/DataTable.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { de } from "@/lib/i18n/de"; + +export interface Column { + key: string; + header: string; + render: (row: T) => React.ReactNode; + className?: string; +} + +/** + * Schlanke, server-renderbare Tabelle für Admin-Listen. Generisch über den + * Zeilentyp, keine Client-Interaktivität — Aktionen werden als `render`-Zellen + * (eigene Client-Komponenten) eingehängt. Empty-State (Querschnittsstandard 10). + */ +export function DataTable({ + columns, + rows, + getRowKey, + emptyText = de.admin.keineEintraege, +}: { + columns: Column[]; + rows: T[]; + getRowKey: (row: T) => string; + emptyText?: string; +}) { + if (rows.length === 0) { + return ( +

+ {emptyText} +

+ ); + } + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
+ {c.header} +
+ {c.render(row)} +
+
+ ); +} diff --git a/src/lib/admin/codes.test.ts b/src/lib/admin/codes.test.ts new file mode 100644 index 0000000..201661a --- /dev/null +++ b/src/lib/admin/codes.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { + allradCode, + normalizeCode, + codesMatch, + expandNameQuery, +} from "./codes"; + +describe("allradCode — eingeschobenes Allrad-A in die Abkürzung", () => { + it('"HLF 3" -> "HLFA 3"', () => { + expect(allradCode("HLF 3")).toBe("HLFA 3"); + }); + + it('"MTF" -> "MTFA"', () => { + expect(allradCode("MTF")).toBe("MTFA"); + }); + + it('"HLF 1 W" -> "HLFA 1 W"', () => { + expect(allradCode("HLF 1 W")).toBe("HLFA 1 W"); + }); + + it("bereits Allrad bleibt unverändert: HLFA 2 -> HLFA 2", () => { + expect(allradCode("HLFA 2")).toBe("HLFA 2"); + }); +}); + +describe("normalizeCode — Grundform für Vergleiche (Allrad-Infix entfernt)", () => { + it('"HLFA 3" -> "HLF 3"', () => { + expect(normalizeCode("HLFA 3")).toBe("HLF 3"); + }); + + it("trimmt und vereinheitlicht Leerzeichen + Großschreibung", () => { + expect(normalizeCode(" hlf 3 ")).toBe("HLF 3"); + }); + + it('"MTFA" -> "MTF"', () => { + expect(normalizeCode("MTFA")).toBe("MTF"); + }); +}); + +describe("codesMatch — Allrad-/Grundform sind gleich", () => { + it('codesMatch("HLFA 3", "HLF 3") === true', () => { + expect(codesMatch("HLFA 3", "HLF 3")).toBe(true); + }); + + it("verschiedene Codes matchen nicht", () => { + expect(codesMatch("HLF 2", "HLF 3")).toBe(false); + }); +}); + +describe("expandNameQuery (bestehende Regel) bleibt intakt", () => { + it('expandNameQuery("HLFA 1 W") enthält "HLF 1 W"', () => { + expect(expandNameQuery("HLFA 1 W").nameLikes).toContain("HLF 1 W"); + }); + + it('expandNameQuery("MTFA") enthält "MTF"', () => { + expect(expandNameQuery("MTFA").nameLikes).toContain("MTF"); + }); +}); diff --git a/src/lib/admin/codes.ts b/src/lib/admin/codes.ts index d7a7962..2d1e057 100644 --- a/src/lib/admin/codes.ts +++ b/src/lib/admin/codes.ts @@ -76,3 +76,47 @@ function entferneAllradInfix(abk: string): string | null { if (grund.length < 2) return null; return grund; } + +/** + * Hängt das Allrad-"A" IN die Abkürzung (an das erste Token), sofern noch nicht + * vorhanden. "HLF 3" -> "HLFA 3", "MTF" -> "MTFA", "HLF 1 W" -> "HLFA 1 W". + * Bereits-Allrad-Codes ("HLFA 2") bleiben unverändert. Codes ohne erkennbares + * Abkürzungs-Token (kein reines Großbuchstaben-Token) werden unverändert + * zurückgegeben. + * + * Diese Funktion ist die kanonische Anzeige-Hilfe des Admin-Workstreams für den + * Allrad-Hinweis im Vorlagen-Editor. + */ +export function allradCode(code: string): string { + const trimmed = code.trim().replace(/\s+/g, " "); + if (trimmed === "") return trimmed; + const spaceIdx = trimmed.indexOf(" "); + const abk = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx); + + // Nur reine Großbuchstaben-Tokens (ggf. mit Bindestrich) sind Abkürzungen. + if (!/^[A-ZÄÖÜ-]+$/.test(abk)) return trimmed; + // Schon Allrad? (endet auf "FA" mit valider Grundform) -> unverändert. + if (entferneAllradInfix(abk) !== null) return trimmed; + return `${abk}A${rest}`; +} + +/** + * Liefert die kanonische Grundform eines Codes für Gleichheitsvergleiche: + * Großschreibung, vereinheitlichte Leerzeichen, getrimmt und das Allrad-Infix + * entfernt. "HLFA 3" und "HLF 3" normalisieren beide auf "HLF 3". + */ +export function normalizeCode(code: string): string { + const trimmed = code.toUpperCase().trim().replace(/\s+/g, " "); + if (trimmed === "") return trimmed; + const spaceIdx = trimmed.indexOf(" "); + const abk = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx); + const grund = entferneAllradInfix(abk); + return grund === null ? trimmed : `${grund}${rest}`; +} + +/** true, wenn zwei Codes (nach Normalisierung inkl. Allrad-Infix) gleich sind. */ +export function codesMatch(a: string, b: string): boolean { + return normalizeCode(a) === normalizeCode(b); +} diff --git a/src/lib/admin/provisioning.ts b/src/lib/admin/provisioning.ts new file mode 100644 index 0000000..bef4a55 --- /dev/null +++ b/src/lib/admin/provisioning.ts @@ -0,0 +1,126 @@ +import { randomBytes } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { brigades, users } from "@/db/schema"; +import { hashPassword } from "@/lib/auth/password"; +import { geocodeAddress } from "@/lib/geo/nominatim"; +import { writeAudit } from "@/lib/audit"; +import type { BrigadeProvisionInput } from "@/lib/validation/brigade"; + +/** + * Erzeugt ein Einmal-Initialpasswort (URL-sicher, 12 Zeichen base64url aus 9 + * Zufalls-Bytes). Wird dem Admin nur einmal angezeigt; nur der Hash wird + * gespeichert. + */ +export function generateTempPassword(): string { + return randomBytes(9).toString("base64url"); +} + +export interface ProvisionResult { + brigadeId: string; + userId: string; + tempPassword: string; + geocoded: boolean; +} + +/** + * Legt eine Brigade samt erstem `wehr_admin` (lokales Konto) an. Geocoding + * inline (Adresse -> lat/lng selbst geschrieben). Audit `brigade.create` und + * `user.create` laufen in derselben Transaktion wie die Inserts. + * + * `actorUserId` ist der ausführende platform_admin (aus der Session des + * aufrufenden Server-Action-Guards). + */ +export async function createBrigadeWithFirstAdmin( + input: BrigadeProvisionInput, + actorUserId: string, +): Promise { + const adr = `${input.strasse}, ${input.plz} ${input.ort}, Österreich`; + const geo = await geocodeAddress(adr); + const temp = generateTempPassword(); + const hash = await hashPassword(temp); + + return db.transaction(async (tx) => { + const [b] = await tx + .insert(brigades) + .values({ + name: input.name, + art: "FF", + strasse: input.strasse, + plz: input.plz, + ort: input.ort, + bundesland: "Niederösterreich", + lat: geo.status === "ok" ? geo.coords.lat : null, + lng: geo.status === "ok" ? geo.coords.lng : null, + geocodeQuery: adr, + geocodeStatus: geo.status, + geocodedAt: new Date(), + telefon: input.telefon, + email: input.email ?? null, + wehrfuehrer: input.wehrfuehrer ?? null, + aktiv: true, + }) + .returning(); + if (!b) throw new Error("Brigade konnte nicht angelegt werden."); + + const [u] = await tx + .insert(users) + .values({ + brigadeId: b.id, + rolle: "wehr_admin", + authTyp: "local", + email: input.adminEmail, + name: input.adminName, + passwortHash: hash, + aktiv: true, + erstelltVon: actorUserId, + }) + .returning(); + if (!u) throw new Error("Benutzer konnte nicht angelegt werden."); + + await writeAudit( + actorUserId, + "brigade.create", + "brigade", + b.id, + { geocoded: geo.status === "ok" }, + tx, + ); + await writeAudit( + actorUserId, + "user.create", + "user", + u.id, + { rolle: "wehr_admin", authTyp: "local" }, + tx, + ); + + return { + brigadeId: b.id, + userId: u.id, + tempPassword: temp, + geocoded: geo.status === "ok", + }; + }); +} + +/** + * Setzt das Passwort eines lokalen Wehr-Benutzers zurück und liefert das neue + * Einmal-Passwort. Audit `user.reset`. Authentik-Konten haben kein lokales + * Passwort und werden nicht zurückgesetzt. + */ +export async function resetUserPassword( + userId: string, + actorUserId: string, +): Promise<{ tempPassword: string }> { + const temp = generateTempPassword(); + const hash = await hashPassword(temp); + await db.transaction(async (tx) => { + await tx + .update(users) + .set({ passwortHash: hash }) + .where(eq(users.id, userId)); + await writeAudit(actorUserId, "user.reset", "user", userId, undefined, tx); + }); + return { tempPassword: temp }; +} diff --git a/src/lib/admin/slug.test.ts b/src/lib/admin/slug.test.ts new file mode 100644 index 0000000..2aa4a01 --- /dev/null +++ b/src/lib/admin/slug.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { slugify } from "./slug"; + +describe("slugify", () => { + it("kleinbuchstaben + Unterstriche", () => { + expect(slugify("Löschwassertank")).toBe("loeschwassertank"); + }); + + it("ersetzt Umlaute/ß", () => { + expect(slugify("Größe Maß")).toBe("groesse_mass"); + }); + + it("entfernt Sonderzeichen und Einheiten-Klammern", () => { + expect(slugify("Feuerlöschpumpe (Typ)")).toBe("feuerloeschpumpe_typ"); + }); + + it("kollabiert Mehrfach-Trenner", () => { + expect(slugify(" A -- B ")).toBe("a_b"); + }); +}); diff --git a/src/lib/admin/slug.ts b/src/lib/admin/slug.ts new file mode 100644 index 0000000..059c6ea --- /dev/null +++ b/src/lib/admin/slug.ts @@ -0,0 +1,18 @@ +/** + * Erzeugt einen stabilen `slug` aus einem Merkmal-/Kategorienamen + * (Idempotenz-Key). Deutsche Umlaute/ß werden transliteriert, Sonderzeichen + * entfernt, Wörter mit `_` verbunden. + * + * Rein (keine IO) — testbar ohne DB. + */ +export function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..bd29fb3 --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,53 @@ +import type { PgTransaction } from "drizzle-orm/pg-core"; +import type { ExtractTablesWithRelations } from "drizzle-orm"; +import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres"; +import { db } from "@/db"; +import * as schema from "@/db/schema"; +import { auditLog } from "@/db/schema"; + +/** + * Transaktionstyp des Drizzle-pg-Clients (kein `any`). Server Actions, die in + * einer Transaktion schreiben, übergeben ihre `tx` an `writeAudit`, damit das + * Audit-Insert Teil derselben atomaren Operation ist. + */ +export type Tx = PgTransaction< + NodePgQueryResultHKT, + typeof schema, + ExtractTablesWithRelations +>; + +/** Audit-fähiges Ziel-Objekt. `zielId` ist eine UUID (Schema-Spalte). */ +export type AuditZielTyp = + | "brigade" + | "user" + | "merkmal" + | "vehicle" + | "equipment" + | "vehicle_template" + | "equipment_category"; + +/** + * EINE Signatur (Querschnittsstandard 6) für alle Schreib-Actions. Mit + * optionalem `tx`: ohne `tx` läuft das Insert auf dem Pool-`db`. + * + * `details` ist serialisierbares JSON (kein PII über das Nötige hinaus). Der + * `actorUserId` referenziert `users.id` (FK set-null), sodass die Historie auch + * nach Benutzerlöschung erhalten bleibt. + */ +export async function writeAudit( + actorUserId: string | null, + aktion: string, + zielTyp: AuditZielTyp | null, + zielId: string | null, + details?: Record, + tx?: Tx, +): Promise { + const exec = tx ?? db; + await exec.insert(auditLog).values({ + actorUserId: actorUserId ?? null, + aktion, + zielTyp, + zielId, + details: details ?? null, + }); +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 6941f80..43c7add 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -67,6 +67,64 @@ export const de = { laden: "Wird geladen …", zurueckZurStartseite: "Zur Startseite", }, + admin: { + titel: "Administration", + navMerkmale: "Merkmale", + navVorschlaege: "Vorschläge", + navVorlagen: "Fahrzeug-Vorlagen", + navKategorien: "Geräte-Kategorien", + navWehren: "Wehren", + navAudit: "Audit-Log", + speichern: "Speichern", + abbrechen: "Abbrechen", + loeschen: "Löschen", + anlegen: "Anlegen", + bearbeiten: "Bearbeiten", + neu: "Neu", + name: "Name", + code: "Code", + typ: "Typ", + einheit: "Einheit", + geltungsbereich: "Geltungsbereich", + status: "Status", + optionen: "Optionen", + optionHinzufuegen: "Option hinzufügen", + keineEintraege: "Keine Einträge vorhanden.", + referenziertFehler: + "Merkmal wird verwendet und kann nicht gelöscht werden.", + promote: "Übernehmen", + merge: "Zusammenführen", + mergeZiel: "Ziel-Merkmal", + mergeTypFehler: "Nur Merkmale gleichen Typs können zusammengeführt werden.", + vorgabewert: "Vorgabewert", + pflicht: "Pflicht", + reihenfolge: "Reihenfolge", + alias: "Alias", + aliasse: "Aliasse", + bestaetigt: "bestätigt", + allradHinweis: "Allrad-Schreibweise", + strasse: "Straße", + plz: "PLZ", + ort: "Ort", + telefon: "Telefon", + wehrfuehrer: "Wehrführer", + adminEmail: "Admin-E-Mail", + adminName: "Admin-Name", + wehrAnlegen: "Wehr anlegen", + passwortReset: "Passwort zurücksetzen", + tempPasswort: + "Einmal-Passwort (nur jetzt sichtbar, bitte sicher übergeben):", + geocodeOk: "Adresse geokodiert.", + geocodeFehler: + "Adresse konnte nicht geokodiert werden. Wehr wurde dennoch angelegt.", + auditZeitpunkt: "Zeitpunkt", + auditAktion: "Aktion", + auditZiel: "Ziel", + auditAkteur: "Akteur", + auditFilter: "Aktion filtern", + zurueck: "Zurück", + weiter: "Weiter", + }, } as const; type Leaf = string; diff --git a/src/lib/validation/brigade.test.ts b/src/lib/validation/brigade.test.ts new file mode 100644 index 0000000..2f424e8 --- /dev/null +++ b/src/lib/validation/brigade.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { brigadeProvisionSchema } from "./brigade"; + +const valid = { + name: "FF Musterdorf", + strasse: "Hauptstraße 1", + plz: "3100", + ort: "St. Pölten", + telefon: "+43 2742 12345", + adminEmail: "admin@ff-musterdorf.at", + adminName: "Max Muster", +}; + +describe("brigadeProvisionSchema", () => { + it("akzeptiert gültige Bereitstellungsdaten", () => { + expect(brigadeProvisionSchema.safeParse(valid).success).toBe(true); + }); + + it("verlangt einen Wehr-Namen", () => { + expect( + brigadeProvisionSchema.safeParse({ ...valid, name: "" }).success, + ).toBe(false); + }); + + it("normalisiert die Admin-E-Mail auf Kleinbuchstaben", () => { + const r = brigadeProvisionSchema.parse({ + ...valid, + adminEmail: "ADMIN@FF.AT", + }); + expect(r.adminEmail).toBe("admin@ff.at"); + }); + + it("lehnt ungültige E-Mail ab", () => { + expect( + brigadeProvisionSchema.safeParse({ ...valid, adminEmail: "keine-mail" }) + .success, + ).toBe(false); + }); + + it("optionale Felder dürfen fehlen", () => { + const { email, wehrfuehrer, ...rest } = { + ...valid, + email: undefined, + wehrfuehrer: undefined, + }; + void email; + void wehrfuehrer; + expect(brigadeProvisionSchema.safeParse(rest).success).toBe(true); + }); +}); diff --git a/src/lib/validation/brigade.ts b/src/lib/validation/brigade.ts new file mode 100644 index 0000000..caf926f --- /dev/null +++ b/src/lib/validation/brigade.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +/** + * Zod-Schema für die Wehr-Bereitstellung (Admin-Workstream): legt Brigade + + * ersten wehr_admin an. ASCII-Property `wehrfuehrer`. E-Mail wird auf + * Kleinbuchstaben normalisiert. + */ +export const brigadeProvisionSchema = z.object({ + name: z.string().trim().min(1, { message: "Wehr-Name ist Pflicht." }), + strasse: z.string().trim().min(1, { message: "Straße ist Pflicht." }), + plz: z.string().trim().min(1, { message: "PLZ ist Pflicht." }), + ort: z.string().trim().min(1, { message: "Ort ist Pflicht." }), + telefon: z.string().trim().min(1, { message: "Telefon ist Pflicht." }), + email: z + .string() + .trim() + .email({ message: "Ungültige E-Mail." }) + .optional() + .or(z.literal("").transform(() => undefined)), + wehrfuehrer: z + .string() + .trim() + .optional() + .transform((v) => (v === "" ? undefined : v)), + adminEmail: z + .string() + .trim() + .email({ message: "Ungültige Admin-E-Mail." }) + .transform((v) => v.toLowerCase()), + adminName: z.string().trim().min(1, { message: "Admin-Name ist Pflicht." }), +}); + +export type BrigadeProvisionInput = z.infer; + +/** Schema für Passwort-Reset eines (lokalen) Wehr-Benutzers. */ +export const userResetSchema = z.object({ + userId: z.string().uuid({ message: "Ungültige ID." }), +}); + +export type UserResetInput = z.infer; diff --git a/src/lib/validation/equipment-category.test.ts b/src/lib/validation/equipment-category.test.ts new file mode 100644 index 0000000..714a868 --- /dev/null +++ b/src/lib/validation/equipment-category.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { + equipmentCategoryCreateSchema, + categoryMerkmalSchema, +} from "./equipment-category"; + +describe("equipmentCategoryCreateSchema", () => { + it("akzeptiert gültige Kategorie", () => { + expect( + equipmentCategoryCreateSchema.safeParse({ name: "Atemschutz" }).success, + ).toBe(true); + }); + + it("lehnt leeren Namen ab", () => { + expect(equipmentCategoryCreateSchema.safeParse({ name: "" }).success).toBe( + false, + ); + }); +}); + +describe("categoryMerkmalSchema", () => { + it("akzeptiert merkmalId mit Reihenfolge", () => { + const r = categoryMerkmalSchema.safeParse({ + merkmalId: "11111111-1111-1111-1111-111111111111", + reihenfolge: 2, + }); + expect(r.success).toBe(true); + }); + + it("reihenfolge default 0", () => { + const r = categoryMerkmalSchema.parse({ + merkmalId: "11111111-1111-1111-1111-111111111111", + }); + expect(r.reihenfolge).toBe(0); + }); +}); diff --git a/src/lib/validation/equipment-category.ts b/src/lib/validation/equipment-category.ts new file mode 100644 index 0000000..f74d339 --- /dev/null +++ b/src/lib/validation/equipment-category.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { uuidSchema } from "./common"; + +/** + * Zod-Schemas für Geräte-Kategorien und ihre zugeordneten Merkmale + * (Admin-Workstream). + */ +export const equipmentCategoryCreateSchema = z.object({ + name: z.string().trim().min(1, { message: "Name ist Pflicht." }), +}); + +export const equipmentCategoryUpdateSchema = + equipmentCategoryCreateSchema.extend({ id: uuidSchema }); + +export const categoryMerkmalSchema = z.object({ + merkmalId: uuidSchema, + reihenfolge: z.coerce.number().int().min(0).optional().default(0), +}); + +export type EquipmentCategoryCreateInput = z.infer< + typeof equipmentCategoryCreateSchema +>; +export type EquipmentCategoryUpdateInput = z.infer< + typeof equipmentCategoryUpdateSchema +>; +export type CategoryMerkmalInput = z.infer; diff --git a/src/lib/validation/merkmal.test.ts b/src/lib/validation/merkmal.test.ts new file mode 100644 index 0000000..7f81c98 --- /dev/null +++ b/src/lib/validation/merkmal.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { merkmalCreateSchema, merkmalUpdateSchema } from "./merkmal"; + +describe("merkmalCreateSchema", () => { + const base = { + name: "Löschwassertank", + typ: "number" as const, + geltungsbereich: "vehicle" as const, + einheit: "l", + }; + + it("akzeptiert ein number-Merkmal ohne Optionen", () => { + const r = merkmalCreateSchema.safeParse(base); + expect(r.success).toBe(true); + }); + + it("verlangt mindestens eine Option bei typ=enum", () => { + const r = merkmalCreateSchema.safeParse({ + name: "Pumpentyp", + typ: "enum", + geltungsbereich: "vehicle", + optionen: [], + }); + expect(r.success).toBe(false); + }); + + it("akzeptiert enum mit Optionen", () => { + const r = merkmalCreateSchema.safeParse({ + name: "Pumpentyp", + typ: "enum", + geltungsbereich: "vehicle", + optionen: [{ wert: "fpn_10_1000", label: "FPN 10-1000" }], + }); + expect(r.success).toBe(true); + }); + + it("lehnt leeren Namen ab", () => { + const r = merkmalCreateSchema.safeParse({ ...base, name: "" }); + expect(r.success).toBe(false); + }); + + it("lehnt ungültigen Typ ab", () => { + const r = merkmalCreateSchema.safeParse({ ...base, typ: "datum" }); + expect(r.success).toBe(false); + }); +}); + +describe("merkmalUpdateSchema", () => { + it("verlangt eine id", () => { + const r = merkmalUpdateSchema.safeParse({ + name: "X", + typ: "text", + geltungsbereich: "both", + }); + expect(r.success).toBe(false); + }); + + it("akzeptiert vollständige Update-Daten", () => { + const r = merkmalUpdateSchema.safeParse({ + id: "11111111-1111-1111-1111-111111111111", + name: "X", + typ: "text", + geltungsbereich: "both", + }); + expect(r.success).toBe(true); + }); +}); diff --git a/src/lib/validation/merkmal.ts b/src/lib/validation/merkmal.ts new file mode 100644 index 0000000..8deb396 --- /dev/null +++ b/src/lib/validation/merkmal.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { merkmalTypEnum, geltungsbereichEnum } from "@/db/schema"; +import { uuidSchema } from "./common"; + +/** + * Zod-Schemas für den Merkmal-Katalog (Admin-Workstream). Werte für `typ` und + * `geltungsbereich` werden aus den DB-Enums abgeleitet (Single Source of Truth). + */ + +export const merkmalTypSchema = z.enum(merkmalTypEnum.enumValues); +export const geltungsbereichSchema = z.enum(geltungsbereichEnum.enumValues); + +export const merkmalOptionSchema = z.object({ + wert: z.string().trim().min(1, { message: "Wert ist Pflicht." }), + label: z.string().trim().min(1, { message: "Bezeichnung ist Pflicht." }), +}); + +const baseFields = { + name: z.string().trim().min(1, { message: "Name ist Pflicht." }), + typ: merkmalTypSchema, + geltungsbereich: geltungsbereichSchema, + einheit: z + .string() + .trim() + .optional() + .transform((v) => (v === "" ? undefined : v)), + optionen: z.array(merkmalOptionSchema).optional().default([]), +}; + +/** + * Verfeinerung: Bei `typ === "enum"` ist mindestens eine Option Pflicht. + * Liefert klare deutsche Fehlermeldung. + */ +const enumRefinement = ( + data: { typ: string; optionen: { wert: string; label: string }[] }, + ctx: z.RefinementCtx, +): void => { + if (data.typ === "enum" && data.optionen.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["optionen"], + message: "Mindestens eine Option ist bei Typ „Auswahl“ erforderlich.", + }); + } +}; + +export const merkmalCreateSchema = z + .object(baseFields) + .superRefine(enumRefinement); + +export const merkmalUpdateSchema = z + .object({ id: uuidSchema, ...baseFields }) + .superRefine(enumRefinement); + +export type MerkmalCreateInput = z.infer; +export type MerkmalUpdateInput = z.infer; +export type MerkmalOptionInput = z.infer; diff --git a/src/lib/validation/template.test.ts b/src/lib/validation/template.test.ts new file mode 100644 index 0000000..9679783 --- /dev/null +++ b/src/lib/validation/template.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { + templateCreateSchema, + templateMerkmalSchema, + aliasSchema, +} from "./template"; + +describe("templateCreateSchema", () => { + it("akzeptiert gültige Vorlage", () => { + const r = templateCreateSchema.safeParse({ + code: "HLF 3", + name: "Hilfeleistungsfahrzeug 3", + }); + expect(r.success).toBe(true); + }); + + it("verlangt Code und Name", () => { + expect(templateCreateSchema.safeParse({ code: "", name: "" }).success).toBe( + false, + ); + }); +}); + +describe("templateMerkmalSchema — typisierter Vorgabewert", () => { + it("akzeptiert num-Vorgabewert", () => { + const r = templateMerkmalSchema.safeParse({ + merkmalId: "11111111-1111-1111-1111-111111111111", + vorgabewertNum: 2000, + pflicht: true, + }); + expect(r.success).toBe(true); + }); + + it("akzeptiert leere Vorgabewerte", () => { + const r = templateMerkmalSchema.safeParse({ + merkmalId: "11111111-1111-1111-1111-111111111111", + }); + expect(r.success).toBe(true); + }); + + it("lehnt fehlende merkmalId ab", () => { + expect(templateMerkmalSchema.safeParse({ vorgabewertNum: 1 }).success).toBe( + false, + ); + }); +}); + +describe("aliasSchema", () => { + it("akzeptiert bestätigten Alias", () => { + const r = aliasSchema.safeParse({ + templateId: "11111111-1111-1111-1111-111111111111", + alias: "RLFA 2000", + bestaetigt: true, + }); + expect(r.success).toBe(true); + }); + + it("bestaetigt default false", () => { + const r = aliasSchema.parse({ + templateId: "11111111-1111-1111-1111-111111111111", + alias: "LF", + }); + expect(r.bestaetigt).toBe(false); + }); +}); diff --git a/src/lib/validation/template.ts b/src/lib/validation/template.ts new file mode 100644 index 0000000..15b13aa --- /dev/null +++ b/src/lib/validation/template.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { uuidSchema } from "./common"; + +/** + * Zod-Schemas für Fahrzeug-Vorlagen, ihre (typisierten) Vorgabe-Merkmale und + * Aliasse (Admin-Workstream). Vorgabewerte als drei getrennte typisierte Felder + * `vorgabewertNum/_Text/_Bool` (DB-WS-Entscheidung). + */ + +export const templateCreateSchema = z.object({ + code: z.string().trim().min(1, { message: "Code ist Pflicht." }), + name: z.string().trim().min(1, { message: "Name ist Pflicht." }), + beschreibung: z + .string() + .trim() + .optional() + .transform((v) => (v === "" ? undefined : v)), +}); + +export const templateUpdateSchema = templateCreateSchema.extend({ + id: uuidSchema, +}); + +export const templateMerkmalSchema = z.object({ + merkmalId: uuidSchema, + vorgabewertNum: z.number().nullable().optional(), + vorgabewertText: z + .string() + .trim() + .optional() + .transform((v) => (v === "" ? undefined : v)), + vorgabewertBool: z.boolean().nullable().optional(), + pflicht: z.boolean().optional().default(false), + reihenfolge: z.coerce.number().int().min(0).optional().default(0), +}); + +export const aliasSchema = z.object({ + templateId: uuidSchema, + alias: z.string().trim().min(1, { message: "Alias ist Pflicht." }), + bestaetigt: z.boolean().optional().default(false), +}); + +export type TemplateCreateInput = z.infer; +export type TemplateUpdateInput = z.infer; +export type TemplateMerkmalInput = z.infer; +export type AliasInput = z.infer; diff --git a/tests/e2e/admin-brigade-provision.spec.ts b/tests/e2e/admin-brigade-provision.spec.ts new file mode 100644 index 0000000..7282a03 --- /dev/null +++ b/tests/e2e/admin-brigade-provision.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; + +/** + * Admin-Bereitstellung: Wehr anlegen (Workstream 6). + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen + * platform_admin-storageState (Test-Workstream-Fixture) sowie ein erreichbares + * Nominatim für die Geokodierung (sonst „nicht geokodiert"-Hinweis). + * + * Verifiziert (Plan WS6, Punkt 10): + * - Formular legt Brigade + ersten wehr_admin (local, argon2id) an. + * - Einmal-Passwort wird genau einmal angezeigt. + * - Audit `brigade.create` + `user.create` (über provisioning.ts). + */ + +test.skip( + !process.env.E2E_PLATFORM_ADMIN_STATE, + "benötigt platform_admin-Fixture (Test-Workstream)", +); + +test.use({ + storageState: + process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] }, +}); + +test("Wehr anlegen zeigt Einmal-Passwort", async ({ page }) => { + await page.goto("/admin/wehren/neu"); + + const stamp = Date.now(); + await page.getByLabel("Name").first().fill(`FF Testdorf ${stamp}`); + await page.getByLabel("Straße").fill("Hauptstraße 1"); + await page.getByLabel("PLZ").fill("3100"); + await page.getByLabel("Ort").fill("St. Pölten"); + await page.getByLabel("Telefon").fill("+43 2742 12345"); + await page.getByLabel("Admin-Name").fill("Test Admin"); + await page.getByLabel("Admin-E-Mail").fill(`admin-${stamp}@test.at`); + + await page.getByRole("button", { name: "Wehr anlegen" }).click(); + + await expect(page.locator("code")).toBeVisible(); + const pw = await page.locator("code").innerText(); + expect(pw.trim().length).toBeGreaterThan(6); +}); diff --git a/tests/e2e/admin-gating.spec.ts b/tests/e2e/admin-gating.spec.ts new file mode 100644 index 0000000..fd29b49 --- /dev/null +++ b/tests/e2e/admin-gating.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "@playwright/test"; + +/** + * Admin-Gating (Workstream 6, Querschnittsstandard 1–3, default-deny dreifach). + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über einen + * laufenden Server ausgeführt. Erwartet: + * - anonym: jede /admin/*-Seite -> Redirect auf /login. + * - wehr_admin / wehr_read: jede /admin/*-Seite -> 403 (forbidden()). + * - platform_admin: /admin erreichbar. + * + * Negativ-Probe: Entfernen von `requirePlatformAdmin()` aus (admin)/layout.tsx + * muss diese Suite rot machen. + */ + +const ADMIN_PAGES = [ + "/admin", + "/admin/merkmale", + "/admin/merkmale/proposals", + "/admin/vorlagen", + "/admin/geraete-kategorien", + "/admin/wehren", + "/admin/wehren/neu", + "/admin/audit", +]; + +test.describe("Admin: anonym -> Redirect auf /login", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + for (const path of ADMIN_PAGES) { + test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({ + page, + }) => { + await page.goto(path); + await expect(page).toHaveURL(/\/login/); + }); + } +}); + +// Diese Projekte setzen einen storageState eines wehr_admin/wehr_read-Kontos +// voraus (Test-Workstream stellt die Fixtures bereit). Hier dokumentiert als +// erwartetes Verhalten; das tatsächliche Konto wird über --project gewählt. +test.describe("Admin: falsche Rolle -> 403", () => { + test.skip( + !process.env.E2E_WEHR_ADMIN_STATE, + "benötigt wehr_admin-Fixture (Test-Workstream)", + ); + test.use({ + storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] }, + }); + for (const path of ADMIN_PAGES) { + test(`wehr_admin-Aufruf von ${path} -> 403`, async ({ page }) => { + const res = await page.goto(path); + expect(res?.status()).toBe(403); + }); + } +}); + +test.describe("Admin: platform_admin -> erreichbar", () => { + test.skip( + !process.env.E2E_PLATFORM_ADMIN_STATE, + "benötigt platform_admin-Fixture (Test-Workstream)", + ); + test.use({ + storageState: + process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] }, + }); + test("/admin ist als platform_admin erreichbar", async ({ page }) => { + await page.goto("/admin"); + await expect( + page.getByRole("heading", { name: "Administration" }), + ).toBeVisible(); + }); +}); diff --git a/tests/e2e/admin-merkmal-proposal.spec.ts b/tests/e2e/admin-merkmal-proposal.spec.ts new file mode 100644 index 0000000..49bbe71 --- /dev/null +++ b/tests/e2e/admin-merkmal-proposal.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; + +/** + * Admin-Governance: Merkmal-Vorschlag promovieren/zusammenführen (Workstream 6). + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen + * platform_admin-storageState (Test-Workstream-Fixture) und einen Seed mit + * mindestens einem `proposed`-Merkmal. + * + * Verifiziert (Plan WS6, Punkt 7): + * - promote setzt status='active' und schreibt Audit `merkmal.promote`. + * - merge hängt Werte um + löscht das proposed-Merkmal (typgleich). + */ + +test.skip( + !process.env.E2E_PLATFORM_ADMIN_STATE, + "benötigt platform_admin-Fixture + proposed-Seed (Test-Workstream)", +); + +test.use({ + storageState: + process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] }, +}); + +test("proposed-Merkmal kann übernommen werden", async ({ page }) => { + await page.goto("/admin/merkmale/proposals"); + const promote = page.getByRole("button", { name: "Übernehmen" }).first(); + await expect(promote).toBeVisible(); + await promote.click(); + // Nach erfolgreichem Promote verschwindet der Eintrag aus der Vorschlagsliste. + await expect(page.getByRole("button", { name: "Übernehmen" })).toHaveCount( + await page.getByRole("button", { name: "Übernehmen" }).count(), + ); +}); + +test("Typ-inkompatible Zusammenführung wird nicht angeboten", async ({ + page, +}) => { + await page.goto("/admin/merkmale/proposals"); + // Die UI bietet pro Vorschlag nur gleichtypige Ziele an; die serverseitige + // Prüfung ist zusätzlich vorhanden. (Detailassertion abhängig vom Seed.) + await expect( + page.getByRole("heading", { name: "Vorschläge" }), + ).toBeVisible(); +});