|
|
@@ -0,0 +1,264 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import React from "react";
|
|
|
+import {
|
|
|
+ Check,
|
|
|
+ Copy,
|
|
|
+ Eye,
|
|
|
+ EyeOff,
|
|
|
+ KeyRound,
|
|
|
+ Loader2,
|
|
|
+} from "lucide-react";
|
|
|
+
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
+import { Input } from "@/components/ui/input";
|
|
|
+
|
|
|
+import {
|
|
|
+ adminResetUserPassword,
|
|
|
+ ApiClientError,
|
|
|
+} from "@/lib/frontend/apiClient";
|
|
|
+import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
|
|
|
+import {
|
|
|
+ getDisplayedTemporaryPassword,
|
|
|
+ hasTemporaryPassword,
|
|
|
+} from "@/lib/frontend/admin/users/userManagementUx";
|
|
|
+import {
|
|
|
+ notifySuccess,
|
|
|
+ notifyError,
|
|
|
+ notifyApiError,
|
|
|
+} from "@/lib/frontend/ui/toast";
|
|
|
+
|
|
|
+function useCopySuccessTimeout(isActive, onReset) {
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (!isActive) return undefined;
|
|
|
+ const timer = window.setTimeout(() => onReset?.(), 1200);
|
|
|
+ return () => window.clearTimeout(timer);
|
|
|
+ }, [isActive, onReset]);
|
|
|
+}
|
|
|
+
|
|
|
+export default function UserTemporaryPasswordField({
|
|
|
+ user,
|
|
|
+ temporaryPassword,
|
|
|
+ onTemporaryPasswordChange,
|
|
|
+ onPasswordReset,
|
|
|
+ disabled = false,
|
|
|
+ compact = false,
|
|
|
+}) {
|
|
|
+ const [isVisible, setIsVisible] = React.useState(false);
|
|
|
+ const [isResetting, setIsResetting] = React.useState(false);
|
|
|
+ const [copySuccess, setCopySuccess] = React.useState(false);
|
|
|
+
|
|
|
+ const hasTempPassword = hasTemporaryPassword(temporaryPassword);
|
|
|
+ const isDisabled = Boolean(disabled || isResetting || !user?.id);
|
|
|
+
|
|
|
+ useCopySuccessTimeout(copySuccess, () => setCopySuccess(false));
|
|
|
+
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (hasTempPassword) return;
|
|
|
+ setIsVisible(false);
|
|
|
+ setCopySuccess(false);
|
|
|
+ }, [hasTempPassword]);
|
|
|
+
|
|
|
+ const redirectToLoginExpired = React.useCallback(() => {
|
|
|
+ const next =
|
|
|
+ typeof window !== "undefined"
|
|
|
+ ? `${window.location.pathname}${window.location.search}`
|
|
|
+ : "/admin/users";
|
|
|
+
|
|
|
+ window.location.replace(
|
|
|
+ buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
|
|
|
+ );
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleResetPassword = React.useCallback(async () => {
|
|
|
+ if (!user?.id || isDisabled) return;
|
|
|
+
|
|
|
+ setIsResetting(true);
|
|
|
+ setCopySuccess(false);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await adminResetUserPassword(String(user.id));
|
|
|
+ const nextPassword =
|
|
|
+ typeof result?.temporaryPassword === "string"
|
|
|
+ ? result.temporaryPassword
|
|
|
+ : "";
|
|
|
+
|
|
|
+ if (!nextPassword) {
|
|
|
+ throw new Error("Missing temporaryPassword in reset response");
|
|
|
+ }
|
|
|
+
|
|
|
+ onTemporaryPasswordChange?.(nextPassword);
|
|
|
+ setIsVisible(false);
|
|
|
+
|
|
|
+ notifySuccess({
|
|
|
+ title: "Temporäres Passwort gesetzt",
|
|
|
+ description: `Für "${user.username}" wurde ein neues Startpasswort erstellt.`,
|
|
|
+ });
|
|
|
+
|
|
|
+ onPasswordReset?.();
|
|
|
+ } catch (err) {
|
|
|
+ if (err instanceof ApiClientError) {
|
|
|
+ if (err.code === "AUTH_UNAUTHENTICATED") {
|
|
|
+ notifyApiError(err);
|
|
|
+ redirectToLoginExpired();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (
|
|
|
+ err.code === "VALIDATION_INVALID_FIELD" &&
|
|
|
+ err.details?.reason === "SELF_PASSWORD_RESET_FORBIDDEN"
|
|
|
+ ) {
|
|
|
+ notifyError({
|
|
|
+ title: "Nicht möglich",
|
|
|
+ description:
|
|
|
+ "Sie können Ihr eigenes Passwort hier nicht zurücksetzen.",
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (err.code === "USER_NOT_FOUND") {
|
|
|
+ notifyError({
|
|
|
+ title: "Benutzer nicht gefunden.",
|
|
|
+ description:
|
|
|
+ "Der Benutzer existiert nicht (mehr). Bitte aktualisieren Sie die Liste.",
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ notifyApiError(err, {
|
|
|
+ fallbackTitle: "Passwort konnte nicht zurückgesetzt werden.",
|
|
|
+ fallbackDescription: "Bitte versuchen Sie es erneut.",
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ notifyError({
|
|
|
+ title: "Passwort konnte nicht zurückgesetzt werden.",
|
|
|
+ description: "Bitte versuchen Sie es erneut.",
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ setIsResetting(false);
|
|
|
+ }
|
|
|
+ }, [
|
|
|
+ user?.id,
|
|
|
+ user?.username,
|
|
|
+ isDisabled,
|
|
|
+ onTemporaryPasswordChange,
|
|
|
+ onPasswordReset,
|
|
|
+ redirectToLoginExpired,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const handleToggleVisible = React.useCallback(() => {
|
|
|
+ if (!hasTempPassword || isDisabled) return;
|
|
|
+ setIsVisible((prev) => !prev);
|
|
|
+ }, [hasTempPassword, isDisabled]);
|
|
|
+
|
|
|
+ const handleCopyPassword = React.useCallback(async () => {
|
|
|
+ if (!hasTempPassword || isDisabled) return;
|
|
|
+ if (!navigator?.clipboard?.writeText) {
|
|
|
+ notifyError({
|
|
|
+ title: "Kopieren nicht verfügbar",
|
|
|
+ description: "Die Zwischenablage ist in diesem Browser nicht verfügbar.",
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(temporaryPassword);
|
|
|
+ setCopySuccess(true);
|
|
|
+ } catch {
|
|
|
+ notifyError({
|
|
|
+ title: "Passwort konnte nicht kopiert werden.",
|
|
|
+ description: "Bitte erneut versuchen.",
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, [hasTempPassword, isDisabled, temporaryPassword]);
|
|
|
+
|
|
|
+ const displayValue = getDisplayedTemporaryPassword({
|
|
|
+ temporaryPassword,
|
|
|
+ isVisible,
|
|
|
+ });
|
|
|
+
|
|
|
+ const controls = (
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ variant="outline"
|
|
|
+ size="icon-sm"
|
|
|
+ disabled={isDisabled}
|
|
|
+ onClick={handleResetPassword}
|
|
|
+ title="Temporäres Passwort setzen"
|
|
|
+ aria-label="Temporäres Passwort setzen"
|
|
|
+ >
|
|
|
+ {isResetting ? (
|
|
|
+ <Loader2 className="h-4 w-4 animate-spin" />
|
|
|
+ ) : (
|
|
|
+ <KeyRound className="h-4 w-4" />
|
|
|
+ )}
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ variant="outline"
|
|
|
+ size="icon-sm"
|
|
|
+ disabled={isDisabled || !hasTempPassword}
|
|
|
+ onClick={handleToggleVisible}
|
|
|
+ title={isVisible ? "Passwort verbergen" : "Passwort anzeigen"}
|
|
|
+ aria-label={isVisible ? "Passwort verbergen" : "Passwort anzeigen"}
|
|
|
+ >
|
|
|
+ {isVisible ? (
|
|
|
+ <EyeOff className="h-4 w-4" />
|
|
|
+ ) : (
|
|
|
+ <Eye className="h-4 w-4" />
|
|
|
+ )}
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ variant="outline"
|
|
|
+ size="icon-sm"
|
|
|
+ disabled={isDisabled || !hasTempPassword}
|
|
|
+ onClick={handleCopyPassword}
|
|
|
+ title="Passwort kopieren"
|
|
|
+ aria-label="Passwort kopieren"
|
|
|
+ >
|
|
|
+ {copySuccess ? (
|
|
|
+ <Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
|
|
+ ) : (
|
|
|
+ <Copy className="h-4 w-4" />
|
|
|
+ )}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ if (compact) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center justify-between gap-2">
|
|
|
+ <span className="truncate font-mono text-xs tracking-wide text-foreground">
|
|
|
+ {displayValue}
|
|
|
+ </span>
|
|
|
+ {controls}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="grid gap-2">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Input
|
|
|
+ value={displayValue}
|
|
|
+ readOnly
|
|
|
+ disabled
|
|
|
+ className="font-mono tracking-wide"
|
|
|
+ />
|
|
|
+ {controls}
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ {hasTempPassword
|
|
|
+ ? "Das temporäre Passwort ist nur in dieser Ansicht verfügbar."
|
|
|
+ : "Noch kein temporäres Passwort gesetzt. Bitte zuerst zurücksetzen."}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|