Forráskód Böngészése

RHL-012 feat(user-management): implement user management UI with create, edit, delete functionalities and role filtering

codeUWE 1 hónapja
szülő
commit
f46923d620

+ 5 - 0
app/(protected)/admin/users/page.jsx

@@ -0,0 +1,5 @@
+import AdminUsersPage from "@/components/admin/users/AdminUsersPage";
+
+export default function AdminUsersRoute() {
+	return <AdminUsersPage />;
+}

+ 185 - 0
components/admin/users/AdminUsersClient.jsx

@@ -0,0 +1,185 @@
+"use client";
+
+import React from "react";
+import { RefreshCw } from "lucide-react";
+
+import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
+import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
+import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
+import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
+import ForbiddenView from "@/components/system/ForbiddenView";
+
+import AdminUsersFilters from "@/components/admin/users/AdminUsersFilters";
+import UsersTable from "@/components/admin/users/UsersTable";
+import CreateUserDialog from "@/components/admin/users/CreateUserDialog";
+import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+
+import { ApiClientError } from "@/lib/frontend/apiClient";
+import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+import { useAdminUsersQuery } from "@/lib/frontend/admin/users/useAdminUsersQuery";
+
+import { Button } from "@/components/ui/button";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+
+const LIMIT = 50;
+
+export default function AdminUsersClient() {
+	const [draft, setDraft] = React.useState({
+		q: "",
+		role: "",
+		branchId: "",
+	});
+
+	const [query, setQuery] = React.useState({
+		q: null,
+		role: null,
+		branchId: null,
+	});
+
+	const {
+		status,
+		items,
+		nextCursor,
+		error,
+		refresh,
+		loadMore,
+		isLoadingMore,
+		loadMoreError,
+	} = useAdminUsersQuery({ query, limit: LIMIT });
+
+	React.useEffect(() => {
+		if (!(error instanceof ApiClientError)) return;
+
+		if (error.code === "AUTH_UNAUTHENTICATED") {
+			const next =
+				typeof window !== "undefined"
+					? `${window.location.pathname}${window.location.search}`
+					: "/admin/users";
+
+			window.location.replace(
+				buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
+			);
+		}
+	}, [error]);
+
+	if (
+		error instanceof ApiClientError &&
+		error.code === "AUTH_FORBIDDEN_USER_MANAGEMENT"
+	) {
+		return <ForbiddenView />;
+	}
+
+	const disabled = status === "loading" || isLoadingMore;
+
+	function onDraftChange(patch) {
+		setDraft((prev) => ({ ...prev, ...(patch || {}) }));
+	}
+
+	function applyFilters() {
+		setQuery({
+			q: draft.q.trim() ? draft.q.trim() : null,
+			role: draft.role.trim() ? draft.role.trim() : null,
+			branchId: normalizeBranchIdDraft(draft.branchId) || null,
+		});
+	}
+
+	function resetFilters() {
+		setDraft({ q: "", role: "", branchId: "" });
+		setQuery({ q: null, role: null, branchId: null });
+	}
+
+	const actions = (
+		<Button
+			variant="outline"
+			size="sm"
+			onClick={refresh}
+			disabled={disabled}
+			title="Aktualisieren"
+		>
+			<RefreshCw className="h-4 w-4" />
+			Aktualisieren
+		</Button>
+	);
+
+	return (
+		<ExplorerPageShell
+			title="Benutzerverwaltung"
+			description="Benutzerkonten anzeigen und filtern (nur Superadmin/Entwicklung)."
+			actions={actions}
+		>
+			<ExplorerSectionCard
+				title="Benutzer"
+				description="Suche und Filter anwenden."
+				headerRight={
+					<div className="flex items-center gap-2">
+						<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+							{items.length} Benutzer geladen
+						</span>
+
+						<CreateUserDialog disabled={disabled} onCreated={refresh} />
+					</div>
+				}
+			>
+				<div className="space-y-4">
+					<AdminUsersFilters
+						draft={draft}
+						onDraftChange={onDraftChange}
+						onApply={applyFilters}
+						onReset={resetFilters}
+						disabled={disabled}
+					/>
+
+					{status === "error" ? (
+						<Alert variant="destructive">
+							<AlertTitle>Fehler</AlertTitle>
+							<AlertDescription>
+								{error instanceof ApiClientError
+									? `Anfrage fehlgeschlagen (${error.code}). Bitte erneut versuchen.`
+									: "Anfrage fehlgeschlagen. Bitte erneut versuchen."}
+							</AlertDescription>
+						</Alert>
+					) : null}
+
+					{status === "loading" ? (
+						<ExplorerLoading variant="table" count={8} />
+					) : items.length === 0 ? (
+						<ExplorerEmpty
+							title="Keine Benutzer gefunden"
+							description="Für die aktuellen Filter wurden keine Benutzer gefunden."
+							upHref={null}
+						/>
+					) : (
+						<UsersTable
+							items={items}
+							disabled={disabled}
+							onUserUpdated={refresh}
+						/>
+					)}
+
+					{loadMoreError ? (
+						<Alert variant="destructive">
+							<AlertTitle>
+								Weitere Benutzer konnten nicht geladen werden
+							</AlertTitle>
+							<AlertDescription>Bitte erneut versuchen.</AlertDescription>
+						</Alert>
+					) : null}
+
+					{nextCursor ? (
+						<div className="flex justify-center">
+							<Button
+								type="button"
+								variant="outline"
+								onClick={loadMore}
+								disabled={isLoadingMore}
+								title="Mehr laden"
+							>
+								{isLoadingMore ? "Lädt…" : "Mehr laden"}
+							</Button>
+						</div>
+					) : null}
+				</div>
+			</ExplorerSectionCard>
+		</ExplorerPageShell>
+	);
+}

