|
<!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 & 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=<path></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><input type="text"></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><input type="password"></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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|