SCR-002 — Root landing redirect (/) · web (oms-web) · app/page.tsx

This screen has no markup. app/page.tsx is a five-line Server Component whose entire body is redirect("/orders") — it paints nothing, ever. The design work here is not a layout, it is a rule: what does the bare origin resolve to once the menu is permission-derived and /orders may be denied to the person asking?

Drawn default: redirect to the first permitted menu item. Artboard A is therefore a picture of where a permission-limited user ends up — the Expenses list (SCR-006), reached by a Staff user who holds Expenses:View/Expenses:Create and nothing else. That is the honest, verifiable output of this node: the rule, plus the frame it produces. Annotation 1 makes the "no pixels of its own" point non-negotiable so nobody reads Artboard A as a new screen; 3 draws the two rejected alternatives (403 page, notFound()) so N31 can overturn the default explicitly; 4 is the degenerate case where the default runs out of road; 5 lists the approximations, including one real bug this frame exposes in an existing file.

Viewport 1440 × 900 (desktop only) · tokens from tailwind.config.ts + app/globals.css · shell mirrored from app/layout.tsx + components/layout/Sidebar.tsx · landing frame mirrored from app/expenses/page.tsx with PageHeader/SearchInput/FilterBar/DataTable/StatusBadge/Pagination · redirect behaviour read from app/page.tsx, middleware.ts, lib/actions/auth.ts · fields["SCR-002"] is [] — correctly so, this screen has no form controls at all
Artboard A — 1440 × 900 · The resolved landing for a Staff user without Orders:View. What / actually produces. Clean frame, no annotation overlay — diffable by W21_ui_verify.

Expenses

New Expense
Search expenses...
Filters
Date Project Name Expense Items Total Amount Status Actions
18/08/2026Riverside Clinic Fit-out418,420.00PendingEdit
17/08/2026Northgate Warehouse23,150.00ApprovedEdit
15/08/2026Riverside Clinic Fit-out726,905.50ApprovedEdit
14/08/2026Depot Cold Storage1780.00RejectedEdit
12/08/2026Northgate Warehouse59,640.25PendingEdit
11/08/2026Harbour Point Retrofit312,300.00ApprovedEdit
08/08/2026Depot Cold Storage67,455.80PendingEdit
05/08/2026Harbour Point Retrofit21,990.00ApprovedEdit
04/08/2026Riverside Clinic Fit-out931,275.00PendingEdit
Showing 1–9 of 9
Prev Page 1 of 1 Next

1What SCR-002 is, and what Artboard A is a picture of

Stated plainly so it cannot be misread downstream: SCR-002 renders nothing. The frame above is SCR-006 (Expenses list) shown as the destination of SCR-002's rule, under a permission set that makes the rule's effect visible. If Artboard A is fed to a UI-diff gate, it is diffing the Expenses list under a filtered sidebar — which is exactly the observable acceptance criterion for this screen, and there is no other pixel evidence SCR-002 could ever produce.

Concern Today Under permissions Visual consequence
The route body app/page.tsx: redirect("/orders"), unconditional, no session read (5 lines, no JSX). Read the session, resolve the permission set, redirect to the first menu entry that set permits. Still no JSX. None on / itself — it is never painted. All consequence is at the destination.
Who reaches it Anyone; middleware.ts's matcher /((?!login|_next/static|_next/image|favicon.ico).*) does match /, so an unauthenticated visitor is bounced to /login?next=/ first. Unchanged. SCR-002 only ever runs for an authenticated user — which is why the question here is authorization, never authentication. None. The signed-out path is already correct and is not touched.
Rendering model Server Component. redirect() throws before any response body is produced — the browser receives a 307 and never paints an intermediate frame. Unchanged, and worth protecting: resolving the target server-side means no flash, no spinner, no "Redirecting…" screen. Do not build a loading state for this screen. There is no moment at which one could be shown.
Failure today A Staff user without Orders:View types the origin → lands on /orders → gets denied. The app's own front door is a dead end for them. Landing target is derived from what the user may actually see. Artboard A. The user lands somewhere they are allowed to be, with a menu that matches.
Duplicate of this rule lib/actions/auth.ts also hardcodes redirect("/orders") after a successful login (SCR-001). Both call points must share one resolver (e.g. resolveLandingPath(session)), not two copies of the same list order. None visible — but two implementations will drift, and the drift is invisible until a user with an unusual permission set hits one path and not the other.

2The resolution rule, and what it produces for real permission sets

The rule drawn is deliberately boring: flatten the menu in its declared order, take the first entry the user is permitted to open, redirect there. "First" needs a defined ordering, and this app already has one — NAV_GROUPS in components/layout/Sidebar.tsx is an ordered literal (OMS: Orders → Expenses → Expense Summary; Admin: Suppliers → Items → UOM → Projects). That order is reused rather than a new priority field being invented.

