Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
password-reset.tsx13.3 KB · 386 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
/**
 * Block P1 — Password reset routes.
 *
 * GET  /forgot-password         → email-entry form
 * POST /forgot-password         → always redirects to ?sent=1
 * GET  /reset-password?token=…  → new-password form (or invalid-link page)
 * POST /reset-password          → rotate password + redirect to /login
 *
 * 2026 polish: each page renders inside the shared `.auth-container`
 * gateway with a display headline, supporting subtitle, "what happens
 * next" copy, visible validation rules, and a loading-state submit
 * button so the surface feels of-a-piece with the polished /login and
 * /register pages. All form actions, POST handlers, redirects and
 * validation semantics are preserved verbatim — only chrome changed.
 */

import { Hono } from "hono";
import { Layout } from "../views/layout";
import { Form, FormGroup, Input, Alert, Text } from "../views/ui";
import { softAuth } from "../middleware/auth";
import type { AuthEnv } from "../middleware/auth";
import {
  createPasswordResetRequest,
  consumeResetToken,
  inspectResetToken,
} from "../lib/password-reset";

const passwordReset = new Hono<AuthEnv>();

// ---------------------------------------------------------------------------
// Shared CSS — scoped to `.auth-extra-*` so it can never collide with the
// locked .auth-container rules in layout.tsx (which we do NOT touch).
// The styles are emitted inline per-page; duplication is fine because the
// browser's CSSOM dedupes identical rules and these pages are rarely
// rendered back-to-back in the same session.
// ---------------------------------------------------------------------------
function ExtraStyles() {
  return (
    <style
      dangerouslySetInnerHTML={{
        __html: `
        .auth-extra-headline {
          font-family: var(--font-display);
          font-weight: 700;
          font-size: clamp(28px, 4.6vw, 40px);
          line-height: 1.08;
          letter-spacing: -0.028em;
          color: var(--text-strong);
          margin: 0 0 10px;
        }
        .auth-extra-sub {
          color: var(--text-muted);
          font-size: 14.5px;
          line-height: 1.55;
          margin: 0 0 22px;
        }
        .auth-extra-next {
          margin-top: 14px;
          padding: 12px 14px;
          background: var(--bg-tertiary, var(--bg-secondary));
          border: 1px solid var(--border);
          border-radius: var(--r-sm, 6px);
          color: var(--text-muted);
          font-size: 13px;
          line-height: 1.55;
        }
        .auth-extra-next strong { color: var(--text); font-weight: 600; }
        .auth-extra-rules {
          margin: 4px 0 14px;
          padding: 0 0 0 16px;
          color: var(--text-muted);
          font-size: 12.5px;
          line-height: 1.6;
        }
        .auth-extra-rules li { margin: 0; }
        .auth-extra-submit {
          width: 100%;
          padding: 12px 16px;
          font-size: 15px;
          font-weight: 600;
          margin-top: 4px;
        }
        /* Loading statedriven by inline script that toggles aria-busy
           + a data attribute on submit. Spinner is a CSS-only pseudo-
           element so we don't ship JS for the visual. */
        .auth-extra-submit[aria-busy="true"] {
          opacity: 0.78;
          cursor: progress;
          pointer-events: none;
        }
        .auth-extra-submit[aria-busy="true"]::after {
          content: '';
          display: inline-block;
          width: 12px;
          height: 12px;
          margin-left: 8px;
          vertical-align: -2px;
          border: 2px solid currentColor;
          border-right-color: transparent;
          border-radius: 50%;
          animation: auth-extra-spin 0.7s linear infinite;
        }
        @keyframes auth-extra-spin {
          to { transform: rotate(360deg); }
        }
        .auth-extra-meta {
          display: flex;
          justify-content: center;
          gap: 8px;
          color: var(--text-muted);
          font-size: 13px;
          margin-top: 18px;
        }
        .auth-extra-meta a { color: var(--text); }
        .auth-extra-divider-dot {
          color: var(--text-faint);
        }
        `,
      }}
    />
  );
}

