2 コミット fe0efa279c ... 31128b0e8d

作者 SHA1 メッセージ 日付
  Code_Uwe 31128b0e8d RHL-043 feat(admin-users): add temporary password controls in UI 1 ヶ月 前
  Code_Uwe b861e93b76 RHL-043 feat(admin-users): add temporary password reset API 1 ヶ月 前

+ 78 - 30
app/api/admin/users/[userId]/route.js

@@ -1,7 +1,10 @@
+import bcrypt from "bcryptjs";
+
 import User, { USER_ROLES } from "@/models/user";
 import { getDb } from "@/lib/db";
 import { getSession } from "@/lib/auth/session";
 import { requireUserManagement } from "@/lib/auth/permissions";
+import { generateAdminTemporaryPassword } from "@/lib/auth/adminTempPassword";
 import {
 	withErrorHandling,
 	json,
@@ -17,6 +20,7 @@ const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
 
 const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
 const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const BCRYPT_SALT_ROUNDS = 12;
 
 const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
 const ALLOWED_UPDATE_FIELDS = Object.freeze([
@@ -97,6 +101,27 @@ function pickDuplicateField(err) {
 	return null;
 }
 
+async function resolveValidatedUserId(ctx) {
+	const { userId } = await ctx.params;
+
+	if (!userId) {
+		throw badRequest(
+			"VALIDATION_MISSING_PARAM",
+			"Missing required route parameter(s)",
+			{ params: ["userId"] },
+		);
+	}
+
+	if (!OBJECT_ID_RE.test(String(userId))) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
+			field: "userId",
+			value: userId,
+		});
+	}
+
+	return String(userId);
+}
+
 export const PATCH = withErrorHandling(
 	async function PATCH(request, ctx) {
 		const session = await getSession();
@@ -106,23 +131,7 @@ export const PATCH = withErrorHandling(
 		}
 
 		requireUserManagement(session);
-
-		const { userId } = await ctx.params;
-
-		if (!userId) {
-			throw badRequest(
-				"VALIDATION_MISSING_PARAM",
-				"Missing required route parameter(s)",
-				{ params: ["userId"] },
-			);
-		}
-
-		if (!OBJECT_ID_RE.test(String(userId))) {
-			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
-				field: "userId",
-				value: userId,
-			});
-		}
+		const userId = await resolveValidatedUserId(ctx);
 
 		let body;
 		try {
@@ -350,8 +359,8 @@ export const PATCH = withErrorHandling(
 	{ logPrefix: "[api/admin/users/[userId]]" },
 );
 
-export const DELETE = withErrorHandling(
-	async function DELETE(request, ctx) {
+export const POST = withErrorHandling(
+	async function POST(_request, ctx) {
 		const session = await getSession();
 
 		if (!session) {
@@ -360,25 +369,64 @@ export const DELETE = withErrorHandling(
 
 		requireUserManagement(session);
 
-		const { userId } = await ctx.params;
+		const userId = await resolveValidatedUserId(ctx);
 
-		if (!userId) {
+		if (String(session.userId) === userId) {
 			throw badRequest(
-				"VALIDATION_MISSING_PARAM",
-				"Missing required route parameter(s)",
-				{ params: ["userId"] },
+				"VALIDATION_INVALID_FIELD",
+				"Cannot reset current user password",
+				{
+					field: "userId",
+					reason: "SELF_PASSWORD_RESET_FORBIDDEN",
+				},
 			);
 		}
 
-		if (!OBJECT_ID_RE.test(String(userId))) {
-			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
-				field: "userId",
-				value: userId,
-			});
+		await getDb();
+
+		const user = await User.findById(userId).exec();
+		if (!user) {
+			throw notFound("USER_NOT_FOUND", "Not found", { userId });
 		}
 
+		const temporaryPassword = generateAdminTemporaryPassword();
+		const passwordHash = await bcrypt.hash(
+			temporaryPassword,
+			BCRYPT_SALT_ROUNDS,
+		);
+
+		user.passwordHash = passwordHash;
+		user.mustChangePassword = true;
+		user.passwordResetToken = null;
+		user.passwordResetExpiresAt = null;
+
+		await user.save();
+
+		return json(
+			{
+				ok: true,
+				user: toSafeUser(user),
+				temporaryPassword,
+			},
+			200,
+		);
+	},
+	{ logPrefix: "[api/admin/users/[userId]]" },
+);
+
+export const DELETE = withErrorHandling(
+	async function DELETE(request, ctx) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		requireUserManagement(session);
+		const userId = await resolveValidatedUserId(ctx);
+
 		// Safety: prevent deleting your own currently active account.
-		if (String(session.userId) === String(userId)) {
+		if (String(session.userId) === userId) {
 			throw badRequest(
 				"VALIDATION_INVALID_FIELD",
 				"Cannot delete current user",

+ 179 - 1
app/api/admin/users/[userId]/route.test.js

@@ -28,11 +28,25 @@ vi.mock("@/models/user", () => {
 	};
 });
 
+vi.mock("bcryptjs", () => {
+	const hash = vi.fn();
+	return {
+		default: { hash },
+		hash,
+	};
+});
+
+vi.mock("@/lib/auth/adminTempPassword", () => ({
+	generateAdminTemporaryPassword: vi.fn(),
+}));
+
 import { getSession } from "@/lib/auth/session";
 import { getDb } from "@/lib/db";
 import User from "@/models/user";
+import { hash as bcryptHash } from "bcryptjs";
+import { generateAdminTemporaryPassword } from "@/lib/auth/adminTempPassword";
 
-import { PATCH, DELETE, dynamic } from "./route.js";
+import { PATCH, POST, DELETE, dynamic } from "./route.js";
 
 function createRequestStub(body) {
 	return {
@@ -335,6 +349,170 @@ describe("PATCH /api/admin/users/[userId]", () => {
 	});
 });
 
+describe("POST /api/admin/users/[userId]", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		getDb.mockResolvedValue({});
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 403 when authenticated but not allowed (admin)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "admin",
+			branchId: null,
+			email: "admin@example.com",
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Forbidden",
+				code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
+			},
+		});
+	});
+
+	it("returns 400 for invalid userId", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "nope" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toMatchObject({
+			error: { code: "VALIDATION_INVALID_FIELD" },
+		});
+	});
+
+	it("returns 400 when trying to reset the current user password", async () => {
+		getSession.mockResolvedValue({
+			userId: "507f1f77bcf86cd799439011",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Cannot reset current user password",
+				code: "VALIDATION_INVALID_FIELD",
+				details: { field: "userId", reason: "SELF_PASSWORD_RESET_FORBIDDEN" },
+			},
+		});
+	});
+
+	it("returns 404 when user does not exist", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(null),
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
+		});
+
+		expect(res.status).toBe(404);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "USER_NOT_FOUND",
+				details: { userId: "507f1f77bcf86cd799439099" },
+			},
+		});
+	});
+
+	it("returns 200 and resets password with mustChangePassword=true", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439099",
+			username: "branch2",
+			email: "branch2@example.com",
+			role: "branch",
+			branchId: "NL02",
+			passwordHash: "old-hash",
+			mustChangePassword: false,
+			passwordResetToken: "token",
+			passwordResetExpiresAt: new Date("2030-01-01"),
+			createdAt: new Date("2026-02-01T10:00:00.000Z"),
+			updatedAt: new Date("2026-02-02T10:00:00.000Z"),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		generateAdminTemporaryPassword.mockReturnValue("TempPass123!");
+		bcryptHash.mockResolvedValue("hashed-temp");
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
+		});
+
+		expect(res.status).toBe(200);
+		expect(generateAdminTemporaryPassword).toHaveBeenCalledTimes(1);
+		expect(bcryptHash).toHaveBeenCalledWith("TempPass123!", 12);
+		expect(user.passwordHash).toBe("hashed-temp");
+		expect(user.mustChangePassword).toBe(true);
+		expect(user.passwordResetToken).toBe(null);
+		expect(user.passwordResetExpiresAt).toBe(null);
+		expect(user.save).toHaveBeenCalledTimes(1);
+
+		expect(await res.json()).toMatchObject({
+			ok: true,
+			temporaryPassword: "TempPass123!",
+			user: {
+				id: "507f1f77bcf86cd799439099",
+				username: "branch2",
+				email: "branch2@example.com",
+				role: "branch",
+				branchId: "NL02",
+				mustChangePassword: true,
+			},
+		});
+	});
+});
+
 describe("DELETE /api/admin/users/[userId]", () => {
 	beforeEach(() => {
 		vi.clearAllMocks();

+ 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"

+ 50 - 0
lib/auth/adminTempPassword.js

@@ -0,0 +1,50 @@
+import crypto from "node:crypto";
+
+import { PASSWORD_POLICY, validateNewPassword } from "@/lib/auth/passwordPolicy";
+
+const LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+const DIGITS = "23456789";
+const SPECIALS = "!@#$%&*_-";
+const CHARSET = `${LETTERS}${DIGITS}${SPECIALS}`;
+
+const DEFAULT_TEMP_PASSWORD_LENGTH = Math.max(PASSWORD_POLICY.minLength, 12);
+const MAX_GENERATION_ATTEMPTS = 20;
+
+function randomChar(chars) {
+	const i = crypto.randomInt(0, chars.length);
+	return chars[i];
+}
+
+function shuffle(input) {
+	const chars = String(input).split("");
+	for (let i = chars.length - 1; i > 0; i -= 1) {
+		const j = crypto.randomInt(0, i + 1);
+		[chars[i], chars[j]] = [chars[j], chars[i]];
+	}
+	return chars.join("");
+}
+
+function normalizeLength(length) {
+	if (!Number.isInteger(length)) return DEFAULT_TEMP_PASSWORD_LENGTH;
+	return Math.max(PASSWORD_POLICY.minLength, length);
+}
+
+export function generateAdminTemporaryPassword({ length } = {}) {
+	const targetLength = normalizeLength(length);
+
+	for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) {
+		const base = [randomChar(LETTERS), randomChar(DIGITS)];
+
+		while (base.length < targetLength) {
+			base.push(randomChar(CHARSET));
+		}
+
+		const candidate = shuffle(base.join(""));
+		if (validateNewPassword({ newPassword: candidate }).ok) {
+			return candidate;
+		}
+	}
+
+	throw new Error("Unable to generate temporary password");
+}
+

+ 31 - 0
lib/auth/adminTempPassword.test.js

@@ -0,0 +1,31 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+
+import { PASSWORD_POLICY, validateNewPassword } from "@/lib/auth/passwordPolicy";
+import { generateAdminTemporaryPassword } from "./adminTempPassword.js";
+
+describe("lib/auth/adminTempPassword", () => {
+	it("generates passwords that pass the configured password policy", () => {
+		for (let i = 0; i < 25; i += 1) {
+			const password = generateAdminTemporaryPassword();
+			const result = validateNewPassword({ newPassword: password });
+
+			expect(password.length).toBeGreaterThanOrEqual(PASSWORD_POLICY.minLength);
+			expect(result.ok).toBe(true);
+		}
+	});
+
+	it("respects custom length but never below the policy minimum", () => {
+		const belowMin = generateAdminTemporaryPassword({
+			length: PASSWORD_POLICY.minLength - 3,
+		});
+		const custom = generateAdminTemporaryPassword({
+			length: PASSWORD_POLICY.minLength + 4,
+		});
+
+		expect(belowMin.length).toBeGreaterThanOrEqual(PASSWORD_POLICY.minLength);
+		expect(custom.length).toBe(PASSWORD_POLICY.minLength + 4);
+	});
+});
+

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

+ 11 - 0
lib/frontend/apiClient.js

@@ -320,3 +320,14 @@ export function adminDeleteUser(userId, options) {
 		...options,
 	});
 }
+
+export function adminResetUserPassword(userId, options) {
+	if (typeof userId !== "string" || !userId.trim()) {
+		throw new Error("adminResetUserPassword requires a userId string");
+	}
+
+	return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, {
+		method: "POST",
+		...options,
+	});
+}

+ 16 - 0
lib/frontend/apiClient.test.js

@@ -13,6 +13,7 @@ import {
 	adminCreateUser,
 	adminUpdateUser,
 	adminDeleteUser,
+	adminResetUserPassword,
 } from "./apiClient.js";
 
 beforeEach(() => {
@@ -163,4 +164,19 @@ describe("lib/frontend/apiClient", () => {
 		expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
 		expect(init.method).toBe("DELETE");
 	});
+
+	it("adminResetUserPassword calls POST /api/admin/users/:id", async () => {
+		fetch.mockResolvedValue(
+			new Response(JSON.stringify({ ok: true, temporaryPassword: "x" }), {
+				status: 200,
+				headers: { "Content-Type": "application/json" },
+			}),
+		);
+
+		await adminResetUserPassword("507f1f77bcf86cd799439099");
+
+		const [url, init] = fetch.mock.calls[0];
+		expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
+		expect(init.method).toBe("POST");
+	});
 });