This document describes the frontend routing scaffold and the application shell layout for the RHL Lieferscheine app.
It started as a pure scaffold in RHL-019 (public vs protected routes + AppShell + placeholder pages), was extended in RHL-020 with a real login flow and a session guard for protected routes, and was extended again in RHL-021 with a UI-side RBAC guard plus consistent Forbidden / NotFound UX.
Public /login route with a functional login form (shadcn/ui primitives).
Protected application shell for all other routes.
Session guard for the protected area:
GET /api/auth/me/login?reason=expired&next=<original-url> when unauthenticatedLogout button wired to GET /api/auth/logout.
Minimal UserStatus that displays session state (role + branch).
Centralized helper utilities for auth redirect behavior (reason / next) and error-to-message mapping.
RHL-021 additions:
BranchGuard).notFound() early).GET /api/branches.AUTH_FORBIDDEN_BRANCH to Forbidden UX inside explorer/search components).Note: Prior to RHL-021, “Full RBAC UI guard” was listed as out of scope. It is now implemented for branch routes.
The app uses Next.js App Router Route Groups to separate public and protected UI.
Public: app/(public)
/loginProtected: app/(protected)
| URL | Purpose | Notes |
|---|---|---|
/login |
Login page | Supports reason and next query params |
/ |
Protected entry placeholder | Only rendered when authenticated |
/:branch |
Branch placeholder | Example: /NL01 |
/:branch/:year |
Year placeholder | Example: /NL01/2025 |
/:branch/:year/:month |
Month placeholder | Example: /NL01/2025/12 |
/:branch/:year/:month/:day |
Day placeholder | Example: /NL01/2025/12/31 |
/:branch/search |
Search placeholder | Explicit segment so search is not interpreted as :year |
/forbidden |
Forbidden page | Optional wrapper route; UI typically renders Forbidden inline |
Important:
/search route. Visiting /search matches /:branch with branch = "search".File: app/layout.jsx
Responsibilities:
app/globals.css).File: app/(public)/layout.jsx
Responsibilities:
/login (and potential future public routes).File: app/(protected)/layout.jsx
Responsibilities:
<Suspense> boundary.components/auth/AuthProvider.jsx.Why the Suspense boundary is required:
useSearchParams().useSearchParams() causes a CSR bailout unless wrapped by a Suspense boundary.File: components/auth/AuthProvider.jsx
Behavior:
On mount, call apiClient.getMe().
If { user: { ... } }:
authenticatedIf { user: null }:
/login?reason=expired&next=<current-url>The next parameter:
pathname and query stringFiles:
app/(public)/login/page.jsx (Server Component)components/auth/LoginForm.jsx (Client Component)Flow:
Login page parses query params using parseLoginParams(...).
If reason is present:
expired → show “Session expired” bannerlogged-out → show “Logged out” bannerOn submit, the form calls apiClient.login({ username, password }).
On success:
next if present/On failure:
Username policy:
The UI enforces this policy as well:
autoCapitalize="none" to prevent mobile auto-capsFile: components/auth/LogoutButton.jsx
Flow:
apiClient.logout()./login?reason=logged-out.Files:
components/auth/authContext.jsxcomponents/app-shell/UserStatus.jsxBehavior:
status, user).UserStatus renders a short indicator:
Loading...<role> (<branchId>) when availableFile:
lib/frontend/apiClient.jsRules:
All UI code must call the backend through this client.
Defaults:
credentials: "include"cache: "no-store"Throws ApiClientError for standardized backend errors.
RHL-020 uses:
login({ username, password })logout()getMe()reason / next)File: lib/frontend/authRedirect.js
Provides:
sanitizeNext(next) to prevent open redirects.buildLoginUrl({ reason, next }).parseLoginParams(searchParams).File: lib/frontend/authMessages.js
reason=expired and reason=logged-out.File: lib/frontend/routes.js
The login UI uses shadcn/ui primitives from components/ui/*.
Required components for the current scope:
cardinputlabelalertThese are added to the repository via the shadcn CLI.
To keep the project consistent and avoid tooling issues:
Use .jsx for files that contain JSX:
app/**/page.jsx, app/**/layout.jsxcomponents/**Use .js for non-JSX files:
lib/** utilities and helpersapp/api/**/route.jsmodels/**Note:
components/auth/authContext.jsx must be .jsx because it renders a JSX Provider.Existing (RHL-019 / RHL-020):
lib/frontend/routes.test.js (route builder)lib/frontend/apiClient.test.js (client defaults + error mapping)lib/frontend/authRedirect.test.js (reason/next parsing + sanitization)lib/frontend/authMessages.test.js (UI message mappings)components/app-shell/AppShell.test.js (SSR smoke test)RHL-021 additions:
lib/frontend/params.test.js (year/month/day/branch param validation)lib/frontend/rbac/branchAccess.test.js (pure RBAC decision helper)lib/frontend/rbac/branchUiDecision.test.js (UI decision helper: allowed/forbidden/not-found)From the repo root:
npx vitest run
Optional build check:
npm run build
Start:
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
Verify flows in the browser:
Open a protected route while logged out (e.g. /NL01/2025/12)
/login?reason=expired&next=/NL01/2025/12Invalid login
Valid login
Logout
/login?reason=logged-outRHL-021 checks:
Branch user:
/NL01/... works (own branch)/NL02/... shows Forbidden (UI guard)/NL01/abcd, /NL01/2024/99/01) show NotFoundAdmin/dev:
/NL9999) shows NotFound (branch existence validation)Note: Branch existence validation for admin/dev uses
GET /api/branches. The backend branch listing is subject to a 60s server-side TTL micro-cache (storage module). New branch folders may appear with up to ~60s delay.
Deploy and verify on the server URL.
Important cookie note:
Secure cookies over HTTP.Therefore the server .env.server must set:
SESSION_COOKIE_SECURE=false
Verify flows on the server URL:
nextnextreason=logged-outRHL-021 checks on server:
RHL-021 adds a friendly UI layer on top of backend RBAC:
Users should receive clear UX:
Backend RBAC remains the source of truth. UI RBAC exists to:
Files:
components/auth/BranchGuard.jsxPure logic:
lib/frontend/rbac/branchAccess.jslib/frontend/rbac/branchUiDecision.jsResponsibilities:
user and status from AuthContext.Enforce branch rules:
branch → allowed only when :branch === user.branchIdadmin / dev → allowed for any branch that existsGuard ordering:
/:branch/....Problem:
/NL200) and still see a placeholder page.Solution:
For admin and dev users, BranchGuard validates that the route branch exists by calling:
GET /api/branches via apiClient.getBranches()Behavior:
While the branch list is being fetched, BranchGuard shows a small loader:
If the requested :branch is not in the returned list:
Fail-open policy:
If fetching the branch list fails (network issues, temporary backend failure):
Security note:
Files:
lib/frontend/params.jsLayout enforcement (server-side notFound()):
app/(protected)/[branch]/layout.jsx (branch syntax)app/(protected)/[branch]/[year]/layout.jsxapp/(protected)/[branch]/[year]/[month]/layout.jsxapp/(protected)/[branch]/[year]/[month]/[day]/layout.jsxRules (syntactic validation only):
year: YYYY (4 digits)month: MM (01–12)day: DD (01–31)If a param is invalid:
notFound() is triggered immediatelyNotes:
FS_NOT_FOUND) is handled later in Explorer/Search UI components.Files:
components/system/ForbiddenView.jsxapp/(protected)/forbidden/page.jsxWhere Forbidden is shown:
CTAs:
/${user.branchId})Files:
components/system/NotFoundView.jsxapp/(protected)/not-found.jsxWhere NotFound is shown:
notFound().Even with UI-side RBAC, the backend remains authoritative.
Recommended policy for later UI tickets (Explorer/Search):
AUTH_UNAUTHENTICATED:
/login?reason=expired)AUTH_FORBIDDEN_BRANCH:
This policy can be implemented as a small helper when Explorer/Search UI begins consuming the navigation endpoints.