Commitcf21786
feat(auth): wire signin-v2 to GET /login
feat(auth): wire signin-v2 to GET /login - Import SignInV2 into auth.tsx; GET /login now renders the new split-screen Quiet Intelligence design instead of the old inline form - signin-v2.tsx: passkey <a> converted to <button id="pk-signin-btn"> with data-redirect attr; WebAuthn assertion JS embedded in the view's own script block (same logic as the old route handler, now co-located) - Added .si-passkey-status text style for the passkey status paragraph - POST /login (username+password) untouched — still works for API clients - Updated GET /login test assertion to match the new design's content (GitHub OAuth button + magic-link button instead of username/password inputs) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 files changed+117−92cf21786d0e21be258d8f4b4f6917cc706a36c5ac
3 changed files+117−92
Modifiedsrc/__tests__/web-routes.test.ts+2−2View fileUnifiedSplit
@@ -66,8 +66,8 @@ describe("web routes", () => {
6666 expect(res.status).toBe(200);
6767 const html = await res.text();
6868 expect(html).toContain("Sign in");
69 expect(html).toContain('name="username"');
70 expect(html).toContain('name="password"');
69 expect(html).toContain("Continue with GitHub");
70 expect(html).toContain("Email me a link");
7171 });
7272
7373 it("GET /register returns registration form", async () => {
Modifiedsrc/routes/auth.tsx+5−59View fileUnifiedSplit
@@ -38,6 +38,7 @@ import {
3838 getGoogleOauthConfig,
3939} from "../lib/sso";
4040import { Layout } from "../views/layout";
41import { SignInV2 } from "../views/signin-v2";
4142import {
4243 Form,
4344 FormGroup,
@@ -360,57 +361,7 @@ auth.get("/login", softAuth, async (c) => {
360361 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
361362 const csrf = c.get("csrfToken") as string | undefined;
362363 return c.html(
363 <Layout title="Sign in" user={null}>
364 <AuthMobileStyle />
365 <div class="auth-container">
366 <h2>Welcome back</h2>
367 <p class="auth-subtitle">
368 Sign in to your gluecron account.
369 </p>
370 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
371 {success && (
372 <div class="auth-success">{decodeURIComponent(success)}</div>
373 )}
374 <Form
375 method="post"
376 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
377 csrfToken={csrf}
378 >
379 <FormGroup label="Username or email" htmlFor="username">
380 <Input
381 type="text"
382 name="username"
383 required
384 placeholder="username or email"
385 autocomplete="username"
386 aria-label="Username or email"
387 />
388 </FormGroup>
389 <FormGroup label="Password" htmlFor="password">
390 <Input
391 type="password"
392 name="password"
393 required
394 placeholder="Password"
395 autocomplete="current-password"
396 aria-label="Password"
397 />
398 </FormGroup>
399 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
400 <a href="/forgot-password">Forgot password?</a>
401 {/* BLOCK Q2 — magic-link sign-in. */}
402 <span style="margin:0 6px;color:var(--text-muted)">·</span>
403 <a href="/login/magic">Sign in with a magic link instead</a>
404 </div>
405 <Button type="submit" variant="primary">
406 Sign in
407 </Button>
408 {/* SSO domain hint — shown dynamically when the typed email domain
409 matches a known org SSO config. JS fetches /api/sso/domain-hint. */}
410 <div id="sso-domain-hint" style="display:none;margin-top:10px;padding:10px 12px;background:rgba(91,110,232,0.10);border:1px solid rgba(91,110,232,0.30);border-radius:8px;font-size:13px;color:var(--text)" aria-live="polite">
411 Your organization uses SSO. You'll be redirected to sign in.
412 </div>
413 </Form>
364 <SignInV2
414365 <script dangerouslySetInnerHTML={{__html: /* js */`
415366 (function(){
416367 var inp = document.querySelector('input[name="username"]');
@@ -586,14 +537,9 @@ auth.get("/login", softAuth, async (c) => {
586537 window.location.href = redirect;
587538 } catch (e) {
588539 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
589 }
590 });
591 })();
592 `,
593 }}
594 />
595 </div>
596 </Layout>
540 redirect={redirect}
541 error={error ? decodeURIComponent(error) : ""}
542 />
597543 );
598544});
599545
Modifiedsrc/views/signin-v2.tsx+110−31View fileUnifiedSplit
@@ -3,10 +3,11 @@ import type { FC } from "hono/jsx";
33export interface SignInV2Props {
44 redirect?: string;
55 error?: string;
6 csrfToken?: string;
67}
78
89export const SignInV2: FC<SignInV2Props> = (props) => {
9 const { redirect = "", error = "" } = props;
10 const { redirect = "", error = "", csrfToken } = props;
1011
1112 return (
1213 <>
@@ -48,10 +49,16 @@ export const SignInV2: FC<SignInV2Props> = (props) => {
4849 Continue with Google
4950 </a>
5051
51 <a href="/passkey" class="si-btn si-btn-secondary">
52 <button
53 id="pk-signin-btn"
54 type="button"
55 class="si-btn si-btn-secondary"
56 data-redirect={redirect || "/"}
57 >
5258 <span class="si-passkey-icon" aria-hidden="true">⌘</span>
5359 Continue with a passkey
54 </a>
60 </button>
61 <p id="pk-signin-status" class="si-passkey-status" aria-live="polite"></p>
5562 </div>
5663
5764 <div class="si-divider">
@@ -122,40 +129,104 @@ export default SignInV2;
122129
123130const js = `
124131(function () {
132 /* ── Magic-link button ── */
125133 var emailInput = document.getElementById('si-email-input');
126134 var magicBtn = document.getElementById('si-magic-btn');
127 if (!emailInput || !magicBtn) return;
128
129 var sent = false;
130
131 function updateBtn() {
132 if (sent) {
133 magicBtn.textContent = '\\u2713 Link sent';
134 magicBtn.classList.add('si-magic-btn--sent');
135 } else {
136 magicBtn.textContent = 'Email me a link';
137 magicBtn.classList.remove('si-magic-btn--sent');
135 if (emailInput && magicBtn) {
136 var sent = false;
137 function updateBtn() {
138 if (sent) {
139 magicBtn.textContent = '\\u2713 Link sent';
140 magicBtn.classList.add('si-magic-btn--sent');
141 } else {
142 magicBtn.textContent = 'Email me a link';
143 magicBtn.classList.remove('si-magic-btn--sent');
144 }
138145 }
146 emailInput.addEventListener('input', function () {
147 if (sent) { sent = false; updateBtn(); }
148 });
149 magicBtn.addEventListener('click', function () {
150 var val = emailInput.value;
151 if (val && val.indexOf('@') !== -1) {
152 sent = true;
153 updateBtn();
154 } else {
155 emailInput.focus();
156 emailInput.classList.add('si-email-input--shake');
157 setTimeout(function () { emailInput.classList.remove('si-email-input--shake'); }, 400);
158 }
159 });
139160 }
140161
141 emailInput.addEventListener('input', function () {
142 if (sent) {
143 sent = false;
144 updateBtn();
145 }
146 });
162 /* ── Passkey / WebAuthn ── */
163 var pkBtn = document.getElementById('pk-signin-btn');
164 var pkStatus = document.getElementById('pk-signin-status');
165 if (!pkBtn || !pkStatus) return;
166
167 function b64uToBuf(s) {
168 s = s.replace(/-/g,'+').replace(/_/g,'/');
169 while (s.length % 4) s += '=';
170 var bin = atob(s);
171 var buf = new Uint8Array(bin.length);
172 for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
173 return buf.buffer;
174 }
175 function bufToB64u(buf) {
176 var bytes = new Uint8Array(buf), bin = '';
177 for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
178 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
179 }
147180
148 magicBtn.addEventListener('click', function () {
149 var val = emailInput.value;
150 if (val && val.indexOf('@') !== -1) {
151 sent = true;
152 updateBtn();
153 } else {
154 emailInput.focus();
155 emailInput.classList.add('si-email-input--shake');
156 setTimeout(function () {
157 emailInput.classList.remove('si-email-input--shake');
158 }, 400);
181 pkBtn.addEventListener('click', async function () {
182 if (!window.PublicKeyCredential) {
183 pkStatus.textContent = 'Passkeys are not supported in this browser.';
184 return;
185 }
186 var redirect = pkBtn.dataset.redirect || '/';
187 pkStatus.textContent = 'Preparing\\u2026';
188 try {
189 var optsRes = await fetch('/api/passkeys/auth/options', {
190 method: 'POST',
191 headers: { 'content-type': 'application/json' },
192 body: JSON.stringify({})
193 });
194 if (!optsRes.ok) throw new Error('options request failed');
195 var data = await optsRes.json();
196 var options = data.options, sessionKey = data.sessionKey;
197 options.challenge = b64uToBuf(options.challenge);
198 if (options.allowCredentials) {
199 options.allowCredentials = options.allowCredentials.map(function (c) {
200 return Object.assign({}, c, { id: b64uToBuf(c.id) });
201 });
202 }
203 pkStatus.textContent = 'Touch your authenticator\\u2026';
204 var cred = await navigator.credentials.get({ publicKey: options });
205 var resp = {
206 id: cred.id,
207 rawId: bufToB64u(cred.rawId),
208 type: cred.type,
209 response: {
210 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
211 authenticatorData: bufToB64u(cred.response.authenticatorData),
212 signature: bufToB64u(cred.response.signature),
213 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
214 },
215 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
216 };
217 var verifyRes = await fetch('/api/passkeys/auth/verify', {
218 method: 'POST',
219 headers: { 'content-type': 'application/json' },
220 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
221 });
222 if (!verifyRes.ok) {
223 var j = await verifyRes.json().catch(function () { return {}; });
224 throw new Error(j.error || 'verify failed');
225 }
226 pkStatus.textContent = 'Signed in. Redirecting\\u2026';
227 window.location.href = redirect;
228 } catch (e) {
229 pkStatus.textContent = 'Error: ' + (e && e.message ? e.message : String(e));
159230 }
160231 });
161232})();
@@ -297,6 +368,14 @@ const css = `
297368 line-height: 1;
298369}
299370
371.si-passkey-status {
372 font-size: 12px;
373 color: #6b7080;
374 margin: 4px 0 0;
375 min-height: 16px;
376 text-align: center;
377}
378
300379/* ── OR divider ── */
301380
302381.si-divider {
303382