UserTemporaryPasswordField.jsx 7.0 KB

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