Project

General

Profile

Feature #3241 » SCR-001-web.html

Bot AI, 08/27/2026 02:05 PM

 
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SCR-001 — web mockup — Login (permission-resolving)</title>
<!--
SCR-001 · side: web (oms-web) · viewport: 1440 × 900 (desktop, the only breakpoint this
app is built for — app/layout.tsx is a fixed flex row with a w-64 sidebar and ships no
mobile nav; app/login/page.tsx sits outside that shell but is authored against the same
single breakpoint).

Screen: app/login/page.tsx — status "existing-modified". Inventory scope:
"must resolve user type (Supplier/Manufacturer) + role + permission set at
authentication time and seed the session; today lib/auth.ts is a SINGLE SHARED
CREDENTIAL (AUTH_USERNAME/AUTH_PASSWORD env) with no user table at all."

The `fields` checklist for SCR-001 is EMPTY. The form's real field set is therefore read
from the existing implementation (app/login/page.tsx: username, password, submit) rather
than invented — see annotation 2 for the field-by-field derivation and annotation 3 for
the one control this mockup deliberately does NOT add.

Every colour, radius, spacing and type size below is transcribed from this repo's real
tokens — tailwind.config.ts (theme.extend.colors) and app/globals.css (@layer components:
.btn* / .field-input / .field-control / .card / .field-label / .field-error / .link).
Nothing is invented. The ONLY non-token styling in this file is the annotation chrome
(dashed outlines, pin numbers, the sheet below the artboard) — deliberately drawn in a
colour that exists nowhere in the design system so it can never be mistaken for a UI
element, and living OUTSIDE the 1440×900 artboard so Artboard A stays a clean, diffable
frame for W21_ui_verify.
-->
<style>
/* ---- design tokens: verbatim from tailwind.config.ts ---- */
:root {
--bg: #fcfcfd;
--surface: #ffffff;
--text: #302828;
--text-muted: #836f6f;
--border: #ede0ce;
--accent: #453028;
--accent-subtle: #f7f1e9;
--status-success: #1f8a4c;
--status-warning: #b8860b;
--status-danger: #c0392b;
--status-neutral: #6b7280;
--status-info: #2563eb;

/* annotation-only — NOT a design token, never ships */
--anno: #7c3aed;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: #e9e7e4;
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
padding: 32px;
}

