DeleteUserDialog.jsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. "use client";
  2. import React from "react";
  3. import { Trash2 } from "lucide-react";
  4. import {
  5. Dialog,
  6. DialogContent,
  7. DialogDescription,
  8. DialogFooter,
  9. DialogHeader,
  10. DialogTitle,
  11. DialogTrigger,
  12. } from "@/components/ui/dialog";
  13. import { Button } from "@/components/ui/button";
  14. import { Badge } from "@/components/ui/badge";
  15. import { Input } from "@/components/ui/input";
  16. import { Label } from "@/components/ui/label";
  17. import { adminDeleteUser, ApiClientError } from "@/lib/frontend/apiClient";
  18. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  19. import { isUsernameConfirmationMatch } from "@/lib/frontend/admin/users/userManagementUx";
  20. import {
  21. notifySuccess,
  22. notifyError,
  23. notifyApiError,
  24. } from "@/lib/frontend/ui/toast";
  25. import { ROLE_LABELS_DE } from "@/components/admin/users/usersUi";
  26. function formatUserLabel(user) {
  27. const username = typeof user?.username === "string" ? user.username : "—";
  28. const email = typeof user?.email === "string" ? user.email : "—";
  29. return `${username} (${email})`;
  30. }
  31. export default function DeleteUserDialog({
  32. user,
  33. disabled = false,
  34. onDeleted,
  35. }) {
  36. const [open, setOpen] = React.useState(false);
  37. const [isSubmitting, setIsSubmitting] = React.useState(false);
  38. const [error, setError] = React.useState(null);
  39. const [typedUsername, setTypedUsername] = React.useState("");
  40. const effectiveDisabled = Boolean(disabled || isSubmitting);
  41. const canConfirmDelete = isUsernameConfirmationMatch({
  42. expectedUsername: user?.username,
  43. typedUsername,
  44. });
  45. const redirectToLoginExpired = React.useCallback(() => {
  46. const next =
  47. typeof window !== "undefined"
  48. ? `${window.location.pathname}${window.location.search}`
  49. : "/admin/users";
  50. window.location.replace(
  51. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
  52. );
  53. }, []);
  54. const handleOpenChange = React.useCallback((nextOpen) => {
  55. setOpen(nextOpen);
  56. if (!nextOpen) {
  57. setError(null);
  58. setTypedUsername("");
  59. }
  60. }, []);
  61. const handleDelete = React.useCallback(async () => {
  62. if (!user?.id) return;
  63. if (effectiveDisabled || !canConfirmDelete) return;
  64. setError(null);
  65. setIsSubmitting(true);
  66. try {
  67. await adminDeleteUser(String(user.id));
  68. notifySuccess({
  69. title: "Benutzer gelöscht",
  70. description: `"${formatUserLabel(user)}" wurde entfernt.`,
  71. });
  72. setOpen(false);
  73. if (typeof onDeleted === "function") onDeleted();
  74. } catch (err) {
  75. if (err instanceof ApiClientError) {
  76. if (err.code === "AUTH_UNAUTHENTICATED") {
  77. notifyApiError(err);
  78. redirectToLoginExpired();
  79. return;
  80. }
  81. if (err.code === "USER_NOT_FOUND") {
  82. const mapped = {
  83. title: "Benutzer nicht gefunden.",
  84. description:
  85. "Der Benutzer existiert nicht (mehr). Bitte aktualisieren Sie die Liste.",
  86. };
  87. setError(mapped);
  88. notifyError(mapped);
  89. return;
  90. }
  91. if (
  92. err.code === "VALIDATION_INVALID_FIELD" &&
  93. err.details?.reason === "SELF_DELETE_FORBIDDEN"
  94. ) {
  95. const mapped = {
  96. title: "Nicht möglich",
  97. description: "Sie können Ihr eigenes Konto nicht löschen.",
  98. };
  99. setError(mapped);
  100. notifyError(mapped);
  101. return;
  102. }
  103. setError({
  104. title: "Benutzer konnte nicht gelöscht werden.",
  105. description: "Bitte versuchen Sie es erneut.",
  106. });
  107. notifyApiError(err, {
  108. fallbackTitle: "Benutzer konnte nicht gelöscht werden.",
  109. fallbackDescription: "Bitte versuchen Sie es erneut.",
  110. });
  111. return;
  112. }
  113. setError({
  114. title: "Benutzer konnte nicht gelöscht werden.",
  115. description: "Bitte versuchen Sie es erneut.",
  116. });
  117. notifyError({
  118. title: "Benutzer konnte nicht gelöscht werden.",
  119. description: "Bitte versuchen Sie es erneut.",
  120. });
  121. } finally {
  122. setIsSubmitting(false);
  123. }
  124. }, [user, effectiveDisabled, canConfirmDelete, onDeleted, redirectToLoginExpired]);
  125. if (!user || typeof user.id !== "string" || !user.id) return null;
  126. const roleLabel = ROLE_LABELS_DE[user.role] || String(user.role || "—");
  127. return (
  128. <Dialog open={open} onOpenChange={handleOpenChange}>
  129. <DialogTrigger asChild>
  130. <Button
  131. type="button"
  132. variant="destructive"
  133. size="icon-sm"
  134. disabled={disabled}
  135. title="Benutzer löschen"
  136. aria-label="Benutzer löschen"
  137. >
  138. <Trash2 className="h-4 w-4" />
  139. </Button>
  140. </DialogTrigger>
  141. <DialogContent className="sm:max-w-lg">
  142. <DialogHeader>
  143. <DialogTitle>Benutzer löschen</DialogTitle>
  144. <DialogDescription>
  145. Diese Aktion kann nicht rückgängig gemacht werden.
  146. </DialogDescription>
  147. </DialogHeader>
  148. <div className="space-y-3">
  149. <div className="rounded-lg border p-3">
  150. <div className="text-sm font-medium">{formatUserLabel(user)}</div>
  151. <div className="mt-2 flex flex-wrap gap-2">
  152. <Badge variant="secondary">{roleLabel}</Badge>
  153. {user.branchId ? (
  154. <Badge variant="outline">{user.branchId}</Badge>
  155. ) : null}
  156. {user.mustChangePassword ? (
  157. <Badge variant="destructive">Passwortwechsel</Badge>
  158. ) : (
  159. <Badge variant="secondary">Kein Passwortwechsel</Badge>
  160. )}
  161. </div>
  162. </div>
  163. <div className="space-y-2 rounded-lg border border-destructive/50 bg-destructive/5 p-3">
  164. <p className="text-sm font-medium text-destructive">
  165. Achtung: Dieser Benutzer wird dauerhaft gelöscht.
  166. </p>
  167. <p className="text-sm text-muted-foreground">
  168. Bitte geben Sie den Benutzernamen{" "}
  169. <strong>{user.username}</strong> ein, um das Löschen zu
  170. bestätigen.
  171. </p>
  172. <div className="grid gap-2">
  173. <Label htmlFor="du-confirm-username">
  174. Bitte Benutzernamen eingeben, um zu bestätigen
  175. </Label>
  176. <Input
  177. id="du-confirm-username"
  178. value={typedUsername}
  179. onChange={(e) => setTypedUsername(e.target.value)}
  180. disabled={effectiveDisabled}
  181. autoCapitalize="none"
  182. autoCorrect="off"
  183. spellCheck={false}
  184. placeholder={String(user.username || "")}
  185. />
  186. </div>
  187. </div>
  188. {error ? (
  189. <div className="rounded-lg border border-destructive/40 bg-card p-3">
  190. <p className="text-sm font-medium text-destructive">
  191. {error.title}
  192. </p>
  193. {error.description ? (
  194. <p className="mt-1 text-sm text-muted-foreground">
  195. {error.description}
  196. </p>
  197. ) : null}
  198. </div>
  199. ) : null}
  200. </div>
  201. <DialogFooter>
  202. <Button
  203. type="button"
  204. variant="outline"
  205. disabled={effectiveDisabled}
  206. onClick={() => setOpen(false)}
  207. >
  208. Abbrechen
  209. </Button>
  210. <Button
  211. type="button"
  212. variant="destructive"
  213. disabled={effectiveDisabled || !canConfirmDelete}
  214. onClick={handleDelete}
  215. title="Endgültig löschen"
  216. >
  217. {isSubmitting ? "Löscht…" : "Löschen"}
  218. </Button>
  219. </DialogFooter>
  220. </DialogContent>
  221. </Dialog>
  222. );
  223. }