浏览代码

RHL-012 feat(user-management): implement create and edit user dialogs with validation and role management

codeUWE 1 月之前
父节点
当前提交
f58a2d28a1

+ 23 - 0
components/admin/users/create-user/createUserUtils.js

@@ -0,0 +1,23 @@
+import { ROLE_LABELS_DE } from "@/components/admin/users/usersUi";
+
+export const CREATE_ROLE_OPTIONS = Object.freeze([
+	{ value: "branch", label: ROLE_LABELS_DE.branch },
+	{ value: "admin", label: ROLE_LABELS_DE.admin },
+	{ value: "superadmin", label: ROLE_LABELS_DE.superadmin },
+	{ value: "dev", label: ROLE_LABELS_DE.dev },
+]);
+
+export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+export const BRANCH_RE = /^NL\d+$/;
+
+export function normalizeUsername(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}
+
+export function normalizeEmail(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}

+ 294 - 0
components/admin/users/create-user/useCreateUserDialog.js

@@ -0,0 +1,294 @@
+"use client";
+
+import React from "react";
+
+import { adminCreateUser, ApiClientError } from "@/lib/frontend/apiClient";
+import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+import {
+	notifySuccess,
+	notifyError,
+	notifyApiError,
+} from "@/lib/frontend/ui/toast";
+
+import {
+	getPasswordPolicyHintLinesDe,
+	reasonsToHintLinesDe,
+	buildWeakPasswordMessageDe,
+} from "@/lib/frontend/profile/passwordPolicyUi";
+
+import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+import {
+	CREATE_ROLE_OPTIONS,
+	EMAIL_RE,
+	BRANCH_RE,
+	normalizeUsername,
+	normalizeEmail,
+} from "@/components/admin/users/create-user/createUserUtils";
+
+const DEFAULT_FORM = Object.freeze({
+	username: "",
+	email: "",
+	role: "branch",
+	branchId: "",
+	initialPassword: "",
+});
+
+function cloneDefaultForm() {
+	return { ...DEFAULT_FORM };
+}
+
+function validateClient(form) {
+	const username = normalizeUsername(form?.username);
+	const email = normalizeEmail(form?.email);
+	const role = String(form?.role || "").trim();
+	const branchId =
+		role === "branch" ? normalizeBranchIdDraft(form?.branchId) : "";
+	const initialPassword = String(form?.initialPassword || "");
+
+	if (!username) return { title: "Benutzername fehlt.", description: null };
+	if (!email) return { title: "E-Mail fehlt.", description: null };
+	if (!EMAIL_RE.test(email)) {
+		return {
+			title: "E-Mail ist ungültig.",
+			description: "Bitte prüfen Sie die Eingabe.",
+		};
+	}
+
+	const isKnownRole = CREATE_ROLE_OPTIONS.some((x) => x.value === role);
+	if (!role || !isKnownRole)
+		return { title: "Rolle fehlt.", description: null };
+
+	if (role === "branch") {
+		if (!branchId) {
+			return {
+				title: "Niederlassung fehlt.",
+				description: "Für Niederlassungs-User ist eine NL erforderlich.",
+			};
+		}
+		if (!BRANCH_RE.test(branchId)) {
+			return {
+				title: "Niederlassung ist ungültig.",
+				description: "Format: NL01, NL02, ...",
+			};
+		}
+	}
+
+	if (!initialPassword.trim()) {
+		return { title: "Initiales Passwort fehlt.", description: null };
+	}
+
+	return null;
+}
+
+function extractDuplicateFields(details) {
+	if (!details || typeof details !== "object") return [];
+
+	// Accept both shapes:
+	// - { field: "username" }
+	// - { fields: ["username","email"] }
+	const one =
+		typeof details.field === "string" && details.field.trim()
+			? details.field.trim()
+			: null;
+
+	const many = Array.isArray(details.fields)
+		? details.fields.map((x) => String(x))
+		: null;
+
+	const list = many ?? (one ? [one] : []);
+	return Array.from(new Set(list.map(String)));
+}
+
+function mapDuplicateFieldsToGermanMessage(fields) {
+	const hasUsername = fields.includes("username");
+	const hasEmail = fields.includes("email");
+
+	if (!hasUsername && !hasEmail) return null;
+
+	if (hasUsername && hasEmail) {
+		return {
+			title: "Benutzername und E-Mail existieren bereits.",
+			description: "Bitte wählen Sie andere Werte und versuchen Sie es erneut.",
+		};
+	}
+
+	if (hasUsername) {
+		return {
+			title: "Benutzername existiert bereits.",
+			description: "Bitte wählen Sie einen anderen Benutzernamen.",
+		};
+	}
+
+	return {
+		title: "E-Mail existiert bereits.",
+		description: "Bitte wählen Sie eine andere E-Mail-Adresse.",
+	};
+}
+
+export function useCreateUserDialog({ disabled = false, onCreated } = {}) {
+	const [open, setOpen] = React.useState(false);
+	const [isSubmitting, setIsSubmitting] = React.useState(false);
+	const [form, setForm] = React.useState(() => cloneDefaultForm());
+	const [error, setError] = React.useState(null);
+
+	const policyLines = React.useMemo(() => getPasswordPolicyHintLinesDe(), []);
+
+	const effectiveDisabled = Boolean(disabled || isSubmitting);
+
+	const setPatch = React.useCallback((patch) => {
+		setForm((prev) => ({ ...prev, ...(patch || {}) }));
+	}, []);
+
+	const resetForm = React.useCallback(() => {
+		setForm(cloneDefaultForm());
+		setError(null);
+	}, []);
+
+	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) resetForm();
+		},
+		[resetForm],
+	);
+
+	const handleSubmit = React.useCallback(
+		async (e) => {
+			e?.preventDefault?.();
+			if (effectiveDisabled) return;
+
+			setError(null);
+
+			const clientErr = validateClient(form);
+			if (clientErr) {
+				setError(clientErr);
+				notifyError({
+					title: clientErr.title,
+					description: clientErr.description,
+				});
+				return;
+			}
+
+			setIsSubmitting(true);
+
+			try {
+				const username = normalizeUsername(form.username);
+				const email = normalizeEmail(form.email);
+				const role = String(form.role || "").trim();
+
+				const branchId =
+					role === "branch" ? normalizeBranchIdDraft(form.branchId) : null;
+
+				const initialPassword = String(form.initialPassword || "");
+
+				await adminCreateUser({
+					username,
+					email,
+					role,
+					branchId,
+					initialPassword,
+				});
+
+				notifySuccess({
+					title: "Benutzer angelegt",
+					description: `Benutzer "${username}" wurde erstellt.`,
+				});
+
+				setOpen(false);
+				resetForm();
+
+				if (typeof onCreated === "function") onCreated();
+			} catch (err) {
+				if (err instanceof ApiClientError) {
+					if (err.code === "AUTH_UNAUTHENTICATED") {
+						notifyApiError(err);
+						redirectToLoginExpired();
+						return;
+					}
+
+					if (err.code === "VALIDATION_WEAK_PASSWORD") {
+						const reasons = err.details?.reasons;
+						const minLength = err.details?.minLength;
+
+						const hints = reasonsToHintLinesDe({ reasons, minLength });
+						const description = buildWeakPasswordMessageDe({
+							reasons,
+							minLength,
+						});
+
+						setError({
+							title: "Passwort ist zu schwach.",
+							description,
+							hints,
+						});
+
+						notifyError({ title: "Passwort ist zu schwach.", description });
+						return;
+					}
+
+					// Precise duplicate feedback (username/email)
+					if (err.code === "VALIDATION_INVALID_FIELD") {
+						const fields = extractDuplicateFields(err.details);
+						const mapped = mapDuplicateFieldsToGermanMessage(fields);
+
+						if (mapped) {
+							setError(mapped);
+							notifyError(mapped);
+							return;
+						}
+					}
+
+					setError({
+						title: "Benutzer konnte nicht angelegt werden.",
+						description:
+							"Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
+					});
+
+					notifyApiError(err, {
+						fallbackTitle: "Benutzer konnte nicht angelegt werden.",
+						fallbackDescription:
+							"Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
+					});
+					return;
+				}
+
+				setError({
+					title: "Benutzer konnte nicht angelegt werden.",
+					description: "Bitte versuchen Sie es erneut.",
+				});
+
+				notifyError({
+					title: "Benutzer konnte nicht angelegt werden.",
+					description: "Bitte versuchen Sie es erneut.",
+				});
+			} finally {
+				setIsSubmitting(false);
+			}
+		},
+		[effectiveDisabled, form, onCreated, redirectToLoginExpired, resetForm],
+	);
+
+	return {
+		open,
+		setOpen,
+		form,
+		setPatch,
+		error,
+		policyLines,
+		isSubmitting,
+		effectiveDisabled,
+		handleSubmit,
+		handleOpenChange,
+	};
+}