Loop hazard — the one thing that can actually break this screen. The permission consulted in step 3 must be the same permission the destination route's own guard checks. If the menu says a user may see /expenses under Expenses:View but the route guard demands something else, / redirects into a denial which (under option C in 3) may redirect back to / — an infinite loop, served as a browser error page, with no server exception to alert on. Both must read one registry. This is an implementation constraint, not a design preference.

Example user Permission set (illustrative) / resolves to Note
Manufacturer · Admin All /orders Identical to today's hardcoded target. No behaviour change for the privileged case, which is why this default is low-risk to ship.
Supplier · Staff Artboard A Expenses:View, Expenses:Create /expenses Orders is first in the menu but denied, so it is skipped. Admin group is entirely denied and is dropped from the sidebar rather than rendered empty.
Manufacturer · Staff (masters only) Item:View, Uom:View /admin/items The whole OMS group is skipped; landing is inside Admin. Nothing about the rule special-cases groups — it is one flat ordered walk.
Reporting-only Staff ExpenseSummary:View /expense-summary Depends on SCR-009's open question (is reporting its own resource?). If reporting collapses into Expenses:View, this user stops existing — the rule is unaffected either way.
Zero-permission account — (empty role, or all permissions revoked) nothing to redirect to The degenerate case. 4.

Ordering is a product decision wearing implementation clothes. Reusing NAV_GROUPS order means the landing page for a limited user is "the topmost thing they can see", not "the most useful thing they can see". For a Supplier Staff user whose actual job is Orders, that is fine; for one whose job is Expenses but who also happens to hold a stray Orders:View, they land on Orders. An explicit per-role defaultLanding would fix that and costs one nullable column — recorded in 6, not drawn, because nothing in the acceptance criteria asks for it and it adds a second thing that can be misconfigured.

3The operator decision: redirect, or deny? A drawn B, C declined

This is the ambiguity the inventory flags — "redirect to first-permitted menu, or render a denial?" — and it is explicitly noted as affecting whether SCR-028 is needed. All three answers are drawn so the choice is visible rather than implied. A is what Artboard A commits to.

A — redirect to first permitted menu drawn
The user never sees that /orders was denied; they simply arrive somewhere they belong. No new screen, no new component, no new string.
GET /
↓ session → permission set
↓ first permitted: /expenses
307 → /expenses
renders Expenses list + filtered sidebar
B — render a 403 denial page declined
Honest about what happened, but requires a new screen — the one thing the requirement forbids verbatim: "Dont create any screen we dont want any screen for this rereuiment".
403
You don't have permission to view this page.
Back
C — notFound(), reusing app/not-found.tsx declined
No new screen (the file exists), and it leaks nothing about what does or doesn't exist. But it tells the user the front door is broken, which for a legitimate account is a lie.
404
The page or record you're looking for doesn't exist.
Back to Orders

Note the CTA. See 5.

Why A
  • It is the only option with no new surface. B is a new screen (forbidden). C reuses an existing one but repurposes it to mean something it doesn't say.
  • / is not a resource. It is a doorway. Denying a doorway is a category error — the user was not asking for anything privileged, they typed the app's address. Denial is the right answer for /admin/suppliers (SCR-028's actual job); it is the wrong answer for /.
  • It keeps SCR-028 narrow. If / is a redirect, SCR-028 stays purely about direct URL access to a specific denied route by a user who has some permissions — a much cleaner question for the operator than one entangled with the landing rule.
  • It matches the sibling mockups. SCR-000 drew a Staff user landing on /orders and SCR-001 drew post-login landing as "first permitted menu". Choosing B or C here contradicts both and re-cuts three artboards, not one.
  • Symmetry with login. Whatever / does, lib/actions/auth.ts must do — a user should not land in one place by logging in and a different place by typing the origin. A is the only option that is sane to apply at both call sites.

The counter-argument, recorded. A is silent: a user who bookmarked /orders and has just lost that permission gets moved without explanation, and an admin debugging "why does this user see Expenses first?" has nothing on screen to read. C at least says something happened. This mockup accepts the silence because the alternative costs a screen the requirement forbids — but it is a genuine trade, not an obvious win, and it is precisely the kind of call N31 exists to make.

4Degenerate case: no permitted menu item exists

Option A's rule has one hole: "redirect to the first permitted entry" has no answer when there are none. This is where SCR-002, SCR-001 and SCR-000 are genuinely coupled, and the three mockups take one coherent position.

Drawn position drawn
  • Primary: it cannot happen. SCR-001's mockup fails closed at login — an account with an empty permission set never receives a session cookie and never reaches /. Handling the degenerate case at authentication time is strictly cheaper than handling it at every route.
  • Fail-safe behind it: notFound(). A permission set can be emptied mid-session (a role edited while the user is logged in), so the primary defence is not sufficient on its own. If the walk in 2 finds nothing, call notFound() — it reuses app/not-found.tsx, so it is still no new screen.
  • Not a redirect loop. The fail-safe must terminate rendering, not redirect anywhere. Sending this user to /login would loop (their cookie is valid); sending them to any route loops (they may open none).

