useCreateUserDialog.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. "use client";
  2. import React from "react";
  3. import { adminCreateUser, ApiClientError } from "@/lib/frontend/apiClient";
  4. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  5. import {
  6. notifySuccess,
  7. notifyError,
  8. notifyApiError,
  9. } from "@/lib/frontend/ui/toast";
  10. import {
  11. getPasswordPolicyHintLinesDe,
  12. reasonsToHintLinesDe,
  13. buildWeakPasswordMessageDe,
  14. } from "@/lib/frontend/profile/passwordPolicyUi";
  15. import { useSearchBranches } from "@/lib/frontend/search/useSearchBranches";
  16. import {
  17. evaluateBranchExistence,
  18. normalizeBranchIdInput,
  19. isValidBranchIdFormat,
  20. } from "@/lib/frontend/admin/users/userManagementUx";
  21. import {
  22. CREATE_ROLE_OPTIONS,
  23. EMAIL_RE,
  24. normalizeUsername,
  25. normalizeEmail,
  26. } from "@/components/admin/users/create-user/createUserUtils";
  27. const DEFAULT_FORM = Object.freeze({
  28. username: "",
  29. email: "",
  30. role: "branch",
  31. branchId: "",
  32. initialPassword: "",
  33. });
  34. function cloneDefaultForm() {
  35. return { ...DEFAULT_FORM };
  36. }
  37. function validateClient(form) {
  38. const username = normalizeUsername(form?.username);
  39. const email = normalizeEmail(form?.email);
  40. const role = String(form?.role || "").trim();
  41. const branchId = role === "branch" ? normalizeBranchIdInput(form?.branchId) : "";
  42. const initialPassword = String(form?.initialPassword || "");
  43. if (!username) return { title: "Benutzername fehlt.", description: null };
  44. if (!email) return { title: "E-Mail fehlt.", description: null };
  45. if (!EMAIL_RE.test(email)) {
  46. return {
  47. title: "E-Mail ist ungültig.",
  48. description: "Bitte prüfen Sie die Eingabe.",
  49. };
  50. }
  51. const isKnownRole = CREATE_ROLE_OPTIONS.some((x) => x.value === role);
  52. if (!role || !isKnownRole)
  53. return { title: "Rolle fehlt.", description: null };
  54. if (role === "branch") {
  55. if (!branchId) {
  56. return {
  57. title: "Niederlassung fehlt.",
  58. description: "Für Niederlassungs-User ist eine NL erforderlich.",
  59. };
  60. }
  61. if (!isValidBranchIdFormat(branchId)) {
  62. return {
  63. title: "Niederlassung ist ungültig.",
  64. description: "Format: NL01, NL02, ...",
  65. };
  66. }
  67. }
  68. if (!initialPassword.trim()) {
  69. return { title: "Initiales Passwort fehlt.", description: null };
  70. }
  71. return null;
  72. }
  73. function extractDuplicateFields(details) {
  74. if (!details || typeof details !== "object") return [];
  75. // Accept both shapes:
  76. // - { field: "username" }
  77. // - { fields: ["username","email"] }
  78. const one =
  79. typeof details.field === "string" && details.field.trim()
  80. ? details.field.trim()
  81. : null;
  82. const many = Array.isArray(details.fields)
  83. ? details.fields.map((x) => String(x))
  84. : null;
  85. const list = many ?? (one ? [one] : []);
  86. return Array.from(new Set(list.map(String)));
  87. }
  88. function mapDuplicateFieldsToGermanMessage(fields) {
  89. const hasUsername = fields.includes("username");
  90. const hasEmail = fields.includes("email");
  91. if (!hasUsername && !hasEmail) return null;
  92. if (hasUsername && hasEmail) {
  93. return {
  94. title: "Benutzername und E-Mail existieren bereits.",
  95. description: "Bitte wählen Sie andere Werte und versuchen Sie es erneut.",
  96. };
  97. }
  98. if (hasUsername) {
  99. return {
  100. title: "Benutzername existiert bereits.",
  101. description: "Bitte wählen Sie einen anderen Benutzernamen.",
  102. };
  103. }
  104. return {
  105. title: "E-Mail existiert bereits.",
  106. description: "Bitte wählen Sie eine andere E-Mail-Adresse.",
  107. };
  108. }
  109. export function useCreateUserDialog({ disabled = false, onCreated } = {}) {
  110. const [open, setOpen] = React.useState(false);
  111. const [isSubmitting, setIsSubmitting] = React.useState(false);
  112. const [form, setForm] = React.useState(() => cloneDefaultForm());
  113. const [error, setError] = React.useState(null);
  114. const policyLines = React.useMemo(() => getPasswordPolicyHintLinesDe(), []);
  115. const role = String(form?.role || "branch").trim();
  116. const shouldLoadBranches = open && role === "branch";
  117. const { status: branchesStatus, branches: availableBranchIds } =
  118. useSearchBranches({ enabled: shouldLoadBranches });
  119. const effectiveDisabled = Boolean(disabled || isSubmitting);
  120. const branchExistence = React.useMemo(
  121. () =>
  122. evaluateBranchExistence({
  123. role,
  124. branchId: form?.branchId,
  125. branchesStatus,
  126. availableBranchIds,
  127. }),
  128. [role, form?.branchId, branchesStatus, availableBranchIds],
  129. );
  130. const canSubmit = !effectiveDisabled && !branchExistence.shouldBlockSubmit;
  131. const setPatch = React.useCallback((patch) => {
  132. setForm((prev) => ({ ...prev, ...(patch || {}) }));
  133. }, []);
  134. const resetForm = React.useCallback(() => {
  135. setForm(cloneDefaultForm());
  136. setError(null);
  137. }, []);
  138. const redirectToLoginExpired = React.useCallback(() => {
  139. const next =
  140. typeof window !== "undefined"
  141. ? `${window.location.pathname}${window.location.search}`
  142. : "/admin/users";
  143. window.location.replace(
  144. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
  145. );
  146. }, []);
  147. const handleOpenChange = React.useCallback(
  148. (nextOpen) => {
  149. setOpen(nextOpen);
  150. if (!nextOpen) resetForm();
  151. },
  152. [resetForm],
  153. );
  154. const handleSubmit = React.useCallback(
  155. async (e) => {
  156. e?.preventDefault?.();
  157. if (effectiveDisabled) return;
  158. setError(null);
  159. const clientErr = validateClient(form);
  160. if (clientErr) {
  161. setError(clientErr);
  162. notifyError({
  163. title: clientErr.title,
  164. description: clientErr.description,
  165. });
  166. return;
  167. }
  168. if (branchExistence.shouldBlockSubmit) {
  169. const mapped = {
  170. title: "Niederlassung existiert nicht.",
  171. description:
  172. "Die gewählte Niederlassung ist nicht in der aktuellen Liste vorhanden.",
  173. };
  174. setError(mapped);
  175. notifyError(mapped);
  176. return;
  177. }
  178. setIsSubmitting(true);
  179. try {
  180. const username = normalizeUsername(form.username);
  181. const email = normalizeEmail(form.email);
  182. const role = String(form.role || "").trim();
  183. const branchId =
  184. role === "branch" ? normalizeBranchIdInput(form.branchId) : null;
  185. const initialPassword = String(form.initialPassword || "");
  186. await adminCreateUser({
  187. username,
  188. email,
  189. role,
  190. branchId,
  191. initialPassword,
  192. });
  193. notifySuccess({
  194. title: "Benutzer angelegt",
  195. description: `Benutzer "${username}" wurde erstellt.`,
  196. });
  197. setOpen(false);
  198. resetForm();
  199. if (typeof onCreated === "function") onCreated();
  200. } catch (err) {
  201. if (err instanceof ApiClientError) {
  202. if (err.code === "AUTH_UNAUTHENTICATED") {
  203. notifyApiError(err);
  204. redirectToLoginExpired();
  205. return;
  206. }
  207. if (err.code === "VALIDATION_WEAK_PASSWORD") {
  208. const reasons = err.details?.reasons;
  209. const minLength = err.details?.minLength;
  210. const hints = reasonsToHintLinesDe({ reasons, minLength });
  211. const description = buildWeakPasswordMessageDe({
  212. reasons,
  213. minLength,
  214. });
  215. setError({
  216. title: "Passwort ist zu schwach.",
  217. description,
  218. hints,
  219. });
  220. notifyError({ title: "Passwort ist zu schwach.", description });
  221. return;
  222. }
  223. // Precise duplicate feedback (username/email)
  224. if (err.code === "VALIDATION_INVALID_FIELD") {
  225. const fields = extractDuplicateFields(err.details);
  226. const mapped = mapDuplicateFieldsToGermanMessage(fields);
  227. if (mapped) {
  228. setError(mapped);
  229. notifyError(mapped);
  230. return;
  231. }
  232. }
  233. setError({
  234. title: "Benutzer konnte nicht angelegt werden.",
  235. description:
  236. "Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
  237. });
  238. notifyApiError(err, {
  239. fallbackTitle: "Benutzer konnte nicht angelegt werden.",
  240. fallbackDescription:
  241. "Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
  242. });
  243. return;
  244. }
  245. setError({
  246. title: "Benutzer konnte nicht angelegt werden.",
  247. description: "Bitte versuchen Sie es erneut.",
  248. });
  249. notifyError({
  250. title: "Benutzer konnte nicht angelegt werden.",
  251. description: "Bitte versuchen Sie es erneut.",
  252. });
  253. } finally {
  254. setIsSubmitting(false);
  255. }
  256. },
  257. [
  258. effectiveDisabled,
  259. form,
  260. onCreated,
  261. redirectToLoginExpired,
  262. resetForm,
  263. branchExistence.shouldBlockSubmit,
  264. ],
  265. );
  266. return {
  267. open,
  268. setOpen,
  269. form,
  270. setPatch,
  271. error,
  272. policyLines,
  273. branchesStatus,
  274. branchExistence,
  275. isSubmitting,
  276. effectiveDisabled,
  277. canSubmit,
  278. handleSubmit,
  279. handleOpenChange,
  280. };
  281. }