/** Inline script — flips `aria-busy=true` on the form's submit button as
 *  soon as the form starts submitting so users get unambiguous feedback
 *  on slower connections. Plain DOM, no framework. Falls back to the
 *  browser's default behaviour if anything throws. */
function SubmitBusyScript() {
  return (
    <script
      dangerouslySetInnerHTML={{
        __html: /* js */ `
        (function () {
          try {
            var forms = document.querySelectorAll('form[data-auth-extra]');
            forms.forEach(function (f) {
              f.addEventListener('submit', function () {
                var btn = f.querySelector('.auth-extra-submit');
                if (btn) {
                  btn.setAttribute('aria-busy', 'true');
                  // Don't actually disabledisabled buttons get
                  // skipped on form submit by some browsers when the
                  // listener fires post-validation.
                  btn.dataset.label = btn.textContent || '';
                }
              });
            });
          } catch (e) { /* no-op */ }
        })();
        `,
      }}
    />
  );
}

passwordReset.get("/forgot-password", softAuth, (c) => {
  const csrf = c.get("csrfToken") as string | undefined;
  const sent = c.req.query("sent") === "1";

  if (sent) {
    return c.html(
      <Layout title="Reset link sent" user={c.get("user") ?? null}>
        <div class="auth-container">
          <ExtraStyles />
          <h2 class="auth-extra-headline">Check your inbox</h2>
          <p class="auth-extra-sub">
            We just dispatched a password-reset email — it usually lands within
            a minute.
          </p>
          <Alert variant="success">
            If we have an account for that email, we've sent a reset link.
            Check your inbox (and spam folder).
          </Alert>
          <div class="auth-extra-next">
            <strong>What happens next:</strong> click the button in the email
            within <strong>1 hour</strong> to set a new password. The link
            works only once.
          </div>
          <p class="auth-switch">
            <Text>
              Didn't get it? <a href="/forgot-password">Send another link</a>.
            </Text>
          </p>
          <p class="auth-switch">
            <a href="/login">Back to sign in</a>
          </p>
        </div>
      </Layout>
    );
  }

  return c.html(
    <Layout title="Forgot password" user={c.get("user") ?? null}>
      <div class="auth-container">
        <ExtraStyles />
        <h2 class="auth-extra-headline">Reset your password</h2>
        <p class="auth-extra-sub">
          Enter the email tied to your account and we'll send you a one-time
          link to set a new password. No call to support needed.
        </p>
        <Form
          method="post"
          action="/forgot-password"
          csrfToken={csrf}
          class="auth-extra-form"
        >
          <FormGroup label="Email" htmlFor="email">
            <Input
              type="email"
              name="email"
              required
              placeholder="you@example.com"
              autocomplete="email"
              aria-label="Email"
              autofocus
            />
          </FormGroup>
          <button
            type="submit"
            class="btn btn-primary auth-extra-submit"
            data-loading-label="Sending link…"
          >
            Send reset link
          </button>
        </Form>
        <div class="auth-extra-next" style="margin-top:18px">
          <strong>What happens next:</strong> we'll email a reset link that
          expires in 1 hour. If you don't see it, check spam — or come back
          and request another.
        </div>
        <p class="auth-switch">
          <Text>
            Remembered it? <a href="/login">Sign in</a>
          </Text>
        </p>
        {/* Hidden marker so the busy-script can find this form. */}
        <script
          dangerouslySetInnerHTML={{
            __html: `document.currentScript.previousElementSibling && document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra', '1'); });`,
          }}
        />
        <SubmitBusyScript />
      </div>
    </Layout>
  );
});

passwordReset.post("/forgot-password", async (c) => {
  const body = await c.req.parseBody();
  const email = String(body.email || "").trim();
  const ip =
    c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
    c.req.header("x-real-ip") ||
    undefined;
  await createPasswordResetRequest(email, { requestIp: ip });
  return c.redirect("/forgot-password?sent=1");
});

