Skip to Content

← All archived runs

Run: console-auth-access-polish

run.md

Run: console-auth-access-polish

  • branch: claude/console-auth-access-polish-yzaomv
  • pr: #910

02_define/output/spec.md

Spec: Console sign-in, invitation and access polish — the first-impression pass

  • slug: console-auth-access-polish
  • personas: Admin (Sustentus staff), Partner
  • touches: apps/console/app/(auth)/, apps/console/app/no-access/page.tsx, apps/console/app/(console)/access/, apps/console/components/
  • complexity: standard

Problem

An invited partner's first two screens are the invitation accept flow and the sign-in page, and they are the least designed surfaces in the console: an unstyled centred <main> with a bare Clerk card on it, a terse "No access" refusal, and a staff access page that predates the batch's page-header, empty-state and loading standards. The console-ui-polish batch exists because partners judge Sustentus by this app — and the judging starts one screen before the console shell the batch has already polished. Credibility on these surfaces advances Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners — under the Scale the Bridge initiative. This is the batch's last stub; the auth pages are seen once per user, but first by every new partner.

Proposed change

A presentation pass over the (auth) segment, the no-access page and the staff access page. Visual/UX only (Jamie's ruling, 2026-08-27): the auth mechanics do not move, and every access- control call site is read-only to this run.

Merge-order dependency. The batch's shared standard — components/page-header.tsx, components/empty-state.tsx, components/console-skeletons.tsx and the per-route loading.tsx convention — is delivered by console-dashboard-activity-polish (#907), which is open at its merge gate, not yet on main. This run adopts those components rather than defining a second standard, so Build starts after #907 merges and rebases this branch onto main first. Nothing here re-implements or edits PageHeader or EmptyState; console-skeletons.tsx is extended (below), not rewritten.

  • Branded auth framing. The (auth) layout gains the console's brand frame around whatever Clerk renders: the sidebar's S mark and the "Sustentus console" wordmark, a one-line statement of what the app is, and brand background/spacing — correct in light and dark. The frame is one component, shared by (auth) and no-access so a partner's first two possible screens match.
  • Clerk appearance. The theme-aware provider-level appearance already exists — the shell run put ThemeAwareClerkProvider in components/providers.tsx, mapping Clerk to the shadcn/dark base themes and the console's --primary / --card / --overlay variables. This run does not rebuild it; it closes the element-level gaps that still read as stock Clerk inside the new frame (card border/shadow reconciled with the frame, header and button typography on brand), extending that same provider-level appearance in both themes.
  • The sign-in footer override stays. The SignIn component keeps its local appearance.elements.footerAction.display = "none", and its comment explaining why invitation- only is enforced in this app rather than at the Clerk instance. No sign-up affordance appears on any surface this run touches.
  • /accept restyled, never re-gated. The ticket logic is untouched: __clerk_ticket absent and no catch-all continuation segment still returns the refusal instead of the SignUp component; everything else still renders SignUp with forceRedirectUrl="/" and signInUrl="/sign-in". Reading of the stub's "byte-for-byte" criterion, stated for approval: it binds the condition and the outcome (no sign-up form is reachable without an invitation), not the pixels — a presentation pass that could not restyle the refusal card would exclude the surface it names. The refusal card is restyled into the new frame; its message keeps its meaning.
  • No-access copy, in brand voice, generic. The page explains the invitation-only model rather than refusing curtly. Jamie's ruling: no contact address — no support@ and no mailto; the person who invited them is their route back in, and the copy points there. It keeps rendering no authenticated identifiers (the existing comment and its reason stay) and keeps the SignOutButton. No new links.
  • Access page to the batch standard. PageHeader replaces the hand-rolled heading block; the four "nothing here" paragraphs (no pending invitations, nobody with access, no account for that address) become EmptyState; a segment-level loading.tsx covers the page's requireStaff() + readConsoleRoster() round-trip to Clerk. /access has no child routes, so the loading file is safe there for the same reason it was on /activity.
  • Table skeleton, added to the shared file. The batch's skeletons are feed-shaped (FeedRowsSkeleton, ListCardSkeleton); the access page needs a table shape. Build adds a table-row skeleton to components/console-skeletons.tsx — the existing home for the console's loading shapes — rather than a second skeleton file.
  • Responsive tables. The pending-invitations and people-with-access tables stay readable at tablet and half-screen widths. The (console) frame clamps horizontal overflow (overflow-x-hidden), so a table that cannot fit scrolls in its own container, per the layout's own note — the page never pushes the frame sideways.

Context budget: read the console auth/access/layout/provider sources and PR #907's new components in lieu of technical/architecture/repository-structure, to pin exact behaviour and the standard this run adopts. No scope.md exists for this batch — rulings trace to the intake breakdown (Jamie, 2026-08-27) and to the Define questions answered 2026-08-28.

Acceptance criteria

  • Sign-in and /accept render inside the shared branded frame — mark, wordmark, one-line statement, brand background — and both are correct in light and dark themes.
  • No sign-up affordance appears on any surface this run touches: the SignIn component's footer action stays hidden, and neither the frame nor the no-access copy adds a route to one.
  • /accept without __clerk_ticket and without a continuation segment renders the refusal and no SignUp component; with a ticket, or on a continuation segment, it renders SignUp with the same forceRedirectUrl and signInUrl as today. The condition and outcome are unchanged; only the refusal's presentation moves.
  • The no-access page states the invitation-only model in brand voice, names no contact address, adds no links, renders no authenticated identifiers, and keeps a working Sign out.
  • The access page opens with PageHeader, and each of its four zero states renders EmptyState — the batch's components, imported, not re-implemented.
  • /access has a loading.tsx whose skeleton mirrors the page it stands in for (header, then the invite form and two tables), built from shapes in components/console-skeletons.tsx.
  • At tablet and half-screen widths both access tables stay readable, and no page this run touches causes horizontal scrolling of the console frame itself.
  • Grant, revoke-access, revoke-invitation and the email lookup behave exactly as today, including every notice banner in NOTICES; requireStaff() still gates the page.
  • All four surfaces are correct in both themes.

Out of scope

  • The invitation lifecycle, the console-access marker, grant/revoke semantics, and Clerk instance configuration — including any instance-level sign-up restriction, which apps/console/AGENTS.md forbids because the platform's own self-registration shares the instance.
  • proxy.ts, requireViewer/requireStaff, and the live-read-vs-claim semantics behind them.
  • apps/web's self-registration and any other surface on the shared Clerk instance.
  • Defining the batch's shared state components — console-dashboard-activity-polish (#907) owns PageHeader, EmptyState and console-skeletons.tsx; this run adopts them and adds one table shape to the last.
  • The (console) shell, sidebar and header — console-shell-sidebar, already shipped.
  • The tenant surfaces — console-tenants-polish, the batch's remaining stub.
  • New capability of any kind on these pages: no search, no filtering, no new data, no new page.

Open questions

  • none. (Non-blocking note: the exact frame composition — mark and wordmark stacked above the Clerk card versus a two-column split — is Build's to pick from the shell's brand baseline; the both-themes and no-sign-up-affordance criteria above are the requirement.)

03_build/output/notes.md

Build notes: console-auth-access-polish

  • commits: feat: console-auth-access-polish — branded auth frame, no-access copy, access page to the batch standard
  • ci: GREEN on eb1b2ea — Quality Project, Review diff against CONVENTIONS.md, Project run labels and the console's own preview (Vercel – tenant-management) all pass. The other Vercel projects are skipped: this diff touches no app but apps/console.

What changed

  • apps/console/components/auth-frame.tsx (new): the branded frame shared by the (auth) segment and /no-access — mark, wordmark, one line on what the console is, brand background. One component rather than three page layouts, because these are the first screens an invited partner sees and they should not each look like a different product.
  • apps/console/app/(auth)/layout.tsx: renders the frame instead of a bare centred <main>.
  • apps/console/app/(auth)/accept/[[...accept]]/page.tsx: the refusal card restyled onto EmptyState inside the frame. The ticket logic is byte-for-byte unchanged — same !ticket && !isContinuationStep condition, same SignUp props, same comment explaining why invitation-only lives in this app rather than at the Clerk instance.
  • apps/console/app/(auth)/sign-in/[[...sign-in]]/page.tsx: untouched. Its local appearance.elements.footerAction.display = "none" is what keeps the sign-up affordance off the page, and Clerk merges it over the provider-level appearance, so it needed no change.
  • apps/console/components/providers.tsx: extended the existing ThemeAwareClerkProvider appearance at element level only — the Clerk card takes our border and radius and drops its shadow, so it reads as one of our cards inside the frame; Clerk's own logo is hidden because the frame already carries the mark. The theme-aware baseTheme mapping the shell run established is unchanged.
  • apps/console/app/no-access/page.tsx: same frame, and copy rewritten in brand voice — invitation-only stated plainly, the person who invited them named as the route back in. No contact address and no links, per Jamie's ruling (2026-08-28). Still renders no authenticated identifiers, and keeps Sign out.
  • apps/console/components/console-skeletons.tsx: added TableRowsSkeleton and TableCardSkeleton. The batch's shapes were all feed-shaped; the access page is the first with tables, so the table shape lands in the shared file rather than a second one.
  • apps/console/app/(console)/access/loading.tsx (new): header, invite form and both tables, built from those shapes. Safe as a segment-level loading file — /access has no child routes.
  • apps/console/app/(console)/access/page.tsx: PageHeader replaces the hand-rolled heading; the zero states become EmptyState; the action column gets whitespace-nowrap so its buttons stay intact when the table scrolls. No change to any action, notice or the requireStaff() gate.

Acceptance criteria status

  • Sign-in and /accept inside the branded frame, correct in both themes — the frame is built from semantic tokens (bg-muted/30, bg-primary, text-primary-foreground), so it follows the theme rather than declaring colours.
  • No sign-up affordance on any surface touched — SignIn's footer action stays hidden (that file is untouched), and neither the frame nor the no-access copy adds a route to one.
  • /accept refusal condition and outcome unchanged; only its presentation moved.
  • No-access states the invitation-only model in brand voice, names no address, adds no links, renders no identifiers, keeps a working Sign out.
  • "each of its four zero states renders EmptyState" — met for every zero state that exists, but there are three, not four: no pending invitations, nobody with access, and no account for a looked-up address. All three now render EmptyState. The "four" is a miscount in the spec, not a gap in the page — nothing was dropped. Flagged rather than papered over; see Notes for Verify.
  • /access has a loading.tsx mirroring the page, built from console-skeletons.tsx.
  • Tables readable at tablet and half-screen widths — the @sustentus/ui Table primitive already wraps itself in overflow-x-auto, so a table that cannot fit scrolls in its own container and never pushes the console frame sideways.
  • Grant, revoke-access, revoke-invitation, the lookup and every NOTICES banner behave as before; requireStaff() still gates the page.
  • All four surfaces token-based, so correct in both themes.

Notes for Verify

  • The spec says four zero states on the access page; there are three. Confirm the three (empty pending table, empty granted table, lookup miss) all render EmptyState, and treat the count as a spec-text correction rather than an unmet criterion. Worth fixing in spec.md if Verify wants the document to match the page.
  • Clerk appearance is the risk surface. The element-level additions (card border/radius/ shadow, logoBox hidden) are the only thing here that can look wrong without failing a build. Check sign-in and /accept?__clerk_ticket=… in both light and dark on the preview — especially that hiding logoBox didn't leave a gap at the top of the Clerk card.
  • The (auth) layout and /no-access each render exactly one <main>/no-access sits outside the (auth) group, so the frame is not nested.

04_verify/output/verify.md

Verify: console-auth-access-polish

  • ci: GREEN on 297beb7 — settled via ci-status.sh after the last push. The last push carrying code was 0bd580a (the three review fixes), which also settled GREEN; 297beb7 and the commit recording this line are .icm-only, so no app rebuilt for them and Vercel reports their previews skipped — correctly, not as a failure.
  • previews smoked: none by the agent — the console preview (tenant-management-git-claude-console-auth-acce-788033-sustentus.vercel.app) is behind Vercel deployment protection. Unauthenticated requests 302 to vercel.com/sso-api, which answers 200 with Vercel's own login page. A naive curl -L therefore looks like a healthy 200 while serving nothing from this app — worth knowing, because it is exactly how a preview smoke gets faked by accident. The build itself is real: Vercel – tenant-management built for this commit; it just cannot be reached from here.
  • production-readiness: checks run, no applicable surface. The trigger matched on "auth" (the diff touches the (auth) segment and the Clerk provider), so each concrete check was performed rather than waived on a glance: no migrations in the diff; no db/models or db/services changes; turbo.json globalEnv unchanged; no new process.env reads anywhere in the diff; no route-policy file touched. Nothing for the readiness pass to bite on.
  • code-review: medium (spec complexity: standard) — 3 findings, all real, all fixed on branch in 0bd580a. Detail below.
  • security-review: run — no HIGH or MEDIUM findings. proxy.ts, console-access.ts, requireViewer/requireStaff and access/actions.ts are absent from the diff entirely; the /accept ticket condition is byte-identical; the sign-in footerAction: none override is merged over the new scoped appearance rather than replaced, so invitation-only enforcement is intact. No user input reaches any new code path, no dangerouslySetInnerHTML, no new routes, no Clerk instance configuration touched.
  • playwright: TODO — manual DoD smoke required instead.

DoD smoke (on the preview — each line says who verified it)

Nothing below is ticked. The agent could not reach the preview (see above), so every line is the operator's — including the ones that would normally be agent-run, because /sign-in and /accept are public in proxy.ts but not public through Vercel's protection.

  • Sign-in and /accept render inside the branded frame — mark, wordmark, one-line statement, brand background — correct in both themes (operator)
  • No sign-up affordance on any touched surface: no "Sign up" on sign-in, none added by the frame or the no-access copy (operator)
  • /accept with no __clerk_ticket and no continuation segment → refusal card, no sign-up form; with a ticket → the sign-up flow, same redirect targets (operator)
  • No-access states the invitation-only model in brand voice, names no address, adds no links, shows no account identifiers, and Sign out works (operator)
  • Access page opens with PageHeader; its three zero states render EmptyState (operator)
  • /access loading skeleton mirrors the page (operator — throttle the network to see it)
  • Access tables readable at tablet/half-screen; no horizontal scroll of the console frame (operator)
  • Grant, revoke-access, revoke-invitation, lookup and every NOTICES banner behave as before; requireStaff() still gates the page (operator)
  • Regression check from the code review: the UserButton popover in the console shell still has its shadow and no stray border (operator) — this is what finding 1 broke
  • auth: Sustentus staff and Partner both still sign in and reach the console (operator)
  • payments: not touched
  • notifications: none expected — this run sends nothing

Findings & cleanup

  • providers.tsxelements.card override was global, not auth-only. It sat on ClerkProvider, so it reached every Clerk surface carrying the cl-card descriptor — including the UserButton popover in the console shell, which lost its elevation and gained a border over the page beneath. A regression introduced by this run's Build commit, in a surface the run does not otherwise touch. — fixed in 0bd580a: moved to components/auth-appearance.ts, applied only by the two pre-console screens.
  • providers.tsxborderRadius: var(--radius-xl) did not do what its comment claimed. --radius-xl is declared inside @theme inline in packages/ui/src/globals.css (line 281), which Tailwind consumes to generate utilities and never emits as a runtime custom property. The var() fell through to Tailwind's default 0.75rem, giving the Clerk card 12px corners beside our 16px ones — the exact "reads as one of ours" goal the change existed to serve, silently missed. — fixed: spelled out as calc(var(--radius) + 4px).
  • EmptyState renders its title as a paragraph, so /accept and /no-access lost their headings. Both previously had an <h1>; after the Build commit all three pre-console screens announced only the frame's generic "Sustentus console", with the actual message as body text. — fixed: added an optional titleAs prop to EmptyState (defaults to "p", so console-dashboard-activity-polish's callers are unchanged) and passed "h2" on both screens. This edits a component the spec lists under Out of scope; the extension is additive and behaviour-preserving for every existing caller, which is why it was done here rather than bounced back to Define.

