7 Commits fe0efa279c ... 9b1a4bf8da

Autor SHA1 Mensaje Fecha
  Code_Uwe 9b1a4bf8da RHL-043 feat(admin-users): add tooltips for temporary password actions hace 1 mes
  Code_Uwe 3de3a90e12 RHL-043 feat(admin-users): add users sorting toolbar and query wiring hace 1 mes
  Code_Uwe 31569ec61d RHL-043 feat(api): add sortable admin users listing with cursor safety hace 1 mes
  Code_Uwe 042cfacf26 RHL-043 feat(admin-users): add deterministic users sorting helpers hace 1 mes
  Code_Uwe 531aff9070 RHL-043 refactor(layout): remove sidebar placeholder and use 75% shell hace 1 mes
  Code_Uwe 31128b0e8d RHL-043 feat(admin-users): add temporary password controls in UI hace 1 mes
  Code_Uwe b861e93b76 RHL-043 feat(admin-users): add temporary password reset API hace 1 mes

+ 1 - 1
app/(protected)/layout.jsx

@@ -6,7 +6,7 @@ import AuthGate from "@/components/auth/AuthGate";
  * Protected layout
  *
  * UX goal:
- * - Keep the AppShell visible at all times (TopNav + Sidebar).
+ * - Keep the AppShell visible at all times (TopNav + main content frame).
  * - Render auth/loading/error states inside the main content area via AuthGate.
  *
  * This avoids "blank spinner" screens on slow connections.

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

+ 147 - 36
app/api/admin/users/route.js

@@ -5,6 +5,11 @@ import { getDb } from "@/lib/db";
 import { getSession } from "@/lib/auth/session";
 import { requireUserManagement } from "@/lib/auth/permissions";
 import { validateNewPassword } from "@/lib/auth/passwordPolicy";
+import {
+	ADMIN_USERS_SORT,
+	normalizeAdminUsersSortMode,
+	sortAdminUsers,
+} from "@/lib/frontend/admin/users/usersSorting";
 import {
 	withErrorHandling,
 	json,
@@ -89,25 +94,56 @@ function encodeCursor(payload) {
 		});
 	}
 
-	const v = payload.v ?? 1;
-	const lastId = payload.lastId;
+	const v = payload.v;
+	if (v === 1) {
+		const lastId = payload.lastId;
+		if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+		return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
+			"base64url",
+		);
+	}
 
-	if (v !== 1 || typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
-		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
-			field: "cursor",
-		});
+	if (v === 2) {
+		const sort = normalizeAdminUsersSortMode(payload.sort);
+		const offset = payload.offset;
+
+		if (sort === null || sort === ADMIN_USERS_SORT.DEFAULT) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+		if (!Number.isInteger(offset) || offset < 0) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		return Buffer.from(JSON.stringify({ v: 2, sort, offset }), "utf8").toString(
+			"base64url",
+		);
 	}
 
-	return Buffer.from(JSON.stringify({ v: 1, lastId }), "utf8").toString(
-		"base64url",
-	);
+	throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+		field: "cursor",
+	});
 }
 
-function decodeCursorOrThrow(raw) {
+function decodeCursorOrThrow(raw, expectedSort) {
 	if (raw === null || raw === undefined || String(raw).trim() === "") {
 		return null;
 	}
 
+	const sort = normalizeAdminUsersSortMode(expectedSort);
+	if (sort === null) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+			field: "cursor",
+		});
+	}
+
 	const s = String(raw).trim();
 
 	let decoded;
@@ -129,19 +165,56 @@ function decodeCursorOrThrow(raw) {
 	}
 
 	if (!isPlainObject(parsed) || parsed.v !== 1) {
-		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
-			field: "cursor",
-		});
+		// continue with shape-specific validation below
 	}
 
-	const lastId = parsed.lastId;
-	if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
-		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
-			field: "cursor",
-		});
+	if (parsed?.v === 1) {
+		const lastId = parsed.lastId;
+		const parsedSort = normalizeAdminUsersSortMode(parsed.sort);
+		const effectiveSort = parsedSort ?? ADMIN_USERS_SORT.DEFAULT;
+
+		if (sort !== effectiveSort) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		if (sort !== ADMIN_USERS_SORT.DEFAULT) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		if (typeof lastId !== "string" || !OBJECT_ID_RE.test(lastId)) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		return { lastId };
 	}
 
-	return lastId;
+	if (parsed?.v === 2) {
+		const parsedSort = normalizeAdminUsersSortMode(parsed.sort);
+		const offset = parsed.offset;
+
+		if (parsedSort === null || parsedSort !== sort) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+		if (!Number.isInteger(offset) || offset < 0) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+				field: "cursor",
+			});
+		}
+
+		return { offset };
+	}
+
+	throw badRequest("VALIDATION_INVALID_FIELD", "Invalid cursor", {
+		field: "cursor",
+	});
 }
 
 function toIsoOrNull(value) {
@@ -218,8 +291,18 @@ export const GET = withErrorHandling(
 				? branchIdRaw.trim()
 				: null;
 
+		const sortRaw = searchParams.get("sort");
+		const sort = normalizeAdminUsersSortMode(sortRaw);
+		if (sort === null) {
+			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid sort", {
+				field: "sort",
+				value: sortRaw,
+				allowed: Object.values(ADMIN_USERS_SORT),
+			});
+		}
+
 		const limit = parseLimitOrThrow(searchParams.get("limit"));
-		const cursor = decodeCursorOrThrow(searchParams.get("cursor"));
+		const cursor = decodeCursorOrThrow(searchParams.get("cursor"), sort);
 
 		if (role && !ALLOWED_ROLES.has(role)) {
 			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
@@ -246,31 +329,59 @@ export const GET = withErrorHandling(
 
 		if (role) filter.role = role;
 		if (branchId) filter.branchId = branchId;
-		if (cursor) filter._id = { $lt: cursor };
 
 		await getDb();
 
-		const docs = await User.find(filter)
-			.sort({ _id: -1 })
-			.limit(limit + 1)
-			.select(
-				"_id username email role branchId mustChangePassword createdAt updatedAt",
-			)
-			.exec();
-
-		const list = Array.isArray(docs) ? docs : [];
+		let docs = [];
+		let nextCursor = null;
+
+		if (sort === ADMIN_USERS_SORT.DEFAULT) {
+			if (cursor?.lastId) filter._id = { $lt: cursor.lastId };
+
+			const rawDocs = await User.find(filter)
+				.sort({ _id: -1 })
+				.limit(limit + 1)
+				.select(
+					"_id username email role branchId mustChangePassword createdAt updatedAt",
+				)
+				.exec();
+
+			const list = Array.isArray(rawDocs) ? rawDocs : [];
+			const hasMore = list.length > limit;
+			docs = hasMore ? list.slice(0, limit) : list;
+
+			nextCursor =
+				hasMore && docs.length > 0
+					? encodeCursor({ v: 1, lastId: String(docs[docs.length - 1]._id) })
+					: null;
+		} else {
+			const rawDocs = await User.find(filter)
+				.select(
+					"_id username email role branchId mustChangePassword createdAt updatedAt",
+				)
+				.exec();
+
+			const sorted = sortAdminUsers(
+				Array.isArray(rawDocs) ? rawDocs : [],
+				sort,
+			);
 
-		const hasMore = list.length > limit;
-		const page = hasMore ? list.slice(0, limit) : list;
+			const offset = cursor?.offset ?? 0;
+			docs = sorted.slice(offset, offset + limit);
+			const hasMore = offset + docs.length < sorted.length;
 
-		const nextCursor =
-			hasMore && page.length > 0
-				? encodeCursor({ v: 1, lastId: String(page[page.length - 1]._id) })
+			nextCursor = hasMore
+				? encodeCursor({
+						v: 2,
+						sort,
+						offset: offset + docs.length,
+					})
 				: null;
+		}
 
 		return json(
 			{
-				items: page.map(toSafeUser),
+				items: docs.map(toSafeUser),
 				nextCursor,
 			},
 			200,

+ 256 - 0
app/api/admin/users/route.test.js

@@ -49,6 +49,12 @@ function buildCursor(lastId) {
 	);
 }
 
+function buildOffsetCursor(sort, offset) {
+	return Buffer.from(JSON.stringify({ v: 2, sort, offset }), "utf8").toString(
+		"base64url",
+	);
+}
+
 function createRequestStub(body) {
 	return {
 		async json() {
@@ -178,6 +184,256 @@ describe("GET /api/admin/users", () => {
 
 		expect(body.nextCursor).toBe(buildCursor("507f1f77bcf86cd799439012"));
 	});
+
+	it("returns 400 for invalid sort parameter", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/admin/users?sort=unknown"),
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid sort",
+				code: "VALIDATION_INVALID_FIELD",
+				details: {
+					field: "sort",
+					value: "unknown",
+					allowed: ["default", "role_rights", "branch_asc"],
+				},
+			},
+		});
+	});
+
+	it("returns sorted users for sort=role_rights with stable nextCursor", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const docs = [
+			{
+				_id: "507f1f77bcf86cd799439011",
+				username: "branchb",
+				email: "branchb@example.com",
+				role: "branch",
+				branchId: "NL10",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439012",
+				username: "admin",
+				email: "admin@example.com",
+				role: "admin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439013",
+				username: "dev",
+				email: "dev@example.com",
+				role: "dev",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439014",
+				username: "super",
+				email: "super@example.com",
+				role: "superadmin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+		];
+
+		const chain = {
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue(docs),
+		};
+
+		User.find.mockReturnValue(chain);
+
+		const res = await GET(
+			new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"),
+		);
+
+		expect(res.status).toBe(200);
+		expect(chain.select).toHaveBeenCalledTimes(1);
+		expect(chain.exec).toHaveBeenCalledTimes(1);
+
+		const body = await res.json();
+		expect(body.items.map((x) => x.role)).toEqual(["superadmin", "dev"]);
+		expect(body.nextCursor).toBe(buildOffsetCursor("role_rights", 2));
+	});
+
+	it("returns sorted users for sort=branch_asc with null branches at the end", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const docs = [
+			{
+				_id: "507f1f77bcf86cd799439011",
+				username: "branch10",
+				email: "branch10@example.com",
+				role: "branch",
+				branchId: "NL10",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439012",
+				username: "admin",
+				email: "admin@example.com",
+				role: "admin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439013",
+				username: "branch2",
+				email: "branch2@example.com",
+				role: "branch",
+				branchId: "NL2",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439014",
+				username: "branch1",
+				email: "branch1@example.com",
+				role: "branch",
+				branchId: "NL01",
+				mustChangePassword: false,
+			},
+		];
+
+		const chain = {
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue(docs),
+		};
+
+		User.find.mockReturnValue(chain);
+
+		const res = await GET(
+			new Request("http://localhost/api/admin/users?sort=branch_asc&limit=10"),
+		);
+
+		expect(res.status).toBe(200);
+		const body = await res.json();
+		expect(body.items.map((x) => x.branchId)).toEqual([
+			"NL01",
+			"NL2",
+			"NL10",
+			null,
+		]);
+		expect(body.nextCursor).toBe(null);
+	});
+
+	it("returns 400 when cursor sort context does not match sort parameter", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const cursor = buildOffsetCursor("role_rights", 2);
+		const res = await GET(
+			new Request(
+				`http://localhost/api/admin/users?sort=branch_asc&cursor=${encodeURIComponent(cursor)}`,
+			),
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid cursor",
+				code: "VALIDATION_INVALID_FIELD",
+				details: { field: "cursor" },
+			},
+		});
+	});
+
+	it("keeps pagination stable within the same custom sort mode", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const docs = [
+			{
+				_id: "507f1f77bcf86cd799439011",
+				username: "branchb",
+				email: "branchb@example.com",
+				role: "branch",
+				branchId: "NL10",
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439012",
+				username: "admin",
+				email: "admin@example.com",
+				role: "admin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439013",
+				username: "dev",
+				email: "dev@example.com",
+				role: "dev",
+				branchId: null,
+				mustChangePassword: false,
+			},
+			{
+				_id: "507f1f77bcf86cd799439014",
+				username: "super",
+				email: "super@example.com",
+				role: "superadmin",
+				branchId: null,
+				mustChangePassword: false,
+			},
+		];
+
+		const chain = {
+			select: vi.fn().mockReturnThis(),
+			exec: vi.fn().mockResolvedValue(docs),
+		};
+
+		User.find.mockReturnValue(chain);
+
+		const firstRes = await GET(
+			new Request("http://localhost/api/admin/users?sort=role_rights&limit=2"),
+		);
+		expect(firstRes.status).toBe(200);
+
+		const firstBody = await firstRes.json();
+		expect(firstBody.items.map((x) => x.role)).toEqual(["superadmin", "dev"]);
+		expect(firstBody.nextCursor).toBe(buildOffsetCursor("role_rights", 2));
+
+		const secondRes = await GET(
+			new Request(
+				`http://localhost/api/admin/users?sort=role_rights&limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`,
+			),
+		);
+		expect(secondRes.status).toBe(200);
+
+		const secondBody = await secondRes.json();
+		expect(secondBody.items.map((x) => x.role)).toEqual(["admin", "branch"]);
+		expect(secondBody.nextCursor).toBe(null);
+	});
 });
 
 describe("POST /api/admin/users", () => {

+ 32 - 3
components/admin/users/AdminUsersClient.jsx

@@ -10,9 +10,14 @@ import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
 import ForbiddenView from "@/components/system/ForbiddenView";
 
 import AdminUsersFilters from "@/components/admin/users/AdminUsersFilters";
+import AdminUsersTableToolbar from "@/components/admin/users/AdminUsersTableToolbar";
 import UsersTable from "@/components/admin/users/UsersTable";
 import CreateUserDialog from "@/components/admin/users/CreateUserDialog";
 import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+import {
+	ADMIN_USERS_SORT,
+	sortAdminUsers,
+} from "@/lib/frontend/admin/users/usersSorting";
 
 import { ApiClientError } from "@/lib/frontend/apiClient";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
@@ -34,6 +39,7 @@ export default function AdminUsersClient() {
 		q: null,
 		role: null,
 		branchId: null,
+		sort: ADMIN_USERS_SORT.DEFAULT,
 	});
 
 	const {
@@ -70,6 +76,10 @@ export default function AdminUsersClient() {
 	}
 
 	const disabled = status === "loading" || isLoadingMore;
+	const effectiveSortMode = query.sort || ADMIN_USERS_SORT.DEFAULT;
+	const visibleItems = React.useMemo(() => {
+		return sortAdminUsers(items, effectiveSortMode);
+	}, [items, effectiveSortMode]);
 
 	function onDraftChange(patch) {
 		setDraft((prev) => ({ ...prev, ...(patch || {}) }));
@@ -80,12 +90,25 @@ export default function AdminUsersClient() {
 			q: draft.q.trim() ? draft.q.trim() : null,
 			role: draft.role.trim() ? draft.role.trim() : null,
 			branchId: normalizeBranchIdDraft(draft.branchId) || null,
+			sort: query.sort || ADMIN_USERS_SORT.DEFAULT,
 		});
 	}
 
 	function resetFilters() {
 		setDraft({ q: "", role: "", branchId: "" });
-		setQuery({ q: null, role: null, branchId: null });
+		setQuery((prev) => ({
+			q: null,
+			role: null,
+			branchId: null,
+			sort: prev.sort || ADMIN_USERS_SORT.DEFAULT,
+		}));
+	}
+
+	function onSortModeChange(nextSortMode) {
+		setQuery((prev) => ({
+			...prev,
+			sort: nextSortMode || ADMIN_USERS_SORT.DEFAULT,
+		}));
 	}
 
 	const actions = (
@@ -120,7 +143,13 @@ export default function AdminUsersClient() {
 						onDraftChange={onDraftChange}
 						onApply={applyFilters}
 						onReset={resetFilters}
-						loadedCount={items.length}
+						disabled={disabled}
+					/>
+
+					<AdminUsersTableToolbar
+						loadedCount={visibleItems.length}
+						sortMode={effectiveSortMode}
+						onSortModeChange={onSortModeChange}
 						disabled={disabled}
 					/>
 
@@ -145,7 +174,7 @@ export default function AdminUsersClient() {
 						/>
 					) : (
 						<UsersTable
-							items={items}
+							items={visibleItems}
 							disabled={disabled}
 							onUserUpdated={refresh}
 						/>

+ 0 - 9
components/admin/users/AdminUsersFilters.jsx

@@ -58,17 +58,12 @@ export default function AdminUsersFilters({
 	onDraftChange,
 	onApply,
 	onReset,
-	loadedCount = 0,
 	disabled,
 }) {
 	const q = draft?.q ?? "";
 	const role = draft?.role ?? "";
 	const branchId = draft?.branchId ?? "";
 
-	const safeLoadedCount = Number.isFinite(loadedCount)
-		? Math.max(0, loadedCount)
-		: 0;
-
 	return (
 		<div className="space-y-3">
 			<div className="grid gap-3 md:grid-cols-3">
@@ -116,10 +111,6 @@ export default function AdminUsersFilters({
 						Zurücksetzen
 					</Button>
 				</div>
-
-				<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
-					{safeLoadedCount} Benutzer geladen
-				</span>
 			</div>
 		</div>
 	);

+ 74 - 0
components/admin/users/AdminUsersTableToolbar.jsx

@@ -0,0 +1,74 @@
+"use client";
+
+import React from "react";
+import { SlidersHorizontal } from "lucide-react";
+
+import { ADMIN_USERS_SORT } from "@/lib/frontend/admin/users/usersSorting";
+
+import { Button } from "@/components/ui/button";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export default function AdminUsersTableToolbar({
+	loadedCount = 0,
+	sortMode = ADMIN_USERS_SORT.DEFAULT,
+	onSortModeChange,
+	disabled = false,
+}) {
+	const safeLoadedCount = Number.isFinite(loadedCount)
+		? Math.max(0, loadedCount)
+		: 0;
+
+	return (
+		<div className="flex flex-wrap items-center justify-between gap-2">
+			<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+				{safeLoadedCount} Benutzer geladen
+			</span>
+
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<Button
+						variant="outline"
+						size="sm"
+						type="button"
+						disabled={disabled}
+						title="Sortierung"
+					>
+						<SlidersHorizontal className="h-4 w-4" />
+						Sortierung
+					</Button>
+				</DropdownMenuTrigger>
+
+				<DropdownMenuContent align="end" className="min-w-[16rem]">
+					<DropdownMenuLabel>Sortierung</DropdownMenuLabel>
+					<DropdownMenuSeparator />
+
+					<DropdownMenuRadioGroup
+						value={sortMode}
+						onValueChange={(value) => onSortModeChange?.(value)}
+					>
+						<DropdownMenuRadioItem value={ADMIN_USERS_SORT.DEFAULT}>
+							Standard
+						</DropdownMenuRadioItem>
+
+						<DropdownMenuRadioItem value={ADMIN_USERS_SORT.ROLE_RIGHTS}>
+							Rolle (Rechte)
+						</DropdownMenuRadioItem>
+
+						<DropdownMenuRadioItem value={ADMIN_USERS_SORT.BRANCH_ASC}>
+							Niederlassung (NL)
+						</DropdownMenuRadioItem>
+					</DropdownMenuRadioGroup>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		</div>
+	);
+}
+

+ 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}
 				/>

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

@@ -0,0 +1,287 @@
+"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 {
+	Tooltip,
+	TooltipContent,
+	TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+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">
+			<Tooltip>
+				<TooltipTrigger asChild>
+					<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>
+				</TooltipTrigger>
+				<TooltipContent sideOffset={6}>
+					Temporäres Passwort setzen
+				</TooltipContent>
+			</Tooltip>
+
+			<Tooltip>
+				<TooltipTrigger asChild>
+					<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>
+				</TooltipTrigger>
+				<TooltipContent sideOffset={6}>
+					{isVisible ? "Passwort verbergen" : "Passwort anzeigen"}
+				</TooltipContent>
+			</Tooltip>
+
+			<Tooltip>
+				<TooltipTrigger asChild>
+					<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>
+				</TooltipTrigger>
+				<TooltipContent sideOffset={6}>Passwort kopieren</TooltipContent>
+			</Tooltip>
+		</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"

+ 1 - 26
components/app-shell/AppShell.jsx

@@ -1,38 +1,13 @@
 import React from "react";
 import TopNav from "@/components/app-shell/TopNav";
-import SidebarPlaceholder from "@/components/app-shell/SidebarPlaceholder";
 
 export default function AppShell({ children }) {
 	return (
 		<div className="min-h-screen flex flex-col">
 			<TopNav />
 
-			{/* 
-				Layout strategy (2xl+):
-				- Center column is exactly 45% width.
-				- Left/right gutters are flexible.
-				- Sidebar is placed in the left gutter and aligned to the right edge,
-				  so it “docks” to the centered content without consuming its width.
-
-				Below 2xl:
-				- Keep the app wide (single-column flow).
-				- Sidebar is hidden (it would otherwise reduce main content width).
-			*/}
 			<div className="flex-1 px-4 py-4">
-				<div className="mx-auto grid w-full gap-4 2xl:grid-cols-[1fr_minmax(0,45%)_1fr]">
-					<aside className="hidden 2xl:col-start-1 2xl:block 2xl:justify-self-end">
-						{/* 
-							Sidebar width policy:
-							- Fixed width keeps it stable and prevents “percentage jitter”.
-							- Adjust these widths if you want a bigger/smaller left rail.
-						*/}
-						<div className="w-96">
-							<SidebarPlaceholder />
-						</div>
-					</aside>
-
-					<main className="min-w-0 2xl:col-start-2">{children}</main>
-				</div>
+				<main className="mx-auto w-full min-w-0 lg:w-3/4">{children}</main>
 			</div>
 		</div>
 	);

+ 3 - 3
components/app-shell/AppShell.test.js

@@ -101,10 +101,10 @@ describe("components/app-shell/AppShell", () => {
 
 		const html = renderToString(element);
 
-		// Sidebar placeholder heading (German)
-		expect(html).toContain("Seitenleiste");
-
 		// Rendered children
 		expect(html).toContain("Child content");
+
+		// Sidebar placeholder must be gone.
+		expect(html).not.toContain("Seitenleiste");
 	});
 });

+ 0 - 31
components/app-shell/SidebarPlaceholder.jsx

@@ -1,31 +0,0 @@
-import React from "react";
-
-/**
- * SidebarPlaceholder
- *
- * Reserved sidebar area for future navigation/filter UI.
- *
- * UX rule:
- * - All user-facing text must be German.
- */
-export default function SidebarPlaceholder() {
-	return (
-		<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
-			<div className="space-y-3">
-				<div>
-					<p className="text-sm font-medium">Seitenleiste</p>
-					<p className="text-xs text-muted-foreground">
-						Navigation und Filter werden hier später angezeigt.
-					</p>
-				</div>
-
-				<ul className="space-y-1 text-xs text-muted-foreground">
-					<li>• Niederlassung (Label oder Auswahl)</li>
-					<li>• Explorer-Navigation (Jahr/Monat/Tag)</li>
-					<li>• Suchfilter (Archiv, Zeitraum, …)</li>
-					<li>• Schnellzugriffe (Zuletzt geöffnet, Favoriten)</li>
-				</ul>
-			</div>
-		</div>
-	);
-}

+ 2 - 2
components/app-shell/TopNav.jsx

@@ -15,8 +15,8 @@ export default function TopNav() {
 		<header className="sticky top-0 z-50 w-full border-b bg-background">
 			<TooltipProvider delayDuration={TOOLTIP_DELAY_MS}>
 				<div className="px-4">
-					<div className="mx-auto grid h-14 w-full items-center 2xl:grid-cols-[1fr_minmax(0,45%)_1fr]">
-						<div className="flex items-center justify-between gap-4 2xl:col-start-2">
+					<div className="mx-auto h-14 w-full lg:w-3/4">
+						<div className="flex h-full items-center justify-between gap-4">
 							<div className="flex items-center gap-2">
 								<Link
 									href="/"

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

+ 14 - 5
lib/frontend/admin/users/useAdminUsersQuery.js

@@ -2,6 +2,7 @@
 
 import React from "react";
 import { adminListUsers } from "@/lib/frontend/apiClient";
+import { ADMIN_USERS_SORT } from "@/lib/frontend/admin/users/usersSorting";
 
 function normalizeQuery(query) {
 	const q =
@@ -17,11 +18,16 @@ function normalizeQuery(query) {
 			? query.branchId.trim()
 			: null;
 
-	return { q, role, branchId };
+	const sort =
+		typeof query?.sort === "string" && query.sort.trim()
+			? query.sort.trim()
+			: ADMIN_USERS_SORT.DEFAULT;
+
+	return { q, role, branchId, sort };
 }
 
-function buildKey({ q, role, branchId, limit }) {
-	return `${q || ""}|${role || ""}|${branchId || ""}|${String(limit)}`;
+function buildKey({ q, role, branchId, sort, limit }) {
+	return `${q || ""}|${role || ""}|${branchId || ""}|${sort}|${String(limit)}`;
 }
 
 /**
@@ -38,7 +44,7 @@ export function useAdminUsersQuery({ query, limit = 50 }) {
 
 	const key = React.useMemo(() => {
 		return buildKey({ ...normalized, limit });
-	}, [normalized.q, normalized.role, normalized.branchId, limit]);
+	}, [normalized.q, normalized.role, normalized.branchId, normalized.sort, limit]);
 
 	const [status, setStatus] = React.useState("loading"); // loading|success|error
 	const [items, setItems] = React.useState([]);
@@ -79,6 +85,7 @@ export function useAdminUsersQuery({ query, limit = 50 }) {
 				q: normalized.q,
 				role: normalized.role,
 				branchId: normalized.branchId,
+				sort: normalized.sort,
 				limit,
 				cursor: null,
 			});
@@ -99,7 +106,7 @@ export function useAdminUsersQuery({ query, limit = 50 }) {
 			setError(err);
 			setStatus("error");
 		}
-	}, [normalized.q, normalized.role, normalized.branchId, limit]);
+	}, [normalized.q, normalized.role, normalized.branchId, normalized.sort, limit]);
 
 	React.useEffect(() => {
 		runFirstPage();
@@ -120,6 +127,7 @@ export function useAdminUsersQuery({ query, limit = 50 }) {
 				q: normalized.q,
 				role: normalized.role,
 				branchId: normalized.branchId,
+				sort: normalized.sort,
 				limit,
 				cursor: nextCursor,
 			});
@@ -149,6 +157,7 @@ export function useAdminUsersQuery({ query, limit = 50 }) {
 		normalized.q,
 		normalized.role,
 		normalized.branchId,
+		normalized.sort,
 		limit,
 	]);
 

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

+ 109 - 0
lib/frontend/admin/users/usersSorting.js

@@ -0,0 +1,109 @@
+export const ADMIN_USERS_SORT = Object.freeze({
+	DEFAULT: "default",
+	ROLE_RIGHTS: "role_rights",
+	BRANCH_ASC: "branch_asc",
+});
+
+const ROLE_RANK = Object.freeze({
+	superadmin: 4,
+	dev: 3,
+	admin: 2,
+	branch: 1,
+});
+
+function normalizeRole(value) {
+	return String(value ?? "")
+		.trim()
+		.toLowerCase();
+}
+
+function toRoleRank(value) {
+	const role = normalizeRole(value);
+	return ROLE_RANK[role] ?? 0;
+}
+
+function toBranchNumber(branchId) {
+	const raw = String(branchId ?? "").trim();
+	if (!raw) return null;
+
+	const match = /^NL(\d+)$/i.exec(raw);
+	if (!match) return null;
+
+	const n = Number(match[1]);
+	return Number.isInteger(n) ? n : null;
+}
+
+function compareBranchAscNullLast(a, b) {
+	const an = toBranchNumber(a);
+	const bn = toBranchNumber(b);
+
+	if (an !== null && bn !== null) return an - bn;
+	if (an === null && bn !== null) return 1;
+	if (an !== null && bn === null) return -1;
+
+	return String(a ?? "").localeCompare(String(b ?? ""), "de", {
+		sensitivity: "base",
+	});
+}
+
+function compareUsernamesAsc(a, b) {
+	return String(a ?? "").localeCompare(String(b ?? ""), "de", {
+		sensitivity: "base",
+	});
+}
+
+function compareIdsAsc(a, b) {
+	return String(a ?? "").localeCompare(String(b ?? ""), "en");
+}
+
+export function normalizeAdminUsersSortMode(value) {
+	const mode = String(value ?? "").trim();
+	if (!mode) return ADMIN_USERS_SORT.DEFAULT;
+
+	if (Object.values(ADMIN_USERS_SORT).includes(mode)) {
+		return mode;
+	}
+
+	return null;
+}
+
+export function compareUsersByRoleRights(a, b) {
+	const roleCmp = toRoleRank(b?.role) - toRoleRank(a?.role);
+	if (roleCmp !== 0) return roleCmp;
+
+	const branchCmp = compareBranchAscNullLast(a?.branchId, b?.branchId);
+	if (branchCmp !== 0) return branchCmp;
+
+	const usernameCmp = compareUsernamesAsc(a?.username, b?.username);
+	if (usernameCmp !== 0) return usernameCmp;
+
+	return compareIdsAsc(a?.id ?? a?._id, b?.id ?? b?._id);
+}
+
+export function compareUsersByBranchAsc(a, b) {
+	const branchCmp = compareBranchAscNullLast(a?.branchId, b?.branchId);
+	if (branchCmp !== 0) return branchCmp;
+
+	const roleCmp = toRoleRank(b?.role) - toRoleRank(a?.role);
+	if (roleCmp !== 0) return roleCmp;
+
+	const usernameCmp = compareUsernamesAsc(a?.username, b?.username);
+	if (usernameCmp !== 0) return usernameCmp;
+
+	return compareIdsAsc(a?.id ?? a?._id, b?.id ?? b?._id);
+}
+
+export function sortAdminUsers(items, sortMode) {
+	const list = Array.isArray(items) ? [...items] : [];
+
+	if (sortMode === ADMIN_USERS_SORT.ROLE_RIGHTS) {
+		return list.sort(compareUsersByRoleRights);
+	}
+
+	if (sortMode === ADMIN_USERS_SORT.BRANCH_ASC) {
+		return list.sort(compareUsersByBranchAsc);
+	}
+
+	return list;
+}
+

+ 110 - 0
lib/frontend/admin/users/usersSorting.test.js

@@ -0,0 +1,110 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	ADMIN_USERS_SORT,
+	normalizeAdminUsersSortMode,
+	sortAdminUsers,
+} from "./usersSorting.js";
+
+function mapRows(rows) {
+	return rows.map((x) => ({
+		id: x.id,
+		role: x.role,
+		branchId: x.branchId,
+		username: x.username,
+	}));
+}
+
+describe("lib/frontend/admin/users/usersSorting", () => {
+	it("normalizes known sort modes and rejects invalid values", () => {
+		expect(normalizeAdminUsersSortMode(undefined)).toBe(
+			ADMIN_USERS_SORT.DEFAULT,
+		);
+		expect(normalizeAdminUsersSortMode("")).toBe(ADMIN_USERS_SORT.DEFAULT);
+		expect(normalizeAdminUsersSortMode(ADMIN_USERS_SORT.ROLE_RIGHTS)).toBe(
+			ADMIN_USERS_SORT.ROLE_RIGHTS,
+		);
+		expect(normalizeAdminUsersSortMode("nope")).toBe(null);
+	});
+
+	it("sorts by role rights with deterministic tie-breakers", () => {
+		const items = [
+			{
+				id: "6",
+				role: "admin",
+				branchId: null,
+				username: "z-admin",
+			},
+			{
+				id: "1",
+				role: "branch",
+				branchId: "NL10",
+				username: "branch-z",
+			},
+			{
+				id: "2",
+				role: "branch",
+				branchId: "NL2",
+				username: "branch-a",
+			},
+			{
+				id: "3",
+				role: "dev",
+				branchId: null,
+				username: "dev",
+			},
+			{
+				id: "4",
+				role: "superadmin",
+				branchId: null,
+				username: "root",
+			},
+			{
+				id: "5",
+				role: "admin",
+				branchId: null,
+				username: "a-admin",
+			},
+		];
+
+		const out = sortAdminUsers(items, ADMIN_USERS_SORT.ROLE_RIGHTS);
+
+		expect(mapRows(out)).toEqual([
+			{ id: "4", role: "superadmin", branchId: null, username: "root" },
+			{ id: "3", role: "dev", branchId: null, username: "dev" },
+			{ id: "5", role: "admin", branchId: null, username: "a-admin" },
+			{ id: "6", role: "admin", branchId: null, username: "z-admin" },
+			{ id: "2", role: "branch", branchId: "NL2", username: "branch-a" },
+			{ id: "1", role: "branch", branchId: "NL10", username: "branch-z" },
+		]);
+	});
+
+	it("sorts by branch asc (numeric) with null branch at the end", () => {
+		const items = [
+			{ id: "1", role: "branch", branchId: "NL10", username: "b1" },
+			{ id: "2", role: "branch", branchId: "NL2", username: "b2" },
+			{ id: "3", role: "admin", branchId: null, username: "admin" },
+			{ id: "4", role: "superadmin", branchId: null, username: "super" },
+			{ id: "5", role: "branch", branchId: "NL01", username: "b0" },
+		];
+
+		const out = sortAdminUsers(items, ADMIN_USERS_SORT.BRANCH_ASC);
+
+		expect(mapRows(out)).toEqual([
+			{ id: "5", role: "branch", branchId: "NL01", username: "b0" },
+			{ id: "2", role: "branch", branchId: "NL2", username: "b2" },
+			{ id: "1", role: "branch", branchId: "NL10", username: "b1" },
+			{ id: "4", role: "superadmin", branchId: null, username: "super" },
+			{ id: "3", role: "admin", branchId: null, username: "admin" },
+		]);
+	});
+
+	it("returns a shallow copy for default mode", () => {
+		const items = [{ id: "1" }, { id: "2" }];
+		const out = sortAdminUsers(items, ADMIN_USERS_SORT.DEFAULT);
+		expect(out).toEqual(items);
+		expect(out).not.toBe(items);
+	});
+});
+

+ 13 - 1
lib/frontend/apiClient.js

@@ -267,13 +267,14 @@ export function search(input, options) {
 }
 
 export function adminListUsers(input, options) {
-	const { q, role, branchId, limit, cursor } = input || {};
+	const { q, role, branchId, sort, limit, cursor } = input || {};
 	const params = new URLSearchParams();
 
 	if (typeof q === "string" && q.trim()) params.set("q", q.trim());
 	if (typeof role === "string" && role.trim()) params.set("role", role.trim());
 	if (typeof branchId === "string" && branchId.trim())
 		params.set("branchId", branchId.trim());
+	if (typeof sort === "string" && sort.trim()) params.set("sort", sort.trim());
 
 	if (limit !== undefined && limit !== null) {
 		const raw = String(limit).trim();
@@ -320,3 +321,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,
+	});
+}

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

@@ -13,6 +13,7 @@ import {
 	adminCreateUser,
 	adminUpdateUser,
 	adminDeleteUser,
+	adminResetUserPassword,
 } from "./apiClient.js";
 
 beforeEach(() => {
@@ -149,6 +150,25 @@ describe("lib/frontend/apiClient", () => {
 		expect(init.method).toBe("PATCH");
 	});
 
+	it("adminListUsers includes sort in query string", async () => {
+		fetch.mockResolvedValue(
+			new Response(JSON.stringify({ items: [], nextCursor: null }), {
+				status: 200,
+				headers: { "Content-Type": "application/json" },
+			}),
+		);
+
+		await adminListUsers({ q: "dev", sort: "role_rights", limit: 50 });
+
+		const [url, init] = fetch.mock.calls[0];
+		const u = new URL(url, "http://localhost");
+		expect(u.pathname).toBe("/api/admin/users");
+		expect(u.searchParams.get("q")).toBe("dev");
+		expect(u.searchParams.get("sort")).toBe("role_rights");
+		expect(u.searchParams.get("limit")).toBe("50");
+		expect(init.method).toBe("GET");
+	});
+
 	it("adminDeleteUser calls DELETE /api/admin/users/:id", async () => {
 		fetch.mockResolvedValue(
 			new Response(JSON.stringify({ ok: true }), {
@@ -163,4 +183,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");
+	});
 });