Просмотр исходного кода

RHL-043 feat(admin-users): add temporary password controls in UI

Code_Uwe 1 месяц назад
Родитель
Сommit
31128b0e8d

+ 11 - 1
components/admin/users/EditUserDialog.jsx

@@ -16,7 +16,14 @@ import { Button } from "@/components/ui/button";
 import EditUserForm from "@/components/admin/users/edit-user/EditUserForm";
 import { useEditUserDialog } from "@/components/admin/users/edit-user/useEditUserDialog";
 
-export default function EditUserDialog({ user, disabled = false, onUpdated }) {
+export default function EditUserDialog({
+	user,
+	disabled = false,
+	onUpdated,
+	onPasswordReset,
+	temporaryPassword,
+	onTemporaryPasswordChange,
+}) {
 	const {
 		open,
 		handleOpenChange,
@@ -67,6 +74,9 @@ export default function EditUserDialog({ user, disabled = false, onUpdated }) {
 					isSubmitting={isSubmitting}
 					disabled={effectiveDisabled}
 					canSubmit={canSubmit}
+					temporaryPassword={temporaryPassword}
+					onTemporaryPasswordChange={onTemporaryPasswordChange}
+					onPasswordReset={onPasswordReset}
 					onCancel={() => handleOpenChange(false)}
 					onSubmit={handleSubmit}
 				/>

+ 264 - 0
components/admin/users/UserTemporaryPasswordField.jsx

@@ -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>
+	);
+}
+

+ 88 - 62
components/admin/users/UsersTable.jsx

@@ -7,6 +7,7 @@ import {
 
 import EditUserDialog from "@/components/admin/users/EditUserDialog";
 import DeleteUserDialog from "@/components/admin/users/DeleteUserDialog";
+import UserTemporaryPasswordField from "@/components/admin/users/UserTemporaryPasswordField";
 
 import { Badge } from "@/components/ui/badge";
 import {
@@ -18,19 +19,94 @@ import {
 	TableRow,
 } from "@/components/ui/table";
 
+function UserTableRow({ user, disabled = false, onUserUpdated }) {
+	const [temporaryPassword, setTemporaryPassword] = React.useState("");
+	const [mustChangePasswordAfterReset, setMustChangePasswordAfterReset] =
+		React.useState(false);
+	const must = Boolean(user.mustChangePassword || mustChangePasswordAfterReset);
+
+	return (
+		<TableRow>
+			<TableCell className="truncate font-medium" title={user.username}>
+				{user.username}
+			</TableCell>
+
+			<TableCell className="min-w-0">
+				<span className="block truncate" title={user.email}>
+					{user.email}
+				</span>
+			</TableCell>
+
+			<TableCell>
+				<Badge variant="secondary">{ROLE_LABELS_DE[user.role] || user.role}</Badge>
+			</TableCell>
+
+			<TableCell>
+				{user.branchId ? (
+					<Badge variant="outline">{user.branchId}</Badge>
+				) : (
+					<span className="text-muted-foreground">—</span>
+				)}
+			</TableCell>
+
+			<TableCell>
+				{must ? (
+					<Badge variant="destructive">Erforderlich</Badge>
+				) : (
+					<Badge variant="secondary">Nein</Badge>
+				)}
+			</TableCell>
+
+			<TableCell>
+				<UserTemporaryPasswordField
+					user={user}
+					temporaryPassword={temporaryPassword}
+					onTemporaryPasswordChange={setTemporaryPassword}
+					onPasswordReset={() => setMustChangePasswordAfterReset(true)}
+					disabled={disabled}
+					compact
+				/>
+			</TableCell>
+
+			<TableCell className="text-xs text-muted-foreground">
+				{formatDateTimeDe(user.updatedAt)}
+			</TableCell>
+
+			<TableCell className="sticky right-0 z-10 w-20 bg-card text-right">
+				<div className="flex items-center justify-end gap-1">
+					<EditUserDialog
+						user={user}
+						disabled={disabled}
+						onUpdated={onUserUpdated}
+						onPasswordReset={() => setMustChangePasswordAfterReset(true)}
+						temporaryPassword={temporaryPassword}
+						onTemporaryPasswordChange={setTemporaryPassword}
+					/>
+					<DeleteUserDialog
+						user={user}
+						disabled={disabled}
+						onDeleted={onUserUpdated}
+					/>
+				</div>
+			</TableCell>
+		</TableRow>
+	);
+}
+
 export default function UsersTable({ items, disabled = false, onUserUpdated }) {
 	const list = Array.isArray(items) ? items : [];
 
 	return (
-		<Table className="min-w-[76rem] table-fixed">
+		<Table className="min-w-[88rem] table-fixed">
 			<TableHeader>
 				<TableRow>
 					<TableHead className="w-44">Benutzername</TableHead>
 					<TableHead className="w-56">E-Mail</TableHead>
 					<TableHead className="w-40">Rolle</TableHead>
-					<TableHead className="w-20">NL</TableHead>
-					<TableHead className="w-40">Passwortwechsel</TableHead>
-					<TableHead className="w-40">Aktualisiert</TableHead>
+					<TableHead className="w-16">NL</TableHead>
+					<TableHead className="w-32">Passwortwechsel</TableHead>
+					<TableHead className="w-56">Passwort</TableHead>
+					<TableHead className="w-32">Aktualisiert</TableHead>
 					<TableHead className="sticky right-0 z-20 w-20 bg-card text-right">
 						Aktion
 					</TableHead>
@@ -38,64 +114,14 @@ export default function UsersTable({ items, disabled = false, onUserUpdated }) {
 			</TableHeader>
 
 			<TableBody>
-				{list.map((u) => {
-					const must = Boolean(u.mustChangePassword);
-
-					return (
-						<TableRow key={u.id}>
-							<TableCell className="truncate font-medium" title={u.username}>
-								{u.username}
-							</TableCell>
-
-							<TableCell className="min-w-0">
-								<span className="block truncate" title={u.email}>
-									{u.email}
-								</span>
-							</TableCell>
-
-							<TableCell>
-								<Badge variant="secondary">
-									{ROLE_LABELS_DE[u.role] || u.role}
-								</Badge>
-							</TableCell>
-
-							<TableCell>
-								{u.branchId ? (
-									<Badge variant="outline">{u.branchId}</Badge>
-								) : (
-									<span className="text-muted-foreground">—</span>
-								)}
-							</TableCell>
-
-							<TableCell>
-								{must ? (
-									<Badge variant="destructive">Erforderlich</Badge>
-								) : (
-									<Badge variant="secondary">Nein</Badge>
-								)}
-							</TableCell>
-
-							<TableCell className="text-xs text-muted-foreground">
-								{formatDateTimeDe(u.updatedAt)}
-							</TableCell>
-
-							<TableCell className="sticky right-0 z-10 w-20 bg-card text-right">
-								<div className="flex items-center justify-end gap-1">
-									<EditUserDialog
-										user={u}
-										disabled={disabled}
-										onUpdated={onUserUpdated}
-									/>
-									<DeleteUserDialog
-										user={u}
-										disabled={disabled}
-										onDeleted={onUserUpdated}
-									/>
-								</div>
-							</TableCell>
-						</TableRow>
-					);
-				})}
+				{list.map((user) => (
+					<UserTableRow
+						key={user.id}
+						user={user}
+						disabled={disabled}
+						onUserUpdated={onUserUpdated}
+					/>
+				))}
 			</TableBody>
 		</Table>
 	);

+ 15 - 0
components/admin/users/edit-user/EditUserForm.jsx

@@ -4,6 +4,7 @@ import React from "react";
 import { Loader2 } from "lucide-react";
 
 import BranchNumberInput from "@/components/admin/users/BranchNumberInput";
+import UserTemporaryPasswordField from "@/components/admin/users/UserTemporaryPasswordField";
 import { EDIT_ROLE_OPTIONS } from "@/components/admin/users/edit-user/editUserUtils";
 
 import { Button } from "@/components/ui/button";
@@ -64,6 +65,9 @@ export default function EditUserForm({
 	disabled,
 	isSubmitting,
 	canSubmit,
+	temporaryPassword,
+	onTemporaryPasswordChange,
+	onPasswordReset,
 	onCancel,
 	onSubmit,
 }) {
@@ -191,6 +195,17 @@ export default function EditUserForm({
 				</div>
 			</div>
 
+			<div className="grid gap-2">
+				<Label>Temporäres Passwort</Label>
+				<UserTemporaryPasswordField
+					user={user}
+					temporaryPassword={temporaryPassword}
+					onTemporaryPasswordChange={onTemporaryPasswordChange}
+					onPasswordReset={onPasswordReset}
+					disabled={disabled}
+				/>
+			</div>
+
 			<DialogFooter>
 				<Button
 					type="button"

+ 15 - 0
lib/frontend/admin/users/userManagementUx.js

@@ -1,5 +1,6 @@
 const BRANCH_ID_RE = /^NL\d+$/;
 const BRANCH_ID_CAPTURE_RE = /^NL(\d+)$/i;
+export const MASKED_PASSWORD_VALUE = "••••••";
 
 function normalizeComparableText(value) {
 	return String(value ?? "")
@@ -98,3 +99,17 @@ export function evaluateBranchExistence({
 		shouldBlockSubmit: Boolean(hasUnknownBranch),
 	};
 }
+
+export function hasTemporaryPassword(value) {
+	return typeof value === "string" && value.length > 0;
+}
+
+export function getDisplayedTemporaryPassword({
+	temporaryPassword,
+	isVisible = false,
+}) {
+	if (isVisible && hasTemporaryPassword(temporaryPassword)) {
+		return temporaryPassword;
+	}
+	return MASKED_PASSWORD_VALUE;
+}

+ 34 - 0
lib/frontend/admin/users/userManagementUx.test.js

@@ -10,6 +10,9 @@ import {
 	extractBranchNumberInputFromBranchId,
 	isValidBranchIdFormat,
 	evaluateBranchExistence,
+	MASKED_PASSWORD_VALUE,
+	hasTemporaryPassword,
+	getDisplayedTemporaryPassword,
 } from "./userManagementUx.js";
 
 describe("lib/frontend/admin/users/userManagementUx", () => {
@@ -125,4 +128,35 @@ describe("lib/frontend/admin/users/userManagementUx", () => {
 			expect(result.shouldBlockSubmit).toBe(false);
 		});
 	});
+
+	describe("temporary password display", () => {
+		it("detects availability of temporary password values", () => {
+			expect(hasTemporaryPassword("TempPass123!")).toBe(true);
+			expect(hasTemporaryPassword("")).toBe(false);
+			expect(hasTemporaryPassword(null)).toBe(false);
+		});
+
+		it("returns masked value by default and reveals only when requested", () => {
+			expect(
+				getDisplayedTemporaryPassword({
+					temporaryPassword: "TempPass123!",
+					isVisible: false,
+				}),
+			).toBe(MASKED_PASSWORD_VALUE);
+
+			expect(
+				getDisplayedTemporaryPassword({
+					temporaryPassword: "TempPass123!",
+					isVisible: true,
+				}),
+			).toBe("TempPass123!");
+
+			expect(
+				getDisplayedTemporaryPassword({
+					temporaryPassword: "",
+					isVisible: true,
+				}),
+			).toBe(MASKED_PASSWORD_VALUE);
+		});
+	});
 });