useEditUserDialog.js 9.0 KB

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