function InvalidLinkPage(props: { user: any }) {
  return (
    <Layout title="Link no longer valid" user={props.user ?? null}>
      <div class="auth-container">
        <ExtraStyles />
        <h2 class="auth-extra-headline">This link is no longer valid</h2>
        <p class="auth-extra-sub">
          Reset links live for 1 hour and can only be used once. The link you
          followed is expired, already used, or unknown.
        </p>
        <Alert variant="error">
          Reset links expire after 1 hour and can only be used once. This link
          is expired, already used, or unknown.
        </Alert>
        <p class="auth-switch" style="margin-top:16px">
          <a href="/forgot-password">Request a new one</a>
        </p>
        <p class="auth-switch">
          <a href="/login">Back to sign in</a>
        </p>
      </div>
    </Layout>
  );
}

passwordReset.get("/reset-password", softAuth, async (c) => {
  const token = String(c.req.query("token") || "").trim();
  const csrf = c.get("csrfToken") as string | undefined;
  const error = c.req.query("error");

  if (!token) return c.html(<InvalidLinkPage user={c.get("user")} />);
  const check = await inspectResetToken(token);
  if (!check.valid) return c.html(<InvalidLinkPage user={c.get("user")} />);

  return c.html(
    <Layout title="Set a new password" user={c.get("user") ?? null}>
      <div class="auth-container">
        <ExtraStyles />
        <h2 class="auth-extra-headline">Set a new password</h2>
        <p class="auth-extra-sub">
          Pick something fresh — your old sessions on other devices will be
          signed out automatically once you save.
        </p>
        {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
        <Form
          method="post"
          action="/reset-password"
          csrfToken={csrf}
          class="auth-extra-form"
        >
          <input type="hidden" name="token" value={token} />
          <FormGroup label="New password" htmlFor="password">
            <Input
              type="password"
              name="password"
              required
              minLength={8}
              placeholder="Min 8 characters"
              autocomplete="new-password"
              aria-label="New password"
              autofocus
            />
          </FormGroup>
          <ul class="auth-extra-rules" aria-label="Password requirements">
            <li>At least 8 characters</li>
            <li>Mix of letters, numbers, or symbols recommended</li>
            <li>Avoid passwords you use elsewhere</li>
          </ul>
          <FormGroup label="Confirm new password" htmlFor="confirm">
            <Input
              type="password"
              name="confirm"
              required
              minLength={8}
              placeholder="Re-enter the new password"
              autocomplete="new-password"
              aria-label="Confirm new password"
            />
          </FormGroup>
          <button
            type="submit"
            class="btn btn-primary auth-extra-submit"
            data-loading-label="Updating…"
          >
            Update password
          </button>
        </Form>
        <div class="auth-extra-next" style="margin-top:18px">
          <strong>What happens next:</strong> we'll sign you out everywhere
          else and bounce you to the sign-in page with your new password.
        </div>
        <p class="auth-switch">
          <a href="/login">Cancel</a>
        </p>
        <script
          dangerouslySetInnerHTML={{
            __html: `document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra', '1'); });`,
          }}
        />
        <SubmitBusyScript />
      </div>
    </Layout>
  );
});

passwordReset.post("/reset-password", async (c) => {
  const body = await c.req.parseBody();
  const token = String(body.token || "").trim();
  const password = String(body.password || "");
  const confirm = String(body.confirm || "");

  const back = (msg: string) =>
    c.redirect(`/reset-password?token=${encodeURIComponent(token)}&error=${encodeURIComponent(msg)}`);

  if (!token) return c.html(<InvalidLinkPage user={null} />);
  if (!password || password.length < 8) return back("Password must be at least 8 characters");
  if (password !== confirm) return back("Passwords do not match");

  const result = await consumeResetToken(token, password);
  if (!result.ok) {
    if (result.reason === "weak") return back("Password must be at least 8 characters");
    return c.html(<InvalidLinkPage user={null} />);
  }

  return c.redirect("/login?success=" + encodeURIComponent("Password updated — please sign in"));
});

export default passwordReset;