+ 115 - 0
components/admin/users/AdminUsersFilters.jsx

@@ -0,0 +1,115 @@
+"use client";
+
+import React from "react";
+
+import { ROLE_OPTIONS_DE } from "@/components/admin/users/usersUi";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+function RoleFilter({ value, onChange, disabled }) {
+	const current =
+		ROLE_OPTIONS_DE.find((x) => x.value === (value ?? ""))?.label ||
+		"Alle Rollen";
+
+	return (
+		<div className="grid gap-2">
+			<Label>Rolle</Label>
+
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<Button type="button" variant="outline" disabled={disabled}>
+						{current}
+					</Button>
+				</DropdownMenuTrigger>
+
+				<DropdownMenuContent align="start" className="min-w-56">
+					<DropdownMenuLabel>Rolle filtern</DropdownMenuLabel>
+					<DropdownMenuSeparator />
+
+					<DropdownMenuRadioGroup
+						value={value ?? ""}
+						onValueChange={(v) => onChange?.(v)}
+					>
+						{ROLE_OPTIONS_DE.map((opt) => (
+							<DropdownMenuRadioItem key={opt.value || "all"} value={opt.value}>
+								{opt.label}
+							</DropdownMenuRadioItem>
+						))}
+					</DropdownMenuRadioGroup>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		</div>
+	);
+}
+
+export default function AdminUsersFilters({
+	draft,
+	onDraftChange,
+	onApply,
+	onReset,
+	disabled,
+}) {
+	const q = draft?.q ?? "";
+	const role = draft?.role ?? "";
+	const branchId = draft?.branchId ?? "";
+
+	return (
+		<div className="space-y-3">
+			<div className="grid gap-3 md:grid-cols-3">
+				<div className="grid gap-2">
+					<Label htmlFor="users-q">Suche</Label>
+					<Input
+						id="users-q"
+						value={q}
+						onChange={(e) => onDraftChange?.({ q: e.target.value })}
+						placeholder="Benutzername oder E-Mail"
+						disabled={disabled}
+					/>
+				</div>
+
+				<RoleFilter
+					value={role}
+					onChange={(v) => onDraftChange?.({ role: v })}
+					disabled={disabled}
+				/>
+
+				<div className="grid gap-2">
+					<Label htmlFor="users-branch">Niederlassung</Label>
+					<Input
+						id="users-branch"
+						value={branchId}
+						onChange={(e) => onDraftChange?.({ branchId: e.target.value })}
+						placeholder="z. B. NL01"
+						disabled={disabled}
+					/>
+				</div>
+			</div>
+
+			<div className="flex flex-wrap gap-2">
+				<Button type="button" onClick={onApply} disabled={disabled}>
+					Anwenden
+				</Button>
+
+				<Button
+					type="button"
+					variant="outline"
+					onClick={onReset}
+					disabled={disabled}
+				>
+					Zurücksetzen
+				</Button>
+			</div>
+		</div>
+	);
+}

+ 29 - 0
components/admin/users/AdminUsersPage.jsx

