Przeglądaj źródła

RHL-041 refactor(auth): implement role refinement for admin-like user management and access control

codeUWE 12 godzin temu
rodzic
commit
29b684626d

+ 22 - 16
components/app-shell/QuickNav.jsx

@@ -24,6 +24,7 @@ import {
 	getPrimaryNavFromPathname,
 	PRIMARY_NAV,
 } from "@/lib/frontend/nav/activeRoute";
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
 
 import { Button } from "@/components/ui/button";
 import {
@@ -68,12 +69,11 @@ export default function QuickNav() {
 	const { status, user, retry } = useAuth();
 
 	const isAuthenticated = status === "authenticated" && user;
-	const isAdminDev =
-		isAuthenticated && (user.role === "admin" || user.role === "dev");
+	const isAdminLike = isAuthenticated && isAdminLikeRole(user.role);
 	const isBranchUser = isAuthenticated && user.role === "branch";
 	const canRevalidate = typeof retry === "function";
 
-	// Persisted selection for admin/dev:
+	// Persisted selection for admin-like users:
 	const [selectedBranch, setSelectedBranch] = React.useState(null);
 
 	// Init gate: prevent “localStorage sync” from running on every state change
@@ -102,13 +102,13 @@ export default function QuickNav() {
 			: null;
 
 	const hasInvalidRouteBranch = Boolean(
-		isAdminDev &&
+		isAdminLike &&
 		routeBranch &&
 		knownBranches &&
 		!knownBranches.includes(routeBranch),
 	);
 
-	// A) Initialize selectedBranch once per authenticated admin/dev user
+	// A) Initialize selectedBranch once per authenticated admin-like user
 	React.useEffect(() => {
 		if (!isAuthenticated) {
 			initRef.current.userId = null;
@@ -122,7 +122,7 @@ export default function QuickNav() {
 			return;
 		}
 
-		if (!isAdminDev) return;
+		if (!isAdminLike) return;
 
 		const uid = String(user.userId || "");
 		if (!uid) return;
@@ -132,11 +132,17 @@ export default function QuickNav() {
 
 		const fromStorage = safeReadLocalStorageBranch(STORAGE_KEY_LAST_BRANCH);
 		setSelectedBranch(fromStorage || null);
-	}, [isAuthenticated, isBranchUser, isAdminDev, user?.userId, user?.branchId]);
-
-	// B) Fetch branch list once for admin/dev
+	}, [
+		isAuthenticated,
+		isBranchUser,
+		isAdminLike,
+		user?.userId,
+		user?.branchId,
+	]);
+
+	// B) Fetch branch list once for admin-like users
 	React.useEffect(() => {
-		if (!isAdminDev) return;
+		if (!isAdminLike) return;
 
 		let cancelled = false;
 
@@ -159,11 +165,11 @@ export default function QuickNav() {
 		return () => {
 			cancelled = true;
 		};
-	}, [isAdminDev, user?.userId]);
+	}, [isAdminLike, user?.userId]);
 
 	// C) Ensure selectedBranch is valid once we have the list
 	React.useEffect(() => {
-		if (!isAdminDev) return;
+		if (!isAdminLike) return;
 		if (!knownBranches || knownBranches.length === 0) return;
 
 		if (selectedBranch && knownBranches.includes(selectedBranch)) return;
@@ -171,11 +177,11 @@ export default function QuickNav() {
 		const next = knownBranches[0];
 		setSelectedBranch(next);
 		safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, next);
-	}, [isAdminDev, knownBranches, selectedBranch]);
+	}, [isAdminLike, knownBranches, selectedBranch]);
 
 	// D) Sync selectedBranch to the current route branch ONLY if it exists
 	React.useEffect(() => {
-		if (!isAdminDev) return;
+		if (!isAdminLike) return;
 		if (!routeBranch) return;
 		if (!knownBranches) return;
 
@@ -184,7 +190,7 @@ export default function QuickNav() {
 
 		setSelectedBranch(routeBranch);
 		safeWriteLocalStorageBranch(STORAGE_KEY_LAST_BRANCH, routeBranch);
-	}, [isAdminDev, routeBranch, knownBranches, selectedBranch]);
+	}, [isAdminLike, routeBranch, knownBranches, selectedBranch]);
 
 	if (!isAuthenticated) return null;
 
