SCR-003 — Orders list · web (oms-web) · app/orders/page.tsx
The screen is unchanged in layout. What this ticket adds is a gate in front of it and a
conditional inside it: the route requires Orders:View, and the one create entry point in
PageHeader requires Orders:Create. Nothing else on this page is permission-sensitive —
search, sort, filter and pagination are deliberately left alone (pin 4).
Artboard A draws the changed state, not the shipping one. A Manufacturer · Admin sees exactly
today's page, so drawing that would produce a mockup with nothing in it to review. The frame instead shows a
Supplier · Staff user holding Orders:View only: no "New Order" button, and a sidebar
filtered to the single entry they may open. Pin 3 puts the two header states side by side
so the delta is one comparison, not two files.
Two open decisions came out of drawing this and are flagged rather than silently resolved:
row-level data scoping — should a Supplier user see all orders or only their own
(5) — and the supplier/project master lists leaking through the filter panel
to a user without Supplier:View (6). Neither blocks the frame; both change
what the frame means.
Viewport 1440 × 900 (desktop only) ·
tokens from tailwind.config.ts + app/globals.css ·
shell from app/layout.tsx + components/layout/Sidebar.tsx ·
page transcribed from app/orders/page.tsx with
PageHeader/SearchInput/FilterBar/DataTable/StatusBadge/Pagination ·
data shape from lib/queries/orders.ts, paging defaults from lib/params.ts
· fields["SCR-003"] is [] — the list has no form of its own; its only inputs are the
shared search box and the FilterBar panel, both drawn from the real components (see
6)
1What Artboard A asserts
Five claims, each checkable against the frame above. If any one of them is wrong the frame is wrong — that is the
point of listing them rather than describing the screen in prose.
| Element |
Permission |
Today |
Drawn in Artboard A |
| Sidebar "Orders" entry |
Orders:View |
NAV_GROUPS is a hardcoded literal in
components/layout/Sidebar.tsx; every user sees all seven entries. |
Present and active (bg-accent-subtle, font-medium) — this user holds
the permission. Everything else is gone, including the entire Admin group label. SCR-027 owns that
behaviour; it is drawn here because a filtered page under an unfiltered menu would be incoherent. |
| The route itself |
Orders:View |
middleware.ts only checks that a session cookie exists. Any authenticated user may
GET /orders. |
Renders. The denied case has no pixels on this screen — it is SCR-028's outcome, drawn small in
3. |
| "New Order" button |
Orders:Create |
Always rendered:
<PageHeader title="Orders" action={<Link href="/orders/new" className="btn-primary">} />
(app/orders/page.tsx:43-50). |
Absent. The action prop is not passed, and
PageHeader already renders null in that case
(PageHeader.tsx:9) — so the title stays left-aligned, the mb-6 gap is unchanged,
and no placeholder or disabled ghost is left behind. |
| Order No cell → detail |
Orders:View |
<Link href={`/orders/${r.id}`} className="link"> — always a link. |
Still a link. Drawn assumption: Orders:View covers both the list and the
per-record detail (SCR-005), so a user who can see the row can open it. See 7. |
| Rows returned |
— (none) |
listOrders() builds its where from search/filter params only — no ownership or
tenancy predicate anywhere (lib/queries/orders.ts:5-19). |
Unscoped: all 12 orders, from four different suppliers. This is the frame's most
consequential assumption and the easiest to miss — 5. |
2Where the gate lives — four layers, one screen
The acceptance criterion is "the user should not be able to access the functionality directly through a URL", so
hiding the button is the least important of these four. Layers 1 and 2 are the only ones drawn (they are
the only ones with pixels); 3 and 4 are listed because a mockup that showed only the hidden button would be read
as the whole change.
- 1 · Menu (SCR-027) — the Orders entry is rendered only if the resolved permission set contains
Orders:View. Cosmetic. Prevents nothing on its own.
- 2 · Route guard —
app/orders/page.tsx resolves the session's permission set and denies
without Orders:View, before listOrders() runs. This is what a typed URL hits.
The outcome of the denial is SCR-028's open decision — the default carried from SCR-002 is "redirect to the
first permitted menu entry".
- 3 · Create entry point — the
action prop here, and the same
Orders:Create check as a route guard on app/orders/new/page.tsx (SCR-004). Hiding a
link does not protect the page it points at; both are required, and only the second is security.
- 4 · Server action —
createOrder() in lib/actions/orders.ts:18 currently calls
requireSession() and nothing more. A Server Action is a POST to the same route URL and is
invocable without ever loading the page, so it needs its own Orders:Create assertion. No pixels,
and the only layer an attacker actually has to get past.
One registry, not four lists. The menu entry, the route guard and the action check must read the
same permission constant. If the sidebar says a user may open /orders but the route guard demands a
differently-named permission, the user gets bounced off a menu item they can see — and under SCR-002's
redirect-to-first-permitted default that bounce can loop. SCR-002 2 records the same
constraint from the other end.
3The four states of this screen
Only one is a full artboard because only one differs from what ships today. The rest are drawn at component
scale — that is genuinely all there is to them.
PageHeader — with Orders:Create (Manufacturer · Admin)
Identical to today. No behaviour change for the privileged case, which is what makes this
ticket low-risk to ship.
PageHeader — without Orders:Create Artboard A
Hidden, not disabled. A greyed-out button advertises a capability the user cannot have and invites a support
ticket; nothing is left in its place.
Empty result — unchanged by permissions
DataTable's existing empty row, with the caller's
emptyLabel="No orders found." (app/orders/page.tsx:78). Reached today by a search or
filter that matches nothing — and, if row-scoping is adopted (5), by a
Supplier user with no orders at all.
| Order No | Date | Supplier | Status |
| No orders found. |
Same label for two different situations. "No orders found." reads as "your filter is too
narrow". If it also becomes the answer to "you have no orders", a Supplier user's first ever visit looks
like a broken filter. Only matters if 5 is overturned — flagged so it is not
discovered afterwards.
Denied — no Orders:View, typed URL
This screen renders nothing at all. Outcome owned by SCR-028; the default inherited from SCR-000/SCR-002 is
drawn.
GET /orders (typed, or a stale bookmark)
↓ session valid — middleware.ts passes
↓ permission set lacks Orders:View
307 → /expenses (first permitted menu entry)
never rendered
no order data is queried
The guard must sit above listOrders(), not filter its result. Denying after the query
has run still puts every order row into the server's memory and into any log or trace on that path.
4What is deliberately not gated
Recorded so a later reviewer does not read these as oversights. Each was considered and left alone.
| Control | Decision | Reasoning |
Search box (SearchInput, ?q=) |
not gated |
Searches orderNo within rows the user is already permitted to see. Gating it would remove a
usability affordance without removing any access. |
Sortable headers (?sort=, ?dir=) |
not gated |
Reordering a permitted result set discloses nothing new. orderBy() already allow-lists the
sortable fields (lib/params.ts), so an arbitrary ?sort= cannot reach an
un-exposed column. |
| Pagination + rows-per-page |
not gated |
Same set, different window. pageSize is clamped to
PAGE_SIZE_OPTIONS so ?pageSize=100000 is already inert. |
| Row-level Edit / Delete controls |
nothing to gate |
The Orders list has no actions column and the app has no order edit or delete route at all
— there is no OrderRowActions.tsx and no deleteOrder action anywhere in
lib/actions/. An Orders:Edit or Orders:Delete permission would
therefore have no surface to control on this screen or any other. Flagged in
7: the permission registry should not grow entries that gate nothing. |
| Supplier / Project / Status / Date filters |
flagged |
Not gated — but the options inside two of them are master data. See 6. |
5Row-level scoping — the decision Artboard A silently makes unscoped drawn
The requirement introduces Supplier and Manufacturer as user types, not
just roles. That naming strongly implies a Supplier user is a supplier — in which case a list showing
Meridian, Ardent, Northgate and Coastline orders side by side shows one supplier its competitors' order book.
Artboard A draws exactly that, and it is a choice, not an accident.
Drawn: no row-level scoping drawn
- No acceptance criterion asks for it. Every stated criterion is about menus, routes,
actions and direct-URL access — the vocabulary is capability, never ownership. Inventing record ownership
would be building beyond the requirement, on a security question, without being asked.
- The schema cannot express it.
prisma/schema.prisma has no
User model at all (SCR-029), so there is nothing to hang a
user.supplierId off. Row-scoping is not a filter this ticket forgot; it is a data model this
ticket does not have.
- It is additive later. Adding a
where predicate to
listOrders() changes no pixels and no components — only which rows appear. The reverse
(shipping a scoping rule and then relaxing it) is the migration that hurts.
The counter-argument, recorded honestly. If "Supplier user type" does mean an external
supplier login, then shipping this unscoped is a cross-tenant data disclosure that no amount of correct
permission gating catches — the user has Orders:View and is being shown orders, so every check
in 2 passes and the audit is clean. This is the single highest-severity
question on the screen and it is not visible in the pixels, which is precisely why it is written
here. N31 should answer "does a Supplier user see other suppliers' orders?" explicitly, in words, before
this is built.
If overturned — what changes
Small in code, large in meaning. No component changes; the frame's row count does.
Schema — User.supplierId (nullable; null = Manufacturer)
Query — listOrders() takes the session and adds
supplierId to its where
Detail — SCR-005 needs the same predicate, or
/orders/<id> re-opens the hole one record at a time
Filter — the Supplier dropdown collapses to one option, or disappears
Artboard A would show 3 rows, all "Meridian Steel Co."
6The filter panel leaks master data — and gating it would be theatre
app/orders/page.tsx:18-22 calls activeSuppliers() and
activeProjects() to populate the filter dropdowns. Every supplier name and every project name is
therefore delivered to anyone who can open the Orders list — including a user with no
Supplier:View and no Project:View, for whom the entire Admin group is hidden from the
menu. The panel is drawn here in its open state because it is the only part of this screen a checklist sweep
would miss.
FilterBar panel, open — w-80 card p-4 shadow-lg
Drawn from components/ui/FilterBar.tsx. Closed by default (it auto-opens only when a filter is
already active), which is why Artboard A shows the button, not the panel.
Supplier
All
Project
All
Status
All
Order Date
dd/mm/yyyy
Drawn: do not gate the dropdowns drawn
- The table already shows the same names. Artboard A's Supplier and Project columns print
"Meridian Steel Co." and "Riverside Clinic Fit-out" in plain text. Hiding a dropdown that lists the names
visible three columns to the left protects nothing and costs the screen its two most useful filters.
- Master data is not the master screen.
Supplier:View gates
/admin/suppliers — the place you manage suppliers, with contact details, supplier numbers and
an edit path. A name rendered inside an order is order data.
- The real containment is 5. If a Supplier user should not know
who the other suppliers are, the fix is scoping the rows — at which point the dropdown narrows to match on
its own. Gating the dropdown while leaving the column is the worst of both: less usable, equally leaky.
What would change the answer. This reasoning holds only because the leak is limited to
names of records already on screen. If the filter ever lists suppliers with no orders — i.e. names
that appear nowhere in the table — it becomes a genuine enumeration of the supplier master, and
activeSuppliers() must be narrowed to suppliers present in the permitted result set. Worth
checking against the real data before build: activeSuppliers() selects on
isActive, not on "has orders", so today it very likely does.
7Approximations — 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 |
| Unscoped rows in Artboard A |
assumption |
Twelve orders across four suppliers, shown to a Supplier · Staff user. The drawn default from
5. If overturned, this frame is re-cut with three rows and a single supplier
name. |
| Order No stays a link |
assumption |
Assumes Orders:View covers list and detail. If SCR-005 splits them, the cell becomes
conditional — plain text when detail is denied, which is a visual change (the blue underline disappears from
every row) and needs its own frame. |
| Filter panel drawn closed in Artboard A |
accurate |
Not an omission. FilterBar initialises open to
activeCount > 0, and the frame draws an unfiltered first load. The open state is
6. |
Listbox / DatePicker controls |
approximation |
Drawn as static .field-control boxes at their real geometry (1px border, 8×12px padding,
min-height: 2.625rem) showing their real placeholders ("All" —
placeholderSelectable; dd/mm/yyyy). They are custom popover components, not native
selects; their open/expanded states are outside this screen's scope. |
| Order numbers, supplier and project names, dates, statuses |
sample data |
Invented content at realistic shape. OMS-2026-000047 follows
nextOrderNo()'s real OMS-YYYY-###### format
(lib/queries/orders.ts:47-52); dates use
toLocaleDateString(); the — in Due Date is the literal em-dash fallback from
app/orders/page.tsx:30. All four order statuses are exercised so every
StatusBadge tone on this screen is visible at once. |
| Twelve rows, "Showing 1–12 of 12" |
sample data |
Row count chosen to fill 900px without clipping the pager. Real default
pageSize is 20 (lib/params.ts:7), so a full page would overflow the viewport and
scroll — as it does in the shipped app. Prev/Next are drawn at opacity-40, the real
single-page state. |
| Icon glyphs |
approximation |
Inline SVG traces of the lucide-react icons the app imports (ClipboardList, LogOut, Search,
SlidersHorizontal, ArrowUpDown, X) 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. |
| Sidebar shown filtered |
borrowed |
SCR-027's design, not this screen's. Drawn because a page gated on Orders:View under a menu
offering six routes this user cannot open would misrepresent the change. If SCR-027's hide-vs-disable
default flips, this frame is re-cut with it. |
| Hover / focus states |
not drawn |
The real components carry hover:bg-accent-subtle/60 on rows,
hover:text-text on sort links and focus:ring-accent on inputs. A static artboard
has no pointer; unchanged by this ticket and deliberately omitted. |
8Open 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. The inventory marks SCR-003
ambiguous: false — the first two below are why that is optimistic.
-
Does a Supplier user see other suppliers' orders?
Drawn: yes — no row-level scoping (5). Highest-severity item on the
screen, invisible in the pixels, and passes every permission check even if wrong. Needs an explicit yes/no,
not silence. Overturning it adds a
User.supplierId and a where predicate to
listOrders() and to SCR-005's getOrder().
-
Do the filter dropdowns leak the supplier/project masters?
Drawn: not gated (6) — the same names already print in the table's
own columns, so hiding the dropdown costs usability and buys nothing. Revisit if
activeSuppliers() can return suppliers that appear in no visible row, which on today's query it
can.
-
Is detail a separate permission from list?
Drawn: no —
Orders:View covers both, so the Order No cell stays a link. Coupled
to SCR-005's own Approve-vs-Fulfil ambiguity; if that screen gains a view-level permission, this cell becomes
conditional and every row loses its blue underline.
-
Denial outcome for a typed
/orders URL.
Drawn: redirect to first permitted menu entry, inherited from SCR-002 and SCR-000 rather than
re-decided here. Owned by SCR-028 — this screen only needs the answer to be consistent with the other two
mockups.
-
Hide vs disable. Drawn: hide, matching SCR-000
5. If N31 prefers disable-with-tooltip, this artboard's PageHeader is re-cut and so is
every list screen's.
-
Orders:Edit / Orders:Delete have no surface.
Not drawn, flagged (4). The app has no order edit route and no order
delete action; the only post-create mutation is SCR-005's fulfilment form. Defining permissions that gate
nothing makes the registry a list of promises the UI does not keep — the same "data/configuration driven"
registry is what the requirement asks to be extensible later, so an unused entry is cheap to add then and
misleading to add now.
-
SCR-029 dependency.
prisma/schema.prisma has no User model
and lib/auth.ts is a single shared credential (AUTH_USERNAME/AUTH_PASSWORD),
so the Supplier · Staff user in Artboard A cannot exist yet. Not a design question — but it is why this frame
cannot be verified end-to-end until the operator rules on how users get created.
9Tokens 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
neutral #6b7280
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),
.field-control (the same geometry restored for non-form elements, min-height: 2.625rem),
.field-label, .link (--status-info + persistent underline — the fix from
the "orders can't be clicked" report, and the reason every Order No in Artboard A is blue rather than brown).
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 with an empty row at
px-4 py-8; Pagination mt-4; FilterBar panel
w-80 p-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.
All five status tones are exercised on this screen except danger (no REJECTED value
exists on OrderStatus — it belongs to ExpenseStatus); it is shown in the swatch row
only. This screen introduces zero new components, zero new strings and zero new tokens — the
entire visible change is one prop that stops being passed.