+ 28 - 0
components/admin/users/edit-user/editUserUtils.js

@@ -0,0 +1,28 @@
+// ---------------------------------------------------------------------------
+// Ordner: components/admin/users/edit-user
+// Datei: editUserUtils.js
+// Relativer Pfad: components/admin/users/edit-user/editUserUtils.js
+// ---------------------------------------------------------------------------
+import { ROLE_LABELS_DE } from "@/components/admin/users/usersUi";
+
+export const EDIT_ROLE_OPTIONS = Object.freeze([
+	{ value: "branch", label: ROLE_LABELS_DE.branch },
+	{ value: "admin", label: ROLE_LABELS_DE.admin },
+	{ value: "superadmin", label: ROLE_LABELS_DE.superadmin },
+	{ value: "dev", label: ROLE_LABELS_DE.dev },
+]);
+
+export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+export const BRANCH_RE = /^NL\d+$/;
+
+export function normalizeUsername(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}
+
+export function normalizeEmail(value) {
+	return String(value || "")
+		.trim()
+		.toLowerCase();
+}

+ 359 - 0
components/admin/users/edit-user/useEditUserDialog.js

@@ -0,0 +1,359 @@
+// ---------------------------------------------------------------------------
+// Ordner: components/admin/users/edit-user
+// Datei: useEditUserDialog.js
+// Relativer Pfad: components/admin/users/edit-user/useEditUserDialog.js
+// ---------------------------------------------------------------------------
+"use client";
+
+import React from "react";
+
+import { adminUpdateUser, ApiClientError } from "@/lib/frontend/apiClient";
+import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+import {
+	notifySuccess,
+	notifyError,
+	notifyApiError,
+	notifyInfo,
+} from "@/lib/frontend/ui/toast";
+
+import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+import {
+	EDIT_ROLE_OPTIONS,
+	EMAIL_RE,
+	BRANCH_RE,
+	normalizeUsername,
+	normalizeEmail,
+} from "@/components/admin/users/edit-user/editUserUtils";
+
+function isNonEmptyString(value) {
+	return typeof value === "string" && value.trim().length > 0;
+}
+
+function buildInitialFormFromUser(user) {
+	return {
+		username: typeof user?.username === "string" ? user.username : "",
+		email: typeof user?.email === "string" ? user.email : "",
+		role: typeof user?.role === "string" ? user.role : "branch",
+		branchId: typeof user?.branchId === "string" ? user.branchId : "",
+		mustChangePassword: Boolean(user?.mustChangePassword),
+	};
+}
+
+function validateClient(form) {
+	const username = normalizeUsername(form?.username);
+	const email = normalizeEmail(form?.email);
+	const role = String(form?.role || "").trim();
+
+	const isKnownRole = EDIT_ROLE_OPTIONS.some((x) => x.value === role);
+
+	const branchId =
+		role === "branch" ? normalizeBranchIdDraft(form?.branchId || "") : "";
+
+	if (!username) return { title: "Benutzername fehlt.", description: null };
+	if (!email) return { title: "E-Mail fehlt.", description: null };
+
+	if (!EMAIL_RE.test(email)) {
+		return {
+			title: "E-Mail ist ungültig.",
+			description: "Bitte prüfen Sie die Eingabe.",
+		};
+	}
+
+	if (!role || !isKnownRole) {
+		return { title: "Rolle fehlt.", description: null };
+	}
+
+	if (role === "branch") {
+		if (!branchId) {
+			return {
+				title: "Niederlassung fehlt.",
+				description: "Für Niederlassungs-User ist eine NL erforderlich.",
+			};
+		}
+		if (!BRANCH_RE.test(branchId)) {
+			return {
+				title: "Niederlassung ist ungültig.",
+				description: "Format: NL01, NL02, ...",
+			};
+		}
+	}
+
+	return null;
+}
+
+function extractDuplicateField(details) {
+	if (!details || typeof details !== "object") return null;
+	if (typeof details.field === "string" && details.field.trim()) {
+		return details.field.trim();
+	}
+	return null;
+}
+
+function mapDuplicateFieldToGermanMessage(field) {
+	if (field === "username") {
+		return {
+			title: "Benutzername existiert bereits.",
+			description: "Bitte wählen Sie einen anderen Benutzernamen.",
+		};
+	}
+	if (field === "email") {
+		return {
+			title: "E-Mail existiert bereits.",
+			description: "Bitte wählen Sie eine andere E-Mail-Adresse.",
+		};
+	}
+	return null;
+}
+
+function buildNormalizedForm(form) {
+	const role = String(form?.role || "").trim();
+
+	return {
+		username: normalizeUsername(form?.username),
+		email: normalizeEmail(form?.email),
+		role,
+		branchId:
+			role === "branch" ? normalizeBranchIdDraft(form?.branchId || "") : "",
+		mustChangePassword: Boolean(form?.mustChangePassword),
+	};
+}
+
+function buildPatch({ user, form }) {
+	const initial = buildNormalizedForm(buildInitialFormFromUser(user));
+	const current = buildNormalizedForm(form);
+
+	const patch = {};
+
+	if (current.username && current.username !== initial.username) {
+		patch.username = current.username;
+	}
+
+	if (current.email && current.email !== initial.email) {
+		patch.email = current.email;
+	}
+
+	if (current.role && current.role !== initial.role) {
+		patch.role = current.role;
+	}
+
+	// branchId handling:
+	// - Only meaningful for role=branch
+	// - If role becomes non-branch, backend will clear branchId automatically.
+	if (current.role === "branch") {
+		// If role is branch (either unchanged or changed), enforce branchId in patch when different
+		if (current.branchId !== initial.branchId) {
+			patch.branchId = current.branchId;
+		}
+	} else {
+		// If user was branch before and we did not change role explicitly (rare),
+		// we do not auto-clear here. Backend will enforce consistency based on role updates.
+	}
+
+	if (current.mustChangePassword !== initial.mustChangePassword) {
+		patch.mustChangePassword = current.mustChangePassword;
+	}
+
+	return patch;
+}
+
+export function useEditUserDialog({ user, disabled = false, onUpdated } = {}) {
+	const [open, setOpen] = React.useState(false);
+	const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+	const [form, setForm] = React.useState(() => buildInitialFormFromUser(user));
+	const [error, setError] = React.useState(null);
+
+	const effectiveDisabled = Boolean(disabled || isSubmitting || !user?.id);
+
+	const patchPreview = React.useMemo(() => {
+		return buildPatch({ user, form });
+	}, [
+		user?.id,
+		user?.username,
+		user?.email,
+		user?.role,
+		user?.branchId,
+		user?.mustChangePassword,
+		form?.username,
+		form?.email,
+		form?.role,
+		form?.branchId,
+		form?.mustChangePassword,
+	]);
+
+	const canSubmit = React.useMemo(() => {
+		return !effectiveDisabled && Object.keys(patchPreview).length > 0;
+	}, [effectiveDisabled, patchPreview]);
+
+	const setPatch = React.useCallback((patch) => {
+		setForm((prev) => ({ ...prev, ...(patch || {}) }));
+	}, []);
+
+	const resetForm = React.useCallback(() => {
+		setForm(buildInitialFormFromUser(user));
+		setError(null);
+	}, [user]);
+
+	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);
+
+			// On open -> initialize from current user snapshot.
+			// On close -> clear error and reset form so reopening is always clean.
+			if (nextOpen) {
+				setForm(buildInitialFormFromUser(user));
+				setError(null);
+			} else {
+				resetForm();
+			}
+		},
+		[user, resetForm],
+	);
+
+	const handleSubmit = React.useCallback(
+		async (e) => {
+			e?.preventDefault?.();
+			if (effectiveDisabled) return;
+
+			setError(null);
+
+			const clientErr = validateClient(form);
+			if (clientErr) {
+				setError(clientErr);
+				notifyError({
+					title: clientErr.title,
+					description: clientErr.description,
+				});
+				return;
+			}
+
+			const patch = buildPatch({ user, form });
+
+			if (Object.keys(patch).length === 0) {
+				notifyInfo({
+					title: "Keine Änderungen",
+					description: "Es wurden keine Felder geändert.",
+				});
+				setOpen(false);
+				resetForm();
+				return;
+			}
+
+			setIsSubmitting(true);
+
+			try {
+				await adminUpdateUser(String(user.id), patch);
+
+				notifySuccess({
+					title: "Benutzer aktualisiert",
+					description: `Benutzer "${normalizeUsername(form.username)}" wurde gespeichert.`,
+				});
+
+				setOpen(false);
+				resetForm();
+
+				if (typeof onUpdated === "function") onUpdated();
+			} 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_MISSING_FIELD") {
+						const fields = err.details?.fields;
+						if (Array.isArray(fields) && fields.includes("branchId")) {
+							const mapped = {
+								title: "Niederlassung fehlt.",
+								description:
+									"Für Niederlassungs-User ist eine Niederlassung erforderlich.",
+							};
+							setError(mapped);
+							notifyError(mapped);
+							return;
+						}
+					}
+
+					if (err.code === "VALIDATION_INVALID_FIELD") {
+						const field = extractDuplicateField(err.details);
+						const mapped = mapDuplicateFieldToGermanMessage(field);
+
+						if (mapped) {
+							setError(mapped);
+							notifyError(mapped);
+							return;
+						}
+					}
+
+					setError({
+						title: "Benutzer konnte nicht aktualisiert werden.",
+						description:
+							"Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
+					});
+
+					notifyApiError(err, {
+						fallbackTitle: "Benutzer konnte nicht aktualisiert werden.",
+						fallbackDescription:
+							"Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
+					});
+					return;
+				}
+
+				setError({
+					title: "Benutzer konnte nicht aktualisiert werden.",
+					description: "Bitte versuchen Sie es erneut.",
+				});
+
+				notifyError({
+					title: "Benutzer konnte nicht aktualisiert werden.",
+					description: "Bitte versuchen Sie es erneut.",
+				});
+			} finally {
+				setIsSubmitting(false);
+			}
+		},
+		[
+			effectiveDisabled,
+			form,
+			user,
+			onUpdated,
+			redirectToLoginExpired,
+			resetForm,
+		],
+	);
+
+	return {
+		open,
+		handleOpenChange,
+		form,
+		setPatch,
+		error,
+		isSubmitting,
+		effectiveDisabled,
+		canSubmit,
+		handleSubmit,
+	};
+}