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) and was extended in RHL-020 with a real login flow, a session guard for protected routes, and a minimal logout + user status UX.
/login route with a functional login form (shadcn/ui primitives).Minimal 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.
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 |
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:
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:
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.js
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.The login UI uses shadcn/ui primitives from components/ui/*.
Required components for RHL-020:
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 must be .jsx because it renders a JSX Provider.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)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-outThe current server deployment is accessed via direct HTTP:
http://<server-ip>:3000Important 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-out