UserTemporaryPasswordField.jsx 6.9 KB

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