Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
6.6 KiB
TypeScript
136 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useActionState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
/**
|
|
* Generische Benutzer-Formulare für Popups (Anlegen/Bearbeiten). Die konkreten
|
|
* Server-Actions werden als (bereits gebundene) Props übergeben — dieselben
|
|
* Komponenten dienen der Plattform-Admin- und der Mandanten-Admin-Verwaltung.
|
|
* Die Zustandstypen sind in beiden Action-Dateien strukturgleich.
|
|
*/
|
|
|
|
export type CreateResult =
|
|
| { status: "idle" }
|
|
| { status: "error"; message: string; values?: { name: string; email: string; roleIds: string[] } }
|
|
| { status: "done"; email: string; generatedPassword: string | null; invited: boolean };
|
|
export type EditResult = { status: "idle" } | { status: "error"; message: string } | { status: "ok" };
|
|
|
|
export interface RoleOption { id: string; name: string }
|
|
|
|
type CreateAction = (prev: CreateResult, formData: FormData) => Promise<CreateResult>;
|
|
type EditAction = (prev: EditResult, formData: FormData) => Promise<EditResult>;
|
|
type PlainAction = (formData: FormData) => void | Promise<void>;
|
|
|
|
function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set<string>; idPrefix: string }) {
|
|
return (
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
|
{roles.map((r) => (
|
|
<label key={r.id} htmlFor={`${idPrefix}-${r.id}`} className="flex items-center gap-1.5 text-[13px]">
|
|
<input id={`${idPrefix}-${r.id}`} type="checkbox" name="roles" value={r.id} defaultChecked={selected.has(r.id)} />
|
|
{r.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UserCreateForm({ action, roles, closeHref }: { action: CreateAction; roles: RoleOption[]; closeHref: string }) {
|
|
const [state, formAction, pending] = useActionState(action, { status: "idle" } as CreateResult);
|
|
|
|
if (state.status === "done") {
|
|
return (
|
|
<div className="space-y-4 p-5">
|
|
<p className="text-sm text-[var(--ok)]">Nutzer {state.email} angelegt.</p>
|
|
{/* Option C (WS3): Anlage nur per Einladung; neutrale Rückmeldung (kein
|
|
Cross-Tenant-Leak, ob die Person schon ein Konto hatte). */}
|
|
<p className="text-[12.5px] text-muted-foreground">Einladung an {state.email} gesendet. Die Person richtet ihr Konto selbst über den Link ein bzw. sieht den Mandanten künftig in ihrer Auswahl.</p>
|
|
<Button nativeButton={false} render={<Link href={closeHref} />}>Fertig</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Bei Fehler die zuvor eingegebenen Werte zurückspielen (React setzt Felder nach
|
|
// einer Action zurück → ohne defaultValue wäre das Formular leer).
|
|
const errVals = state.status === "error" ? state.values : undefined;
|
|
|
|
return (
|
|
<form action={formAction} className="grid gap-3 p-5 md:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="cu-name">Name *</Label>
|
|
<Input id="cu-name" name="name" required className="mt-1" placeholder="Vor- und Nachname" defaultValue={errVals?.name} />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="cu-email">E-Mail *</Label>
|
|
<Input id="cu-email" name="email" type="email" required className="mt-1" placeholder="person@firma.example" defaultValue={errVals?.email} />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<Label>Rollen *</Label>
|
|
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(errVals?.roleIds ?? [])} idPrefix="cu" /></div>
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground md:col-span-2">
|
|
Die Person erhält eine Einladung und setzt ihr Passwort selbst (Option C). Ein Initialpasswort wird nicht mehr vergeben.
|
|
</p>
|
|
{state.status === "error" && (
|
|
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)] md:col-span-2">{state.message}</p>
|
|
)}
|
|
<div className="flex gap-2 md:col-span-2">
|
|
<Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Benutzer anlegen"}</Button>
|
|
<Button variant="outline" nativeButton={false} render={<Link href={closeHref} />}>Abbrechen</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function UserEditForm({
|
|
user, roles, updateAction, rolesAction, statusAction,
|
|
}: {
|
|
user: { id: string; name: string; email: string; status: string; roleIds: string[]; isSelf?: boolean };
|
|
roles: RoleOption[];
|
|
updateAction: EditAction;
|
|
rolesAction: PlainAction;
|
|
statusAction: PlainAction;
|
|
}) {
|
|
const [editState, editFormAction, editPending] = useActionState(updateAction, { status: "idle" } as EditResult);
|
|
const active = user.status === "ACTIVE";
|
|
|
|
return (
|
|
<div className="space-y-5 p-5">
|
|
<form action={editFormAction} className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="eu-name">Name {user.isSelf && <span className="text-[11px] text-muted-foreground">(Sie)</span>}</Label>
|
|
<Input id="eu-name" name="name" defaultValue={user.name} className="mt-1" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="eu-email">E-Mail</Label>
|
|
<Input id="eu-email" name="email" type="email" defaultValue={user.email} className="mt-1" />
|
|
</div>
|
|
<div className="flex items-center gap-3 sm:col-span-2">
|
|
<Button type="submit" size="sm" disabled={editPending}>{editPending ? "…" : "Stammdaten speichern"}</Button>
|
|
{editState.status === "ok" && <span className="text-[12px] text-[var(--ok)]">Gespeichert</span>}
|
|
{editState.status === "error" && <span className="text-[12px] text-[var(--risk)]">{editState.message}</span>}
|
|
</div>
|
|
</form>
|
|
|
|
<form action={rolesAction} className="border-t pt-4">
|
|
<Label className="text-[12px] text-muted-foreground">Rollen</Label>
|
|
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(user.roleIds)} idPrefix="eu" /></div>
|
|
<Button type="submit" variant="outline" size="sm" className="mt-2">Rollen speichern</Button>
|
|
</form>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
|
<form action={statusAction}><Button type="submit" variant="outline" size="sm">{active ? "Deaktivieren" : "Reaktivieren"}</Button></form>
|
|
</div>
|
|
{/* Option C (WS4): Passwort/MFA gehören der globalen Identity — kein Mandanten-
|
|
Admin-Reset mehr. Zurücksetzen erfolgt per Self-Service (Recovery-Codes) bzw.
|
|
über die Plattform-Ebene. */}
|
|
<p className="text-[12px] text-muted-foreground">
|
|
Passwort und MFA verwaltet die Person selbst (Anmeldung → „Passwort vergessen“) — ein Zurücksetzen durch die Administration ist nicht mehr vorgesehen.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|