Skip to content

feat(shell): tell the admin denials apart, and give members a way out - #67

Merged
guarzo merged 1 commit into
mainfrom
worktree-admin-shell-frame
Aug 4, 2026
Merged

feat(shell): tell the admin denials apart, and give members a way out#67
guarzo merged 1 commit into
mainfrom
worktree-admin-shell-frame

Conversation

@guarzo

@guarzo guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Follow-up to a design critique of src/app/admin/layout.tsx and src/app/_components/admin-nav.tsx — the frame all three admin pages sit in.

The problems

Every refusal looked the same. resolveAdmin returned AdminContext | null, so a de-roled admin and a signed-out visitor were indistinguishable. The first got a login screen for a session they still had; clicking a stale admin button surfaced the error boundary's "Something broke — that's a fault on this end, not something you did" for what is an expected race.

There was no way to sign out.

The two headers were distinguishable only by which links happened to be in them — no register name, no distinct accessible name, and the same destination called Admin from one bar and Accounts from the other.

What's here

Guard. resolveAdmin returns a discriminated union; one private denyAdmin(reason): never maps a reason to a destination. It's a switch with an explicit never return, so a third denial reason fails to compile rather than falling through to the login redirect — verified by adding one (TS2534), then restoring.

getAdminContext is no longer exported. An AdminResolution is always truthy, so the old if (!ctx) redirect(...) still compiles against the new signature and simply never fires — an unguarded admin page with no type error and no failing test. Private makes a resurrected copy a compile error.

The layout guards too, without replacing the per-page calls. Neither is sufficient alone: layouts don't re-run on soft navigation; pages don't cover the admin route added next and forgotten.

Sign-out, this device only. One session row, keyed by the digest of the caller's own cookie. POST-only — the session cookie is sameSite: "lax", which the browser does attach to cross-site top-level GET navigations, so a GET route would be triggerable from any external link. Clearing attributes now come from one constant shared with the login callback: a Set-Cookie whose path/sameSite/secure differs names a different cookie, leaving the real session live while the response looks successful.

Naming. The admin bar names its register and takes its own aria-label. The roster is renamed Accounts → Members: both navs must call one destination one name (WCAG 3.2.4), but that name can't contain "account" when the member nav shows it beside "Your account" — a pair separated only by a possessive. Route and account table unchanged.

NavItem loses key. Two callers used different conventions (route vs. arbitrary label); both typechecked, so a mismatch silently produced no active tab. Matching on href removes the second identity.

Active label --gold--ink. Gold vs --ink-dim is 1.18:1, carried almost entirely by hue, which nearly vanishes for a red-green colorblind reader. --ink is 1.68:1 — still short of 3:1, so the gold hairline is still doing necessary work, not decorating.

Verification

Check Result
npm run typecheck clean
npm run lint 0 errors (5 pre-existing <img> warnings)
npm run format:check clean
npm test 432 passed (432)
npx playwright test 62 passed

Three claims checked by mutation, not just assertion:

  • third AdminDenial member → TS2534, restored
  • old throw in requireAdminAction → new de-role test fails, restored
  • stubbed endSession → sign-out test fails, restored

Not run: npm run build. Contrast figures are computed from the OKLCH tokens, not measured in a browser.

Review focus

  1. admin-guard.ts — the layout/page/action three-way split. requireAdminPage and requireAdminAction now have identical bodies; both names kept because merging touches twelve unrelated files. There's a NOTE in the source. Reasonable, or merge now?
  2. requireAdminAction throw → redirect: actions that previously surfaced errors now navigate away.
  3. Whether Members is the right domain word, and whether leaving the route at /admin/accounts while the page says Members is acceptable.
  4. Active and :hover are now both --ink, so a hovered inactive tab matches the active tab's text exactly — separated by hover's --hull-hi fill vs. the active hairline.

🤖 Generated with Claude Code

The admin frame treated every refusal the same. A de-roled admin was sent
to the login screen as if they had never signed in, and a stale button
click surfaced "Something broke - a fault on this end" for what is really
an expected race: another admin clearing your admin bit while you had the
page open.

resolveAdmin now returns a discriminated union rather than a nullable
context, and a single private denyAdmin() turns a reason into a
destination. It is a switch with an explicit `never` return, so adding a
third denial reason fails to compile instead of silently falling through
to the login redirect. getAdminContext is no longer exported: an
AdminResolution is always truthy, so the old `if (!ctx) redirect(...)`
pattern still compiles against the new signature and simply never fires -
an unguarded admin page with no type error. Keeping it private makes a
resurrected copy a compile error.

The layout guards too, without replacing the per-page calls. Neither is
sufficient alone: layouts do not re-run on soft navigation, and pages do
not cover the admin route someone adds next and forgets to gate.

Sign-out is this-device-only - one session row, keyed by the digest of
the caller's own cookie, leaving other devices alone. POST-only, because
the session cookie is sameSite=lax and the browser still attaches it to a
cross-site top-level GET, so a link on any external page would sign
people out. The clearing attributes now come from one constant shared
with the login callback: a Set-Cookie whose path, sameSite or secure
differs names a *different* cookie, which would leave the real session
live while the response looked like success.

The admin bar names its own register and takes a distinct aria-label, so
the two headers are not distinguishable only by which links happen to be
in them. The roster is renamed Accounts -> Members: both navs must call
one destination by one name (WCAG 3.2.4), but that shared name cannot
contain "account" when the member nav shows it directly beside "Your
account" - a pair separated only by a possessive. The route and the
account table are unchanged.

NavItem loses its `key` field. Two callers keyed items by different
conventions - one to the route, one to an arbitrary label - and both
typechecked, so a mismatch silently produced no active tab. Matching on
href removes the second identity. The active label moves from --gold to
--ink: gold against --ink-dim is 1.18:1, carried almost entirely by hue,
which nearly vanishes for a red-green colorblind reader. --ink is 1.68:1,
still short of 3:1, so the gold hairline is still doing necessary work.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e2d9cf60-889b-424b-b655-d4d9cacd568f

📥 Commits

Reviewing files that changed from the base of the PR and between a148de0 and 126d82b.

📒 Files selected for processing (19)
  • e2e/admin.spec.ts
  • e2e/shell.spec.ts
  • src/app/_components/admin-nav.tsx
  • src/app/_components/ui.tsx
  • src/app/account/page.tsx
  • src/app/admin/accounts/page.tsx
  • src/app/admin/audit/page.tsx
  • src/app/admin/layout.tsx
  • src/app/admin/page.tsx
  • src/app/admin/sync/page.tsx
  • src/app/auth/eve/callback/route.ts
  • src/app/auth/signout/route.ts
  • src/app/error.tsx
  • src/app/globals.css
  • src/lib/admin-guard.ts
  • src/lib/session-cookie.ts
  • src/services/session.ts
  • tests/admin-guard.test.ts
  • tests/session.test.ts

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo merged commit b0ba95d into main Aug 4, 2026
6 checks passed
guarzo added a commit that referenced this pull request Aug 4, 2026
#67 split "no session" from "not admin" but left the first case
covering two things: a visitor with no cookie at all, and one whose
cookie no longer resolves. Both redirected to
/login?error=session_expired, so anyone who typed /admin without ever
signing in was told a session they never had had ended.

resolveAdmin now returns session-expired only for a cookie that fails
to resolve; an absent cookie gets the plain login page. This is the
same line account/page.tsx draws, and the first time denyAdmin's
exhaustive switch has actually caught an added union member.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant