useEditUserDialog.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. "use client";
  2. import React from "react";
  3. import { adminUpdateUser, ApiClientError } from "@/lib/frontend/apiClient";
  4. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  5. import { useSearchBranches } from "@/lib/frontend/search/useSearchBranches";
  6. import {
  7. evaluateBranchExistence,
  8. isValidBranchIdFormat,
  9. normalizeBranchIdInput,
  10. } from "@/lib/frontend/admin/users/userManagementUx";
  11. import {
  12. notifySuccess,
  13. notifyError,
  14. notifyApiError,
  15. notifyInfo,
  16. } from "@/lib/frontend/ui/toast";
  17. import {
  18. EDIT_ROLE_OPTIONS,
  19. EMAIL_RE,
  20. normalizeUsername,
  21. normalizeEmail,
  22. } from "@/components/admin/users/edit-user/editUserUtils";
  23. function isNonEmptyString(value) {
  24. return typeof value === "string" && value.trim().length > 0;
  25. }
  26. function buildInitialFormFromUser(user) {
  27. return {
  28. username: typeof user?.username === "string" ? user.username : "",
  29. email: typeof user?.email === "string" ? user.email : "",
  30. role: typeof user?.role === "string" ? user.role : "branch",
  31. branchId: typeof user?.branchId === "string" ? user.branchId : "",
  32. mustChangePassword: Boolean(user?.mustChangePassword),
  33. };
  34. }
  35. function validateClient(form) {
  36. const username = normalizeUsername(form?.username);
  37. const email = normalizeEmail(form?.email);
  38. const role = String(form?.role || "").trim();
  39. const isKnownRole = EDIT_ROLE_OPTIONS.some((x) => x.value === role);
  40. const branchId =
  41. role === "branch" ? normalizeBranchIdInput(form?.branchId || "") : "";
  42. if (!username) return { title: "Benutzername fehlt.", description: null };
  43. if (!email) return { title: "E-Mail fehlt.", description: null };
  44. if (!EMAIL_RE.test(email)) {
  45. return {
  46. title: "E-Mail ist ungültig.",
  47. description: "Bitte prüfen Sie die Eingabe.",
  48. };
  49. }
  50. if (!role || !isKnownRole) {
  51. return { title: "Rolle fehlt.", description: null };
  52. }
  53. if (role === "branch") {
  54. if (!branchId) {
  55. return {
  56. title: "Niederlassung fehlt.",
  57. description: "Für Niederlassungs-User ist eine NL erforderlich.",
  58. };
  59. }
  60. if (!isValidBranchIdFormat(branchId)) {
  61. return {
  62. title: "Niederlassung ist ungültig.",
  63. description: "Format: NL01, NL02, ...",
  64. };
  65. }
  66. }
  67. return null;
  68. }
  69. function extractDuplicateField(details) {
  70. if (!details || typeof details !== "object") return null;
  71. if (typeof details.field === "string" && details.field.trim()) {
  72. return details.field.trim();
  73. }
  74. return null;
  75. }
  76. function mapDuplicateFieldToGermanMessage(field) {
  77. if (field === "username") {
  78. return {
  79. title: "Benutzername existiert bereits.",
  80. description: "Bitte wählen Sie einen anderen Benutzernamen.",
  81. };
  82. }
  83. if (field === "email") {
  84. return {
  85. title: "E-Mail existiert bereits.",
  86. description: "Bitte wählen Sie eine andere E-Mail-Adresse.",
  87. };
  88. }
  89. return null;
  90. }
  91. function buildNormalizedForm(form) {
  92. const role = String(form?.role || "").trim();
  93. return {
  94. username: normalizeUsername(form?.username),
  95. email: normalizeEmail(form?.email),
  96. role,
  97. branchId:
  98. role === "branch" ? normalizeBranchIdInput(form?.branchId || "") : "",
  99. mustChangePassword: Boolean(form?.mustChangePassword),
  100. };
  101. }
  102. function buildPatch({ user, form }) {
  103. const initial = buildNormalizedForm(buildInitialFormFromUser(user));
  104. const current = buildNormalizedForm(form);
  105. const patch = {};
  106. if (current.username && current.username !== initial.username) {
  107. patch.username = current.username;
  108. }
  109. if (current.email && current.email !== initial.email) {
  110. patch.email = current.email;
  111. }
  112. if (current.role && current.role !== initial.role) {
  113. patch.role = current.role;
  114. }
  115. // branchId handling:
  116. // - Only meaningful for role=branch
  117. // - If role becomes non-branch, backend will clear branchId automatically.
  118. if (current.role === "branch") {
  119. // If role is branch (either unchanged or changed), enforce branchId in patch when different
  120. if (current.branchId !== initial.branchId) {
  121. patch.branchId = current.branchId;
  122. }
  123. } else {
  124. // If user was branch before and we did not change role explicitly (rare),
  125. // we do not auto-clear here. Backend will enforce consistency based on role updates.
  126. }
  127. if (current.mustChangePassword !== initial.mustChangePassword) {
  128. patch.mustChangePassword = current.mustChangePassword;
  129. }
  130. return patch;
  131. }
  132. export function useEditUserDialog({ user, disabled = false, onUpdated } = {}) {
  133. const [open, setOpen] = React.useState(false);
  134. const [isSubmitting, setIsSubmitting] = React.useState(false);
  135. const [form, setForm] = React.useState(() => buildInitialFormFromUser(user));
  136. const [error, setError] = React.useState(null);
  137. const role = String(form?.role || "branch").trim();
  138. const shouldLoadBranches = open && role === "branch";
  139. const { status: branchesStatus, branches: availableBranchIds } =
  140. useSearchBranches({ enabled: shouldLoadBranches });
  141. const effectiveDisabled = Boolean(disabled || isSubmitting || !user?.id);
  142. const branchExistence = React.useMemo(
  143. () =>
  144. evaluateBranchExistence({
  145. role,
  146. branchId: form?.branchId,
  147. branchesStatus,
  148. availableBranchIds,
  149. }),
  150. [role, form?.branchId, branchesStatus, availableBranchIds],
  151. );
  152. const patchPreview = React.useMemo(() => {
  153. return buildPatch({ user, form });
  154. }, [
  155. user?.id,
  156. user?.username,
  157. user?.email,
  158. user?.role,
  159. user?.branchId,
  160. user?.mustChangePassword,
  161. form?.username,
  162. form?.email,
  163. form?.role,
  164. form?.branchId,
  165. form?.mustChangePassword,
  166. ]);
  167. const canSubmit = React.useMemo(() => {
  168. return (
  169. !effectiveDisabled &&
  170. Object.keys(patchPreview).length > 0 &&
  171. !branchExistence.shouldBlockSubmit
  172. );
  173. }, [effectiveDisabled, patchPreview, branchExistence.shouldBlockSubmit]);
  174. const setPatch = React.useCallback((patch) => {
  175. setForm((prev) => ({ ...prev, ...(patch || {}) }));
  176. }, []);
  177. const resetForm = React.useCallback(() => {
  178. setForm(buildInitialFormFromUser(user));
  179. setError(null);
  180. }, [user]);
  181. const redirectToLoginExpired = React.useCallback(() => {
  182. const next =
  183. typeof window !== "undefined"
  184. ? `${window.location.pathname}${window.location.search}`
  185. : "/admin/users";
  186. window.location.replace(
  187. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
  188. );
  189. }, []);
  190. const handleOpenChange = React.useCallback(
  191. (nextOpen) => {
  192. setOpen(nextOpen);
  193. // On open -> initialize from current user snapshot.
  194. // On close -> clear error and reset form so reopening is always clean.
  195. if (nextOpen) {
  196. setForm(buildInitialFormFromUser(user));
  197. setError(null);
  198. } else {
  199. resetForm();
  200. }
  201. },
  202. [user, resetForm],
  203. );
  204. const handleSubmit = React.useCallback(
  205. async (e) => {
  206. e?.preventDefault?.();
  207. if (effectiveDisabled) return;
  208. setError(null);
  209. const clientErr = validateClient(form);
  210. if (clientErr) {
  211. setError(clientErr);
  212. notifyError({
  213. title: clientErr.title,
  214. description: clientErr.description,
  215. });
  216. return;
  217. }
  218. if (branchExistence.shouldBlockSubmit) {
  219. const mapped = {
  220. title: "Niederlassung existiert nicht.",
  221. description:
  222. "Die gewählte Niederlassung ist nicht in der aktuellen Liste vorhanden.",
  223. };
  224. setError(mapped);
  225. notifyError(mapped);
  226. return;
  227. }
  228. const patch = buildPatch({ user, form });
  229. if (Object.keys(patch).length === 0) {
  230. notifyInfo({
  231. title: "Keine Änderungen",
  232. description: "Es wurden keine Felder geändert.",
  233. });
  234. setOpen(false);
  235. resetForm();
  236. return;
  237. }
  238. setIsSubmitting(true);
  239. try {
  240. await adminUpdateUser(String(user.id), patch);
  241. notifySuccess({
  242. title: "Benutzer aktualisiert",
  243. description: `Benutzer "${normalizeUsername(form.username)}" wurde gespeichert.`,
  244. });
  245. setOpen(false);
  246. resetForm();
  247. if (typeof onUpdated === "function") onUpdated();
  248. } catch (err) {
  249. if (err instanceof ApiClientError) {
  250. if (err.code === "AUTH_UNAUTHENTICATED") {
  251. notifyApiError(err);
  252. redirectToLoginExpired();
  253. return;
  254. }
  255. if (err.code === "USER_NOT_FOUND") {
  256. const mapped = {
  257. title: "Benutzer nicht gefunden.",
  258. description:
  259. "Der Benutzer existiert nicht (mehr). Bitte aktualisieren Sie die Liste.",
  260. };
  261. setError(mapped);
  262. notifyError(mapped);
  263. return;
  264. }
  265. if (err.code === "VALIDATION_MISSING_FIELD") {
  266. const fields = err.details?.fields;
  267. if (Array.isArray(fields) && fields.includes("branchId")) {
  268. const mapped = {
  269. title: "Niederlassung fehlt.",
  270. description:
  271. "Für Niederlassungs-User ist eine Niederlassung erforderlich.",
  272. };
  273. setError(mapped);
  274. notifyError(mapped);
  275. return;
  276. }
  277. }
  278. if (err.code === "VALIDATION_INVALID_FIELD") {
  279. const field = extractDuplicateField(err.details);
  280. const mapped = mapDuplicateFieldToGermanMessage(field);
  281. if (mapped) {
  282. setError(mapped);
  283. notifyError(mapped);
  284. return;
  285. }
  286. }
  287. setError({
  288. title: "Benutzer konnte nicht aktualisiert werden.",
  289. description:
  290. "Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
  291. });
  292. notifyApiError(err, {
  293. fallbackTitle: "Benutzer konnte nicht aktualisiert werden.",
  294. fallbackDescription:
  295. "Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.",
  296. });
  297. return;
  298. }
  299. setError({
  300. title: "Benutzer konnte nicht aktualisiert werden.",
  301. description: "Bitte versuchen Sie es erneut.",
  302. });
  303. notifyError({
  304. title: "Benutzer konnte nicht aktualisiert werden.",
  305. description: "Bitte versuchen Sie es erneut.",
  306. });
  307. } finally {
  308. setIsSubmitting(false);
  309. }
  310. },
  311. [
  312. effectiveDisabled,
  313. form,
  314. user,
  315. onUpdated,
  316. redirectToLoginExpired,
  317. resetForm,
  318. branchExistence.shouldBlockSubmit,
  319. ],
  320. );
  321. return {
  322. open,
  323. handleOpenChange,
  324. form,
  325. setPatch,
  326. error,
  327. branchesStatus,
  328. branchExistence,
  329. isSubmitting,
  330. effectiveDisabled,
  331. canSubmit,
  332. handleSubmit,
  333. };
  334. }