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
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.
- 1 · Authenticated? No →
middleware.ts already redirected to /login?next=/.
SCR-002 never runs. Unchanged behaviour.
- 2 · Flatten the menu in
NAV_GROUPS order into a single ordered list of
{ href, permission }. This is the same registry SCR-027 renders the sidebar from — not a second
list.
- 3 · Take the first entry the permission set permits and
redirect() to its
href.
- 4 · No entry is permitted → the degenerate case. See 4; the drawn position is
that this is unreachable because SCR-001 fails closed at login, with a fail-safe behind it.
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.
| Item | Kind | Detail |
| 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.
-
Redirect vs denial at
/ — the inventory's stated ambiguity.
Drawn: redirect to first permitted menu item (option A, 3).
Consequence if overturned: option B creates the new screen the requirement forbids and pulls SCR-028 forward;
option C repurposes app/not-found.tsx and makes its "Back to Orders" defect blocking.
-
Ordering authority for "first permitted".
Drawn:
NAV_GROUPS array order (OMS: Orders → Expenses → Expense Summary;
Admin: Suppliers → Items → UOM → Projects). Alternative is an explicit per-role
defaultLanding. Not drawn — no acceptance criterion needs it, and it is a second thing to
misconfigure.
-
No-permitted-menu fallback.
Drawn: unreachable by design (SCR-001 fails closed at login), with
notFound() as the
mid-session fail-safe. Coupled to SCR-000 2 and SCR-001
5 — decide all three together.
-
One resolver or two. Drawn: one.
app/page.tsx and
lib/actions/auth.ts must call the same resolveLandingPath(session). Recorded as a
design constraint rather than left to implementation, because two copies of an ordered list is exactly the
kind of duplication that passes review and drifts a month later.
-
Are permissions re-read per request, or frozen in the cookie?
Not drawn — genuinely out of this screen's scope, but SCR-002 is where the difference first
becomes observable (see 4). Frozen means up to 7 days of stale authorization; per-request
means a query on every navigation. SCR-001 owns the session payload; flagging it here so it is not lost.
-
app/not-found.tsx's hardcoded "Back to Orders".
Not fixed in this mockup, flagged as a defect (5). It is a
one-line change in a file this ticket otherwise doesn't touch, and it affects every 404 in the app — so it
wants an explicit yes, not an incidental edit.
-
SCR-029 dependency.
prisma/schema.prisma has no User
model and lib/auth.ts is a single shared credential, so no user with a partial permission set can
exist yet. Every frame here presumes seeded users with types, roles and permissions. Not a design question —
but it is why SCR-002 cannot be verified end-to-end until the operator rules on how users get created.
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.