app/orders/new/page.tsx
The screen is unchanged in layout. What this ticket adds is a gate in front of it and a
second, independent gate behind it: the route requires Orders:Create, and so does
createOrder() in lib/actions/orders.ts — which today asserts only
requireSession(). Those two are the whole of SCR-004. Nothing inside the form is
individually permission-sensitive, because there is no partial state of this screen: you may create an
order or you may not.
Artboard A draws the permitted state, which inverts SCR-003's choice — deliberately.
SCR-003 drew its changed state because its unchanged state had nothing to review. Here the changed
state has no pixels at all: a user without Orders:Create is redirected before a body is
emitted, so there is no denied rendering of this route to draw. Pin 2 argues that in
full. The frame therefore shows the minimum permission set that can reach the route —
Supplier · Staff holding { Orders:View, Orders:Create } — which is also the set that makes the
screen's real finding visible.
That finding is pin 4: the sidebar carries no Suppliers, Items, UOM or
Projects entry, yet the form below it is populated with every active supplier, project, item and UOM name in
the database. page.tsx runs all four active*() lookups in a
Promise.all before rendering. SCR-003 pin 6 declined to gate its filter dropdowns on the grounds
that the same names already printed in the table's own columns — that argument does not survive on this
screen, and this is the case SCR-003 said to revisit. Two further decisions are flagged rather than silently
resolved: Orders:Create granted without Orders:View
(5), where two hardcoded /orders redirects send the user somewhere their
own guard denies, and whether the guard can live in middleware at all
(3) — it cannot, on today's session model.
Each row is a claim the frame makes that a reviewer can check against the running app. "Today" is what
main ships right now; "Drawn" is what Artboard A shows for a user holding
{ Orders:View, Orders:Create }.
| Element | Permission | Today (main) | Drawn in Artboard A |
|---|---|---|---|
Route /orders/new |
Orders:Create |
Any session renders it. middleware.ts checks the cookie only. |
Rendered. This user holds the permission, so the frame is byte-identical to today's page — the gate's effect on a permitted user is nil, which is the point of pin 2. |
Server action createOrder() |
Orders:Create |
lib/actions/orders.ts:19 — await requireSession() and nothing more. |
No pixels. Listed because a mockup showing only the form would be read as the whole change; it is the opposite — this is the only layer with security value. Pin 3. |
| Sidebar — OMS group | Orders:View |
All three entries hardcoded in NAV_GROUPS. |
Orders only. Expenses and Expense Summary absent (hidden, not disabled — SCR-000 pin 1 treatment A). |
| Sidebar — Admin group | every item denied | All four entries hardcoded. | Whole group dropped, heading included (SCR-000 pin 1 treatment B). The 20px group gap collapses. |
PageHeader action slot |
— | Not passed on this screen. | Title-only. Unchanged in every permission state — SCR-004 adds no header action. |
Supplier / Project SelectField |
none — see pin 4 | Populated from activeSuppliers() / activeProjects(). |
Populated in full, for a user with neither Supplier:View nor Project:View. Drawn, and flagged. |
| Product Name / UOM per row | none — see pin 4 | Populated from activeItems() / activeUoms(). |
Same. The item and UOM masters are handed to a user who cannot open either admin screen. |
| Add Row / Remove row | — | Pure client state (useState<Row[]>). |
Drawn enabled. Nothing to gate: the rows never touch the server until submit, and submit is already gated. With a single row the Remove button is disabled (rows.length === 1) — four rows are drawn, so all four are live. |
| Cancel | assumes Orders:View |
router.push("/orders"), hardcoded. |
Drawn enabled, on the assumption Orders:Create implies Orders:View. Pin 5. |
| Generate Order | Orders:Create |
SubmitButton → createOrder. |
Drawn enabled. It is never conditionally hidden, because a user without the permission never reaches the form it lives in. |
SCR-003 drew its changed state and explained that drawing the unchanged one "would produce a mockup with nothing in it to review". SCR-004 reaches the opposite conclusion from the same principle, and the reason is worth stating plainly so the inversion does not read as an inconsistency.
Orders:View without Orders:Create is a
real, renderable state: the page loads, and one button is missing from it. There are two frames, and the
delta between them is a picture.createOrder call. Gating any of them individually
would produce a form that cannot be submitted — worse than no form.GET /orders/new resolves to 307 → first permitted menu entry. The response has
no body. There is nothing to draw, and drawing a 403 page instead would pre-empt exactly the operator
decision SCR-028 exists to make.Consequence for W21_ui_verify: a screenshot diff of this route against today's build should show zero pixel change for a permitted user. That is the correct result, not a sign the gate is missing. SCR-004 cannot be verified by looking at it — only by attempting the route and the action without the permission. Whatever gate N31 approves needs a test, not a screenshot.
The acceptance criterion is "the user should not be able to access the functionality directly through a URL". On this screen there is no button to hide, so both layers below are load-bearing — unlike SCR-003, where three of the four layers were cosmetic.
app/orders/new/page.tsx resolves the session's permission set and
denies without Orders:Create, before the four active*() lookups run.
Placement matters for the same reason SCR-003 pin 2 gave: the guard must sit above the query, not filter
its result. Here it matters more — the queries are the leak (pin 4), so a guard
that runs after them has already loaded four master tables for a user being turned away.createOrder() at lib/actions/orders.ts:18 needs its
own Orders:Create assertion, and this is the only layer an attacker actually has to get past.
A Server Action is a POST to the same route URL, and the page component does not run for it.
Guarding page.tsx alone leaves createOrder fully reachable by anyone with a valid
session cookie. The two guards are not redundant; they cover disjoint request types.Orders:Create is absent. That is cosmetic and
prevents nothing. It is listed here only so SCR-004 is not read as duplicating it.
Middleware is not available as a guard layer, and that is a design constraint, not an oversight.
middleware.ts already matches this route and the action POST — it would be the natural
single choke point. It cannot be used: it runs on the Edge runtime, and the only thing it can read is the
session cookie, whose payload is { u, exp } (lib/auth.ts:21). Checking a permission
there needs either a database read (Prisma is not available on Edge) or the permission set baked into the
cookie — which is precisely the "re-read per request, or frozen in the cookie?" question SCR-002 pin 6
recorded as out of scope. SCR-004 is where that question stops being theoretical. The default drawn
here assumes it stays unanswered: guard in the page component and in the action, both on the Node runtime,
both reading one registry.
This is the highest-value finding on the screen, it is visible in Artboard A, and it is the case SCR-003 pin 6 explicitly deferred to a screen like this one.
app/orders/new/page.tsx opens with a Promise.all over
activeSuppliers(), activeProjects(), activeItems() and
activeUoms(). Each returns every active row of its table
(lib/queries/masters.ts:97–104 — no pagination, no scoping, id + name only). Those names are
serialised into the initial HTML payload as Listbox options. The user drawn in Artboard A holds
Orders:Create and nothing else in the master-data space: no Supplier:View, no
Item:View, no Uom:View, no Project:View. Their sidebar has no Admin
group at all. They still receive the complete supplier list, project list, item catalogue and UOM list.
| Query | Permission that would gate it | What the user gets without it |
|---|---|---|
activeSuppliers() | Supplier:View | Every active supplier name — the same list /admin/suppliers would show them, minus the columns. |
activeProjects() | Project:View | Every active project name. For a Supplier·Staff user this is arguably the most sensitive of the four: project names describe the manufacturer's client work. |
activeItems() | Item:View | The full active item catalogue. |
activeUoms() | Uom:View | The UOM list. Low sensitivity, listed for completeness. |
Why SCR-003's argument does not transfer. SCR-003 declined to gate its filter dropdowns
because "the same names already print in the table's own columns… gating the dropdown while leaving the
column is the worst of both". It also wrote the escape clause: revisit if activeSuppliers()
can return suppliers in no visible row — which on today's query it can. On SCR-004 that condition is not
a hypothetical, it is the normal case: the create form has no table beside it, so 100% of
the names shown appear in no row this user can see. The redundancy that justified leaving SCR-003's
dropdown open does not exist here.
Drawn anyway, and here is the defence. Creating an order is choosing a supplier, a
project and some items. A gated dropdown makes Orders:Create a permission that grants a form
which cannot be completed — the "denied user sees a control that will fail server-side" failure SCR-000
treatment D was written to prevent, just relocated. The coherent alternatives are all bigger than a mockup
decision, and all belong to N31:
Orders:Create is
defined to include read access to the four master lists, and the permission registry says so out
loud. Cheapest, and honest — but an administrator granting "just Orders:Create" must be able to see that
it is not just that. This is what Artboard A draws.User.supplierId predicate SCR-003 pin 5
flagged for the orders list — one decision, two screens, and it should be made once.
Note this leak is not closed by either guard in pin 3. Both of those check
Orders:Create, which this user has. The gate works exactly as specified and the data still
leaves. That is why it is drawn and flagged rather than treated as a bug the ticket already fixes.
Orders:Create without Orders:View — two hardcoded redirects that can bounce, or loop assumed away — flagged
Permissions in this design are independent flags, so { Orders:Create } without
Orders:View is grantable. Whether it should be is an operator call; what it does today is not
ambiguous, and it is worse than a cosmetic problem because one of the two failures happens
after a successful write.
| Exit path | Code | What happens without Orders:View |
|---|---|---|
| Cancel button | router.push("/orders")OrderForm.tsx:114 |
Client navigation to a route whose own guard denies this user → bounced to the first permitted menu entry. Confusing, but recoverable; nothing was written. |
| Successful submit | redirect("/orders")lib/actions/orders.ts:43 |
The order is created — the write commits before the redirect — and the user is then thrown somewhere unrelated with no confirmation that it worked. They will reasonably submit again. Duplicate orders are the realistic outcome. |
There is also a loop hazard, and SCR-002 pin 2 already named it from the other end: if the first permitted menu entry resolves back to a route this user cannot open, the bounce repeats. That cannot happen through Orders here because Artboard A assumes the implication below — which is another way of saying the assumption is load-bearing, not decorative.
Drawn: Orders:Create implies Orders:View. Artboard A shows the
Orders nav entry present and Cancel enabled, which is only coherent under that implication. Two ways to make
it true, both for N31:
Orders:Create declares
Orders:View as a prerequisite, and granting one grants the other. Makes the drawn frame
correct by construction and removes the case entirely. Simplest.resolveLandingPath(session) — the
single resolver SCR-002 pin 1/pin 6 already requires to exist. Strictly more correct (it also fixes the
post-create case for any future permission shape) and strictly more work, since
createOrder's redirect currently hardcodes a literal.
These are not exclusive, and A alone leaves redirect("/orders") hardcoded for the next
permission that does not imply Orders:View. Recommendation for N31: A now, B when the
resolver from SCR-002 lands — but this is flagged, not decided.
Per this node's instruction: anything drawn without a real control behind it is called out here. An unflagged approximation is what gets built. Three entries below are defects in the shipping app that this frame reproduces faithfully rather than quietly correcting — none of them is SCR-004's to fix, and all three are listed so the frame is not mistaken for approving them.
| What | Kind | Detail |
|---|---|---|
| Disabled UOM field has no disabled appearance | defect | OrderForm.tsx:79 renders <input className="field-input" … disabled />.
globals.css defines no :disabled rule for .field-input
(confirmed against the compiled CSS), and the class sets background-color and
color explicitly, which overrides the browser's own disabled shading. The result: a field
that looks fully editable and silently refuses input. Listbox and DatePicker
both do style their disabled state (disabled:bg-accent-subtle disabled:text-text-muted),
so the inconsistency is within the design system, not inherent to it. Drawn as it ships
— white background, full-strength text. Not this ticket's to fix; flagged because a reviewer will
otherwise read the frame as approving it. |
| Long UOM labels truncate in the row | defect | The Order Items grid is grid-cols-[2fr_1fr_1fr_1fr_auto] gap-3 inside a
max-w-3xl form (768px) with p-6 card padding, leaving a 720px content box.
After the auto trash column (34px) and four 12px gaps, the UOM track measures
118.3px on a row whose date is set and 122.6px on one where it is not
(the DatePicker's clear icon only exists in the first case, so it pushes the Due Date floor wider at the
other tracks' expense) — leaving about 92–97px of text once
.field-input's px-3 and borders are removed. The UOM value is not a bare
symbol: page.tsx builds it as
`${u.name} (${u.symbol})`, so realistic labels run past that: "Sq Metre (sqm)" is
107.7px and clips in either row state, while "Kilogram (kg)" at
94.8px clips on a date-set row and only just fits on the others — the defect is
therefore row-state dependent, which makes it easy to miss in testing. Because the control is a real
<input> it
clips — no ellipsis, no wrap, no tooltip — and the user cannot scroll it because the
field is disabled. Drawn clipped, as it ships. The third row's
"Sq Metre (sqm)" is the deliberate demonstration. Note this compounds the row above: the field looks
editable, so a user who sees a cut-off unit has no affordance to reveal the rest. Pre-existing, not
introduced by SCR-004, and outside this ticket's permission scope — recorded so the frame is not read
as approving it. |
| Three control heights in one row | defect | Measured off the compiled stylesheet, the Order Items row contains
.field-input at 38px (20px line + 16px padding + 2px border, no
min-height), .field-control at 42px
(min-height: 2.625rem wins), and DatePicker at 46px (a
.field-control wrapping a p-1.5 icon button, whose 28px content beats the
min-height). items-end bottom-aligns them, so the four labels in a row sit at three
different heights. The footer has the same problem in miniature: .btn-primary is 36px and
.btn-secondary is 38px, side by side. Drawn at the real measurements —
the misalignment in Artboard A is transcribed, not sloppy. Related to, but not the same as, the open
P2 in verification/W15_DESIGN_CONSISTENCY_3238.md; the height mismatch is not recorded
there. |
Listbox and DatePicker |
approximation | Both are custom popover components. Drawn as static .field-control boxes in their
closed state at real geometry (1px border, 8×12px padding, real placeholders "Select..." /
"dd/mm/yyyy", real ChevronDown/Calendar/X icon sizes and
paddings). Open panels, keyboard focus rings and the
border-accent ring-1 ring-accent open state are out of scope for a static artboard. Same
approximation SCR-003 pin 7 made. |
| No order-date control | accurate | Called out because a reviewer will look for one. orderDate is a hidden input fixed to
today (useMemo + format(new Date(), "yyyy-MM-dd"),
OrderForm.tsx:37,50) and has never been user-editable. Its absence from the frame is the
screen, not an omission. itemsJson is likewise hidden. |
| The denied state is not drawn | deliberate | It has no body to draw — see pin 2. Not an omission, and not a blocker. |
| Error states not drawn | deliberate | state.message (.field-error above the form),
fieldErrors.supplierId/projectId (under each SelectField) and
fieldErrors.items (under the Order Items heading) all exist and none are
permission-sensitive. A clean frame diffs better. |
SubmitButton pending copy |
inherited | Not drawn (the frame is not pending), but the same defect SCR-001 pin 4 flagged applies here and is
worse: the shared component hardcodes "Saving…", so "Generate Order" becomes "Saving…" rather than
"Generating…". Fix is a pendingLabel prop on the shared component — an N31 call, and
arguably outside this ticket. |
| Supplier / project / item / UOM names, quantities, dates | sample data | Invented, but consistent with SCR-003's frame (Meridian Steel Co., Riverside Clinic Fit-out) so the
two mockups read as one system. UOM labels follow the real
`${u.name} (${u.symbol})` template. |
| Four rows, mixed fill states | sample data | The real form initialises with one empty row. Four are drawn (two complete, one
missing its due date, one freshly added and empty) to show the repeater, the derived-UOM behaviour and
the DatePicker's with/without-value states in a single frame. A one-row frame would also show the
Remove button disabled at opacity: .5, which is not visible here. |
| Icon glyphs | approximation | Hand-transcribed lucide-react paths (ClipboardList, LogOut, Plus, Trash2, ChevronDown,
Calendar, X) at real h-4 w-4. Sub-pixel differences from the real font-rendered icons are
expected. |
| Sidebar shown filtered | borrowed | The permission-filtered nav is SCR-027's design, not this screen's. Drawn because the shell is always on screen and an unfiltered sidebar would contradict the permission set in the frame label — and, on this screen specifically, would hide pin 4's whole point. |
| Hover / focus / open states | not drawn | hover:bg-accent-subtle, focus:ring-accent,
focus-visible:ring-2, the Listbox's rotate-180 chevron. A static artboard has
no pointer. |
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-004
ambiguous: false; the first three below are why that is optimistic.
| Question | Default drawn, and what overturning it costs |
|---|---|
Does Orders:Create hand over the supplier, project, item and UOM masters?
Pin 4. |
Yes — not gated. Highest-severity item on this screen and, like SCR-003 pin 5,
invisible in the pixels while passing every permission check. Option B (scope the queries) is the same
User.supplierId decision SCR-003 pin 5 raised for listOrders() —
decide the two together, they are one predicate. Option A costs only a line in the permission
registry, but that line must exist or the grant is silently broader than its name. |
Is Orders:Create without Orders:View grantable?
Pin 5. |
Assumed no — Create implies View. If N31 says it is grantable, both
OrderForm.tsx:114 and lib/actions/orders.ts:43 must route through
resolveLandingPath(), and the post-create case is the urgent one: the write commits before
the redirect, so the user gets no confirmation and resubmits. |
Can the guard live in middleware.ts? Pin 3. |
Drawn as no — guard in the page component and in the action, both Node-runtime.
This forces SCR-002 pin 6's deferred question ("permissions re-read per request, or frozen in the
cookie?") into the open, because putting the permission set in the cookie is the only thing that would
make an Edge guard possible — at the price of up to 7 days of stale authorization
(MAX_AGE_SECONDS). |
| Are the two guards both required, or is the route guard enough? | Both — drawn as non-negotiable, not as a preference. A Server Action POST does not execute the page component. This is the one item on this sheet recorded as a correctness constraint rather than an open question; it is listed so it cannot be traded away as belt-and-braces. |
Do updateFulfillment and cancelOrder get gated here? |
No — out of scope, flagged so the file is not swept. They live in the same
lib/actions/orders.ts and share the same bare requireSession(), so an
implementer opening that file will see three ungated actions and may gate all three under
Orders:Create. They belong to SCR-005, whose own ambiguity (Approve vs Fulfil, one
permission or two) is unresolved. Gating them under Orders:Create would be wrong and would
look finished. |
| Should the frame have drawn a Manufacturer · Admin instead? | No — minimum permission set drawn. Recorded because it is a real editorial choice: an Admin frame would be identical to today's page and would have concealed pin 4 entirely behind a full sidebar. |
| SCR-029 — no way to create the user this frame depicts. | Dependency, not a design question. There is no User model and
lib/auth.ts is a single shared credential, so "Supplier · Staff holding
{ Orders:View, Orders:Create }" cannot be instantiated to verify this frame end-to-end.
Same note SCR-000/001/002/003 each carried. |
Every value in Artboard A resolves to tailwind.config.ts or a globals.css
component class. SCR-004 adds zero new pixels to the shipping surface: the permitted frame
is byte-identical to today's page, and the denied state has no frame. Nothing on this screen needed an
invented control — the one thing that came close, a disabled-field treatment, is flagged in pin
6 as missing from the design system rather than approximated into it.
| Token / class | Value | Where it lands in Artboard A |
|---|---|---|
bg | #fcfcfd | main background |
surface | #ffffff | sidebar, both cards, every field, .btn-secondary |
text / text-muted | #302828 / #836f6f | labels and values / nav items, placeholders, field icons, sign-out |
border | #ede0ce | sidebar edge, card borders, every field border, .btn-secondary border |
accent | #453028 | Generate Order only — the single accent-filled element on the screen |
accent-subtle | #f7f1e9 | active Orders nav item |
status-danger | #c0392b | reserved, not painted — .field-error; no error state is drawn (pin 6) |
status-success/warning/neutral/info | — | unused on this screen; it has no status badges and no links |
rounded-md / rounded-lg / rounded-full | 6px / 8px / 9999px | fields + buttons / cards / DatePicker's calendar icon button |
| type scale | 18px·28 / 20px·28 / 14px·20 | text-lg brand / text-xl page title / text-sm everything else. text-xs 12px·16 for the nav group heading only. |
| spacing | 24 / 16 / 12 / 8 / 4px | p-6 cards + space-y-6 / gap-4 grid / gap-3 rows + mb-3 / gap-2 footer / mb-1 labels |
annotation --anno | #7c3aed | this sheet only — exists in no design token and appears nowhere inside the artboard |