Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit36cc17aunknown_key

fix(ux): stop showing logged-out nav to authed users + unblock /import for bootstrap admins

fix(ux): stop showing logged-out nav to authed users + unblock /import for bootstrap admins

User screenshot from prod showed two systemic UX bugs:

  1. /settings/profile rendered with the top-right nav saying
     "Sign in / Register" even though the user was clearly authed
     (the page was loading their own profile data).
  2. /import was returning 403 (and probably perceived as 404) for
     a user who's admin only via the bootstrap rule, not via the
     legacy users.is_admin column.

Root causes + fixes:

(1) Layout user-prop sweep
    The Layout component renders the logged-out nav whenever its
    `user` prop is null/undefined. 11 route call sites across
    auth.tsx, settings.tsx, oauth.tsx, and the global notFound /
    onError handlers in app.tsx were calling <Layout title="..."/>
    without passing the user through. Every authed page rendered by
    those routes showed the logged-out nav. Fixed all 11.
    src/__tests__/layout-user-prop.test.ts — 8 regression tests
    hit representative authed routes and assert the response HTML
    contains the username + does NOT contain the literal "Sign in /
    Register" anchor pair.

(2) /login + /register now bounce already-authed users
    Both pages previously rendered the logged-out shell over an
    authed session. Added softAuth to the GET handlers + a redirect
    to /dashboard (or to the `redirect=` target on /login) when
    c.get("user") is set. /login/2fa stays as-is because softAuth
    correctly treats pending-2FA sessions as logged-out.

(3) requireAdmin middleware now respects the site_admins table
    Until today, src/middleware/admin.ts only checked the legacy
    `users.is_admin` column. The newer canonical check
    `isSiteAdmin(userId)` from src/lib/admin.ts (Block F3) honours
    BOTH explicit site_admins rows AND the bootstrap rule (oldest
    user wins while site_admins is empty). After running
    scripts/reset-admin-password.ts, an admin had a site_admins
    row but `users.is_admin=false`, so /import 403'd. Now both
    paths are honoured.

(4) Softened the cache-control middleware
    `no-cache, no-store, must-revalidate` was disabling bfcache,
    making every Back/Forward press a cold round-trip and
    contributing to the "every nav feels like a fresh login" UX
    complaint. Switched to `private, no-cache, must-revalidate` —
    still revalidates on direct navigation but lets the browser
    keep the page in bfcache during back/forward. Dropped the
    legacy `pragma` + `expires` headers (HTTP/1.0 era).