@@ -223,7 +229,7 @@ export default function QuickNav() {
 
 	return (
 		<div className="hidden items-center gap-2 md:flex">
-			{isAdminDev ? (
+			{isAdminLike ? (
 				<DropdownMenu>
 					<Tooltip>
 						<TooltipTrigger asChild>

+ 1 - 0
components/app-shell/UserStatus.jsx

@@ -29,6 +29,7 @@ import {
 function formatRole(role) {
 	if (role === "branch") return "Niederlassung";
 	if (role === "admin") return "Admin";
+	if (role === "superadmin") return "Superadmin";
 	if (role === "dev") return "Entwicklung";
 	return role ? String(role) : "Unbekannt";
 }

+ 3 - 3
components/auth/BranchGuard.jsx

@@ -8,6 +8,7 @@ import {
 	decideBranchUi,
 	BRANCH_UI_DECISION,
 } from "@/lib/frontend/rbac/branchUiDecision";
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
 
 import ForbiddenView from "@/components/system/ForbiddenView";
 import NotFoundView from "@/components/system/NotFoundView";
@@ -23,7 +24,7 @@ const BRANCH_LIST_STATE = Object.freeze({
  * BranchGuard
  *
  * UX improvement:
- * - We do NOT block rendering while admin/dev branch list is loading.
+ * - We do NOT block rendering while admin-like branch list is loading.
  * - Existence validation is applied once the list is READY.
  * - While LOADING/ERROR we fail open to avoid "solo spinner" screens.
  *
@@ -35,8 +36,7 @@ export default function BranchGuard({ branch, children }) {
 	const { status, user } = useAuth();
 
 	const isAuthenticated = status === "authenticated" && user;
-	const needsExistenceCheck =
-		isAuthenticated && (user.role === "admin" || user.role === "dev");
+	const needsExistenceCheck = isAuthenticated && isAdminLikeRole(user.role);
 
 	const [branchList, setBranchList] = React.useState({
 		status: BRANCH_LIST_STATE.IDLE,

+ 1 - 0
components/profile/ProfilePage.jsx

@@ -16,6 +16,7 @@ import {
 function formatRole(role) {
 	if (role === "branch") return "Niederlassung";
 	if (role === "admin") return "Admin";
+	if (role === "superadmin") return "Superadmin";
 	if (role === "dev") return "Entwicklung";
 	return role ? String(role) : "Unbekannt";
 }

+ 13 - 13
components/search/SearchPage.jsx

@@ -8,6 +8,7 @@ import { useAuth } from "@/components/auth/authContext";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { searchPath } from "@/lib/frontend/routes";
 import { isValidBranchParam } from "@/lib/frontend/params";
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
 
 import { parseSearchUrlState } from "@/lib/frontend/search/urlState";
 import { normalizeSearchUrlStateForUser } from "@/lib/frontend/search/normalizeState";
@@ -45,8 +46,7 @@ export default function SearchPage({ branch: routeBranch }) {
 	const { status: authStatus, user } = useAuth();
 
 	const isAuthenticated = authStatus === "authenticated" && user;
-	const isAdminDev =
-		isAuthenticated && (user.role === "admin" || user.role === "dev");
+	const isAdminLike = isAuthenticated && isAdminLikeRole(user.role);
 
 	const parsedUrlState = React.useMemo(() => {
 		return parseSearchUrlState(searchParams, { routeBranch });
@@ -68,7 +68,7 @@ export default function SearchPage({ branch: routeBranch }) {
 		setQDraft(urlState.q || "");
 	}, [urlState.q]);
 
-	const branchesQuery = useSearchBranches({ enabled: isAdminDev });
+	const branchesQuery = useSearchBranches({ enabled: isAdminLike });
 
 	const query = useSearchQuery({
 		searchKey,
@@ -138,24 +138,24 @@ export default function SearchPage({ branch: routeBranch }) {
 
 	const handleScopeChange = React.useCallback(
 		(nextScope) => {
-			if (!isAdminDev) return;
+			if (!isAdminLike) return;
 			replaceStateToUrl(buildNextStateForScopeChange({ urlState, nextScope }));
 		},
-		[isAdminDev, urlState, replaceStateToUrl],
+		[isAdminLike, urlState, replaceStateToUrl],
 	);
 
 	const handleToggleBranch = React.useCallback(
 		(branchId) => {
-			if (!isAdminDev) return;
+			if (!isAdminLike) return;
 			replaceStateToUrl(buildNextStateForToggleBranch({ urlState, branchId }));
 		},
-		[isAdminDev, urlState, replaceStateToUrl],
+		[isAdminLike, urlState, replaceStateToUrl],
 	);
 
 	const handleClearAllBranches = React.useCallback(() => {
-		if (!isAdminDev) return;
+		if (!isAdminLike) return;
 		replaceStateToUrl(buildNextStateForClearAllBranches({ urlState }));
-	}, [isAdminDev, urlState, replaceStateToUrl]);
+	}, [isAdminLike, urlState, replaceStateToUrl]);
 
 	const handleLimitChange = React.useCallback(
 		(nextLimit) => {
@@ -166,7 +166,7 @@ export default function SearchPage({ branch: routeBranch }) {
 
 	const handleSingleBranchChange = React.useCallback(
 		(nextBranch) => {
-			if (!isAdminDev) return;
+			if (!isAdminLike) return;
 			if (!isValidBranchParam(nextBranch)) return;
 
 			const href = buildHrefForSingleBranchSwitch({ nextBranch, urlState });
@@ -174,7 +174,7 @@ export default function SearchPage({ branch: routeBranch }) {
 
 			router.push(href);
 		},
-		[isAdminDev, urlState, router],
+		[isAdminLike, urlState, router],
 	);
 
 	const handleDateRangeChange = React.useCallback(
@@ -218,7 +218,7 @@ export default function SearchPage({ branch: routeBranch }) {
 		: "Geben Sie einen Suchbegriff ein, um zu starten.";
 
 	const needsBranchSelection = needsMultiBranchSelectionHint({
-		isAdminDev,
+		isAdminDev: isAdminLike,
 		urlState,
 	});
 
@@ -239,7 +239,7 @@ export default function SearchPage({ branch: routeBranch }) {
 					onSubmit={handleSubmit}
 					currentQuery={urlState.q}
 					isSubmitting={query.status === "loading"}
-					isAdminDev={isAdminDev}
+					isAdminDev={isAdminLike}
 					scope={urlState.scope}
 					onScopeChange={handleScopeChange}
 					onSingleBranchChange={handleSingleBranchChange}

+ 30 - 0
lib/frontend/auth/roles.js

@@ -0,0 +1,30 @@
+export const ROLES = Object.freeze({
+	BRANCH: "branch",
+	ADMIN: "admin",
+	SUPERADMIN: "superadmin",
+	DEV: "dev",
+});
+
+/**
+ * Returns true for roles that are "admin-like" in the UI:
+ * - admin, superadmin, dev
+ *
+ * @param {unknown} role
+ * @returns {boolean}
+ */
+export function isAdminLike(role) {
+	return (
+		role === ROLES.ADMIN || role === ROLES.SUPERADMIN || role === ROLES.DEV
+	);
+}
+
+/**
+ * Returns true for roles that can manage users (RHL-012 capability):
+ * - superadmin, dev
+ *
+ * @param {unknown} role
+ * @returns {boolean}
+ */
+export function canManageUsers(role) {
+	return role === ROLES.SUPERADMIN || role === ROLES.DEV;
+}

+ 36 - 0
lib/frontend/auth/roles.test.js

@@ -0,0 +1,36 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { ROLES, isAdminLike, canManageUsers } from "./roles.js";
+
+describe("lib/frontend/auth/roles", () => {
+	describe("isAdminLike", () => {
+		it("returns true for admin, superadmin, dev", () => {
+			expect(isAdminLike(ROLES.ADMIN)).toBe(true);
+			expect(isAdminLike(ROLES.SUPERADMIN)).toBe(true);
+			expect(isAdminLike(ROLES.DEV)).toBe(true);
+		});
+
+		it("returns false for branch and unknown roles", () => {
+			expect(isAdminLike(ROLES.BRANCH)).toBe(false);
+			expect(isAdminLike("user")).toBe(false);
+			expect(isAdminLike(null)).toBe(false);
+			expect(isAdminLike(undefined)).toBe(false);
+		});
+	});
+
+	describe("canManageUsers", () => {
+		it("returns true for superadmin and dev", () => {
+			expect(canManageUsers(ROLES.SUPERADMIN)).toBe(true);
+			expect(canManageUsers(ROLES.DEV)).toBe(true);
+		});
+
+		it("returns false for admin, branch and unknown roles", () => {
+			expect(canManageUsers(ROLES.ADMIN)).toBe(false);
+			expect(canManageUsers(ROLES.BRANCH)).toBe(false);
+			expect(canManageUsers("user")).toBe(false);
+			expect(canManageUsers(null)).toBe(false);
+			expect(canManageUsers(undefined)).toBe(false);
+		});
+	});
+});

+ 4 - 2
lib/frontend/rbac/branchAccess.js

@@ -5,6 +5,8 @@
  * The UI can use it to decide whether a user may access a given `:branch` segment.
  */
 
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
+
 /**
  * @typedef {Object} AuthUser
  * @property {string} userId
@@ -22,7 +24,7 @@ export const BRANCH_ACCESS = Object.freeze({
  *
  * Rules:
  * - role "branch": only allowed when routeBranch === user.branchId
- * - role "admin" / "dev": allowed for any route branch
+ * - roles admin-like (admin/superadmin/dev): allowed for any route branch
  * - unknown roles or missing required data: forbidden
  *
  * @param {AuthUser|null} user
@@ -40,7 +42,7 @@ export function getBranchAccess(user, routeBranch) {
 			: BRANCH_ACCESS.FORBIDDEN;
 	}
 
-	if (user.role === "admin" || user.role === "dev") {
+	if (isAdminLikeRole(user.role)) {
 		return BRANCH_ACCESS.ALLOWED;
 	}
 

+ 6 - 2
lib/frontend/rbac/branchAccess.test.js

@@ -11,13 +11,17 @@ describe("lib/frontend/rbac/branchAccess", () => {
 		expect(getBranchAccess(user, "NL02")).toBe(BRANCH_ACCESS.FORBIDDEN);
 	});
 
-	it("allows admin/dev users for any branch", () => {
+	it("allows admin-like users for any branch (admin/superadmin/dev)", () => {
 		const admin = { userId: "u2", role: "admin", branchId: null };
-		const dev = { userId: "u3", role: "dev", branchId: null };
+		const superadmin = { userId: "u3", role: "superadmin", branchId: null };
+		const dev = { userId: "u4", role: "dev", branchId: null };
 
 		expect(getBranchAccess(admin, "NL01")).toBe(BRANCH_ACCESS.ALLOWED);
 		expect(getBranchAccess(admin, "NL99")).toBe(BRANCH_ACCESS.ALLOWED);
 
+		expect(getBranchAccess(superadmin, "NL01")).toBe(BRANCH_ACCESS.ALLOWED);
+		expect(getBranchAccess(superadmin, "NL99")).toBe(BRANCH_ACCESS.ALLOWED);
+
 		expect(getBranchAccess(dev, "NL01")).toBe(BRANCH_ACCESS.ALLOWED);
 		expect(getBranchAccess(dev, "NL99")).toBe(BRANCH_ACCESS.ALLOWED);
 	});

+ 4 - 3
lib/frontend/rbac/branchUiDecision.js

@@ -2,6 +2,7 @@ import {
 	getBranchAccess,
 	BRANCH_ACCESS,
 } from "@/lib/frontend/rbac/branchAccess";
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
 
 export const BRANCH_UI_DECISION = Object.freeze({
 	ALLOWED: "allowed",
@@ -14,7 +15,7 @@ export const BRANCH_UI_DECISION = Object.freeze({
  *
  * Notes:
  * - This function assumes the user is authenticated (handled by AuthProvider).
- * - For admin/dev we can optionally validate branch existence using `allowedBranches`
+ * - For admin-like roles we can optionally validate branch existence using `allowedBranches`
  *   from GET /api/branches. If the list is not available, we fail open.
  *
  * @param {{
@@ -31,8 +32,8 @@ export function decideBranchUi({ user, branch, allowedBranches = null }) {
 		return BRANCH_UI_DECISION.FORBIDDEN;
 	}
 
-	// Only admin/dev may validate existence across branches.
-	if (user.role === "admin" || user.role === "dev") {
+	// Only admin-like roles may validate existence across branches.
+	if (isAdminLikeRole(user.role)) {
 		if (Array.isArray(allowedBranches) && !allowedBranches.includes(branch)) {
 			return BRANCH_UI_DECISION.NOT_FOUND;
 		}

+ 29 - 6
lib/frontend/rbac/branchUiDecision.test.js

@@ -8,7 +8,7 @@ describe("lib/frontend/rbac/branchUiDecision", () => {
 		const user = { userId: "u1", role: "branch", branchId: "NL01" };
 
 		expect(decideBranchUi({ user, branch: "NL02" })).toBe(
-			BRANCH_UI_DECISION.FORBIDDEN
+			BRANCH_UI_DECISION.FORBIDDEN,
 		);
 	});
 
@@ -16,27 +16,50 @@ describe("lib/frontend/rbac/branchUiDecision", () => {
 		const user = { userId: "u1", role: "branch", branchId: "NL01" };
 
 		expect(decideBranchUi({ user, branch: "NL01" })).toBe(
-			BRANCH_UI_DECISION.ALLOWED
+			BRANCH_UI_DECISION.ALLOWED,
 		);
 	});
 
-	it("returns NOT_FOUND for admin/dev when branch is not in allowedBranches", () => {
+	it("returns NOT_FOUND for admin-like users when branch is not in allowedBranches", () => {
 		const admin = { userId: "u2", role: "admin", branchId: null };
+		const superadmin = { userId: "u3", role: "superadmin", branchId: null };
+		const dev = { userId: "u4", role: "dev", branchId: null };
 
 		expect(
 			decideBranchUi({
 				user: admin,
 				branch: "NL200",
 				allowedBranches: ["NL01", "NL02"],
-			})
+			}),
+		).toBe(BRANCH_UI_DECISION.NOT_FOUND);
+
+		expect(
+			decideBranchUi({
+				user: superadmin,
+				branch: "NL200",
+				allowedBranches: ["NL01", "NL02"],
+			}),
+		).toBe(BRANCH_UI_DECISION.NOT_FOUND);
+
+		expect(
+			decideBranchUi({
+				user: dev,
+				branch: "NL200",
+				allowedBranches: ["NL01", "NL02"],
+			}),
 		).toBe(BRANCH_UI_DECISION.NOT_FOUND);
 	});
 
-	it("fails open (ALLOWED) for admin/dev when allowedBranches is not available", () => {
+	it("fails open (ALLOWED) for admin-like users when allowedBranches is not available", () => {
 		const admin = { userId: "u2", role: "admin", branchId: null };
+		const superadmin = { userId: "u3", role: "superadmin", branchId: null };
 
 		expect(decideBranchUi({ user: admin, branch: "NL200" })).toBe(
-			BRANCH_UI_DECISION.ALLOWED
+			BRANCH_UI_DECISION.ALLOWED,
+		);
+
+		expect(decideBranchUi({ user: superadmin, branch: "NL200" })).toBe(
+			BRANCH_UI_DECISION.ALLOWED,
 		);
 	});
 });

+ 5 - 4
lib/frontend/search/normalizeState.js

@@ -3,6 +3,7 @@ import {
 	DEFAULT_SEARCH_LIMIT,
 	SEARCH_LIMITS,
 } from "@/lib/frontend/search/urlState";
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
 
 function isNonEmptyString(value) {
 	return typeof value === "string" && value.trim().length > 0;
@@ -28,7 +29,7 @@ function normalizeLimit(value) {
  *
  * Policy:
  * - branch users: force SINGLE on the current route branch
- * - admin/dev: SINGLE always uses the current route branch
+ * - admin-like: SINGLE always uses the current route branch
  * - MULTI/ALL: do not carry a single-branch value (branch=null)
  *
  * @param {{
@@ -53,7 +54,7 @@ function normalizeLimit(value) {
  */
 export function normalizeSearchUrlStateForUser(
 	state,
-	{ routeBranch, user } = {}
+	{ routeBranch, user } = {},
 ) {
 	const route = normalizeBranch(routeBranch);
 
@@ -89,8 +90,8 @@ export function normalizeSearchUrlStateForUser(
 		};
 	}
 
-	// Admin/dev: SINGLE always uses the current route branch (route context wins over URL param).
-	if (role === "admin" || role === "dev") {
+	// Admin-like: SINGLE always uses the current route branch (route context wins over URL param).
+	if (isAdminLikeRole(role)) {
 		if (base.scope === SEARCH_SCOPE.SINGLE) {
 			return { ...base, branch: route, branches: [] };
 		}

+ 27 - 8
lib/frontend/search/normalizeState.test.js

@@ -19,7 +19,7 @@ describe("lib/frontend/search/normalizeState", () => {
 				from: null,
 				to: null,
 			},
-			{ routeBranch: "NL01", user: { role: "branch", branchId: "NL01" } }
+			{ routeBranch: "NL01", user: { role: "branch", branchId: "NL01" } },
 		);
 
 		expect(normalized).toEqual({
@@ -33,7 +33,7 @@ describe("lib/frontend/search/normalizeState", () => {
 		});
 	});
 
-	it("admin/dev: SINGLE uses route branch even if URL carries a different branch", () => {
+	it("admin-like: SINGLE uses route branch even if URL carries a different branch (admin)", () => {
 		const normalized = normalizeSearchUrlStateForUser(
 			{
 				q: "x",
@@ -44,7 +44,7 @@ describe("lib/frontend/search/normalizeState", () => {
 				from: null,
 				to: null,
 			},
-			{ routeBranch: "NL01", user: { role: "admin", branchId: null } }
+			{ routeBranch: "NL01", user: { role: "admin", branchId: null } },
 		);
 
 		expect(normalized.branch).toBe("NL01");
@@ -52,7 +52,26 @@ describe("lib/frontend/search/normalizeState", () => {
 		expect(normalized.branches).toEqual([]);
 	});
 
-	it("admin/dev: MULTI keeps branches and clears branch", () => {
+	it("admin-like: SINGLE uses route branch even if URL carries a different branch (superadmin)", () => {
+		const normalized = normalizeSearchUrlStateForUser(
+			{
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL77",
+				branches: [],
+				limit: DEFAULT_SEARCH_LIMIT,
+				from: null,
+				to: null,
+			},
+			{ routeBranch: "NL01", user: { role: "superadmin", branchId: null } },
+		);
+
+		expect(normalized.branch).toBe("NL01");
+		expect(normalized.scope).toBe(SEARCH_SCOPE.SINGLE);
+		expect(normalized.branches).toEqual([]);
+	});
+
+	it("admin-like: MULTI keeps branches and clears branch (dev)", () => {
 		const normalized = normalizeSearchUrlStateForUser(
 			{
 				q: "x",
@@ -63,7 +82,7 @@ describe("lib/frontend/search/normalizeState", () => {
 				from: null,
 				to: null,
 			},
-			{ routeBranch: "NL99", user: { role: "dev", branchId: null } }
+			{ routeBranch: "NL99", user: { role: "dev", branchId: null } },
 		);
 
 		expect(normalized.scope).toBe(SEARCH_SCOPE.MULTI);
@@ -72,7 +91,7 @@ describe("lib/frontend/search/normalizeState", () => {
 		expect(normalized.limit).toBe(50);
 	});
 
-	it("admin/dev: ALL clears branch and branches", () => {
+	it("admin-like: ALL clears branch and branches", () => {
 		const normalized = normalizeSearchUrlStateForUser(
 			{
 				q: "x",
@@ -83,7 +102,7 @@ describe("lib/frontend/search/normalizeState", () => {
 				from: null,
 				to: null,
 			},
-			{ routeBranch: "NL99", user: { role: "admin", branchId: null } }
+			{ routeBranch: "NL99", user: { role: "admin", branchId: null } },
 		);
 
 		expect(normalized).toEqual({
@@ -108,7 +127,7 @@ describe("lib/frontend/search/normalizeState", () => {
 				from: null,
 				to: null,
 			},
-			{ routeBranch: "NL02", user: { role: "user", branchId: "NL02" } }
+			{ routeBranch: "NL02", user: { role: "user", branchId: "NL02" } },
 		);
 
 		expect(normalized.scope).toBe(SEARCH_SCOPE.SINGLE);

+ 4 - 3
lib/frontend/search/searchApiInput.js

@@ -1,6 +1,7 @@
 import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
 import { ApiClientError } from "@/lib/frontend/apiClient";
 import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
+import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
 
 function isNonEmptyString(value) {
 	return typeof value === "string" && value.trim().length > 0;
@@ -68,7 +69,7 @@ export function buildSearchApiInput({
 			error: buildValidationError(
 				dateValidation.code,
 				dateValidation.message,
-				dateValidation.details
+				dateValidation.details,
 			),
 		};
 	}
@@ -89,8 +90,8 @@ export function buildSearchApiInput({
 		return { input, error: null };
 	}
 
-	// Admin/dev: respect scope rules from URL state.
-	if (role === "admin" || role === "dev") {
+	// Admin-like: respect scope rules from URL state.
+	if (isAdminLikeRole(role)) {
 		if (urlState.scope === SEARCH_SCOPE.ALL) {
 			input.scope = "all";
 			return { input, error: null };

+ 21 - 4
lib/frontend/search/searchApiInput.test.js

@@ -42,7 +42,7 @@ describe("lib/frontend/search/searchApiInput", () => {
 		expect(input).toEqual({ q: "x", limit: 100, branch: "NL01" });
 	});
 
-	it("admin/dev: ALL uses scope=all", () => {
+	it("admin-like: ALL uses scope=all (admin)", () => {
 		const { input } = buildSearchApiInput({
 			urlState: {
 				q: "x",
@@ -59,7 +59,24 @@ describe("lib/frontend/search/searchApiInput", () => {
 		expect(input).toEqual({ q: "x", limit: 100, scope: "all" });
 	});
 
-	it("admin/dev: MULTI uses scope=multi + branches", () => {
+	it("admin-like: ALL uses scope=all (superadmin)", () => {
+		const { input } = buildSearchApiInput({
+			urlState: {
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+				branch: null,
+				branches: [],
+				from: null,
+				to: null,
+			},
+			routeBranch: "NL01",
+			user: { role: "superadmin", branchId: null },
+		});
+
+		expect(input).toEqual({ q: "x", limit: 100, scope: "all" });
+	});
+
+	it("admin-like: MULTI uses scope=multi + branches (dev)", () => {
 		const { input, error } = buildSearchApiInput({
 			urlState: {
 				q: "x",
@@ -85,7 +102,7 @@ describe("lib/frontend/search/searchApiInput", () => {
 		});
 	});
 
-	it("admin/dev: MULTI without branches is treated as not-ready (no error)", () => {
+	it("admin-like: MULTI without branches is treated as not-ready (no error)", () => {
 		const { input, error } = buildSearchApiInput({
 			urlState: {
 				q: "x",
@@ -103,7 +120,7 @@ describe("lib/frontend/search/searchApiInput", () => {
 		expect(error).toBe(null);
 	});
 
-	it("admin/dev: SINGLE uses routeBranch as branch param", () => {
+	it("admin-like: SINGLE uses routeBranch as branch param", () => {
 		const { input } = buildSearchApiInput({
 			urlState: {
 				q: "x",