SCR-001 — Login · web (oms-web) · 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.

Viewport 1440 × 900 (desktop only) · tokens from tailwind.config.ts + app/globals.css · components mirrored from app/login/page.tsx, components/ui/SubmitButton.tsx, components/ui/fields/{Listbox,SelectField}.tsx · behaviour read from lib/auth.ts, lib/actions/auth.ts, middleware.ts
Artboard A — 1440 × 900 · Login, default (empty) state. Clean frame, no annotation overlay — diffable by W21_ui_verify.

1What changes on SCR-001 — and what deliberately does not

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.

2Field checklist — derived, because the input checklist is empty

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.

FieldControl / classSourceNotes
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.

3The one real design choice: no user-type selector A drawn B declined

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.

A — resolved from the user record drawn
Identical to today. The user proves who they are; the server decides what they are. This is the shape in Artboard A.
B — user picks their type at login declined
Adds a third control (SelectFieldListbox, .field-control). Drawn only so N31 can see what overturning A costs.
Why A
  • Trust boundary. A client-submitted user type is an assertion, not a fact. The server would have to re-derive the real value from the user record anyway, at which point the control is decoration that can disagree with the truth.
  • Failure mode. If the picked type contradicts the record, the only honest response is a generic failure — which reads to the user as “wrong password” and generates support tickets.
  • Scope. The requirement is explicit: “Dont create any screen … so just so the functionalities.” Adding a control to an existing screen isn’t creating a screen, but it is the same impulse, and nothing in the acceptance criteria needs it.
  • Reversible. Choosing A costs nothing later — B is additive. Choosing B first and removing it is a visible regression for anyone already trained on it.

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.

4Form states — all four, at the card’s real 384px width

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.

Default (Artboard A)
Username autofocused, no error row, button enabled.
Invalid credentials — unchanged copy
Stays deliberately generic under permissions: no username enumeration, no hint at user type or role.
Submitting — useFormStatus pending
SubmitButton 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.

Server not configured — unchanged
Existing fail-closed branch when the auth env vars are unset. Under a user table its wording changes (see 6) but its treatment does not.

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.

5New state: authenticated, but zero permissions

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.

Drawn default drawn
Login fails closed: no session cookie is set, the user stays on /login, and the existing .field-error carries the reason.
Why here, and why not elsewhere
  • No new screen. It reuses .field-error on a screen that already exists. The only new artefact is a string.
  • Doesn’t pre-empt SCR-028. Handling it at login means the 403-vs-notFound-vs-redirect decision never has to fire for this case. SCR-028 stays about direct URL access to a denied route by a user who does have some permissions — a narrower, cleaner question for the operator.
  • Doesn’t need the empty-nav component. SCR-000’s mockup flagged an “No modules available for this account” empty-nav box as its one invented pixel, for exactly this user. Failing closed at login means that user never reaches the shell, so N31 can strike that box and lose nothing. These two decisions are coupled — decide them together.
  • The counter-argument, recorded: letting the user in with an empty sidebar is more debuggable for an admin who wants to see the account exists. It also means shipping the empty-nav component. This mockup picks fail-closed; it is a genuine choice.

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.

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
danger #c0392b
annotation
(NOT a token)

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.