@@ -0,0 +1,29 @@
+"use client";
+
+import React from "react";
+
+import { useAuth } from "@/components/auth/authContext";
+import ForbiddenView from "@/components/system/ForbiddenView";
+
+import { canManageUsers as canManageUsersRole } from "@/lib/frontend/auth/roles";
+import AdminUsersClient from "@/components/admin/users/AdminUsersClient";
+
+/**
+ * AdminUsersPage
+ *
+ * This component only gates access and then renders the real client UI.
+ * No conditional hooks issue here because we only call useAuth().
+ */
+export default function AdminUsersPage() {
+	const { status, user } = useAuth();
+	const isAuthenticated = status === "authenticated" && user;
+
+	// AuthGate already prevents rendering for unauthenticated users,
+	// but we keep this as a defensive guard.
+	if (!isAuthenticated) return null;
+
+	const allowed = canManageUsersRole(user.role);
+	if (!allowed) return <ForbiddenView />;
+
+	return <AdminUsersClient />;
+}

+ 64 - 0
components/admin/users/CreateUserDialog.jsx

@@ -0,0 +1,64 @@
+"use client";
+
+import React from "react";
+import { UserPlus } from "lucide-react";
+
+import {
+	Dialog,
+	DialogContent,
+	DialogDescription,
+	DialogHeader,
+	DialogTitle,
+	DialogTrigger,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+
+import CreateUserForm from "@/components/admin/users/create-user/CreateUserForm";
+import { useCreateUserDialog } from "@/components/admin/users/create-user/useCreateUserDialog";
+
+export default function CreateUserDialog({ disabled = false, onCreated }) {
+	const {
+		open,
+		setOpen,
+		form,
+		setPatch,
+		error,
+		policyLines,
+		isSubmitting,
+		effectiveDisabled,
+		handleSubmit,
+		handleOpenChange,
+	} = useCreateUserDialog({ disabled, onCreated });
+
+	return (
+		<Dialog open={open} onOpenChange={handleOpenChange}>
+			<DialogTrigger asChild>
+				<Button type="button" disabled={disabled} title="Benutzer anlegen">
+					<UserPlus className="h-4 w-4" />
+					Benutzer anlegen
+				</Button>
+			</DialogTrigger>
+
+			<DialogContent className="sm:max-w-xl">
+				<DialogHeader>
+					<DialogTitle>Benutzer anlegen</DialogTitle>
+					<DialogDescription>
+						Neues Benutzerkonto erstellen. Der Benutzer muss das Passwort beim
+						ersten Login ändern.
+					</DialogDescription>
+				</DialogHeader>
+
+				<CreateUserForm
+					form={form}
+					setPatch={setPatch}
+					error={error}
+					policyLines={policyLines}
+					isSubmitting={isSubmitting}
+					disabled={effectiveDisabled}
+					onCancel={() => setOpen(false)}
+					onSubmit={handleSubmit}
+				/>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 217 - 0
components/admin/users/DeleteUserDialog.jsx

@@ -0,0 +1,217 @@
+"use client";
+
+import React from "react";
+import { Trash2 } from "lucide-react";
+
+import {
+	Dialog,
+	DialogContent,
+	DialogDescription,
+	DialogFooter,
+	DialogHeader,
+	DialogTitle,
+	DialogTrigger,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+import { adminDeleteUser, ApiClientError } from "@/lib/frontend/apiClient";
+import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+import {
+	notifySuccess,
+	notifyError,
+	notifyApiError,
+} from "@/lib/frontend/ui/toast";
+
+import { ROLE_LABELS_DE } from "@/components/admin/users/usersUi";
+
+function formatUserLabel(user) {
+	const username = typeof user?.username === "string" ? user.username : "—";
+	const email = typeof user?.email === "string" ? user.email : "—";
+	return `${username} (${email})`;
+}
+
+export default function DeleteUserDialog({
+	user,
+	disabled = false,
+	onDeleted,
+}) {
+	const [open, setOpen] = React.useState(false);
+	const [isSubmitting, setIsSubmitting] = React.useState(false);
+	const [error, setError] = React.useState(null);
+
+	const effectiveDisabled = Boolean(disabled || isSubmitting);
+
+	const redirectToLoginExpired = React.useCallback(() => {
+		const next =
+			typeof window !== "undefined"
+				? `${window.location.pathname}${window.location.search}`
+				: "/admin/users";
+
+		window.location.replace(
+			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
+		);
+	}, []);
+
+	const handleOpenChange = React.useCallback((nextOpen) => {
+		setOpen(nextOpen);
+		if (!nextOpen) setError(null);
+	}, []);
+
+	const handleDelete = React.useCallback(async () => {
+		if (!user?.id) return;
+		if (effectiveDisabled) return;
+
+		setError(null);
+		setIsSubmitting(true);
+
+		try {
+			await adminDeleteUser(String(user.id));
+
+			notifySuccess({
+				title: "Benutzer gelöscht",
+				description: `"${formatUserLabel(user)}" wurde entfernt.`,
+			});
+
+			setOpen(false);
+
+			if (typeof onDeleted === "function") onDeleted();
+		} catch (err) {
+			if (err instanceof ApiClientError) {
+				if (err.code === "AUTH_UNAUTHENTICATED") {
+					notifyApiError(err);
+					redirectToLoginExpired();
+					return;
+				}
+
+				if (err.code === "USER_NOT_FOUND") {
+					const mapped = {
+						title: "Benutzer nicht gefunden.",
+						description:
+							"Der Benutzer existiert nicht (mehr). Bitte aktualisieren Sie die Liste.",
+					};
+					setError(mapped);
+					notifyError(mapped);
+					return;
+				}
+
+				if (
+					err.code === "VALIDATION_INVALID_FIELD" &&
+					err.details?.reason === "SELF_DELETE_FORBIDDEN"
+				) {
+					const mapped = {
+						title: "Nicht möglich",
+						description: "Sie können Ihr eigenes Konto nicht löschen.",
+					};
+					setError(mapped);
+					notifyError(mapped);
+					return;
+				}
+
+				setError({
+					title: "Benutzer konnte nicht gelöscht werden.",
+					description: "Bitte versuchen Sie es erneut.",
+				});
+
+				notifyApiError(err, {
+					fallbackTitle: "Benutzer konnte nicht gelöscht werden.",
+					fallbackDescription: "Bitte versuchen Sie es erneut.",
+				});
+				return;
+			}
+
+			setError({
+				title: "Benutzer konnte nicht gelöscht werden.",
+				description: "Bitte versuchen Sie es erneut.",
+			});
+
+			notifyError({
+				title: "Benutzer konnte nicht gelöscht werden.",
+				description: "Bitte versuchen Sie es erneut.",
+			});
+		} finally {
+			setIsSubmitting(false);
+		}
+	}, [user, effectiveDisabled, onDeleted, redirectToLoginExpired]);
+
+	if (!user || typeof user.id !== "string" || !user.id) return null;
+
+	const roleLabel = ROLE_LABELS_DE[user.role] || String(user.role || "—");
+
+	return (
+		<Dialog open={open} onOpenChange={handleOpenChange}>
+			<DialogTrigger asChild>
+				<Button
+					type="button"
+					variant="destructive"
+					size="icon-sm"
+					disabled={disabled}
+					title="Benutzer löschen"
+					aria-label="Benutzer löschen"
+				>
+					<Trash2 className="h-4 w-4" />
+				</Button>
+			</DialogTrigger>
+
+			<DialogContent className="sm:max-w-lg">
+				<DialogHeader>
+					<DialogTitle>Benutzer löschen</DialogTitle>
+					<DialogDescription>
+						Diese Aktion kann nicht rückgängig gemacht werden.
+					</DialogDescription>
+				</DialogHeader>
+
+				<div className="space-y-3">
+					<div className="rounded-lg border p-3">
+						<div className="text-sm font-medium">{formatUserLabel(user)}</div>
+						<div className="mt-2 flex flex-wrap gap-2">
+							<Badge variant="secondary">{roleLabel}</Badge>
+							{user.branchId ? (
+								<Badge variant="outline">{user.branchId}</Badge>
+							) : null}
+							{user.mustChangePassword ? (
+								<Badge variant="destructive">Passwortwechsel</Badge>
+							) : (
+								<Badge variant="secondary">Kein Passwortwechsel</Badge>
+							)}
+						</div>
+					</div>
+
+					{error ? (
+						<div className="rounded-lg border border-destructive/40 bg-card p-3">
+							<p className="text-sm font-medium text-destructive">
+								{error.title}
+							</p>
+							{error.description ? (
+								<p className="mt-1 text-sm text-muted-foreground">
+									{error.description}
+								</p>
+							) : null}
+						</div>
+					) : null}
+				</div>
+
+				<DialogFooter>
+					<Button
+						type="button"
+						variant="outline"
+						disabled={effectiveDisabled}
+						onClick={() => setOpen(false)}
+					>
+						Abbrechen
+					</Button>
+
+					<Button
+						type="button"
+						variant="destructive"
+						disabled={effectiveDisabled}
+						onClick={handleDelete}
+						title="Endgültig löschen"
+					>
+						{isSubmitting ? "Löscht…" : "Löschen"}
+					</Button>
+				</DialogFooter>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 77 - 0
components/admin/users/EditUserDialog.jsx

@@ -0,0 +1,77 @@
+// ---------------------------------------------------------------------------
+// Ordner: components/admin/users
+// Datei: EditUserDialog.jsx
+// Relativer Pfad: components/admin/users/EditUserDialog.jsx
+// ---------------------------------------------------------------------------
+"use client";
+
+import React from "react";
+import { Pencil } from "lucide-react";
+
+import {
+	Dialog,
+	DialogContent,
+	DialogDescription,
+	DialogHeader,
+	DialogTitle,
+	DialogTrigger,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+
+import EditUserForm from "@/components/admin/users/edit-user/EditUserForm";
+import { useEditUserDialog } from "@/components/admin/users/edit-user/useEditUserDialog";
+
+export default function EditUserDialog({ user, disabled = false, onUpdated }) {
+	const {
+		open,
+		handleOpenChange,
+		form,
+		setPatch,
+		error,
+		isSubmitting,
+		effectiveDisabled,
+		canSubmit,
+		handleSubmit,
+	} = useEditUserDialog({ user, disabled, onUpdated });
+
+	// Defensive: if the row has no valid user object, do not render an action.
+	if (!user || typeof user.id !== "string" || !user.id) return null;
+
+	return (
+		<Dialog open={open} onOpenChange={handleOpenChange}>
+			<DialogTrigger asChild>
+				<Button
+					type="button"
+					variant="outline"
+					size="sm"
+					disabled={disabled}
+					title="Benutzer bearbeiten"
+				>
+					<Pencil className="h-4 w-4" />
+					Bearbeiten
+				</Button>
+			</DialogTrigger>
+
+			<DialogContent className="sm:max-w-xl">
+				<DialogHeader>
+					<DialogTitle>Benutzer bearbeiten</DialogTitle>
+					<DialogDescription>
+						Ändern Sie Benutzername, E-Mail, Rolle oder Niederlassung.
+					</DialogDescription>
+				</DialogHeader>
+
+				<EditUserForm
+					user={user}
+					form={form}
+					setPatch={setPatch}
+					error={error}
+					isSubmitting={isSubmitting}
+					disabled={effectiveDisabled}
+					canSubmit={canSubmit}
+					onCancel={() => handleOpenChange(false)}
+					onSubmit={handleSubmit}
+				/>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 100 - 0
components/admin/users/UsersTable.jsx

@@ -0,0 +1,100 @@
+import React from "react";
+
+import {
+	ROLE_LABELS_DE,
+	formatDateTimeDe,
+} from "@/components/admin/users/usersUi";
+
+import EditUserDialog from "@/components/admin/users/EditUserDialog";
+import DeleteUserDialog from "@/components/admin/users/DeleteUserDialog";
+
+import { Badge } from "@/components/ui/badge";
+import {
+	Table,
+	TableBody,
+	TableCell,
+	TableHead,
+	TableHeader,
+	TableRow,
+} from "@/components/ui/table";
+
+export default function UsersTable({ items, disabled = false, onUserUpdated }) {
+	const list = Array.isArray(items) ? items : [];
+
+	return (
+		<Table className="table-fixed">
+			<TableHeader>
+				<TableRow>
+					<TableHead className="w-44">Benutzername</TableHead>
+					<TableHead>E-Mail</TableHead>
+					<TableHead className="w-36">Rolle</TableHead>
+					<TableHead className="w-32">NL</TableHead>
+					<TableHead className="w-40">Passwortwechsel</TableHead>
+					<TableHead className="w-56">Aktualisiert</TableHead>
+					<TableHead className="w-44 text-right">Aktion</TableHead>
+				</TableRow>
+			</TableHeader>
+
+			<TableBody>
+				{list.map((u) => {
+					const must = Boolean(u.mustChangePassword);
+
+					return (
+						<TableRow key={u.id}>
+							<TableCell className="truncate font-medium" title={u.username}>
+								{u.username}
+							</TableCell>
+
+							<TableCell className="min-w-0">
+								<span className="block truncate" title={u.email}>
+									{u.email}
+								</span>
+							</TableCell>
+
+							<TableCell>
+								<Badge variant="secondary">
+									{ROLE_LABELS_DE[u.role] || u.role}
+								</Badge>
+							</TableCell>
+
+							<TableCell>
+								{u.branchId ? (
+									<Badge variant="outline">{u.branchId}</Badge>
+								) : (
+									<span className="text-muted-foreground">—</span>
+								)}
+							</TableCell>
+
+							<TableCell>
+								{must ? (
+									<Badge variant="destructive">Erforderlich</Badge>
+								) : (
+									<Badge variant="secondary">Nein</Badge>
+								)}
+							</TableCell>
+
+							<TableCell className="text-xs text-muted-foreground">
+								{formatDateTimeDe(u.updatedAt)}
+							</TableCell>
+
+							<TableCell className="text-right">
+								<div className="flex items-center justify-end gap-2">
+									<EditUserDialog
+										user={u}
+										disabled={disabled}
+										onUpdated={onUserUpdated}
+									/>
+									<DeleteUserDialog
+										user={u}
+										disabled={disabled}
+										onDeleted={onUserUpdated}
+									/>
+								</div>
+							</TableCell>
+						</TableRow>
+					);
+				})}
+			</TableBody>
+		</Table>
+	);
+}

+ 190 - 0
components/admin/users/create-user/CreateUserForm.jsx

@@ -0,0 +1,190 @@
+"use client";
+
+import React from "react";
+import { Loader2 } from "lucide-react";
+
+import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+import { CREATE_ROLE_OPTIONS } from "@/components/admin/users/create-user/createUserUtils";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import { DialogFooter } from "@/components/ui/dialog";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+function RoleSelect({ value, onChange, disabled }) {
+	const label =
+		CREATE_ROLE_OPTIONS.find((x) => x.value === value)?.label || "Rolle wählen";
+
+	return (
+		<div className="grid gap-2">
+			<Label>Rolle</Label>
+
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<Button type="button" variant="outline" disabled={disabled}>
+						{label}
+					</Button>
+				</DropdownMenuTrigger>
+
+				<DropdownMenuContent align="start" className="min-w-56">
+					<DropdownMenuLabel>Rolle auswählen</DropdownMenuLabel>
+					<DropdownMenuSeparator />
+					<DropdownMenuRadioGroup value={value} onValueChange={onChange}>
+						{CREATE_ROLE_OPTIONS.map((opt) => (
+							<DropdownMenuRadioItem key={opt.value} value={opt.value}>
+								{opt.label}
+							</DropdownMenuRadioItem>
+						))}
+					</DropdownMenuRadioGroup>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		</div>
+	);
+}
+
+export default function CreateUserForm({
+	form,
+	setPatch,
+	error,
+	policyLines,
+	disabled,
+	isSubmitting,
+	onCancel,
+	onSubmit,
+}) {
+	const role = String(form?.role || "branch");
+
+	return (
+		<form onSubmit={onSubmit} className="space-y-4">
+			{error ? (
+				<Alert variant="destructive">
+					<AlertTitle>{error.title}</AlertTitle>
+
+					{error.description ? (
+						<AlertDescription>{error.description}</AlertDescription>
+					) : null}
+
+					{Array.isArray(error.hints) && error.hints.length > 0 ? (
+						<AlertDescription>
+							<ul className="mt-2 list-disc pl-5">
+								{error.hints.map((line) => (
+									<li key={line}>{line}</li>
+								))}
+							</ul>
+						</AlertDescription>
+					) : null}
+				</Alert>
+			) : null}
+
+			<div className="grid gap-3 md:grid-cols-2">
+				<div className="grid gap-2">
+					<Label htmlFor="cu-username">Benutzername</Label>
+					<Input
+						id="cu-username"
+						value={form?.username ?? ""}
+						onChange={(e) => setPatch({ username: e.target.value })}
+						disabled={disabled}
+						autoCapitalize="none"
+						autoCorrect="off"
+						spellCheck={false}
+						placeholder="z. B. branchuser"
+					/>
+				</div>
+
+				<div className="grid gap-2">
+					<Label htmlFor="cu-email">E-Mail</Label>
+					<Input
+						id="cu-email"
+						value={form?.email ?? ""}
+						onChange={(e) => setPatch({ email: e.target.value })}
+						disabled={disabled}
+						placeholder="name@firma.de"
+					/>
+				</div>
+			</div>
+
+			<div className="grid gap-3 md:grid-cols-2">
+				<RoleSelect
+					value={role}
+					onChange={(v) => setPatch({ role: v })}
+					disabled={disabled}
+				/>
+
+				{role === "branch" ? (
+					<div className="grid gap-2">
+						<Label htmlFor="cu-branch">Niederlassung</Label>
+						<Input
+							id="cu-branch"
+							value={form?.branchId ?? ""}
+							onChange={(e) => setPatch({ branchId: e.target.value })}
+							disabled={disabled}
+							placeholder="z. B. NL01"
+						/>
+					</div>
+				) : (
+					<div className="grid gap-2">
+						<Label>Niederlassung</Label>
+						<Input value="—" disabled />
+					</div>
+				)}
+			</div>
+
+			{/* Keep branchId normalized as user types (optional UX polish) */}
+			{role === "branch" ? (
+				<div className="text-xs text-muted-foreground">
+					Aktuell: {normalizeBranchIdDraft(form?.branchId || "") || "—"}
+				</div>
+			) : null}
+
+			<div className="grid gap-2">
+				<Label htmlFor="cu-password">Initiales Passwort</Label>
+				<Input
+					id="cu-password"
+					type="password"
+					value={form?.initialPassword ?? ""}
+					onChange={(e) => setPatch({ initialPassword: e.target.value })}
+					disabled={disabled}
+					placeholder="••••••••"
+				/>
+
+				<ul className="mt-1 list-disc pl-5 text-xs text-muted-foreground">
+					{(Array.isArray(policyLines) ? policyLines : []).map((line) => (
+						<li key={line}>{line}</li>
+					))}
+				</ul>
+			</div>
+
+			<DialogFooter>
+				<Button
+					type="button"
+					variant="outline"
+					disabled={disabled}
+					onClick={onCancel}
+				>
+					Abbrechen
+				</Button>
+
+				<Button type="submit" disabled={disabled}>
+					{isSubmitting ? (
+						<>
+							<Loader2 className="h-4 w-4 animate-spin" />
+							Speichern…
+						</>
+					) : (
+						"Anlegen"
+					)}
+				</Button>
+			</DialogFooter>
+		</form>
+	);
+}

+ 201 - 0
components/admin/users/edit-user/EditUserForm.jsx

@@ -0,0 +1,201 @@
+// ---------------------------------------------------------------------------
+// Ordner: components/admin/users/edit-user
+// Datei: EditUserForm.jsx
+// Relativer Pfad: components/admin/users/edit-user/EditUserForm.jsx
+// ---------------------------------------------------------------------------
+"use client";
+
+import React from "react";
+import { Loader2 } from "lucide-react";
+
+import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+import { EDIT_ROLE_OPTIONS } from "@/components/admin/users/edit-user/editUserUtils";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import { DialogFooter } from "@/components/ui/dialog";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+function RoleSelect({ value, onChange, disabled }) {
+	const label =
+		EDIT_ROLE_OPTIONS.find((x) => x.value === value)?.label || "Rolle wählen";
+
+	return (
+		<div className="grid gap-2">
+			<Label>Rolle</Label>
+
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<Button type="button" variant="outline" disabled={disabled}>
+						{label}
+					</Button>
+				</DropdownMenuTrigger>
+
+				<DropdownMenuContent align="start" className="min-w-56">
+					<DropdownMenuLabel>Rolle auswählen</DropdownMenuLabel>
+					<DropdownMenuSeparator />
+
+					<DropdownMenuRadioGroup value={value} onValueChange={onChange}>
+						{EDIT_ROLE_OPTIONS.map((opt) => (
+							<DropdownMenuRadioItem key={opt.value} value={opt.value}>
+								{opt.label}
+							</DropdownMenuRadioItem>
+						))}
+					</DropdownMenuRadioGroup>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		</div>
+	);
+}
+
+export default function EditUserForm({
+	user,
+	form,
+	setPatch,
+	error,
+	disabled,
+	isSubmitting,
+	canSubmit,
+	onCancel,
+	onSubmit,
+}) {
+	const role = String(form?.role || "branch");
+	const userId = String(user?.id || "");
+
+	return (
+		<form onSubmit={onSubmit} className="space-y-4">
+			{error ? (
+				<Alert variant="destructive">
+					<AlertTitle>{error.title}</AlertTitle>
+					{error.description ? (
+						<AlertDescription>{error.description}</AlertDescription>
+					) : null}
+
+					{Array.isArray(error.hints) && error.hints.length > 0 ? (
+						<AlertDescription>
+							<ul className="mt-2 list-disc pl-5">
+								{error.hints.map((line) => (
+									<li key={line}>{line}</li>
+								))}
+							</ul>
+						</AlertDescription>
+					) : null}
+				</Alert>
+			) : null}
+
+			<div className="grid gap-2">
+				<Label>User ID</Label>
+				<Input value={userId || "—"} disabled />
+			</div>
+
+			<div className="grid gap-3 md:grid-cols-2">
+				<div className="grid gap-2">
+					<Label htmlFor="eu-username">Benutzername</Label>
+					<Input
+						id="eu-username"
+						value={form?.username ?? ""}
+						onChange={(e) => setPatch({ username: e.target.value })}
+						disabled={disabled}
+						autoCapitalize="none"
+						autoCorrect="off"
+						spellCheck={false}
+						placeholder="z. B. branchuser"
+					/>
+				</div>
+
+				<div className="grid gap-2">
+					<Label htmlFor="eu-email">E-Mail</Label>
+					<Input
+						id="eu-email"
+						value={form?.email ?? ""}
+						onChange={(e) => setPatch({ email: e.target.value })}
+						disabled={disabled}
+						placeholder="name@firma.de"
+					/>
+				</div>
+			</div>
+
+			<div className="grid gap-3 md:grid-cols-2">
+				<RoleSelect
+					value={role}
+					onChange={(v) => setPatch({ role: v })}
+					disabled={disabled}
+				/>
+
+				{role === "branch" ? (
+					<div className="grid gap-2">
+						<Label htmlFor="eu-branch">Niederlassung</Label>
+						<Input
+							id="eu-branch"
+							value={form?.branchId ?? ""}
+							onChange={(e) => setPatch({ branchId: e.target.value })}
+							disabled={disabled}
+							placeholder="z. B. NL01"
+						/>
+					</div>
+				) : (
+					<div className="grid gap-2">
+						<Label>Niederlassung</Label>
+						<Input value="—" disabled />
+					</div>
+				)}
+			</div>
+
+			{role === "branch" ? (
+				<div className="text-xs text-muted-foreground">
+					Aktuell: {normalizeBranchIdDraft(form?.branchId || "") || "—"}
+				</div>
+			) : null}
+
+			<div className="flex items-start gap-3 rounded-lg border p-3">
+				<Checkbox
+					checked={Boolean(form?.mustChangePassword)}
+					disabled={disabled}
+					onCheckedChange={(v) => {
+						setPatch({ mustChangePassword: Boolean(v) });
+					}}
+				/>
+				<div className="grid gap-1">
+					<p className="text-sm font-medium">Passwortwechsel erforderlich</p>
+					<p className="text-xs text-muted-foreground">
+						Wenn aktiviert, muss der Benutzer beim nächsten Login sein Passwort
+						ändern.
+					</p>
+				</div>
+			</div>
+
+			<DialogFooter>
+				<Button
+					type="button"
+					variant="outline"
+					disabled={disabled}
+					onClick={onCancel}
+				>
+					Abbrechen
+				</Button>
+
+				<Button type="submit" disabled={disabled || !canSubmit}>
+					{isSubmitting ? (
+						<>
+							<Loader2 className="h-4 w-4 animate-spin" />
+							Speichern…
+						</>
+					) : (
+						"Speichern"
+					)}
+				</Button>
+			</DialogFooter>
+		</form>
+	);
+}

+ 15 - 1
components/app-shell/UserStatus.jsx

@@ -3,13 +3,14 @@
 import React from "react";
 import Link from "next/link";
 import { usePathname } from "next/navigation";
-import { LifeBuoy, LogOut, User } from "lucide-react";
+import { LifeBuoy, LogOut, User, Users } from "lucide-react";
 
 import { cn } from "@/lib/utils";
 
 import { useAuth } from "@/components/auth/authContext";
 import { logout } from "@/lib/frontend/apiClient";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+import { canManageUsers as canManageUsersRole } from "@/lib/frontend/auth/roles";
 
 import { Button } from "@/components/ui/button";
 import {
@@ -96,6 +97,7 @@ export default function UserStatus() {
 	const { status, user } = useAuth();
 
 	const isAuthenticated = status === "authenticated" && user;
+	const canManageUsers = isAuthenticated && canManageUsersRole(user.role);
 
 	let text = "Nicht geladen";
 	if (status === "loading") text = "Lädt…";
@@ -173,6 +175,18 @@ export default function UserStatus() {
 					</Link>
 				</DropdownMenuItem>
 
+				{canManageUsers ? (
+					<DropdownMenuItem asChild>
+						<Link
+							href="/admin/users"
+							className="flex w-full items-center gap-2"
+						>
+							<Users className="h-4 w-4" aria-hidden="true" />
+							Benutzerverwaltung
+						</Link>
+					</DropdownMenuItem>
+				) : null}
+
 				<DropdownMenuItem asChild>
 					<a href={supportMailto} className="flex w-full items-center gap-2">
 						<LifeBuoy className="h-4 w-4" aria-hidden="true" />