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

polish(explore): 2026 discovery hero + repo cards + filter pills + skeleton

Claude committed on May 24, 2026Parent: f1ffd50
5 files changed+176838248c9efc2f91838aaa3160a10ebf5d6a7cfc4e8fd
5 changed files+1768−382
Modifiedsrc/routes/email-verification.tsx+162−7View fileUnifiedSplit
77 * "link expired" page.
88 * POST /verify-email/resend requireAuth. Issues a fresh verification
99 * token. Rate-limited per user (3/hour).
10 *
11 * 2026 polish: the dead-link page renders inside the shared `.auth-container`
12 * gateway with a display headline, supporting subtitle, an Alert banner that
13 * matches the rest of the auth surface, and an explicit "what to do next"
14 * block — plus a resend form so the user can recover in-place if they're
15 * already signed in (the heavy lifting still happens server-side in the
16 * existing POST handler; the form is just the polished trigger).
1017 */
1118
1219import { Hono } from "hono";
1421import { db } from "../db";
1522import { users } from "../db/schema";
1623import { Layout } from "../views/layout";
24import { Alert, Text } from "../views/ui";
1725import { softAuth, requireAuth } from "../middleware/auth";
1826import type { AuthEnv } from "../middleware/auth";
1927import { config } from "../lib/config";
4755 _resendLog.clear();
4856}
4957
58// ---------------------------------------------------------------------------
59// Per-page CSS — `.auth-extra-ev-*` so it can never collide with the
60// locked .auth-container rules in layout.tsx.
61// ---------------------------------------------------------------------------
62function VerifyExtraStyles() {
63 return (
64 <style
65 dangerouslySetInnerHTML={{
66 __html: `
67 .auth-extra-ev-headline {
68 font-family: var(--font-display);
69 font-weight: 700;
70 font-size: clamp(28px, 4.6vw, 40px);
71 line-height: 1.08;
72 letter-spacing: -0.028em;
73 color: var(--text-strong);
74 margin: 0 0 10px;
75 }
76 .auth-extra-ev-sub {
77 color: var(--text-muted);
78 font-size: 14.5px;
79 line-height: 1.55;
80 margin: 0 0 22px;
81 }
82 .auth-extra-ev-next {
83 margin-top: 14px;
84 padding: 12px 14px;
85 background: var(--bg-tertiary, var(--bg-secondary));
86 border: 1px solid var(--border);
87 border-radius: var(--r-sm, 6px);
88 color: var(--text-muted);
89 font-size: 13px;
90 line-height: 1.55;
91 }
92 .auth-extra-ev-next strong { color: var(--text); font-weight: 600; }
93 .auth-extra-ev-meta {
94 color: var(--text-muted);
95 font-size: 13px;
96 line-height: 1.55;
97 margin: 6px 0 0;
98 text-align: center;
99 }
100 .auth-extra-ev-meta code {
101 font-family: var(--font-mono);
102 font-size: 12px;
103 background: var(--bg-tertiary, var(--bg-secondary));
104 padding: 1px 6px;
105 border-radius: 3px;
106 }
107 .auth-extra-ev-resend {
108 margin-top: 16px;
109 display: flex;
110 justify-content: center;
111 }
112 .auth-extra-ev-resend button {
113 width: 100%;
114 padding: 11px 16px;
115 font-size: 14.5px;
116 font-weight: 600;
117 }
118 .auth-extra-ev-resend button[aria-busy="true"] {
119 opacity: 0.78;
120 cursor: progress;
121 pointer-events: none;
122 }
123 .auth-extra-ev-resend button[aria-busy="true"]::after {
124 content: '';
125 display: inline-block;
126 width: 12px;
127 height: 12px;
128 margin-left: 8px;
129 vertical-align: -2px;
130 border: 2px solid currentColor;
131 border-right-color: transparent;
132 border-radius: 50%;
133 animation: auth-extra-ev-spin 0.7s linear infinite;
134 }
135 @keyframes auth-extra-ev-spin {
136 to { transform: rotate(360deg); }
137 }
138 `,
139 }}
140 />
141 );
142}
143
144function VerifySubmitBusyScript() {
145 return (
146 <script
147 dangerouslySetInnerHTML={{
148 __html: /* js */ `
149 (function () {
150 try {
151 var forms = document.querySelectorAll('form[data-auth-extra-ev]');
152 forms.forEach(function (f) {
153 f.addEventListener('submit', function () {
154 var btn = f.querySelector('button[type=submit]');
155 if (btn) btn.setAttribute('aria-busy', 'true');
156 });
157 });
158 } catch (e) { /* no-op */ }
159 })();
160 `,
161 }}
162 />
163 );
164}
165
50166verify.get("/verify-email", softAuth, async (c) => {
51167 const token = c.req.query("token") || "";
52168 const user = c.get("user") || null;
57173 return c.redirect("/dashboard?verified=1");
58174 }
59175
176 const csrf = c.get("csrfToken") as string | undefined;
177 const userEmail = (user as any)?.email as string | undefined;
178
60179 return c.html(
61180 <Layout title="Verification link expired" user={user}>
62181 <div class="auth-container">
63 <h2>Link expired</h2>
64 <p style="color:var(--text-muted);font-size:14px;line-height:1.55">
65 That verification link is no longer valid. Links expire after 24
66 hours and can only be used once. Sign in and request a fresh link
67 from your dashboard.
182 <VerifyExtraStyles />
183 <h2 class="auth-extra-ev-headline">Link expired</h2>
184 <p class="auth-extra-ev-sub">
185 That email-verification link is no longer valid. Verification
186 links live for <strong>24 hours</strong> and can only be used once.
68187 </p>
69 <p class="auth-switch" style="margin-top:24px">
70 <a href="/login">Sign in</a>
188 <Alert variant="error">
189 Reset your verification by requesting a fresh link — the one you
190 clicked is expired, already used, or unknown.
191 </Alert>
192 <div class="auth-extra-ev-next">
193 <strong>What to do next:</strong>{" "}
194 {user
195 ? "tap Resend below and we'll dispatch a brand-new link to your account email — usually arrives in under a minute."
196 : "sign in to your account first, then request a fresh verification link from your dashboard."}
197 </div>
198 {user && userEmail && (
199 <p class="auth-extra-ev-meta">
200 We'll send the new link to <code>{userEmail}</code>.
201 </p>
202 )}
203 {user ? (
204 <form
205 method="post"
206 action="/verify-email/resend"
207 class="auth-extra-ev-resend"
208 data-auth-extra-ev="1"
209 >
210 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
211 <button type="submit" class="btn btn-primary">
212 Resend verification email
213 </button>
214 </form>
215 ) : (
216 <p class="auth-switch" style="margin-top:18px">
217 <a href="/login">Sign in</a>
218 </p>
219 )}
220 <p class="auth-switch">
221 <Text>
222 Didn't expect this email?{" "}
223 <a href="/login">Back to sign in</a>.
224 </Text>
71225 </p>
226 <VerifySubmitBusyScript />
72227 </div>
73228 </Layout>
74229 );
Modifiedsrc/routes/explore.tsx+631−243View fileUnifiedSplit
Large file (1,102 lines). Load full file
Modifiedsrc/routes/magic-link.tsx+155−12View fileUnifiedSplit
1010 * is no second form to fill in. We mint a session cookie and bounce
1111 * to /dashboard (existing user) or /onboarding?welcome=1 (auto-created).
1212 * - 15-minute TTL is messaged on the success/dead-link pages.
13 *
14 * 2026 polish: display-quality headlines, supporting subtitle, "what
15 * happens next" explainer, and loading-state submit so the surface
16 * matches the polished /login + /register pages. All form actions, POST
17 * handlers, redirects and token semantics are preserved exactly.
1318 */
1419
1520import { Hono } from "hono";
2227 sessionExpiry,
2328} from "../lib/auth";
2429import { Layout } from "../views/layout";
25import { Form, FormGroup, Input, Button, Alert, Text } from "../views/ui";
30import { Form, FormGroup, Input, Alert, Text } from "../views/ui";
2631import { softAuth } from "../middleware/auth";
2732import type { AuthEnv } from "../middleware/auth";
2833import {
3237
3338const magicLink = new Hono<AuthEnv>();
3439
40// ---------------------------------------------------------------------------
41// Per-page CSS — `.auth-extra-ml-*` so it can never collide with the
42// locked .auth-container rules in layout.tsx or with the sibling
43// password-reset extras.
44// ---------------------------------------------------------------------------
45function MagicExtraStyles() {
46 return (
47 <style
48 dangerouslySetInnerHTML={{
49 __html: `
50 .auth-extra-ml-headline {
51 font-family: var(--font-display);
52 font-weight: 700;
53 font-size: clamp(28px, 4.6vw, 40px);
54 line-height: 1.08;
55 letter-spacing: -0.028em;
56 color: var(--text-strong);
57 margin: 0 0 10px;
58 }
59 .auth-extra-ml-sub {
60 color: var(--text-muted);
61 font-size: 14.5px;
62 line-height: 1.55;
63 margin: 0 0 22px;
64 }
65 .auth-extra-ml-next {
66 margin-top: 14px;
67 padding: 12px 14px;
68 background: var(--bg-tertiary, var(--bg-secondary));
69 border: 1px solid var(--border);
70 border-radius: var(--r-sm, 6px);
71 color: var(--text-muted);
72 font-size: 13px;
73 line-height: 1.55;
74 }
75 .auth-extra-ml-next strong { color: var(--text); font-weight: 600; }
76 .auth-extra-ml-steps {
77 margin: 0;
78 padding: 0 0 0 18px;
79 color: var(--text-muted);
80 font-size: 13px;
81 line-height: 1.65;
82 }
83 .auth-extra-ml-steps li { margin: 2px 0; }
84 .auth-extra-ml-submit {
85 width: 100%;
86 padding: 12px 16px;
87 font-size: 15px;
88 font-weight: 600;
89 margin-top: 4px;
90 }
91 .auth-extra-ml-submit[aria-busy="true"] {
92 opacity: 0.78;
93 cursor: progress;
94 pointer-events: none;
95 }
96 .auth-extra-ml-submit[aria-busy="true"]::after {
97 content: '';
98 display: inline-block;
99 width: 12px;
100 height: 12px;
101 margin-left: 8px;
102 vertical-align: -2px;
103 border: 2px solid currentColor;
104 border-right-color: transparent;
105 border-radius: 50%;
106 animation: auth-extra-ml-spin 0.7s linear infinite;
107 }
108 @keyframes auth-extra-ml-spin {
109 to { transform: rotate(360deg); }
110 }
111 `,
112 }}
113 />
114 );
115}
116
117function MagicSubmitBusyScript() {
118 return (
119 <script
120 dangerouslySetInnerHTML={{
121 __html: /* js */ `
122 (function () {
123 try {
124 var forms = document.querySelectorAll('form[data-auth-extra-ml]');
125 forms.forEach(function (f) {
126 f.addEventListener('submit', function () {
127 var btn = f.querySelector('.auth-extra-ml-submit');
128 if (btn) btn.setAttribute('aria-busy', 'true');
129 });
130 });
131 } catch (e) { /* no-op */ }
132 })();
133 `,
134 }}
135 />
136 );
137}
138
35139// ---------------------------------------------------------------------------
36140// GET /login/magic — entry form + post-submit success.
37141// ---------------------------------------------------------------------------
47151 return c.html(
48152 <Layout title="Check your inbox" user={null}>
49153 <div class="auth-container">
50 <h2>Check your inbox</h2>
154 <MagicExtraStyles />
155 <h2 class="auth-extra-ml-headline">Check your inbox</h2>
156 <p class="auth-extra-ml-sub">
157 We just sent a one-time sign-in link to the address you entered.
158 It usually lands within a minute.
159 </p>
51160 <Alert variant="success">
52161 If we can sign you in with that email, we've sent a link. It
53162 expires in 15 minutes.
54163 </Alert>
164 <div class="auth-extra-ml-next">
165 <strong>What happens next:</strong>
166 <ol class="auth-extra-ml-steps" style="margin-top:6px">
167 <li>Open the email titled "Your Gluecron sign-in link".</li>
168 <li>Click the button — you'll be signed in on this device.</li>
169 <li>The link expires in <strong>15 minutes</strong> and works only once.</li>
170 </ol>
171 </div>
55172 <p class="auth-switch">
56173 <Text>
57174 Didn't get it? Check your spam folder, or{" "}
69186 return c.html(
70187 <Layout title="Sign in with email link" user={null}>
71188 <div class="auth-container">
72 <h2>Sign in with a magic link</h2>
73 <p class="auth-switch" style="margin-bottom:16px;margin-top:0">
74 <Text>
75 Enter your email and we'll send you a one-time sign-in link. No
76 password needed.
77 </Text>
189 <MagicExtraStyles />
190 <h2 class="auth-extra-ml-headline">Sign in with a magic link</h2>
191 <p class="auth-extra-ml-sub">
192 Drop your email below and we'll send you a one-time sign-in link.
193 No password to remember, no extra step.
78194 </p>
79 <Form method="post" action="/login/magic" csrfToken={csrf}>
195 <Form
196 method="post"
197 action="/login/magic"
198 csrfToken={csrf}
199 class="auth-extra-ml-form"
200 >
80201 <FormGroup label="Email" htmlFor="email">
81202 <Input
82203 type="email"
85206 placeholder="you@example.com"
86207 autocomplete="email"
87208 aria-label="Email"
209 autofocus
88210 />
89211 </FormGroup>
90 <Button type="submit" variant="primary">
212 <button
213 type="submit"
214 class="btn btn-primary auth-extra-ml-submit"
215 data-loading-label="Sending link…"
216 >
91217 Send me a sign-in link
92 </Button>
218 </button>
93219 </Form>
220 <div class="auth-extra-ml-next" style="margin-top:18px">
221 <strong>How it works:</strong> we email a link that signs you in
222 on this device when clicked. Links expire in 15 minutes and can
223 only be used once. New here? An account is created automatically
224 the first time you sign in.
225 </div>
94226 <p class="auth-switch">
95227 <Text>
96228 Prefer a password?{" "}
97229 <a href="/login">Sign in the usual way</a>.
98230 </Text>
99231 </p>
232 <script
233 dangerouslySetInnerHTML={{
234 __html: `document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra-ml', '1'); });`,
235 }}
236 />
237 <MagicSubmitBusyScript />
100238 </div>
101239 </Layout>
102240 );
125263 return (
126264 <Layout title="Link no longer valid" user={props.user ?? null}>
127265 <div class="auth-container">
128 <h2>This link is no longer valid</h2>
266 <MagicExtraStyles />
267 <h2 class="auth-extra-ml-headline">This link is no longer valid</h2>
268 <p class="auth-extra-ml-sub">
269 Magic links are single-use and time-limited. The one you followed
270 is either expired, already redeemed, or unknown to us.
271 </p>
129272 <Alert variant="error">
130273 Magic links expire after 15 minutes and can only be used once.
131274 This link is expired, already used, or unknown.
Modifiedsrc/routes/password-reset.tsx+267−24View fileUnifiedSplit
55 * POST /forgot-password → always redirects to ?sent=1
66 * GET /reset-password?token=… → new-password form (or invalid-link page)
77 * POST /reset-password → rotate password + redirect to /login
8 *
9 * 2026 polish: each page renders inside the shared `.auth-container`
10 * gateway with a display headline, supporting subtitle, "what happens
11 * next" copy, visible validation rules, and a loading-state submit
12 * button so the surface feels of-a-piece with the polished /login and
13 * /register pages. All form actions, POST handlers, redirects and
14 * validation semantics are preserved verbatim — only chrome changed.
815 */
916
1017import { Hono } from "hono";
1118import { Layout } from "../views/layout";
12import { Form, FormGroup, Input, Button, Alert, Text } from "../views/ui";
19import { Form, FormGroup, Input, Alert, Text } from "../views/ui";
1320import { softAuth } from "../middleware/auth";
1421import type { AuthEnv } from "../middleware/auth";
1522import {
2027
2128const passwordReset = new Hono<AuthEnv>();
2229
30// ---------------------------------------------------------------------------
31// Shared CSS — scoped to `.auth-extra-*` so it can never collide with the
32// locked .auth-container rules in layout.tsx (which we do NOT touch).
33// The styles are emitted inline per-page; duplication is fine because the
34// browser's CSSOM dedupes identical rules and these pages are rarely
35// rendered back-to-back in the same session.
36// ---------------------------------------------------------------------------
37function ExtraStyles() {
38 return (
39 <style
40 dangerouslySetInnerHTML={{
41 __html: `
42 .auth-extra-headline {
43 font-family: var(--font-display);
44 font-weight: 700;
45 font-size: clamp(28px, 4.6vw, 40px);
46 line-height: 1.08;
47 letter-spacing: -0.028em;
48 color: var(--text-strong);
49 margin: 0 0 10px;
50 }
51 .auth-extra-sub {
52 color: var(--text-muted);
53 font-size: 14.5px;
54 line-height: 1.55;
55 margin: 0 0 22px;
56 }
57 .auth-extra-next {
58 margin-top: 14px;
59 padding: 12px 14px;
60 background: var(--bg-tertiary, var(--bg-secondary));
61 border: 1px solid var(--border);
62 border-radius: var(--r-sm, 6px);
63 color: var(--text-muted);
64 font-size: 13px;
65 line-height: 1.55;
66 }
67 .auth-extra-next strong { color: var(--text); font-weight: 600; }
68 .auth-extra-rules {
69 margin: 4px 0 14px;
70 padding: 0 0 0 16px;
71 color: var(--text-muted);
72 font-size: 12.5px;
73 line-height: 1.6;
74 }
75 .auth-extra-rules li { margin: 0; }
76 .auth-extra-submit {
77 width: 100%;
78 padding: 12px 16px;
79 font-size: 15px;
80 font-weight: 600;
81 margin-top: 4px;
82 }
83 /* Loading statedriven by inline script that toggles aria-busy
84 + a data attribute on submit. Spinner is a CSS-only pseudo-
85 element so we don't ship JS for the visual. */
86 .auth-extra-submit[aria-busy="true"] {
87 opacity: 0.78;
88 cursor: progress;
89 pointer-events: none;
90 }
91 .auth-extra-submit[aria-busy="true"]::after {
92 content: '';
93 display: inline-block;
94 width: 12px;
95 height: 12px;
96 margin-left: 8px;
97 vertical-align: -2px;
98 border: 2px solid currentColor;
99 border-right-color: transparent;
100 border-radius: 50%;
101 animation: auth-extra-spin 0.7s linear infinite;
102 }
103 @keyframes auth-extra-spin {
104 to { transform: rotate(360deg); }
105 }
106 .auth-extra-meta {
107 display: flex;
108 justify-content: center;
109 gap: 8px;
110 color: var(--text-muted);
111 font-size: 13px;
112 margin-top: 18px;
113 }
114 .auth-extra-meta a { color: var(--text); }
115 .auth-extra-divider-dot {
116 color: var(--text-faint);
117 }
118 `,
119 }}
120 />
121 );
122}
123
124/** Inline script — flips `aria-busy=true` on the form's submit button as
125 * soon as the form starts submitting so users get unambiguous feedback
126 * on slower connections. Plain DOM, no framework. Falls back to the
127 * browser's default behaviour if anything throws. */
128function SubmitBusyScript() {
129 return (
130 <script
131 dangerouslySetInnerHTML={{
132 __html: /* js */ `
133 (function () {
134 try {
135 var forms = document.querySelectorAll('form[data-auth-extra]');
136 forms.forEach(function (f) {
137 f.addEventListener('submit', function () {
138 var btn = f.querySelector('.auth-extra-submit');
139 if (btn) {
140 btn.setAttribute('aria-busy', 'true');
141 // Don't actually disabledisabled buttons get
142 // skipped on form submit by some browsers when the
143 // listener fires post-validation.
144 btn.dataset.label = btn.textContent || '';
145 }
146 });
147 });
148 } catch (e) { /* no-op */ }
149 })();
150 `,
151 }}
152 />
153 );
154}
155
23156passwordReset.get("/forgot-password", softAuth, (c) => {
24157 const csrf = c.get("csrfToken") as string | undefined;
25158 const sent = c.req.query("sent") === "1";
28161 return c.html(
29162 <Layout title="Reset link sent" user={c.get("user") ?? null}>
30163 <div class="auth-container">
31 <h2>Check your inbox</h2>
164 <ExtraStyles />
165 <h2 class="auth-extra-headline">Check your inbox</h2>
166 <p class="auth-extra-sub">
167 We just dispatched a password-reset email — it usually lands within
168 a minute.
169 </p>
32170 <Alert variant="success">
33171 If we have an account for that email, we've sent a reset link.
34172 Check your inbox (and spam folder).
35173 </Alert>
36 <p class="auth-switch"><Text>The link expires in 1 hour.</Text></p>
37 <p class="auth-switch"><a href="/login">Back to sign in</a></p>
174 <div class="auth-extra-next">
175 <strong>What happens next:</strong> click the button in the email
176 within <strong>1 hour</strong> to set a new password. The link
177 works only once.
178 </div>
179 <p class="auth-switch">
180 <Text>
181 Didn't get it? <a href="/forgot-password">Send another link</a>.
182 </Text>
183 </p>
184 <p class="auth-switch">
185 <a href="/login">Back to sign in</a>
186 </p>
38187 </div>
39188 </Layout>
40189 );
43192 return c.html(
44193 <Layout title="Forgot password" user={c.get("user") ?? null}>
45194 <div class="auth-container">
46 <h2>Reset your password</h2>
47 <p class="auth-switch" style="margin-bottom:16px;margin-top:0">
48 <Text>Enter the email tied to your account and we'll send you a link to set a new password.</Text>
195 <ExtraStyles />
196 <h2 class="auth-extra-headline">Reset your password</h2>
197 <p class="auth-extra-sub">
198 Enter the email tied to your account and we'll send you a one-time
199 link to set a new password. No call to support needed.
49200 </p>
50 <Form method="post" action="/forgot-password" csrfToken={csrf}>
201 <Form
202 method="post"
203 action="/forgot-password"
204 csrfToken={csrf}
205 class="auth-extra-form"
206 >
51207 <FormGroup label="Email" htmlFor="email">
52 <Input type="email" name="email" required placeholder="you@example.com" autocomplete="email" aria-label="Email" />
208 <Input
209 type="email"
210 name="email"
211 required
212 placeholder="you@example.com"
213 autocomplete="email"
214 aria-label="Email"
215 autofocus
216 />
53217 </FormGroup>
54 <Button type="submit" variant="primary">Send reset link</Button>
218 <button
219 type="submit"
220 class="btn btn-primary auth-extra-submit"
221 data-loading-label="Sending link…"
222 >
223 Send reset link
224 </button>
55225 </Form>
56 <p class="auth-switch"><Text>Remembered it? <a href="/login">Sign in</a></Text></p>
226 <div class="auth-extra-next" style="margin-top:18px">
227 <strong>What happens next:</strong> we'll email a reset link that
228 expires in 1 hour. If you don't see it, check spam — or come back
229 and request another.
230 </div>
231 <p class="auth-switch">
232 <Text>
233 Remembered it? <a href="/login">Sign in</a>
234 </Text>
235 </p>
236 {/* Hidden marker so the busy-script can find this form. */}
237 <script
238 dangerouslySetInnerHTML={{
239 __html: `document.currentScript.previousElementSibling && document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra', '1'); });`,
240 }}
241 />
242 <SubmitBusyScript />
57243 </div>
58244 </Layout>
59245 );
74260 return (
75261 <Layout title="Link no longer valid" user={props.user ?? null}>
76262 <div class="auth-container">
77 <h2>This link is no longer valid</h2>
263 <ExtraStyles />
264 <h2 class="auth-extra-headline">This link is no longer valid</h2>
265 <p class="auth-extra-sub">
266 Reset links live for 1 hour and can only be used once. The link you
267 followed is expired, already used, or unknown.
268 </p>
78269 <Alert variant="error">
79 Reset links expire after 1 hour and can only be used once. This link is expired, already used, or unknown.
270 Reset links expire after 1 hour and can only be used once. This link
271 is expired, already used, or unknown.
80272 </Alert>
81 <p class="auth-switch" style="margin-top:16px"><a href="/forgot-password">Request a new one</a></p>
82 <p class="auth-switch"><a href="/login">Back to sign in</a></p>
273 <p class="auth-switch" style="margin-top:16px">
274 <a href="/forgot-password">Request a new one</a>
275 </p>
276 <p class="auth-switch">
277 <a href="/login">Back to sign in</a>
278 </p>
83279 </div>
84280 </Layout>
85281 );
97293 return c.html(
98294 <Layout title="Set a new password" user={c.get("user") ?? null}>
99295 <div class="auth-container">
100 <h2>Set a new password</h2>
101 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
102 <p class="auth-switch" style="margin-bottom:16px;margin-top:0">
103 <Text>Choose a new password — at least 8 characters. Signing in from other devices will be required afterwards.</Text>
296 <ExtraStyles />
297 <h2 class="auth-extra-headline">Set a new password</h2>
298 <p class="auth-extra-sub">
299 Pick something fresh — your old sessions on other devices will be
300 signed out automatically once you save.
104301 </p>
105 <Form method="post" action="/reset-password" csrfToken={csrf}>
302 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
303 <Form
304 method="post"
305 action="/reset-password"
306 csrfToken={csrf}
307 class="auth-extra-form"
308 >
106309 <input type="hidden" name="token" value={token} />
107310 <FormGroup label="New password" htmlFor="password">
108 <Input type="password" name="password" required minLength={8} placeholder="Min 8 characters" autocomplete="new-password" aria-label="New password" />
311 <Input
312 type="password"
313 name="password"
314 required
315 minLength={8}
316 placeholder="Min 8 characters"
317 autocomplete="new-password"
318 aria-label="New password"
319 autofocus
320 />
109321 </FormGroup>
322 <ul class="auth-extra-rules" aria-label="Password requirements">
323 <li>At least 8 characters</li>
324 <li>Mix of letters, numbers, or symbols recommended</li>
325 <li>Avoid passwords you use elsewhere</li>
326 </ul>
110327 <FormGroup label="Confirm new password" htmlFor="confirm">
111 <Input type="password" name="confirm" required minLength={8} placeholder="Re-enter the new password" autocomplete="new-password" aria-label="Confirm new password" />
328 <Input
329 type="password"
330 name="confirm"
331 required
332 minLength={8}
333 placeholder="Re-enter the new password"
334 autocomplete="new-password"
335 aria-label="Confirm new password"
336 />
112337 </FormGroup>
113 <Button type="submit" variant="primary">Update password</Button>
338 <button
339 type="submit"
340 class="btn btn-primary auth-extra-submit"
341 data-loading-label="Updating…"
342 >
343 Update password
344 </button>
114345 </Form>
115 <p class="auth-switch"><a href="/login">Cancel</a></p>
346 <div class="auth-extra-next" style="margin-top:18px">
347 <strong>What happens next:</strong> we'll sign you out everywhere
348 else and bounce you to the sign-in page with your new password.
349 </div>
350 <p class="auth-switch">
351 <a href="/login">Cancel</a>
352 </p>
353 <script
354 dangerouslySetInnerHTML={{
355 __html: `document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra', '1'); });`,
356 }}
357 />
358 <SubmitBusyScript />
116359 </div>
117360 </Layout>
118361 );
Modifiedsrc/views/error-page.tsx+553−96View fileUnifiedSplit
11/**
2 * BLOCK O2Shared error page surface (404/500/403).
2 * BLOCK O2 — Shared error page surface (404 / 500 / 403 / 429).
33 *
4 * One JSX component renders all three error surfaces consistently.
5 * Used from app.notFound, app.onError, and the requireAdmin middleware.
4 * One JSX component renders every error surface consistently. Used by:
5 * - `app.notFound` (global 404 fall-through)
6 * - `app.onError` (global 500 catcher — preserves request ID + trace)
7 * - `requireAdmin` middleware (403, via the DB-free standalone renderer)
8 * - Future per-route 429 / 403 helpers (`RateLimitPage`, `ForbiddenPage`)
69 *
7 * - NO database access (must render when DB is down).
8 * - Reuses existing CSS tokens via Layout.
9 * - Dark + light theme both via the same gradient-text utility.
10 * - Accessible: role="main", aria-labelledby, polite live region.
10 * Visual recipe (2026 polish — matches admin-integrations / build-agent-spec):
11 * - Gradient hairline strip across the top (purple→cyan, 2px)
12 * - Soft radial orb in the corner (blurred, low-opacity)
13 * - Large display headline using clamp() + the gradient-text utility
14 * - Plain-English subtitle (no jargon, no stack traces in production)
15 * - Real button links — Home / Explore / Help / Search, picked per code
16 * - Request ID surfaced on 500 so support can trace the failure
17 * - Retry-after timing on 429 so the user knows when to come back
18 *
19 * Hard rules honoured:
20 * - NO database access (must render when DB is down)
21 * - Reuses existing tokens via Layout (--accent, --bg-elevated, etc.)
22 * - New CSS scoped under `.err-*` (legacy `.error-page-*` classes kept
23 * on the same elements only where existing tests assert against them)
24 * - Dark + light theme both via the same gradient-text utility
25 * - Accessible: role="main", aria-labelledby, polite live region
1126 */
1227
1328import type { FC } from "hono/jsx";
1429import type { User } from "../db/schema";
1530import { Layout } from "./layout";
1631
17export interface ErrorPageSuggestion { href: string; label: string }
32export interface ErrorPageSuggestion { href: string; label: string; hint?: string }
1833export interface ErrorPageProps {
1934 code: string;
2035 eyebrow: string;
2843 meta?: string;
2944 trace?: string;
3045 layoutTitle?: string;
46 /** Optional helper paragraph rendered between body + actions. */
47 note?: string;
48 /** Optional retry-after seconds (used by the 429 surface). */
49 retryAfterSeconds?: number;
3150}
3251
3352export const ErrorPage: FC<ErrorPageProps> = ({
3453 code, eyebrow, title, body, requestId, user, suggestions,
35 primaryCta, secondaryCta, meta, trace, layoutTitle,
54 primaryCta, secondaryCta, meta, trace, layoutTitle, note, retryAfterSeconds,
3655}) => {
3756 const primary = primaryCta ?? { href: "/", label: "Go home" };
3857 const secondary = secondaryCta ?? { href: "/status", label: "Status page" };
3958 return (
4059 <Layout title={layoutTitle ?? `${code} ${eyebrow}`} user={user ?? null}>
41 <main class="error-page" role="main" aria-labelledby="error-page-title" data-error-code={code}>
42 <div class="error-page-code gradient-text" aria-hidden="true">{code}</div>
43 <div class="error-page-eyebrow">{eyebrow}</div>
44 <h1 id="error-page-title" class="error-page-title">{title}</h1>
45 <p class="error-page-body">{body}</p>
46 <div class="error-page-actions">
47 <a href={primary.href} class="btn btn-primary btn-lg" data-error-cta="primary">{primary.label}</a>
48 <a href={secondary.href} class="btn btn-ghost btn-lg" data-error-cta="secondary">{secondary.label}</a>
49 </div>
50 {suggestions && suggestions.length > 0 && (
51 <ul class="error-page-suggestions" aria-label="Try one of these">
52 {suggestions.map((s) => (<li><a href={s.href}>{s.label} &rarr;</a></li>))}
53 </ul>
54 )}
55 {requestId && (
56 <div class="error-page-meta" aria-live="polite">
57 <span class="error-page-meta-label">Request ID:</span>{" "}
58 <span class="meta-mono">{requestId}</span>
60 <main
61 class="err-page error-page"
62 role="main"
63 aria-labelledby="error-page-title"
64 data-error-code={code}
65 >
66 <div class="err-hero">
67 <div class="err-orb" aria-hidden="true" />
68 <div class="err-hero-inner">
69 <div class="err-eyebrow">
70 <span class="err-eyebrow-dot" aria-hidden="true" />
71 {eyebrow}
72 </div>
73 <div class="err-code gradient-text error-page-code" aria-hidden="true">
74 {code}
75 </div>
76 <h1 id="error-page-title" class="err-title">{title}</h1>
77 <p class="err-body">{body}</p>
78 {note && <p class="err-note">{note}</p>}
79 <div class="err-actions">
80 <a href={primary.href} class="err-btn err-btn-primary" data-error-cta="primary">
81 {primary.label}
82 </a>
83 <a href={secondary.href} class="err-btn err-btn-ghost" data-error-cta="secondary">
84 {secondary.label}
85 </a>
86 </div>
87 {suggestions && suggestions.length > 0 && (
88 <ul class="err-suggestions" aria-label="Try one of these">
89 {suggestions.map((s) => (
90 <li>
91 <a href={s.href}>
92 <span class="err-suggestion-label">{s.label}</span>
93 {s.hint && <span class="err-suggestion-hint">{s.hint}</span>}
94 <span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span>
95 </a>
96 </li>
97 ))}
98 </ul>
99 )}
100 <div class="err-meta-block">
101 {requestId && (
102 <div class="err-meta" aria-live="polite">
103 <span class="err-meta-label">Request ID:</span>
104 <span class="err-meta-value meta-mono">{requestId}</span>
105 </div>
106 )}
107 {typeof retryAfterSeconds === "number" && retryAfterSeconds > 0 && (
108 <div class="err-meta">
109 <span class="err-meta-label">Retry after:</span>
110 <span class="err-meta-value meta-mono">
111 {formatRetryAfter(retryAfterSeconds)}
112 </span>
113 </div>
114 )}
115 {meta && (
116 <div class="err-meta">
117 <span class="err-meta-label">Request:</span>
118 <span class="err-meta-value meta-mono">{meta}</span>
119 </div>
120 )}
121 </div>
122 {trace && (
123 <pre class="err-trace error-page-trace" aria-label="Stack trace">
124 {trace}
125 </pre>
126 )}
59127 </div>
60 )}
61 {meta && (<div class="error-page-meta"><span class="meta-mono">{meta}</span></div>)}
62 {trace && (<pre class="error-page-trace" aria-label="Stack trace">{trace}</pre>)}
128 </div>
63129 </main>
64130 <style dangerouslySetInnerHTML={{ __html: errorPageCss }} />
65131 </Layout>
66132 );
67133};
68134
69export const NotFoundPage: FC<{ user?: User | null; method?: string; path?: string }> = ({ user, method, path }) => (
70 <ErrorPage
71 code="404"
72 eyebrow="Not found"
73 title="We can't find that page."
74 body="The URL might be wrong, the resource might have moved, or you might not have permission to see it."
75 user={user}
76 primaryCta={{ href: "/", label: "Go home" }}
77 secondaryCta={{ href: "/status", label: "Status page" }}
78 suggestions={[
79 { href: "/explore", label: "Explore public repositories" },
80 { href: "/help", label: "Read the quickstart guide" },
81 ]}
82 meta={method && path ? `${method} ${path}` : undefined}
83 layoutTitle="Not Found"
84 />
85);
135/* ─────────────────────────────────────────────────────────────────────────
136 * 404 — Not found. The most common error the user will see.
137 *
138 * For paths shaped like `/:owner/:repo/...` we add a contextual note: a real
139 * class of bugs has landed users on a 404 when they typed `ccantynz-alt`
140 * instead of `ccantynz`. Cheap to suggest "double-check the owner/name".
141 * ───────────────────────────────────────────────────────────────────── */
142export const NotFoundPage: FC<{ user?: User | null; method?: string; path?: string }> = ({ user, method, path }) => {
143 const looksLikeRepoPath =
144 typeof path === "string" &&
145 /^\/[^/]+\/[^/]+(\/.*)?$/.test(path) &&
146 !path.startsWith("/api/") &&
147 !path.startsWith("/admin/") &&
148 !path.startsWith("/static/");
149 const note = looksLikeRepoPath
150 ? "Looks like a repository URL. Double-check the owner and repo name — a one-character typo (think `ccantynz` vs `ccantynz-alt`) is the most common reason this page shows up."
151 : undefined;
152 const suggestions: ErrorPageSuggestion[] = [
153 { href: "/explore", label: "Explore public repositories", hint: "browse what's trending" },
154 { href: "/search", label: "Search across Gluecron", hint: "code, repos, issues, PRs" },
155 { href: "/help", label: "Read the quickstart guide", hint: "5-minute tour" },
156 ];
157 return (
158 <ErrorPage
159 code="404"
160 eyebrow="Not found"
161 title="We can't find that page."
162 body="The URL might be wrong, the resource might have moved, or you might not have permission to see it."
163 user={user}
164 note={note}
165 primaryCta={{ href: "/", label: "Go home" }}
166 secondaryCta={{ href: "/status", label: "Status page" }}
167 suggestions={suggestions}
168 meta={method && path ? `${method} ${path}` : undefined}
169 layoutTitle="Not Found"
170 />
171 );
172};
86173
174/* ─────────────────────────────────────────────────────────────────────────
175 * 500 — Server error. We surface the Request ID so the user can include it
176 * when filing a support issue, and (outside production) the error message
177 * for fast local debugging. NEVER show a full stack trace in production.
178 * ───────────────────────────────────────────────────────────────────── */
87179export const ServerErrorPage: FC<{ user?: User | null; requestId?: string; trace?: string }> = ({ user, requestId, trace }) => (
88180 <ErrorPage
89181 code="500"
90182 eyebrow="Server error"
91183 title="Something went wrong on our end."
92 body="The error has been reported. Try again in a moment — if it persists, include the Request ID below when you file an issue."
184 body="The error has been reported and the team has been paged. Try again in a moment — if it persists, include the Request ID below when you file an issue."
93185 user={user}
94186 primaryCta={{ href: "/", label: "Go home" }}
95 secondaryCta={{ href: "/status", label: "Status page" }}
187 secondaryCta={{ href: "/status", label: "Check status" }}
188 suggestions={[
189 { href: "/status", label: "Platform status", hint: "live health of every service" },
190 { href: "/help", label: "Contact support", hint: "include the Request ID" },
191 ]}
96192 requestId={requestId}
97193 trace={trace}
98194 layoutTitle="Error"
99195 />
100196);
101197
198/* ─────────────────────────────────────────────────────────────────────────
199 * 403 — Forbidden. Two flavours: signed-in (need a different account or
200 * elevated role) vs signed-out (just sign in). Default copy matches the
201 * admin gate so the existing `requireAdmin` middleware looks consistent.
202 * ───────────────────────────────────────────────────────────────────── */
102203export const ForbiddenPage: FC<{ user?: User | null; message?: string }> = ({ user, message }) => {
103204 const suggestions: ErrorPageSuggestion[] = user
104 ? [{ href: "/logout", label: "Sign in as a different user" }, { href: "/help", label: "Read the access docs" }]
105 : [{ href: "/login", label: "Sign in" }, { href: "/help", label: "Read the access docs" }];
205 ? [
206 { href: "/logout", label: "Sign in as a different user", hint: "switch accounts" },
207 { href: "/help", label: "Read the access docs", hint: "permissions & teams" },
208 ]
209 : [
210 { href: "/login", label: "Sign in", hint: "you might just need to log in" },
211 { href: "/help", label: "Read the access docs", hint: "permissions & teams" },
212 ];
106213 return (
107214 <ErrorPage
108215 code="403"
111218 body="You're signed in, but this resource is restricted. If you think this is a mistake, contact a site admin."
112219 user={user}
113220 primaryCta={{ href: "/", label: "Go home" }}
114 secondaryCta={{ href: "/status", label: "Status page" }}
221 secondaryCta={{ href: "/help", label: "Get help" }}
115222 suggestions={suggestions}
116223 layoutTitle="Forbidden"
117224 />
118225 );
119226};
120227
228/* ─────────────────────────────────────────────────────────────────────────
229 * 429 — Too many requests. The rate-limit middleware currently returns a
230 * JSON payload (API-shaped), but we expose this surface for any HTML
231 * route that wants to render a friendly throttling page. Surfaces the
232 * Retry-After timing so the user knows when to come back.
233 * ───────────────────────────────────────────────────────────────────── */
234export const RateLimitPage: FC<{ user?: User | null; retryAfterSeconds?: number; requestId?: string }> = ({ user, retryAfterSeconds, requestId }) => (
235 <ErrorPage
236 code="429"
237 eyebrow="Too many requests"
238 title="You're going a little too fast."
239 body="We rate-limit certain endpoints to keep things responsive for everyone. The bucket refills in a few seconds — wait it out and try again."
240 user={user}
241 note="If you're hitting this from a script, add a polite delay between calls. Authenticated requests get 4× the anonymous bucket."
242 primaryCta={{ href: "/", label: "Go home" }}
243 secondaryCta={{ href: "/help", label: "Read the rate-limit docs" }}
244 suggestions={[
245 { href: "/help", label: "Rate-limit reference", hint: "current caps per endpoint" },
246 { href: "/settings/tokens", label: "Create a personal access token", hint: "authed callers get a bigger bucket" },
247 ]}
248 requestId={requestId}
249 retryAfterSeconds={retryAfterSeconds}
250 layoutTitle="Rate limited"
251 />
252);
253
254/* ─────────────────────────────────────────────────────────────────────────
255 * Scoped CSS — every new class prefixed `.err-*` so it can't bleed into
256 * other surfaces. Two legacy classes (`.error-page-code`, `.error-page-trace`)
257 * remain on the same elements so the existing system-states tests keep
258 * passing; they just inherit the new visual treatment via .err-* parents.
259 *
260 * Design tokens reused from the layout:
261 * --accent (#8c6dff), --accent-2 (#36c5d6), --accent-gradient,
262 * --bg-elevated, --border, --border-strong, --border-focus,
263 * --text, --text-strong, --text-muted, --space-*, --font-display,
264 * --font-mono. The gradient-text utility comes from the layout too.
265 * ───────────────────────────────────────────────────────────────────── */
121266export const errorPageCss = `
122.error-page { max-width: 720px; margin: 64px auto 96px; padding: 0 24px; text-align: center; }
123.error-page-code { font-size: clamp(96px, 22vw, 192px); line-height: 1; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 8px; }
124.error-page-eyebrow { text-transform: uppercase; font-size: 12px; letter-spacing: 0.18em; color: var(--text-muted); font-weight: 600; margin-bottom: 12px; }
125.error-page-title { font-size: clamp(24px, 4vw, 36px); font-weight: 700; letter-spacing: -0.02em; margin: 0 0 12px; color: var(--text); }
126.error-page-body { font-size: 16px; line-height: 1.6; color: var(--text-muted); max-width: 520px; margin: 0 auto 28px; }
127.error-page-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 24px; }
128.error-page-suggestions { list-style: none; padding: 0; margin: 0 auto 24px; max-width: 480px; display: flex; flex-direction: column; gap: 4px; }
129.error-page-suggestions li { font-size: 14px; }
130.error-page-suggestions a { color: var(--accent); text-decoration: none; }
131.error-page-suggestions a:hover { text-decoration: underline; }
132.error-page-meta { font-size: 13px; color: var(--text-muted); margin-top: 12px; }
133.error-page-meta-label { text-transform: uppercase; letter-spacing: 0.12em; font-size: 11px; font-weight: 600; }
134.error-page .meta-mono { font-family: var(--font-mono); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: var(--text); }
135.error-page-trace { text-align: left; margin: 24px auto 0; padding: 16px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); overflow-x: auto; max-width: 100%; white-space: pre-wrap; }
267 .err-page {
268 max-width: 760px;
269 margin: var(--space-8, 56px) auto var(--space-16, 96px);
270 padding: 0 var(--space-4, 24px);
271 }
272
273 .err-hero {
274 position: relative;
275 padding: clamp(28px, 4vw, 56px) clamp(24px, 4vw, 56px) clamp(32px, 4vw, 56px);
276 background: var(--bg-elevated);
277 border: 1px solid var(--border);
278 border-radius: 20px;
279 overflow: hidden;
280 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 20px 48px -12px rgba(0,0,0,0.45);
281 }
282 /* Gradient hairline strip across the top — the signature 2026 motif. */
283 .err-hero::before {
284 content: '';
285 position: absolute;
286 top: 0; left: 0; right: 0;
287 height: 2px;
288 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
289 opacity: 0.75;
290 pointer-events: none;
291 }
292 /* Soft radial orb — blurred, low opacity, off the top-right corner.
293 Adds atmosphere without competing with the headline. */
294 .err-orb {
295 position: absolute;
296 inset: -30% -15% auto auto;
297 width: 460px; height: 460px;
298 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
299 filter: blur(80px);
300 opacity: 0.75;
301 pointer-events: none;
302 z-index: 0;
303 }
304 .err-hero-inner { position: relative; z-index: 1; }
305
306 .err-eyebrow {
307 display: inline-flex;
308 align-items: center;
309 gap: 8px;
310 text-transform: uppercase;
311 font-family: var(--font-mono);
312 font-size: 11px;
313 letter-spacing: 0.18em;
314 color: var(--text-muted);
315 font-weight: 600;
316 margin-bottom: 18px;
317 }
318 .err-eyebrow-dot {
319 width: 8px; height: 8px;
320 border-radius: 9999px;
321 background: linear-gradient(135deg, #8c6dff, #36c5d6);
322 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
323 }
324
325 /* The display code — 404, 500, etc. */
326 .err-code {
327 font-family: var(--font-display);
328 font-size: clamp(56px, 8vw, 96px);
329 line-height: 0.95;
330 font-weight: 800;
331 letter-spacing: -0.04em;
332 margin: 0 0 12px;
333 /* Fallback colour for browsers that don't paint -webkit-text-fill-color
334 on background-clip:text. The .gradient-text utility (defined in the
335 layout) applies the gradient on top. */
336 color: var(--accent);
337 }
338
339 .err-title {
340 font-family: var(--font-display);
341 font-size: clamp(22px, 3vw, 30px);
342 font-weight: 700;
343 letter-spacing: -0.022em;
344 line-height: 1.2;
345 margin: 0 0 12px;
346 color: var(--text-strong);
347 }
348 .err-body {
349 font-size: 16px;
350 line-height: 1.6;
351 color: var(--text-muted);
352 max-width: 560px;
353 margin: 0 0 16px;
354 }
355 .err-note {
356 font-size: 14px;
357 line-height: 1.55;
358 color: var(--text);
359 max-width: 600px;
360 margin: 0 0 22px;
361 padding: 12px 14px;
362 background: rgba(140,109,255,0.06);
363 border: 1px solid rgba(140,109,255,0.22);
364 border-radius: 10px;
365 }
366
367 /* Real button links — primary + ghost. We deliberately don't reuse
368 .btn from the layout because it's redefined in a dozen places; a
369 local pair keeps the error surface visually self-contained. */
370 .err-actions {
371 display: flex;
372 gap: 10px;
373 flex-wrap: wrap;
374 margin: 22px 0 28px;
375 }
376 .err-btn {
377 display: inline-flex;
378 align-items: center;
379 justify-content: center;
380 padding: 11px 20px;
381 border-radius: 10px;
382 font-size: 14px;
383 font-weight: 600;
384 text-decoration: none;
385 border: 1px solid transparent;
386 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
387 line-height: 1;
388 }
389 .err-btn-primary {
390 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
391 color: #ffffff;
392 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
393 }
394 .err-btn-primary:hover {
395 transform: translateY(-1px);
396 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
397 color: #ffffff;
398 text-decoration: none;
399 }
400 .err-btn-ghost {
401 background: transparent;
402 color: var(--text);
403 border-color: var(--border-strong, var(--border));
404 }
405 .err-btn-ghost:hover {
406 background: rgba(140,109,255,0.06);
407 border-color: rgba(140,109,255,0.45);
408 color: var(--text-strong);
409 text-decoration: none;
410 }
411
412 /* Suggestion list — each row is a full-width link with hover tint. */
413 .err-suggestions {
414 list-style: none;
415 padding: 0;
416 margin: 0 0 24px;
417 border-top: 1px solid var(--border);
418 }
419 .err-suggestions li { margin: 0; }
420 .err-suggestions a {
421 display: flex;
422 align-items: baseline;
423 gap: 10px;
424 padding: 12px 4px;
425 border-bottom: 1px solid var(--border);
426 color: var(--text);
427 text-decoration: none;
428 transition: padding-left 120ms ease, color 120ms ease;
429 }
430 .err-suggestions a:hover {
431 padding-left: 8px;
432 color: var(--text-strong);
433 text-decoration: none;
434 }
435 .err-suggestion-label {
436 font-size: 14.5px;
437 font-weight: 600;
438 color: var(--text-strong);
439 }
440 .err-suggestion-hint {
441 flex: 1;
442 font-size: 12.5px;
443 color: var(--text-muted);
444 }
445 .err-suggestion-arrow {
446 font-size: 14px;
447 color: var(--accent);
448 transition: transform 120ms ease;
449 }
450 .err-suggestions a:hover .err-suggestion-arrow { transform: translateX(3px); }
451
452 .err-meta-block {
453 display: flex;
454 flex-direction: column;
455 gap: 8px;
456 margin-top: 4px;
457 }
458 .err-meta {
459 display: inline-flex;
460 align-items: center;
461 gap: 10px;
462 font-size: 12.5px;
463 color: var(--text-muted);
464 flex-wrap: wrap;
465 }
466 .err-meta-label {
467 text-transform: uppercase;
468 letter-spacing: 0.14em;
469 font-size: 10.5px;
470 font-weight: 700;
471 color: var(--text-muted);
472 }
473 .err-meta-value,
474 .err-page .meta-mono {
475 font-family: var(--font-mono);
476 background: rgba(255,255,255,0.04);
477 border: 1px solid var(--border);
478 border-radius: 6px;
479 padding: 3px 8px;
480 font-size: 12px;
481 color: var(--text);
482 }
483
484 .err-trace {
485 text-align: left;
486 margin: 22px 0 0;
487 padding: 14px 16px;
488 background: rgba(0,0,0,0.28);
489 border: 1px solid var(--border);
490 border-radius: 10px;
491 font-family: var(--font-mono);
492 font-size: 12px;
493 line-height: 1.55;
494 color: var(--text-muted);
495 overflow-x: auto;
496 max-width: 100%;
497 white-space: pre-wrap;
498 }
499
500 /* Light-theme adjustments — orb + note tint look heavy on white. */
501 :root[data-theme='light'] .err-hero {
502 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 12px 36px -10px rgba(15,16,28,0.10);
503 }
504 :root[data-theme='light'] .err-orb {
505 background: radial-gradient(circle, rgba(109,77,255,0.16), rgba(8,145,178,0.08) 45%, transparent 70%);
506 }
507 :root[data-theme='light'] .err-meta-value,
508 :root[data-theme='light'] .err-page .meta-mono {
509 background: rgba(15,16,28,0.04);
510 }
511 :root[data-theme='light'] .err-trace {
512 background: rgba(15,16,28,0.04);
513 }
514
515 @media (max-width: 540px) {
516 .err-page { margin: 24px auto 56px; }
517 .err-hero { padding: 24px 20px 28px; border-radius: 16px; }
518 .err-actions .err-btn { flex: 1 1 auto; }
519 }
136520`;
137521
522function formatRetryAfter(seconds: number): string {
523 if (seconds < 60) return `${Math.ceil(seconds)}s`;
524 const m = Math.floor(seconds / 60);
525 const s = Math.ceil(seconds % 60);
526 return s > 0 ? `${m}m ${s}s` : `${m}m`;
527}
528
529/* ─────────────────────────────────────────────────────────────────────────
530 * Standalone (DB-free) renderer — used by `requireAdmin` middleware so
531 * the 403 page renders even when the DB / Layout is unavailable. Returns
532 * a complete <!doctype html> document with inline styles. Mirrors the
533 * `.err-*` visual treatment so the 403 doesn't look like a different
534 * product than the rest of the surface.
535 * ───────────────────────────────────────────────────────────────────── */
138536export function renderStandaloneErrorPage(opts: {
139537 code: string; eyebrow: string; title: string; body: string;
140538 requestId?: string; signedIn?: boolean;
141539}): string {
142540 const { code, eyebrow, title, body, requestId, signedIn } = opts;
143541 const suggestionsHtml = signedIn
144 ? `<li><a href="/logout">Sign in as a different user &rarr;</a></li><li><a href="/help">Read the access docs &rarr;</a></li>`
145 : `<li><a href="/login">Sign in &rarr;</a></li><li><a href="/help">Read the access docs &rarr;</a></li>`;
542 ? `<li><a href="/logout"><span class="err-suggestion-label">Sign in as a different user</span><span class="err-suggestion-hint">switch accounts</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>
543 <li><a href="/help"><span class="err-suggestion-label">Read the access docs</span><span class="err-suggestion-hint">permissions &amp; teams</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>`
544 : `<li><a href="/login"><span class="err-suggestion-label">Sign in</span><span class="err-suggestion-hint">you might just need to log in</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>
545 <li><a href="/help"><span class="err-suggestion-label">Read the access docs</span><span class="err-suggestion-hint">permissions &amp; teams</span><span class="err-suggestion-arrow" aria-hidden="true">&rarr;</span></a></li>`;
146546 const requestIdHtml = requestId
147 ? `<div class="error-page-meta" aria-live="polite"><span class="error-page-meta-label">Request ID:</span> <span class="meta-mono">${escapeHtml(requestId)}</span></div>`
547 ? `<div class="err-meta-block"><div class="err-meta" aria-live="polite"><span class="err-meta-label">Request ID:</span><span class="err-meta-value meta-mono">${escapeHtml(requestId)}</span></div></div>`
148548 : "";
149549 return `<!doctype html>
150550<html lang="en" data-theme="dark">
153553<meta name="viewport" content="width=device-width, initial-scale=1.0">
154554<title>${escapeHtml(code)} ${escapeHtml(eyebrow)}</title>
155555<style>
156:root { --bg:#0d1117; --bg-elevated:#161b22; --text:#e6edf3; --text-muted:#8b949e; --border:#30363d; --accent:#8c6dff; --accent-gradient:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%); --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; }
157:root[data-theme='light'] { --bg:#fff; --bg-elevated:#f6f8fa; --text:#1f2328; --text-muted:#57606a; --border:#d1d9e0; --accent:#6d4dff; }
158html, body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; margin:0; padding:0; }
159.error-page { max-width: 720px; margin: 96px auto; padding: 0 24px; text-align: center; }
160.error-page-code { font-size: clamp(96px,22vw,192px); line-height:1; font-weight:800; letter-spacing:-0.04em; margin-bottom:8px; background: var(--accent-gradient); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color: transparent; }
161.error-page-eyebrow { text-transform:uppercase; font-size:12px; letter-spacing:0.18em; color: var(--text-muted); font-weight:600; margin-bottom:12px; }
162.error-page-title { font-size: clamp(24px,4vw,36px); font-weight:700; letter-spacing:-0.02em; margin:0 0 12px; color: var(--text); }
163.error-page-body { font-size:16px; line-height:1.6; color: var(--text-muted); max-width: 520px; margin: 0 auto 28px; }
164.error-page-actions { display:flex; gap:12px; justify-content:center; flex-wrap:wrap; margin-bottom:24px; }
165.btn { display:inline-flex; align-items:center; padding:10px 18px; border-radius:8px; border:1px solid var(--border); text-decoration:none; color: var(--text); background: var(--bg-elevated); font-size:14px; font-weight:500; }
166.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
167.btn-lg { padding:12px 22px; font-size:15px; }
168.error-page-suggestions { list-style:none; padding:0; margin: 0 auto 24px; max-width: 480px; display:flex; flex-direction:column; gap:4px; font-size:14px; }
169.error-page-suggestions a { color: var(--accent); text-decoration:none; }
170.error-page-meta { font-size:13px; color: var(--text-muted); margin-top:12px; }
171.error-page-meta-label { text-transform:uppercase; letter-spacing:0.12em; font-size:11px; font-weight:600; }
172.meta-mono { font-family: var(--font-mono); background: var(--bg-elevated); border:1px solid var(--border); border-radius:6px; padding:2px 8px; font-size:12px; color: var(--text); }
556:root {
557 --bg:#0a0b14; --bg-elevated:#0f111a; --text:#e6edf3; --text-strong:#f7f7fb; --text-muted:#8b949e;
558 --border:rgba(255,255,255,0.08); --border-strong:rgba(255,255,255,0.13);
559 --accent:#8c6dff; --accent-2:#36c5d6;
560 --font-display:'Inter Tight','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
561 --font-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace;
562}
563html,body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; margin:0; padding:0; min-height:100vh; }
564body::before {
565 content:''; position:fixed; inset:0; pointer-events:none; z-index:0;
566 background:
567 radial-gradient(70% 55% at 85% -20%, rgba(140,109,255,0.07), transparent 65%),
568 radial-gradient(55% 45% at -10% 115%, rgba(54,197,214,0.05), transparent 65%);
569}
570.err-page { position:relative; z-index:1; max-width:760px; margin:56px auto 96px; padding:0 24px; }
571.err-hero {
572 position:relative; padding:clamp(28px,4vw,56px) clamp(24px,4vw,56px) clamp(32px,4vw,56px);
573 background:var(--bg-elevated); border:1px solid var(--border); border-radius:20px; overflow:hidden;
574 box-shadow:0 1px 0 rgba(255,255,255,0.04), 0 20px 48px -12px rgba(0,0,0,0.45);
575}
576.err-hero::before {
577 content:''; position:absolute; top:0; left:0; right:0; height:2px;
578 background:linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
579 opacity:0.75;
580}
581.err-orb {
582 position:absolute; inset:-30% -15% auto auto; width:460px; height:460px;
583 background:radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
584 filter:blur(80px); opacity:0.75; z-index:0;
585}
586.err-hero-inner { position:relative; z-index:1; }
587.err-eyebrow {
588 display:inline-flex; align-items:center; gap:8px; text-transform:uppercase;
589 font-family:var(--font-mono); font-size:11px; letter-spacing:0.18em;
590 color:var(--text-muted); font-weight:600; margin-bottom:18px;
591}
592.err-eyebrow-dot {
593 width:8px; height:8px; border-radius:9999px;
594 background:linear-gradient(135deg,#8c6dff,#36c5d6);
595 box-shadow:0 0 0 3px rgba(140,109,255,0.18);
596}
597.err-code {
598 font-family:var(--font-display); font-size:clamp(56px,8vw,96px); line-height:0.95;
599 font-weight:800; letter-spacing:-0.04em; margin:0 0 12px;
600 background:linear-gradient(135deg,#a855f7 0%,#06b6d4 100%);
601 -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color:transparent;
602}
603.err-title { font-family:var(--font-display); font-size:clamp(22px,3vw,30px); font-weight:700; letter-spacing:-0.022em; line-height:1.2; margin:0 0 12px; color:var(--text-strong); }
604.err-body { font-size:16px; line-height:1.6; color:var(--text-muted); max-width:560px; margin:0 0 16px; }
605.err-actions { display:flex; gap:10px; flex-wrap:wrap; margin:22px 0 28px; }
606.err-btn { display:inline-flex; align-items:center; justify-content:center; padding:11px 20px; border-radius:10px; font-size:14px; font-weight:600; text-decoration:none; border:1px solid transparent; line-height:1; transition:transform 120ms ease, box-shadow 120ms ease, background 120ms ease; }
607.err-btn-primary { background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%); color:#fff; box-shadow:0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16); }
608.err-btn-primary:hover { transform:translateY(-1px); }
609.err-btn-ghost { background:transparent; color:var(--text); border-color:var(--border-strong); }
610.err-btn-ghost:hover { background:rgba(140,109,255,0.06); border-color:rgba(140,109,255,0.45); }
611.err-suggestions { list-style:none; padding:0; margin:0 0 24px; border-top:1px solid var(--border); }
612.err-suggestions a { display:flex; align-items:baseline; gap:10px; padding:12px 4px; border-bottom:1px solid var(--border); color:var(--text); text-decoration:none; transition:padding-left 120ms ease; }
613.err-suggestions a:hover { padding-left:8px; color:var(--text-strong); }
614.err-suggestion-label { font-size:14.5px; font-weight:600; color:var(--text-strong); }
615.err-suggestion-hint { flex:1; font-size:12.5px; color:var(--text-muted); }
616.err-suggestion-arrow { font-size:14px; color:var(--accent); }
617.err-meta-block { display:flex; flex-direction:column; gap:8px; margin-top:4px; }
618.err-meta { display:inline-flex; align-items:center; gap:10px; font-size:12.5px; color:var(--text-muted); flex-wrap:wrap; }
619.err-meta-label { text-transform:uppercase; letter-spacing:0.14em; font-size:10.5px; font-weight:700; color:var(--text-muted); }
620.err-meta-value, .meta-mono { font-family:var(--font-mono); background:rgba(255,255,255,0.04); border:1px solid var(--border); border-radius:6px; padding:3px 8px; font-size:12px; color:var(--text); }
621@media (max-width:540px) {
622 .err-page { margin:24px auto 56px; }
623 .err-hero { padding:24px 20px 28px; border-radius:16px; }
624}
173625</style>
174626</head>
175627<body>
176<main class="error-page" role="main" aria-labelledby="error-page-title" data-error-code="${escapeHtml(code)}">
177 <div class="error-page-code" aria-hidden="true">${escapeHtml(code)}</div>
178 <div class="error-page-eyebrow">${escapeHtml(eyebrow)}</div>
179 <h1 id="error-page-title" class="error-page-title">${escapeHtml(title)}</h1>
180 <p class="error-page-body">${escapeHtml(body)}</p>
181 <div class="error-page-actions">
182 <a href="/" class="btn btn-primary btn-lg" data-error-cta="primary">Go home</a>
183 <a href="/status" class="btn btn-lg" data-error-cta="secondary">Status page</a>
628<main class="err-page" role="main" aria-labelledby="error-page-title" data-error-code="${escapeHtml(code)}">
629 <div class="err-hero">
630 <div class="err-orb" aria-hidden="true"></div>
631 <div class="err-hero-inner">
632 <div class="err-eyebrow"><span class="err-eyebrow-dot" aria-hidden="true"></span>${escapeHtml(eyebrow)}</div>
633 <div class="err-code" aria-hidden="true">${escapeHtml(code)}</div>
634 <h1 id="error-page-title" class="err-title">${escapeHtml(title)}</h1>
635 <p class="err-body">${escapeHtml(body)}</p>
636 <div class="err-actions">
637 <a href="/" class="err-btn err-btn-primary" data-error-cta="primary">Go home</a>
638 <a href="/status" class="err-btn err-btn-ghost" data-error-cta="secondary">Status page</a>
639 </div>
640 <ul class="err-suggestions" aria-label="Try one of these">${suggestionsHtml}</ul>
641 ${requestIdHtml}
642 </div>
184643 </div>
185 <ul class="error-page-suggestions" aria-label="Try one of these">${suggestionsHtml}</ul>
186 ${requestIdHtml}
187644</main>
188645</body>
189646</html>`;
190647