app/login/page.tsx
SCR-001 is an existing-modified screen and its field checklist is empty, so the
form drawn below is the form the app already renders — username, password, submit — read out of
app/login/page.tsx rather than invented. The whole of this ticket’s change to SCR-001 is
behind the screen: authentication must stop resolving to a single shared credential and start resolving a
user type (Supplier / Manufacturer), a role (Admin / Staff) and a
permission set, then seed them into the session cookie that every later gate reads.
Artboard A therefore renders as visually identical to today’s login screen — that is the design
position this mockup is taking, not an oversight. Annotation 3 draws the alternative
(a “sign in as…” user-type selector), marks it declined, and gives the reasoning, so N31 can overturn it
explicitly. Annotations 4 and 5 carry the two states that are
genuinely new on this screen and the copy approximation inside SubmitButton.
Every row below is a change the inventory scope requires. Only one of them has any pixels attached, and it is an error string, not a component.
| Concern | Today | Under permissions | Visual consequence on this screen |
|---|---|---|---|
| Credential check | lib/actions/auth.ts compares against AUTH_USERNAME/AUTH_PASSWORD
env vars — one credential for the whole deployment. |
Look the submitted username up in a User record, verify a stored password hash. |
None. Same two fields, same labels, same autocomplete hints. |
| Identity resolution | None. Session is { u: string } (lib/auth.ts:21). |
Resolve user type (Supplier/Manufacturer) + role (Admin/Staff) + the effective permission set from that user’s record, server-side. | None — and see 3 for why no selector is added. |
| Session payload | HMAC-signed cookie carrying { u, exp } only. |
Same cookie mechanism, widened payload (user id, type, role, permissions or a role reference the
server re-expands). Signing/verification path in lib/auth.ts is unchanged. |
None. Not user-visible on any screen. |
| Post-login landing | redirect("/orders"), hardcoded (lib/actions/auth.ts:38). |
Redirect to the first permitted menu item — the same rule SCR-002 applies at /.
A Staff user without Orders:View must not be dropped onto a denial. |
None on this screen; it is the screen the user leaves. Coupled to SCR-002 — if N31 overturns the landing rule there, this changes with it. |
?next= handling |
middleware.ts:24 sets ?next=<path> on the login redirect. The login
action ignores it entirely and always goes to /orders. |
If next is ever honoured it must be permission-checked and constrained to a
same-origin relative path — otherwise it is both a permission bypass vector and an open redirect. |
None. Recorded because it is a live, already-shipped loose end this ticket walks straight past. Flagged for N31 in 6. |
| Failure feedback | One generic string: “Invalid username or password.” Plus a config error when the env vars are unset. | Generic string stays generic — must not leak whether the username exists, nor the user’s type or role. | One new state: authenticated but zero permissions. See 5. |
fields["SCR-001"] is []. Rather than treat that as “no form”, the field set is read
from the shipped implementation. Nothing here is a new control.
| Field | Control / class | Source | Notes |
|---|---|---|---|
| Username | <input type="text"> + .field-input, label .field-label |
app/login/page.tsx:20-31 |
required, autoFocus, autoComplete="username". Artboard A shows the
focus ring on it for that reason. |
| Password | <input type="password"> + .field-input |
app/login/page.tsx:34-44 |
required, autoComplete="current-password". No reveal toggle exists in this
design system and none is added. |
| Error | .field-error paragraph, rendered between the password field and the submit button |
app/login/page.tsx:46 |
Form-level, not field-level — it is state.error from useFormState, so it sits
above the button rather than under either input. |
| Submit | SubmitButton → .btn-primary w-full |
components/ui/SubmitButton.tsx |
Pending label is the shared literal “Saving…”. See the approximation flag in 4. |
| User type | not rendered | — | Resolved server-side from the user record. Declined alternative drawn in 3. |
| Remember me / forgot password | not rendered | — | Neither exists today; neither is in scope. The session already has a fixed 7-day
MAX_AGE_SECONDS, so “remember me” has nothing to toggle. |
The inventory says login “must resolve user type (Supplier/Manufacturer) + role + permission set at authentication time”. Resolve can mean “ask the user” or “look it up”. Both are drawn so the choice is visible; A is what Artboard A commits to.
SelectField → Listbox, .field-control). Drawn
only so N31 can see what overturning A costs.
If N31 picks B, the only real consequence is that SelectField becomes a dependency of the login
route (it is a client component; the page already is one, so this is not a rendering problem) and the login
Server Action gains a third FormData key it must ignore for authorization purposes.
Three of these ship today and are drawn unchanged. The fourth (no permissions) is new and is the one thing on this screen with new pixels; it gets its own section in 5.
useFormStatus pendingSubmitButton disables itself (disabled:opacity-50) and swaps the label.
Approximation — flagged. “Saving…” is SubmitButton’s single hardcoded pending
label, shared by every Server Action form in the app. On a login form it is wrong copy — nothing is being
saved. The design system has no per-form pending label, so this mockup draws the literal that ships today
rather than inventing one. Fixing it means a pendingLabel prop on
SubmitButton (touches one shared component, no visual change anywhere else) — an N31 call, and
arguably out of this ticket’s scope entirely.
Error text wraps to two or three lines and pushes the button down; the card has no fixed height, so nothing
clips. The .field-error row carries space-y-4’s 16px top gap like every other child of
the form — it is not visually attached to the password input, which is correct, because it is a form-level
error, not a field-level one.
This state cannot occur today (one credential, no permission model) and will occur once roles are data-driven — a seeded user with an empty role, or a role whose permissions were all revoked. It has to have a rendered answer, because the alternative is a redirect loop: login succeeds → landing resolves to “first permitted menu” → there isn’t one.
/login, and the
existing .field-error carries the reason.
.field-error on a screen that already exists. The
only new artefact is a string.Security note: this message does reveal that the credential pair was valid. That is a deliberate trade — the user is already past authentication, and an unhelpful generic error here is indistinguishable from a wrong password, which is precisely the support burden this state exists to avoid. If N31 disagrees, the fallback is the generic string and a logged server-side warning.
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.
SelectField above Username and nothing else.
.field-error (5).
Coupled to SCR-000’s empty-nav approximation and to SCR-028 — if the reviewer prefers “let them in with an
empty shell”, both of those come back into scope.
lib/actions/auth.ts is where the hardcoded
redirect("/orders") actually lives, so SCR-001 is the file that changes when SCR-002 is decided.
?next= is set but never honoured.
Drawn: leave it unhonoured. middleware.ts already appends it and the login action
already discards it, so today’s behaviour is safe by accident. Recorded because the obvious “nice to have” —
returning the user to where they were — is exactly the change that would need same-origin validation *and* a
permission check before it is safe. Do not let it in as an incidental improvement.
AUTH_SECRET? no users seeded? unreachable DB?), which is a
SCR-029 question. Left as-is rather than guessed at.
prisma/schema.prisma has no
User model (models: Supplier, Project, Uom, Item, Order, OrderItem, Expense, ExpenseItem,
Attachment) and lib/auth.ts is a single shared credential. Every state above presumes seeded
users with types, roles and permissions. This is a dependency, not a design question, but it is the reason
SCR-001 cannot be verified end-to-end until the operator rules on how users get created.
Classes reproduced verbatim: .card (surface + --border + rounded-lg 8px),
.field-label (14px/500, mb-1 4px), .field-input
(@tailwindcss/forms base geometry: 1px border, 8px×12px padding, sm:text-sm 14px;
focus = focus:border-accent focus:ring-accent), .field-error
(mt-1, 14px, --status-danger), .btn-primary
(--accent on white text, px-4 py-2, rounded-md 6px, 14px/500,
disabled:opacity-50). Layout: card max-w-sm = 384px, p-6 = 24px,
space-y-4 = 16px between children; page min-h-screen flex items-center justify-center
with p-6 gutter. Only accent-subtle, the status colours other than danger, and
.link are unused on this screen — nothing is added.
Accent brown #453028 on white measures well past 4.5:1, so the primary button and the focus ring
both clear AA at 14px. Danger #c0392b on #ffffff is the app’s existing error colour and
is used here exactly as .field-error already uses it elsewhere — no new contrast risk is introduced
by this screen.