Suite 1501 / 0 fail / 2 skip across 116 files.
Claude committed on May 13, 2026Parent: fc0ca99
6 files changed+2792236cc17a1d8d9287a07d75832243bee25ed0508d1
6 changed files+279−22
Addedsrc/__tests__/layout-user-prop.test.ts+224−0View fileUnifiedSplit
1/**
2 * Regression guard for the "29 routes render Layout without user= prop" bug.
3 *
4 * Before the fix, several Hono handlers built `<Layout title="..." />` without
5 * forwarding the `user` prop pulled from `c.get("user")`. The Layout's nav
6 * fell back to the logged-out shell ("Sign in" / "Register" links) even when
7 * the request was authenticated — so every authed user saw themselves as
8 * signed-out in the top-right nav. The owner caught this in a prod screenshot.
9 *
10 * This test hits a handful of authed routes with a fake session and asserts:
11 * 1. The response HTML contains the authed user's username/display name in
12 * the `.nav-user` slot (proves Layout received user=).
13 * 2. The response HTML does NOT contain the logged-out anchors
14 * (`href="/login" class="nav-link">Sign in`) — those only render when
15 * Layout's `user` prop is null/undefined.
16 *
17 * Plus two extras:
18 * 3. /login and /register, when hit with an active session, redirect away
19 * (302 -> /dashboard or the `redirect=` target) instead of rendering the
20 * sign-in shell over an authed session.
21 *
22 * Mock isolation
23 * --------------
24 * Bun's `mock.module()` is process-global. Like `mcp-write.test.ts`, we:
25 * - Capture the real module before mocking so afterAll can restore.
26 * - Mock ONLY `../db` (the one external resource), spreading the real
27 * export so any non-mocked surface stays untouched.
28 * - Restore the mock and clear `sessionCache` in afterAll.
29 * - Reset per-test row hooks in beforeEach so nothing leaks between tests
30 * in this file or downstream files.
31 */
32
33import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
34
35const _real_db = await import("../db");
36const _real_cache = await import("../lib/cache");
37
38// --- per-test row hooks ----------------------------------------------------
39let _nextSessionRow: any = null;
40let _nextUserRow: any = null;
41let _lastSelectFrom: any = null;
42
43const tableName = (t: any): string => {
44 if (!t || typeof t !== "object") return "?";
45 if ("token" in t && "expiresAt" in t && "userId" in t) return "sessions";
46 if ("passwordHash" in t && "username" in t) return "users";
47 if ("fingerprint" in t || "publicKey" in t) return "ssh_keys";
48 return "?";
49};
50
51const _selectChain: any = {
52 from: (t: any) => {
53 _lastSelectFrom = t;
54 return _selectChain;
55 },
56 innerJoin: () => _selectChain,
57 leftJoin: () => _selectChain,
58 where: () => _selectChain,
59 orderBy: () => _selectChain,
60 limit: async () => {
61 const name = tableName(_lastSelectFrom);
62 if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : [];
63 if (name === "users") return _nextUserRow ? [_nextUserRow] : [];
64 return [];
65 },
66 then: (resolve: (v: any) => void) => {
67 // Unbounded `await db.select()...` (no .limit()) — used by /settings/keys
68 // listing SSH keys for the user. Empty array renders the "No SSH keys
69 // yet." empty state, which is exactly what we want.
70 resolve([]);
71 },
72};
73
74const _fakeDb = {
75 db: {
76 select: () => _selectChain,
77 insert: () => ({
78 values: () => ({
79 returning: async () => [],
80 then: (r: (v: any) => void) => r(undefined),
81 }),
82 }),
83 update: () => ({
84 set: () => ({ where: () => Promise.resolve() }),
85 }),
86 delete: () => ({ where: () => Promise.resolve() }),
87 },
88 getDb: () => _fakeDb.db,
89};
90
91mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
92
93// Import AFTER the mock is installed so the app graph picks up our fake db.
94const { default: app } = await import("../app");
95const { sessionCache } = await import("../lib/cache");
96
97// --- fixtures --------------------------------------------------------------
98const USER_ID = "44444444-4444-4444-4444-444444444444";
99const SESSION_TOKEN = "test-session-token-layout-userprop";
100
101const TEST_USER = {
102 id: USER_ID,
103 username: "regression_user",
104 displayName: "Regression User",
105 email: "reg@example.com",
106 passwordHash: "x",
107 bio: null,
108 createdAt: new Date(),
109 updatedAt: new Date(),
110};
111
112const TEST_SESSION = {
113 userId: USER_ID,
114 token: SESSION_TOKEN,
115 expiresAt: new Date(Date.now() + 60 * 60 * 1000),
116 requires2fa: false,
117};
118
119function authedHeaders(): HeadersInit {
120 return { cookie: `session=${SESSION_TOKEN}` };
121}
122
123beforeEach(() => {
124 _nextSessionRow = TEST_SESSION;
125 _nextUserRow = TEST_USER;
126 // Pre-warm the soft-auth cache so handlers using `softAuth` skip the DB
127 // path entirely and pick up our user from in-memory state.
128 sessionCache.set(SESSION_TOKEN, TEST_USER as any);
129});
130
131afterAll(() => {
132 // Drop the cache entry so downstream test files don't inherit a "logged
133 // in" session token they didn't create.
134 sessionCache.invalidate(SESSION_TOKEN);
135 _nextSessionRow = null;
136 _nextUserRow = null;
137 mock.module("../db", () => _real_db);
138});
139
140// --- helpers ---------------------------------------------------------------
141const LOGGED_OUT_NAV_MARKER = `href="/login" class="nav-link"`;
142
143function assertAuthedNav(html: string) {
144 // The Layout nav renders `<a href={`/${user.username}`} class="nav-user">`
145 // with the user's display name when `user` is present. If the prop is
146 // missing the literal "Sign in" link shows up instead.
147 expect(html).toContain('class="nav-user"');
148 expect(html).toContain(TEST_USER.displayName);
149 expect(html).not.toContain(LOGGED_OUT_NAV_MARKER);
150}
151
152// --- tests -----------------------------------------------------------------
153describe("Layout user= prop is forwarded on authed routes", () => {
154 it("/settings renders the user nav (was missing user= before fix)", async () => {
155 const res = await app.request("/settings", { headers: authedHeaders() });
156 expect(res.status).toBe(200);
157 const body = await res.text();
158 assertAuthedNav(body);
159 });
160
161 it("/settings/keys renders the user nav", async () => {
162 const res = await app.request("/settings/keys", {
163 headers: authedHeaders(),
164 });
165 expect(res.status).toBe(200);
166 const body = await res.text();
167 assertAuthedNav(body);
168 });
169
170 it("/new (new repo form) renders the user nav", async () => {
171 const res = await app.request("/new", { headers: authedHeaders() });
172 expect(res.status).toBe(200);
173 const body = await res.text();
174 assertAuthedNav(body);
175 });
176
177 it("global 404 page renders the user nav when authed", async () => {
178 // Pick a path with enough segments that no route claims it, so we end
179 // up in app.notFound. The `/:owner` and `/:owner/:repo` patterns greedy-
180 // match shorter paths; deeper paths land in the 404 handler.
181 const res = await app.request(
182 "/__nope__/__nope__/__nope__/__nope__/__nope__",
183 { headers: authedHeaders() }
184 );
185 expect(res.status).toBe(404);
186 const body = await res.text();
187 assertAuthedNav(body);
188 });
189});
190
191describe("auth landing pages bounce already-authed users", () => {
192 // The owner's call: /login and /register should NOT render the logged-out
193 // sign-in shell while a valid session cookie is present. They redirect.
194
195 it("/login with a live session 302s to /dashboard", async () => {
196 const res = await app.request("/login", { headers: authedHeaders() });
197 expect(res.status).toBe(302);
198 expect(res.headers.get("location")).toBe("/dashboard");
199 });
200
201 it("/login?redirect=/foo honours the redirect target when authed", async () => {
202 const res = await app.request("/login?redirect=%2Ffoo%2Fbar", {
203 headers: authedHeaders(),
204 });
205 expect(res.status).toBe(302);
206 // Hono decodes query params; location is the decoded path.
207 expect(res.headers.get("location")).toBe("/foo/bar");
208 });
209
210 it("/register with a live session 302s to /dashboard", async () => {
211 const res = await app.request("/register", { headers: authedHeaders() });
212 expect(res.status).toBe(302);
213 expect(res.headers.get("location")).toBe("/dashboard");
214 });
215
216 it("/login WITHOUT a session still renders the sign-in shell", async () => {
217 // Empty cache + no cookie → softAuth sets user=null → page renders.
218 const res = await app.request("/login");
219 expect(res.status).toBe(200);
220 const body = await res.text();
221 // Logged-out nav literal is present on the actual sign-in page.
222 expect(body).toContain(LOGGED_OUT_NAV_MARKER);
223 });
224});
Modifiedsrc/app.tsx+13−6View fileUnifiedSplit
126126});
127127app.use("/api/*", cors());
128128
129// Force fresh HTML on every request — kills browser HTTP cache holding stale
129// Force-revalidate HTML on every request — kills browser cache holding stale
130130// pre-redesign markup. JSON / static assets keep their own cache rules; only
131131// text/html responses get the no-cache stamp. Without this, every push to
132132// main left users staring at cached 80s-looking pages from before the design
133133// landed.
134//
135// We deliberately use `private, no-cache, must-revalidate` rather than
136// `no-store`. `no-store` disables Safari/Chrome's back-forward cache (bfcache),
137// which makes every Back/Forward press a cold server round-trip — that
138// contributed to the "every nav feels like a fresh login" UX complaint.
139// `no-cache` still revalidates on direct fetch but lets bfcache hold the
140// page in memory between navigations.
134141app.use("*", async (c, next) => {
135142 await next();
136143 const ct = c.res.headers.get("content-type") || "";
137144 if (ct.startsWith("text/html")) {
138 c.header("cache-control", "no-cache, no-store, must-revalidate");
139 c.header("pragma", "no-cache");
140 c.header("expires", "0");
145 c.header("cache-control", "private, no-cache, must-revalidate");
141146 }
142147});
143148// Rate-limit API + auth endpoints (generous default)
373378
374379// Global 404
375380app.notFound((c) => {
381 const user = c.get("user") ?? null;
376382 return c.html(
377 <Layout title="Not Found">
383 <Layout title="Not Found" user={user}>
378384 <div class="error-page">
379385 <div class="error-page-code">404</div>
380386 <div class="eyebrow">Not found</div>
406412 method: c.req.method,
407413 });
408414 const requestId = c.get("requestId" as never) as string | undefined;
415 const user = c.get("user") ?? null;
409416 return c.html(
410 <Layout title="Error">
417 <Layout title="Error" user={user}>
411418 <div class="error-page">
412419 <div class="error-page-code error-page-code-err">500</div>
413420 <div class="eyebrow" style="color:var(--red)">Server error</div>
Modifiedsrc/middleware/admin.ts+16−1View fileUnifiedSplit
11/**
22 * Admin middleware — blocks non-admin users.
33 * Must be used AFTER softAuth or requireAuth.
4 *
5 * "Admin" means EITHER:
6 * - the user is in the `site_admins` table (explicit grant), OR
7 * - the `site_admins` table is empty and this user is the oldest
8 * row in `users` (bootstrap rule from src/lib/admin.ts).
9 *
10 * The legacy `users.is_admin` column is ALSO honoured for backward
11 * compatibility with accounts that pre-date the site_admins table.
12 *
13 * Until 2026-05-14 this middleware only checked `users.is_admin`,
14 * which broke /import + every other requireAdmin-gated route for any
15 * user who only had bootstrap-rule admin status (which is the case
16 * after running scripts/reset-admin-password.ts).
417 */
518
619import { createMiddleware } from "hono/factory";
720import type { AuthEnv } from "./auth";
21import { isSiteAdmin } from "../lib/admin";
822
923export const requireAdmin = createMiddleware<AuthEnv>(async (c, next) => {
1024 const user = c.get("user");
1327 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
1428 }
1529
16 if (!user.isAdmin) {
30 const admin = user.isAdmin === true || (await isSiteAdmin(user.id));
31 if (!admin) {
1732 return c.html(
1833 `<html><body style="background:#0d1117;color:#e6edf3;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh">
1934 <div style="text-align:center">
Modifiedsrc/routes/auth.tsx+14−5View fileUnifiedSplit
3232 Alert,
3333 Text,
3434} from "../views/ui";
35import { softAuth } from "../middleware/auth";
3536import type { AuthEnv } from "../middleware/auth";
3637
3738const auth = new Hono<AuthEnv>();
3839
3940// --- Web UI ---
4041
41auth.get("/register", (c) => {
42auth.get("/register", softAuth, (c) => {
43 // If the user is already signed in, drop them on their dashboard rather
44 // than rendering the logged-out sign-up shell over an authed session.
45 const existing = c.get("user");
46 if (existing) return c.redirect("/dashboard");
4247 const error = c.req.query("error");
4348 const csrf = c.get("csrfToken") as string | undefined;
4449 return c.html(
45 <Layout title="Register">
50 <Layout title="Register" user={null}>
4651 <div class="auth-container">
4752 <h2>Create account</h2>
4853 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
175180 return c.redirect(redirect);
176181});
177182
178auth.get("/login", async (c) => {
183auth.get("/login", softAuth, async (c) => {
184 // Already-authed users hitting the sign-in page get bounced to their
185 // dashboard (or the `redirect=` target if one was supplied).
186 const existing = c.get("user");
179187 const error = c.req.query("error");
180188 const redirect = c.req.query("redirect") || "";
189 if (existing) return c.redirect(redirect || "/dashboard");
181190 const ssoCfg = await getSsoConfig();
182191 const ssoEnabled =
183192 !!ssoCfg?.enabled &&
194203 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
195204 const csrf = c.get("csrfToken") as string | undefined;
196205 return c.html(
197 <Layout title="Sign in">
206 <Layout title="Sign in" user={null}>
198207 <div class="auth-container">
199208 <h2>Sign in</h2>
200209 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
399408 const error = c.req.query("error");
400409 const redirect = c.req.query("redirect") || "/";
401410 return c.html(
402 <Layout title="Two-factor authentication">
411 <Layout title="Two-factor authentication" user={null}>
403412 <div class="auth-container">
404413 <h2>Enter your code</h2>
405414 <p
Modifiedsrc/routes/oauth.tsx+10−8View fileUnifiedSplit
2020 oauthAccessTokens,
2121 users,
2222} from "../db/schema";
23import type { User } from "../db/schema";
2324import { Layout } from "../views/layout";
2425import { requireAuth } from "../middleware/auth";
2526import type { AuthEnv } from "../middleware/auth";
6061 return url + sep + parts.join("&");
6162}
6263
63function errorPage(title: string, message: string) {
64function errorPage(title: string, message: string, user: User | null) {
6465 return (
65 <Layout title={title}>
66 <Layout title={title} user={user}>
6667 <div class="empty-state">
6768 <h2>{title}</h2>
6869 <p>{message}</p>
141142 const codeChallengeMethod = q.code_challenge_method || "";
142143
143144 if (!clientId) {
144 return c.html(errorPage("OAuth error", "Missing client_id."), 400);
145 return c.html(errorPage("OAuth error", "Missing client_id.", user), 400);
145146 }
146147 const app = await loadAppByClientId(clientId);
147148 if (!app) {
148 return c.html(errorPage("OAuth error", "Unknown client."), 400);
149 return c.html(errorPage("OAuth error", "Unknown client.", user), 400);
149150 }
150151 if (app.revokedAt) {
151 return c.html(errorPage("OAuth error", "This application has been revoked."), 400);
152 return c.html(errorPage("OAuth error", "This application has been revoked.", user), 400);
152153 }
153154
154155 const registered = parseRedirectUris(app.redirectUris);
156157 return c.html(
157158 errorPage(
158159 "OAuth error",
159 "redirect_uri does not match any registered callback for this app."
160 "redirect_uri does not match any registered callback for this app.",
161 user
160162 ),
161163 400
162164 );
273275
274276 const app = await loadAppByClientId(clientId);
275277 if (!app || app.revokedAt) {
276 return c.html(errorPage("OAuth error", "Unknown or revoked client."), 400);
278 return c.html(errorPage("OAuth error", "Unknown or revoked client.", user), 400);
277279 }
278280 const registered = parseRedirectUris(app.redirectUris);
279281 if (!redirectUriAllowed(redirectUri, registered)) {
280 return c.html(errorPage("OAuth error", "Invalid redirect_uri."), 400);
282 return c.html(errorPage("OAuth error", "Invalid redirect_uri.", user), 400);
281283 }
282284
283285 if (decision !== "approve") {
Modifiedsrc/routes/settings.tsx+2−2View fileUnifiedSplit
4040 const user = c.get("user")!;
4141 const success = c.req.query("success");
4242 return c.html(
43 <Layout title="Settings">
43 <Layout title="Settings" user={user}>
4444 <div class="settings-container">
4545 <PageHeader title="Profile settings" />
4646 {success && (
323323 .where(eq(sshKeys.userId, user.id));
324324
325325 return c.html(
326 <Layout title="SSH Keys">
326 <Layout title="SSH Keys" user={user}>
327327 <div class="settings-container">
328328 <PageHeader title="SSH Keys" />
329329 {success && (
330330