5 Incheckningar 31128b0e8d ... 9b1a4bf8da

Upphovsman SHA1 Meddelande Datum
  Code_Uwe 9b1a4bf8da RHL-043 feat(admin-users): add tooltips for temporary password actions 1 månad sedan
  Code_Uwe 3de3a90e12 RHL-043 feat(admin-users): add users sorting toolbar and query wiring 1 månad sedan
  Code_Uwe 31569ec61d RHL-043 feat(api): add sortable admin users listing with cursor safety 1 månad sedan
  Code_Uwe 042cfacf26 RHL-043 feat(admin-users): add deterministic users sorting helpers 1 månad sedan
  Code_Uwe 531aff9070 RHL-043 refactor(layout): remove sidebar placeholder and use 75% shell 1 månad sedan

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

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

+ 69 - 46
components/admin/users/UserTemporaryPasswordField.jsx

@@ -12,6 +12,11 @@ import {
 
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
+import {
+	Tooltip,
+	TooltipContent,
+	TooltipTrigger,
+} from "@/components/ui/tooltip";
 
 import {
 	adminResetUserPassword,
@@ -181,53 +186,72 @@ export default function UserTemporaryPasswordField({
 
 	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>
+			<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>
 
-			<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>
+			<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>
 
-			<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>
+			<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>
 	);
 
@@ -261,4 +285,3 @@ export default function UserTemporaryPasswordField({
 		</div>
 	);
 }
-

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

+ 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,
 	]);
 

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

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

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

@@ -150,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 }), {