Coupling, stated for N31. If the reviewer overturns SCR-001's fail-closed and lets zero-permission users in with an empty shell, then this fail-safe becomes the primary path, it will be hit routinely rather than exceptionally, and SCR-000's flagged "No modules available for this account" empty-nav box comes back into scope — at which point notFound() is the wrong treatment and a real empty state is needed. Decide SCR-000 2, SCR-001 5 and this box together, or they will disagree.

Mid-session revocation — the sequence
Why the fail-safe is not redundant with SCR-001's fail-closed login.
1 · user signs in, holds Expenses:View
2 · admin empties that role
3 · user's cookie is still valid
4 · user navigates to /
5 · menu walk returns nothing
notFound() — terminate, don't redirect

Assumes permissions are re-read per request rather than trusted from the cookie payload. If the session cookie carries a frozen permission list (SCR-001 1 leaves that open), step 5 never fires until the cookie's 7-day expiry — a stale-authorization window that is a real security property, and a question for whoever specifies the session payload.

5Approximations and one live defect — flagged, not silently drawn

Per this node's instruction: anything drawn without a real control behind it is called out here. An unflagged approximation is what gets built.

ItemKindDetail
Artboard A is not this screen approximation The frame is SCR-006 (Expenses list) standing in for SCR-002's outcome. SCR-002 has no renderable form of its own, so there is no non-approximate frame available. Any UI-diff gate consuming this artboard is diffing the Expenses list under a filtered sidebar, which is the correct observable assertion but is not "the SCR-002 screen" — because there isn't one.
app/not-found.tsx CTA reads "Back to Orders" defect This is a real bug in an existing file, exposed by this screen's rule, not a design question. app/not-found.tsx:8 hardcodes href="/orders". Option C in 3 and the fail-safe in 4 both route permission-limited users to that page — where the only escape hatch points at the one route they are most likely to be denied. It must become the same resolved landing path from 2 (making not-found.tsx a Server Component that reads the session), or the button must be dropped. Applies to every 404 in the app, not just this path.
Menu ordering as landing priority approximation No defaultLanding / priority field exists anywhere in the codebase, so NAV_GROUPS' array order is used as the ordering authority. It is a real order and a defensible one, but it was authored as a menu layout, never as a statement about which screen matters most to which role. Adding a priority field later changes no pixels.
Sidebar shown filtered borrowed The permission-filtered sidebar in Artboard A is SCR-027's design, not this screen's, and is drawn here only because a landing frame with today's full hardcoded menu would be incoherent — it would show Orders in the nav for a user who was just redirected away from it. If SCR-027's hide-vs-disable decision changes (SCR-000 6 drew hide), this frame is re-cut with it.
Expense rows, project names, amounts sample data Invented content at realistic shape/length. Column set, formatting (toLocaleDateString(), toFixed(2)) and the StatusBadge tones are transcribed from app/expenses/page.tsx.
Icon glyphs approximation Inline SVG traces of the lucide-react icons the app imports (Receipt, LogOut, Search, SlidersHorizontal, ArrowUpDown) at their real h-4 w-4 / h-3.5 w-3.5 sizes. Same glyphs, hand-transcribed paths — sub-pixel differences from the shipped icons are expected.
No loading / transition state drawn deliberate Not an omission. redirect() in a Server Component emits a 307 before any body; there is no frame in which a spinner could appear. Drawing one would invite someone to build it.

6Open decisions carried into N31 — and the default this mockup drew

None of these blocked the mockup. A defensible default was drawn for each and is recorded here so the reviewer overturns it explicitly rather than discovering it after it ships.

7Tokens used — no new colour, radius or type scale is introduced

bg #fcfcfd
surface #ffffff
text #302828
text-muted #836f6f
border #ede0ce
accent #453028
accent-subtle
#f7f1e9
success #1f8a4c
warning #b8860b
danger #c0392b
info #2563eb
annotation
(NOT a token)

Classes reproduced verbatim: .card (surface + --border + rounded-lg 8px), .btn-primary (--accent, white text, px-4 py-2, rounded-md 6px, 14px/500), .btn-secondary (surface + 1px --border), .field-input (@tailwindcss/forms base geometry: 1px border, 8px×12px padding, 14px), .link (--status-info + persistent underline — the fix from the "orders can't be clicked" report). Layout: sidebar w-64 = 256px with px-5 py-4 brand and px-3/mb-5 nav groups; main.flex-1.p-6 = 24px gutter; PageHeader mb-6 = 24px; toolbar mb-4 = 16px; DataTable cells px-4 py-3; Pagination mt-4. StatusBadge tones are the token colour at /10 alpha on a rounded-full px-2 py-0.5 text-xs pill, exactly as components/ui/StatusBadge.tsx defines them.

The only token not exercised on this frame is status-neutral (no Inactive/Cancelled rows in the sample). Nothing is added: this screen introduces zero new components, zero new strings and zero new tokens — which is the strongest argument for the default drawn in 3, since both alternatives introduce at least one of the three.