UserTemporaryPasswordField.jsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. "use client";
  2. import React from "react";
  3. import {
  4. Check,
  5. Copy,
  6. Eye,
  7. EyeOff,
  8. KeyRound,
  9. Loader2,
  10. } from "lucide-react";
  11. import { Button } from "@/components/ui/button";
  12. import { Input } from "@/components/ui/input";
  13. import {
  14. adminResetUserPassword,
  15. ApiClientError,
  16. } from "@/lib/frontend/apiClient";
  17. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  18. import {
  19. getDisplayedTemporaryPassword,
  20. hasTemporaryPassword,
  21. } from "@/lib/frontend/admin/users/userManagementUx";
  22. import {
  23. notifySuccess,
  24. notifyError,
  25. notifyApiError,
  26. } from "@/lib/frontend/ui/toast";
  27. function useCopySuccessTimeout(isActive, onReset) {
  28. React.useEffect(() => {
  29. if (!isActive) return undefined;
  30. const timer = window.setTimeout(() => onReset?.(), 1200);
  31. return () => window.clearTimeout(timer);
  32. }, [isActive, onReset]);
  33. }
  34. export default function UserTemporaryPasswordField({
  35. user,
  36. temporaryPassword,
  37. onTemporaryPasswordChange,
  38. onPasswordReset,
  39. disabled = false,
  40. compact = false,
  41. }) {
  42. const [isVisible, setIsVisible] = React.useState(false);
  43. const [isResetting, setIsResetting] = React.useState(false);
  44. const [copySuccess, setCopySuccess] = React.useState(false);
  45. const hasTempPassword = hasTemporaryPassword(temporaryPassword);
  46. const isDisabled = Boolean(disabled || isResetting || !user?.id);
  47. useCopySuccessTimeout(copySuccess, () => setCopySuccess(false));
  48. React.useEffect(() => {
  49. if (hasTempPassword) return;
  50. setIsVisible(false);
  51. setCopySuccess(false);
  52. }, [hasTempPassword]);
  53. const redirectToLoginExpired = React.useCallback(() => {
  54. const next =
  55. typeof window !== "undefined"
  56. ? `${window.location.pathname}${window.location.search}`
  57. : "/admin/users";
  58. window.location.replace(
  59. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
  60. );
  61. }, []);
  62. const handleResetPassword = React.useCallback(async () => {
  63. if (!user?.id || isDisabled) return;
  64. setIsResetting(true);
  65. setCopySuccess(false);
  66. try {
  67. const result = await adminResetUserPassword(String(user.id));
  68. const nextPassword =
  69. typeof result?.temporaryPassword === "string"
  70. ? result.temporaryPassword
  71. : "";
  72. if (!nextPassword) {
  73. throw new Error("Missing temporaryPassword in reset response");
  74. }
  75. onTemporaryPasswordChange?.(nextPassword);
  76. setIsVisible(false);
  77. notifySuccess({
  78. title: "Temporäres Passwort gesetzt",
  79. description: `Für "${user.username}" wurde ein neues Startpasswort erstellt.`,
  80. });
  81. onPasswordReset?.();
  82. } catch (err) {
  83. if (err instanceof ApiClientError) {
  84. if (err.code === "AUTH_UNAUTHENTICATED") {
  85. notifyApiError(err);
  86. redirectToLoginExpired();
  87. return;
  88. }
  89. if (
  90. err.code === "VALIDATION_INVALID_FIELD" &&
  91. err.details?.reason === "SELF_PASSWORD_RESET_FORBIDDEN"
  92. ) {
  93. notifyError({
  94. title: "Nicht möglich",
  95. description:
  96. "Sie können Ihr eigenes Passwort hier nicht zurücksetzen.",
  97. });
  98. return;
  99. }
  100. if (err.code === "USER_NOT_FOUND") {
  101. notifyError({
  102. title: "Benutzer nicht gefunden.",
  103. description:
  104. "Der Benutzer existiert nicht (mehr). Bitte aktualisieren Sie die Liste.",
  105. });
  106. return;
  107. }
  108. notifyApiError(err, {
  109. fallbackTitle: "Passwort konnte nicht zurückgesetzt werden.",
  110. fallbackDescription: "Bitte versuchen Sie es erneut.",
  111. });
  112. return;
  113. }
  114. notifyError({
  115. title: "Passwort konnte nicht zurückgesetzt werden.",
  116. description: "Bitte versuchen Sie es erneut.",
  117. });
  118. } finally {
  119. setIsResetting(false);
  120. }
  121. }, [
  122. user?.id,
  123. user?.username,
  124. isDisabled,
  125. onTemporaryPasswordChange,
  126. onPasswordReset,
  127. redirectToLoginExpired,
  128. ]);
  129. const handleToggleVisible = React.useCallback(() => {
  130. if (!hasTempPassword || isDisabled) return;
  131. setIsVisible((prev) => !prev);
  132. }, [hasTempPassword, isDisabled]);
  133. const handleCopyPassword = React.useCallback(async () => {
  134. if (!hasTempPassword || isDisabled) return;
  135. if (!navigator?.clipboard?.writeText) {
  136. notifyError({
  137. title: "Kopieren nicht verfügbar",
  138. description: "Die Zwischenablage ist in diesem Browser nicht verfügbar.",
  139. });
  140. return;
  141. }
  142. try {
  143. await navigator.clipboard.writeText(temporaryPassword);
  144. setCopySuccess(true);
  145. } catch {
  146. notifyError({
  147. title: "Passwort konnte nicht kopiert werden.",
  148. description: "Bitte erneut versuchen.",
  149. });
  150. }
  151. }, [hasTempPassword, isDisabled, temporaryPassword]);
  152. const displayValue = getDisplayedTemporaryPassword({
  153. temporaryPassword,
  154. isVisible,
  155. });
  156. const controls = (
  157. <div className="flex items-center gap-1">
  158. <Button
  159. type="button"
  160. variant="outline"
  161. size="icon-sm"
  162. disabled={isDisabled}
  163. onClick={handleResetPassword}
  164. title="Temporäres Passwort setzen"
  165. aria-label="Temporäres Passwort setzen"
  166. >
  167. {isResetting ? (
  168. <Loader2 className="h-4 w-4 animate-spin" />
  169. ) : (
  170. <KeyRound className="h-4 w-4" />
  171. )}
  172. </Button>
  173. <Button
  174. type="button"
  175. variant="outline"
  176. size="icon-sm"
  177. disabled={isDisabled || !hasTempPassword}
  178. onClick={handleToggleVisible}
  179. title={isVisible ? "Passwort verbergen" : "Passwort anzeigen"}
  180. aria-label={isVisible ? "Passwort verbergen" : "Passwort anzeigen"}
  181. >
  182. {isVisible ? (
  183. <EyeOff className="h-4 w-4" />
  184. ) : (
  185. <Eye className="h-4 w-4" />
  186. )}
  187. </Button>
  188. <Button
  189. type="button"
  190. variant="outline"
  191. size="icon-sm"
  192. disabled={isDisabled || !hasTempPassword}
  193. onClick={handleCopyPassword}
  194. title="Passwort kopieren"
  195. aria-label="Passwort kopieren"
  196. >
  197. {copySuccess ? (
  198. <Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
  199. ) : (
  200. <Copy className="h-4 w-4" />
  201. )}
  202. </Button>
  203. </div>
  204. );
  205. if (compact) {
  206. return (
  207. <div className="flex items-center justify-between gap-2">
  208. <span className="truncate font-mono text-xs tracking-wide text-foreground">
  209. {displayValue}
  210. </span>
  211. {controls}
  212. </div>
  213. );
  214. }
  215. return (
  216. <div className="grid gap-2">
  217. <div className="flex items-center gap-2">
  218. <Input
  219. value={displayValue}
  220. readOnly
  221. disabled
  222. className="font-mono tracking-wide"
  223. />
  224. {controls}
  225. </div>
  226. <p className="text-xs text-muted-foreground">
  227. {hasTempPassword
  228. ? "Das temporäre Passwort ist nur in dieser Ansicht verfügbar."
  229. : "Noch kein temporäres Passwort gesetzt. Bitte zuerst zurücksetzen."}
  230. </p>
  231. </div>
  232. );
  233. }