/* ================= mockup document chrome (outside the artboard) ================= */
.doc-head { max-width: 1440px; margin: 0 auto 20px; }
.doc-head h1 { font-size: 20px; line-height: 28px; margin: 0 0 6px; font-weight: 600; }
.doc-head p { font-size: 13px; line-height: 20px; color: #4b4b4b; margin: 0 0 4px; max-width: 1100px; }
.doc-meta { font-size: 12px; color: #5c5c5c; margin-top: 8px; }
.doc-meta code { background: #fff; border: 1px solid #d8d5d1; border-radius: 4px; padding: 1px 5px; }
.frame-label {
max-width: 1440px; margin: 28px auto 8px; font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: .06em; color: #4b4b4b;
}
.frame-label span { font-weight: 400; text-transform: none; letter-spacing: 0; color: #6b6b6b; }

/* ================= ARTBOARD A — exactly 1440 × 900, no annotation inside ================= */
.artboard {
width: 1440px; height: 900px; margin: 0 auto;
overflow: hidden; background: var(--bg);
outline: 1px solid #cfcbc6; /* outline, not border — does not alter the 1440×900 box */
box-shadow: 0 8px 24px rgba(0,0,0,.10);
}

/* ---- app/login/page.tsx: min-h-screen flex items-center justify-center bg-bg p-6 ---- */
.login-screen {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
padding: 24px; /* p-6 */
}

/* ---- app/globals.css @layer components ---- */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; } /* rounded-lg */
.btn {
display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; /* rounded-md */
padding: 8px 16px; /* px-4 py-2 */
font-size: 14px; line-height: 20px; /* text-sm */
font-weight: 500; /* font-medium */
font-family: inherit; border: 0; cursor: default;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-secondary { background: var(--surface); border: 1px solid var(--border); color: var(--text); }
.btn-disabled { opacity: .5; } /* disabled:opacity-50 — SubmitButton pending state */
.field-input {
display: block; width: 100%; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text);
font-size: 14px; line-height: 20px; /* sm:text-sm */
padding: 8px 12px; font-family: inherit;
}
.field-input.focused { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.field-control {
display: block; width: 100%; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); padding: 8px 12px;
font-size: 14px; line-height: 20px; font-family: inherit; min-height: 42px; /* 2.625rem */
}
.field-label { display: block; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--text); margin-bottom: 4px; }
.field-error { margin-top: 4px; font-size: 14px; line-height: 20px; color: var(--status-danger); }
.link { color: var(--status-info); text-decoration: underline; }

/* ---- the login card itself: form.card.w-full.max-w-sm.p-6.space-y-4 ---- */
.login-card { width: 100%; max-width: 384px; padding: 24px; } /* max-w-sm = 24rem, p-6 */
.login-card > * + * { margin-top: 16px; } /* space-y-4 */
.login-card h1 { font-size: 18px; line-height: 28px; font-weight: 600; color: var(--text); margin: 0; } /* text-lg */
.login-card .sub { font-size: 14px; line-height: 20px; color: var(--text-muted); margin: 4px 0 0; } /* text-sm mt-1 */
.login-card .btn-primary { width: 100%; }

/* ================= annotation sheet (never ships) ================= */
.sheet { max-width: 1440px; margin: 0 auto; }
.sheet section {
background: #fff; border: 1px solid #d8d5d1; border-radius: 8px;
padding: 20px 24px; margin-top: 16px;
}
.sheet h2 { font-size: 15px; margin: 0 0 4px; font-weight: 600; }
.sheet h2 .pin { margin-right: 8px; }
.sheet .lede { font-size: 13px; line-height: 20px; color: #4b4b4b; margin: 0 0 16px; max-width: 1180px; }
.pin {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 9999px; background: var(--anno);
color: #fff; font-size: 11px; font-weight: 700; line-height: 20px; vertical-align: middle;
}
.anno-note { font-size: 12px; line-height: 18px; color: var(--anno); margin-top: 8px; }
.anno-box { outline: 2px dashed var(--anno); outline-offset: 3px; }
.sheet table { width: 100%; border-collapse: collapse; font-size: 13px; }
.sheet thead th { border-bottom: 1px solid #d8d5d1; color: #4b4b4b; padding: 8px 10px; text-align: left; font-weight: 500; }
.sheet tbody td { padding: 8px 10px; vertical-align: top; border-top: 1px solid #ece9e6; }
.sheet code { background: #f5f3f1; border-radius: 4px; padding: 1px 5px; font-size: 12px; }
.states { display: flex; gap: 28px; flex-wrap: wrap; align-items: flex-start; }
.state-cap { font-size: 12px; font-weight: 600; color: #3b3b3b; margin-bottom: 6px; }
.state-sub { font-size: 12px; line-height: 17px; color: #6b6b6b; margin-bottom: 10px; max-width: 320px; }
.mini-frame {
width: 320px; background: var(--bg); border: 1px solid #d8d5d1; border-radius: 8px; padding: 16px;
display: flex; justify-content: center;
}
.mini-frame .login-card { max-width: 288px; padding: 20px; }
.mini-frame .login-card h1 { font-size: 16px; line-height: 24px; }
.swatches { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
.sw { font-size: 11px; color: #4b4b4b; text-align: center; }
.sw i { display: block; width: 56px; height: 28px; border-radius: 4px; border: 1px solid #d8d5d1; margin-bottom: 4px; }
.decisions li { font-size: 13px; line-height: 20px; margin-bottom: 10px; }
.flag { color: var(--anno); font-weight: 600; }
.chip {
display: inline-block; font-size: 11px; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; border-radius: 4px; padding: 2px 6px; vertical-align: middle;
}
.chip-drawn { background: rgba(31,138,76,.12); color: var(--status-success); }
.chip-declined { background: rgba(192,57,43,.10); color: var(--status-danger); }
</style>
</head>
<body>

<div class="doc-head">
<h1>SCR-001 — Login · web (oms-web) · <code>app/login/page.tsx</code></h1>
<p>
SCR-001 is an <strong>existing-modified</strong> screen and its field checklist is <strong>empty</strong>, so the
form drawn below is the form the app already renders — username, password, submit — read out of
<code>app/login/page.tsx</code> rather than invented. The whole of this ticket’s change to SCR-001 is
<em>behind</em> the screen: authentication must stop resolving to a single shared credential and start resolving a
<strong>user type (Supplier / Manufacturer)</strong>, a <strong>role (Admin / Staff)</strong> and a
<strong>permission set</strong>, then seed them into the session cookie that every later gate reads.
</p>
<p>
<strong>Artboard A therefore renders as visually identical to today’s login screen</strong> — that is the design
position this mockup is taking, not an oversight. Annotation <span class="pin">3</span> draws the alternative
(a “sign in as…” user-type selector), marks it declined, and gives the reasoning, so N31 can overturn it
explicitly. Annotations <span class="pin">4</span> and <span class="pin">5</span> carry the two states that are
genuinely <em>new</em> on this screen and the copy approximation inside <code>SubmitButton</code>.
</p>
<div class="doc-meta">
Viewport <code>1440 × 900</code> (desktop only) ·
tokens from <code>tailwind.config.ts</code> + <code>app/globals.css</code> ·
components mirrored from <code>app/login/page.tsx</code>, <code>components/ui/SubmitButton.tsx</code>,
<code>components/ui/fields/{Listbox,SelectField}.tsx</code> ·
behaviour read from <code>lib/auth.ts</code>, <code>lib/actions/auth.ts</code>, <code>middleware.ts</code>
</div>
</div>

<div class="frame-label">Artboard A — 1440 × 900 · <span>Login, default (empty) state. Clean frame, no annotation overlay — diffable by W21_ui_verify.</span></div>

<div class="artboard">
<div class="login-screen">
<!-- form.card.w-full.max-w-sm.p-6.space-y-4 — app/login/page.tsx:14 -->
<form class="card login-card">
<div>
<h1>Sign in to OMS</h1>
<p class="sub">Orders &amp; Expense Management</p>
</div>
<div>
<label class="field-label" for="username">Username</label>
<!-- autoFocus lives on this input today, so the default frame shows the focus ring -->
<input class="field-input focused" id="username" type="text" value="" />
</div>
<div>
<label class="field-label" for="password">Password</label>
<input class="field-input" id="password" type="password" value="" />
</div>
<div>
<span class="btn btn-primary">Sign in</span>
</div>
</form>
</div>
</div>

<!-- ======================= annotation sheet — reference only, never ships ======================= -->
<div class="sheet">

<section>
<h2><span class="pin">1</span>What changes on SCR-001 — and what deliberately does not</h2>
<p class="lede">
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.
</p>
<table>
<thead>
<tr>
<th style="width:190px">Concern</th>
<th style="width:320px">Today</th>
<th style="width:340px">Under permissions</th>
<th>Visual consequence on this screen</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Credential check</strong></td>
<td><code>lib/actions/auth.ts</code> compares against <code>AUTH_USERNAME</code>/<code>AUTH_PASSWORD</code>
env vars — one credential for the whole deployment.</td>
<td>Look the submitted username up in a <code>User</code> record, verify a stored password hash.</td>
<td><strong>None.</strong> Same two fields, same labels, same autocomplete hints.</td>
</tr>
<tr>
<td><strong>Identity resolution</strong></td>
<td>None. <code>Session</code> is <code>{ u: string }</code> (<code>lib/auth.ts:21</code>).</td>
<td>Resolve user type (Supplier/Manufacturer) + role (Admin/Staff) + the effective permission set from
that user’s record, server-side.</td>
<td><strong>None</strong> — and see <span class="pin">3</span> for why no selector is added.</td>
</tr>
<tr>
<td><strong>Session payload</strong></td>
<td>HMAC-signed cookie carrying <code>{ u, exp }</code> only.</td>
<td>Same cookie mechanism, widened payload (user id, type, role, permissions or a role reference the
server re-expands). Signing/verification path in <code>lib/auth.ts</code> is unchanged.</td>
<td><strong>None.</strong> Not user-visible on any screen.</td>
</tr>
<tr>
<td><strong>Post-login landing</strong></td>
<td><code>redirect("/orders")</code>, hardcoded (<code>lib/actions/auth.ts:38</code>).</td>
<td>Redirect to the <em>first permitted menu item</em> — the same rule SCR-002 applies at <code>/</code>.
A Staff user without Orders:View must not be dropped onto a denial.</td>
<td><strong>None on this screen</strong>; it is the screen the user leaves. Coupled to SCR-002 —
if N31 overturns the landing rule there, this changes with it.</td>
</tr>
<tr>
<td><strong><code>?next=</code> handling</strong></td>
<td><code>middleware.ts:24</code> sets <code>?next=&lt;path&gt;</code> on the login redirect. The login
action <em>ignores it entirely</em> and always goes to <code>/orders</code>.</td>
<td>If <code>next</code> is ever honoured it must be permission-checked <em>and</em> constrained to a
same-origin relative path — otherwise it is both a permission bypass vector and an open redirect.</td>
<td><strong>None.</strong> Recorded because it is a live, already-shipped loose end this ticket walks
straight past. Flagged for N31 in <span class="pin">6</span>.</td>
</tr>
<tr>
<td><strong>Failure feedback</strong></td>
<td>One generic string: <em>“Invalid username or password.”</em> Plus a config error when the env vars are
unset.</td>
<td>Generic string stays generic — must not leak whether the username exists, nor the user’s type or
role.</td>
<td><strong>One new state</strong>: authenticated but zero permissions. See <span class="pin">5</span>.</td>
</tr>
</tbody>
</table>
</section>

<section>
<h2><span class="pin">2</span>Field checklist — derived, because the input checklist is empty</h2>
<p class="lede">
<code>fields["SCR-001"]</code> is <code>[]</code>. Rather than treat that as “no form”, the field set is read
from the shipped implementation. Nothing here is a new control.
</p>
<table>
<thead>
<tr><th style="width:170px">Field</th><th style="width:250px">Control / class</th><th style="width:190px">Source</th><th>Notes</th></tr>
</thead>
<tbody>
<tr>
<td>Username</td>
<td><code>&lt;input type="text"&gt;</code> + <code>.field-input</code>, label <code>.field-label</code></td>
<td><code>app/login/page.tsx:20-31</code></td>
<td><code>required</code>, <code>autoFocus</code>, <code>autoComplete="username"</code>. Artboard A shows the
focus ring on it for that reason.</td>
</tr>
<tr>
<td>Password</td>
<td><code>&lt;input type="password"&gt;</code> + <code>.field-input</code></td>
<td><code>app/login/page.tsx:34-44</code></td>
<td><code>required</code>, <code>autoComplete="current-password"</code>. No reveal toggle exists in this
design system and none is added.</td>
</tr>
<tr>
<td>Error</td>
<td><code>.field-error</code> paragraph, rendered between the password field and the submit button</td>
<td><code>app/login/page.tsx:46</code></td>
<td>Form-level, not field-level — it is <code>state.error</code> from <code>useFormState</code>, so it sits
above the button rather than under either input.</td>
</tr>
<tr>
<td>Submit</td>
<td><code>SubmitButton</code><code>.btn-primary w-full</code></td>
<td><code>components/ui/SubmitButton.tsx</code></td>
<td>Pending label is the shared literal <em>“Saving…”</em>. See the approximation flag in
<span class="pin">4</span>.</td>
</tr>
<tr>
<td><em>User type</em></td>
<td><em>not rendered</em></td>
<td></td>
<td>Resolved server-side from the user record. Declined alternative drawn in <span class="pin">3</span>.</td>
</tr>
<tr>
<td><em>Remember me / forgot password</em></td>
<td><em>not rendered</em></td>
<td></td>
<td>Neither exists today; neither is in scope. The session already has a fixed 7-day
<code>MAX_AGE_SECONDS</code>, so “remember me” has nothing to toggle.</td>
</tr>
</tbody>
</table>
</section>

<section>
<h2><span class="pin">3</span>The one real design choice: no user-type selector <span class="chip chip-drawn">A drawn</span> <span class="chip chip-declined">B declined</span></h2>
<p class="lede">
The inventory says login “must resolve user type (Supplier/Manufacturer) + role + permission set at
authentication time”. <em>Resolve</em> can mean “ask the user” or “look it up”. Both are drawn so the choice is
visible; A is what Artboard A commits to.
</p>
<div class="states">

<div>
<div class="state-cap">A — resolved from the user record <span class="chip chip-drawn">drawn</span></div>
<div class="state-sub">
Identical to today. The user proves who they are; the server decides what they are. This is the shape in
Artboard A.
</div>
<div class="mini-frame">
<form class="card login-card">
<div>
<h1>Sign in to OMS</h1>
<p class="sub">Orders &amp; Expense Management</p>
</div>
<div>
<label class="field-label">Username</label>
<input class="field-input" type="text" value="" />
</div>
<div>
<label class="field-label">Password</label>
<input class="field-input" type="password" value="" />
</div>
<div><span class="btn btn-primary">Sign in</span></div>
</form>
</div>
</div>

<div>
<div class="state-cap">B — user picks their type at login <span class="chip chip-declined">declined</span></div>
<div class="state-sub">
Adds a third control (<code>SelectField</code><code>Listbox</code>, <code>.field-control</code>). Drawn
only so N31 can see what overturning A costs.
</div>
<div class="mini-frame anno-box">
<form class="card login-card">
<div>
<h1>Sign in to OMS</h1>
<p class="sub">Orders &amp; Expense Management</p>
</div>
<div>
<label class="field-label">Sign in as *</label>
<div class="field-control" style="display:flex; align-items:center; justify-content:space-between;">
<span style="color:var(--text-muted)">Select...</span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
<div>
<label class="field-label">Username</label>
<input class="field-input" type="text" value="" />
</div>
<div>
<label class="field-label">Password</label>
<input class="field-input" type="password" value="" />
</div>
<div><span class="btn btn-primary">Sign in</span></div>
</form>
</div>
</div>

<div style="max-width: 420px;">
<div class="state-cap">Why A</div>
<ul style="font-size:13px; line-height:20px; color:#4b4b4b; margin:0; padding-left:18px;">
<li><strong>Trust boundary.</strong> A client-submitted user type is an <em>assertion</em>, 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.</li>
<li><strong>Failure mode.</strong> 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.</li>
<li><strong>Scope.</strong> The requirement is explicit: <em>“Dont create any screen … so just so the
functionalities.”</em> 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.</li>
<li><strong>Reversible.</strong> Choosing A costs nothing later — B is additive. Choosing B first and
removing it is a visible regression for anyone already trained on it.</li>
</ul>
<p class="anno-note">
If N31 picks B, the only real consequence is that <code>SelectField</code> 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 <code>FormData</code> key it must ignore for authorization purposes.
</p>
</div>

</div>
</section>

<section>
<h2><span class="pin">4</span>Form states — all four, at the card’s real 384px width</h2>
<p class="lede">
Three of these ship today and are drawn unchanged. The fourth (<em>no permissions</em>) is new and is the one
thing on this screen with new pixels; it gets its own section in <span class="pin">5</span>.
</p>
<div class="states">

<div>
<div class="state-cap">Default (Artboard A)</div>
<div class="state-sub">Username autofocused, no error row, button enabled.</div>
<div class="mini-frame">
<form class="card login-card">
<div><h1>Sign in to OMS</h1><p class="sub">Orders &amp; Expense Management</p></div>
<div><label class="field-label">Username</label><input class="field-input focused" type="text" value="" /></div>
<div><label class="field-label">Password</label><input class="field-input" type="password" value="" /></div>
<div><span class="btn btn-primary">Sign in</span></div>
</form>
</div>
</div>

<div>
<div class="state-cap">Invalid credentials — unchanged copy</div>
<div class="state-sub">
Stays deliberately generic under permissions: no username enumeration, no hint at user type or role.
</div>
<div class="mini-frame">
<form class="card login-card">
<div><h1>Sign in to OMS</h1><p class="sub">Orders &amp; Expense Management</p></div>
<div><label class="field-label">Username</label><input class="field-input" type="text" value="a.morrow" /></div>
<div><label class="field-label">Password</label><input class="field-input" type="password" value="••••••••" /></div>
<p class="field-error" style="margin-top:16px;">Invalid username or password.</p>
<div><span class="btn btn-primary">Sign in</span></div>
</form>
</div>
</div>

<div>
<div class="state-cap">Submitting — <code>useFormStatus</code> pending</div>
<div class="state-sub">
<code>SubmitButton</code> disables itself (<code>disabled:opacity-50</code>) and swaps the label.
</div>
<div class="mini-frame">
<form class="card login-card">
<div><h1>Sign in to OMS</h1><p class="sub">Orders &amp; Expense Management</p></div>
<div><label class="field-label">Username</label><input class="field-input" type="text" value="a.morrow" /></div>
<div><label class="field-label">Password</label><input class="field-input" type="password" value="••••••••" /></div>
<div><span class="btn btn-primary btn-disabled anno-box">Saving…</span></div>
</form>
</div>
<p class="anno-note" style="max-width:320px">
<strong>Approximation — flagged.</strong> “Saving…” is <code>SubmitButton</code>’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 <code>pendingLabel</code> prop on
<code>SubmitButton</code> (touches one shared component, no visual change anywhere else) — an N31 call, and
arguably out of this ticket’s scope entirely.
</p>
</div>

<div>
<div class="state-cap">Server not configured — unchanged</div>
<div class="state-sub">
Existing fail-closed branch when the auth env vars are unset. Under a user table its wording changes
(see <span class="pin">6</span>) but its treatment does not.
</div>
<div class="mini-frame">
<form class="card login-card">
<div><h1>Sign in to OMS</h1><p class="sub">Orders &amp; Expense Management</p></div>
<div><label class="field-label">Username</label><input class="field-input" type="text" value="" /></div>
<div><label class="field-label">Password</label><input class="field-input" type="password" value="" /></div>
<p class="field-error" style="margin-top:16px;">Login is not configured on this server (AUTH_USERNAME / AUTH_PASSWORD are unset).</p>
<div><span class="btn btn-primary">Sign in</span></div>
</form>
</div>
</div>

</div>
<p class="anno-note">
Error text wraps to two or three lines and pushes the button down; the card has no fixed height, so nothing
clips. The <code>.field-error</code> row carries <code>space-y-4</code>’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.
</p>
</section>

<section>
<h2><span class="pin">5</span>New state: authenticated, but zero permissions</h2>
<p class="lede">
This state cannot occur today (one credential, no permission model) and <em>will</em> 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.
</p>
<div class="states">
<div>
<div class="state-cap">Drawn default <span class="chip chip-drawn">drawn</span></div>
<div class="state-sub">
Login <strong>fails closed</strong>: no session cookie is set, the user stays on <code>/login</code>, and the
existing <code>.field-error</code> carries the reason.
</div>
<div class="mini-frame anno-box">
<form class="card login-card">
<div><h1>Sign in to OMS</h1><p class="sub">Orders &amp; Expense Management</p></div>
<div><label class="field-label">Username</label><input class="field-input" type="text" value="j.okafor" /></div>
<div><label class="field-label">Password</label><input class="field-input" type="password" value="••••••••" /></div>
<p class="field-error" style="margin-top:16px;">Your account has no permissions assigned. Contact your administrator.</p>
<div><span class="btn btn-primary">Sign in</span></div>
</form>
</div>
</div>
<div style="max-width: 520px;">
<div class="state-cap">Why here, and why not elsewhere</div>
<ul style="font-size:13px; line-height:20px; color:#4b4b4b; margin:0; padding-left:18px;">
<li><strong>No new screen.</strong> It reuses <code>.field-error</code> on a screen that already exists. The
only new artefact is a string.</li>
<li><strong>Doesn’t pre-empt SCR-028.</strong> Handling it at login means the 403-vs-notFound-vs-redirect
decision never has to fire for this case. SCR-028 stays about <em>direct URL access to a denied
route by a user who does have some permissions</em> — a narrower, cleaner question for the operator.</li>
<li><strong>Doesn’t need the empty-nav component.</strong> 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. <span class="flag">These
two decisions are coupled — decide them together.</span></li>
<li><strong>The counter-argument, recorded:</strong> 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.</li>
</ul>
<p class="anno-note">
Security note: this message <em>does</em> 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.
</p>
</div>
</div>
</section>

<section>
<h2><span class="pin">6</span>Open decisions carried into N31 — and the default this mockup drew</h2>
<p class="lede">
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.
</p>
<ul class="decisions">
<li>
<span class="flag">User type at login: asked or looked up?</span>
<strong>Drawn: looked up (option A, <span class="pin">3</span>).</strong> The form is unchanged. Overturning
this adds one <code>SelectField</code> above Username and nothing else.
</li>
<li>
<span class="flag">Zero-permission account.</span>
<strong>Drawn: fail closed at login with a specific <code>.field-error</code> (<span class="pin">5</span>).</strong>
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.
</li>
<li>
<span class="flag">Post-login landing target.</span>
<strong>Drawn: first permitted menu item</strong>, matching the default SCR-000 drew for SCR-002. Not visible
on this screen; listed because <code>lib/actions/auth.ts</code> is where the hardcoded
<code>redirect("/orders")</code> actually lives, so SCR-001 is the file that changes when SCR-002 is decided.
</li>
<li>
<span class="flag"><code>?next=</code> is set but never honoured.</span>
<strong>Drawn: leave it unhonoured.</strong> <code>middleware.ts</code> 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.
</li>
<li>
<span class="flag">Pending-button copy.</span> <strong>Drawn: “Saving…” as shipped</strong>, flagged as an
approximation in <span class="pin">4</span> rather than silently corrected, because fixing it edits a
component shared by every form in the app.
</li>
<li>
<span class="flag">“Login is not configured” copy.</span> <strong>Drawn: unchanged.</strong> Once auth reads a
user table the env-var names in that sentence are wrong, but the correct replacement wording depends on what
the failure actually becomes (no <code>AUTH_SECRET</code>? no users seeded? unreachable DB?), which is a
SCR-029 question. Left as-is rather than guessed at.
</li>
<li>
<span class="flag">SCR-029 — none of this is demonstrable yet.</span> <code>prisma/schema.prisma</code> has no
<code>User</code> model (models: Supplier, Project, Uom, Item, Order, OrderItem, Expense, ExpenseItem,
Attachment) and <code>lib/auth.ts</code> 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.
</li>
</ul>
</section>

<section>
<h2><span class="pin">7</span>Tokens used — no new colour, radius or type scale is introduced</h2>
<div class="swatches">
<div class="sw"><i style="background:#fcfcfd"></i>bg #fcfcfd</div>
<div class="sw"><i style="background:#ffffff"></i>surface #ffffff</div>
<div class="sw"><i style="background:#302828"></i>text #302828</div>
<div class="sw"><i style="background:#836f6f"></i>text-muted #836f6f</div>
<div class="sw"><i style="background:#ede0ce"></i>border #ede0ce</div>
<div class="sw"><i style="background:#453028"></i>accent #453028</div>
<div class="sw"><i style="background:#c0392b"></i>danger #c0392b</div>
<div class="sw"><i style="background:#7c3aed"></i>annotation<br/>(NOT a token)</div>
</div>
<p class="anno-note">
Classes reproduced verbatim: <code>.card</code> (surface + <code>--border</code> + <code>rounded-lg</code> 8px),
<code>.field-label</code> (14px/500, <code>mb-1</code> 4px), <code>.field-input</code>
(<code>@tailwindcss/forms</code> base geometry: 1px border, 8px×12px padding, <code>sm:text-sm</code> 14px;
focus = <code>focus:border-accent focus:ring-accent</code>), <code>.field-error</code>
(<code>mt-1</code>, 14px, <code>--status-danger</code>), <code>.btn-primary</code>
(<code>--accent</code> on white text, <code>px-4 py-2</code>, <code>rounded-md</code> 6px, 14px/500,
<code>disabled:opacity-50</code>). Layout: card <code>max-w-sm</code> = 384px, <code>p-6</code> = 24px,
<code>space-y-4</code> = 16px between children; page <code>min-h-screen flex items-center justify-center</code>
with <code>p-6</code> gutter. Only <code>accent-subtle</code>, the status colours other than danger, and
<code>.link</code> are unused on this screen — nothing is added.
</p>
<p class="anno-note">
Accent brown <code>#453028</code> on white measures well past 4.5:1, so the primary button and the focus ring
both clear AA at 14px. Danger <code>#c0392b</code> on <code>#ffffff</code> is the app’s existing error colour and
is used here exactly as <code>.field-error</code> already uses it elsewhere — no new contrast risk is introduced
by this screen.
</p>
</section>

</div>

</body>
</html>
(2-2/5)