Needs a decision (not blocking CI)

  • The spec's acceptance criterion says the access page has four zero states; it has three. All three (empty pending invitations, nobody with access, lookup miss) render EmptyState, so the page is complete — the document is wrong, not the code. Build flagged it and left the criterion unticked on the PR rather than counting to four. Correct it with a one-line /pipeline define console-auth-access-polish revision if the spec should match the page.

Context budget: within the Inputs table. The preview-protection finding came from probing the preview host directly, which the DoD smoke step requires.

05_ship/output/changelog.md


title: A branded sign-in, invitation and access experience in the console date: 2026-08-28T00:00:00Z personas: [admin] slug: console-auth-access-polish pr: https://github.com/sustentus/sustentus/pull/910

A branded sign-in, invitation and access experience in the console

The screens you see before the console are the ones a newly invited partner sees first, and until now they were the plainest part of the product. They have been brought up to the same standard as the rest. Nothing new to do, nothing new on show, and no change to who can get in or how.

Signing in and accepting an invitation look like Sustentus. Both screens now sit inside the same frame — the mark, the name, and a line saying what the console is for — so the first two screens a partner can land on no longer look like two different products. Correct in light and dark, as everywhere else.

Being turned away now explains itself. Someone who signs in without console access used to get a bare refusal. It now says plainly that accounts here come from an invitation and never from signing up, and suggests asking whoever invited them to send it again.

Managing who has access matches the other pages. For the Sustentus team, the access page now opens with the same header as everywhere else, arrives in shape while it loads, and says what each empty list means — no invitations outstanding, nobody granted access yet, no match for a search — rather than leaving a blank. Both tables stay readable on a narrow window.

Inviting, granting and revoking work exactly as they did.

The tenant pages are next.

05_ship/output/investor-update.md

A partner's first screen now looks like Sustentus

Who it's for: Sustentus staff and delivery partners What shipped: Sign-in, invitation and no-access screens now carry the brand; the access page matches the rest. Why it matters: Partners judge us by this console — credibility here advances Q2 Objective 1, Establish Product-Market Fit with Vendor Partners, under Scale the Bridge.

Dig deeper: https://github.com/sustentus/sustentus/pull/910 · https://help.sustentus.com/changelog/2026-08-28-console-auth-access-polish

05_ship/output/release.md

Ship: console-auth-access-polish

  • pr: #910 (https://github.com/sustentus/sustentus/pull/910) · merge: authorised — Ready to merge ticked by Jamie (read 2026-08-28 from the PR body, with his /pipeline ship); this commit rides the squash
  • CI: gated — the merge proceeds only on a settled GREEN from ci-status.sh on the head carrying this commit, established after this stage's push; Verify's settled verdict was GREEN on bbf5a2b
  • technical docs: no technical docs impact — technical/applications/console documents the access model, the route table and the invitation mechanics, and this run moved none of them: the ticket condition on /accept is byte-for-byte unchanged, proxy.ts is untouched, and no route, audience, env var or capability changed. Presentation only, so every sentence on that page stays true as written
  • business docs: no business docs impact — no business/** page describes the console's pre-sign-in screens, and no persona gained or lost a capability
  • release notes: both — ship note + changelog entry (2026-08-28-console-auth-access-polish)
  • sent: the ship note rides this merge — .github/workflows/ship-note.yaml reads it from the squash and emails the #product-update channel; nothing sends before the authorised merge
  • close-out: archive the run to apps/docs/archive/pipeline-runs/; the console-ui-polish batch stays live in intake — console-tenants-polish (sequence 3 of 4) has not been spun out, so this run is the batch's last stub only in the spec's wording, not in fact

Acceptance check (vs spec)

  • Sign-in and /accept render inside the shared branded frame — AuthFrame is imported by the (auth) layout and /no-access; mark, wordmark and one-line statement are in the one component, on semantic tokens that resolve in both themes — code-verified (agent). The visual pass is the operator's line in verify.md and is not ticked there; the merge is authorised past it by Jamie's gate
  • No sign-up affordance on any surface this run touches — footerAction: { display: "none" } survives the merge with the scoped appearance on the SignIn call site (the one override that is not presentation, commented as such); neither the frame nor the no-access copy adds a route to one — diff-verified (agent)
  • /accept's ticket condition and outcome unchanged — the guard is byte-for-byte identical (if (!ticket && !isContinuationStep) return <InvitationRequired />;); only the refusal's presentation moved, and SignUp keeps the same forceRedirectUrl and signInUrl — diff-verified (agent)
  • The no-access page states the invitation-only model in brand voice, names no contact address, adds no links, renders no authenticated identifiers, keeps a working Sign out — code-verified (agent), to Jamie's "keep it generic" ruling
  • The access page opens with PageHeader, and each of its three zero states renders EmptyState — the batch's components imported from #907, not re-implemented. The spec says "four"; the page has three (invitations, granted access, search-no-match) and all three are covered. The miscount is the spec's, not the page's — this criterion was left unticked on the PR through Build and Verify rather than counted up to four, and the fix belongs in a spec revision, not in code
  • /access has a loading.tsx mirroring the page — header skeleton, invite form, then two TableCardSkeleton (rows 2/cols 3, rows 3/cols 4), built from console-skeletons.tsx, which this run extended with the batch's first table shape — code-verified (agent)
  • Both access tables stay readable at tablet and half-screen widths — action columns carry whitespace-nowrap and Table already wraps in an overflow-x-auto container, so the console frame itself does not scroll horizontally — code-verified (agent); the width smoke is the operator's line in verify.md
  • Grant, revoke-access, revoke-invitation and the email lookup behave exactly as today, every NOTICES banner intact, requireStaff() still gating the page — diff-verified (agent): no action, notice or guard is in the diff
  • All four surfaces correct in both themes — theming goes through the pre-existing ThemeAwareClerkProvider plus semantic tokens; the Clerk card radius is spelled calc(var(--radius) + 4px) because --radius-xl lives in @theme inline and never resolves at runtime (code-review finding, fixed in 0bd580a) — code-verified (agent); the two-theme visual pass is the operator's line in verify.md

Verify record

Verify's gate carried no unresolved blocking finding: code review raised three, all fixed in 0bd580a (global Clerk card override scoped out of the UserButton popover; the unresolvable radius token; EmptyState losing the h1 on the pre-console screens, fixed with an additive titleAs); security review found nothing at HIGH or MEDIUM; production-readiness ran and found no applicable surface.

What is not verified, stated plainly: no preview smoke was performed by the agent — Vercel deployment protection returns the whole preview behind an SSO redirect, so every DoD line in verify.md is unticked and attributed to the operator, and none of them came back ticked. The visual and responsive criteria above are code-verified only. Jamie's Ready to merge tick is what carries the merge past that gap; this line is the record that the gap existed.