fix(auth): sign-in screen polish, and a local preview of every state (#355)

Polishes the sign-in screens and adds a way to see all of them locally.

**Screens**
- Code fields are drawn as one box per character (device code shows a
dash between halves). One real input sits under the boxes, so typing,
paste and autofill work normally, and with script off it falls back to
the plain field.
- A wrong answer or a refused character shakes the field and rings it
red.
- Focus is white instead of the brand colour; the button stays dim until
the form can be sent.
- One alert at a time: an error replaces "Code sent to …" instead of
joining it.
- The device confirm screen says "A device" instead of the client's
self-chosen name, with the Deny warning moved under the buttons.
- A device code typed with its dash (`BCDF-3467`) is no longer cut to
`BCDF-346`.

**Preview**
`bun run preview` in `apps/auth` serves all 18 screen states on :1338,
at desktop and phone sizes, reloading on save. No database or mail
needed.

To make that possible the issuer's screens moved out of the route
handlers into `packages/auth/src/ui/issuer.ts`, unchanged.

Tests: `bun test` in `packages/auth` (67 pass).
This commit is contained in:
Wanjohi
2026-10-02 03:13:34 +00:00
committed by GitHub
15 changed files with 860 additions and 226 deletions
+8
View File
@@ -37,6 +37,8 @@ Hosts the OAuth issuer and the sign-in UI:
src/index.ts # The handler: issuer config, stores, success callback
src/server.ts # The same handler behind a listening socket
src/email.ts # Verification code delivery
src/theme.ts # Title, favicon, brand colour and fonts
script/preview.ts # Every screen state, for styling without a database
wrangler.jsonc # Worker configuration, one environment per stage
Dockerfile # The container, built from the repository root
test/
@@ -47,8 +49,14 @@ test/
```sh
bun run dev # under the Workers runtime, on :1337
bun run serve # as a plain process, on $PORT (default 1337)
bun run preview # every sign-in screen state on :1338, reloading on save
```
The preview needs no database or mail. It renders each screen the flow can show — every
alert, every device-grant outcome — with the real renderer and theme, so styling
`packages/auth/src/ui/` is edit, save, look. A screen added to the flow belongs in its `STATES`
list too, or nobody will see it before it ships.
Its only stateful dependency is Postgres — as a `HYPERDRIVE` binding on Workers, or as
`DATABASE_URL` anywhere else — alongside the mail settings. Full list and deployment steps:
[`docs/deploy.md`](../../docs/deploy.md).
+1
View File
@@ -4,6 +4,7 @@
"scripts": {
"dev": "wrangler dev",
"serve": "bun run src/server.ts",
"preview": "bun script/preview-watch.ts",
"deploy": "wrangler deploy"
},
"dependencies": {
+49
View File
@@ -0,0 +1,49 @@
/**
* Runs `preview.ts`, and runs it again whenever the sign-in UI's source changes.
*
* Not `bun --watch`, which watches each imported file. Many editors save by
* writing a new file and renaming it over the old one, and a watch on a file
* does not follow the rename: the first such save reloads, and every one after
* it is silently ignored. A watch on the directory sees every save, however it
* was made.
*/
import { watch } from 'node:fs';
import { join } from 'node:path';
const ROOT = join(import.meta.dir, '..', '..', '..');
const DIRS = [join(ROOT, 'packages/auth/src'), join(ROOT, 'apps/auth/src'), import.meta.dir];
const SERVER = join(import.meta.dir, 'preview.ts');
let child = start();
let timer: ReturnType<typeof setTimeout> | undefined;
function start() {
return Bun.spawn(['bun', SERVER], { stdio: ['inherit', 'inherit', 'inherit'] });
}
async function restart(file: string) {
child.kill();
// The port is free only once the old server has exited.
await child.exited;
console.log(`changed: ${file}`);
child = start();
}
for (const dir of DIRS) {
watch(dir, { recursive: true }, (_event, file) => {
if (!file || !/\.(ts|tsx)$/.test(file)) return;
// One save can be several events: a write, then a rename.
clearTimeout(timer);
timer = setTimeout(() => restart(file), 60);
});
}
// Ctrl-C reaches the whole process group, but a plain `kill` reaches only
// this process, and would leave the server holding the port.
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.on(signal, () => {
child.kill();
process.exit(0);
});
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Every sign-in screen, side by side, reloading as you edit.
*
* bun run preview # from apps/auth, then open http://localhost:1338
*
* No database, no mail, no cookies. Each state is the real screen value — the
* code provider's own `request` called with a made-up state, and the issuer's
* screens from `ui/issuer.ts` — drawn by the real renderer with the real
* theme. What differs from production is only how the page was reached.
*
* `preview-watch.ts` restarts this process when the UI's source changes —
* the renderer, the stylesheet, the copy, the issuer's screens. Each page polls
* for the restart and reloads itself, so saving a file is the whole loop.
*
* A state that is missing here is a state nobody will look at before it
* ships. Adding a screen to the flow means adding it to `STATES`.
*/
import type { CodeProviderState } from '@nestri/auth/provider/code';
import { CodeUI } from '@nestri/auth/ui/code';
import { IssuerScreens } from '@nestri/auth/ui/issuer';
import { MARK_CODE, MARK_GITHUB, MARK_GOOGLE } from '@nestri/auth/ui/mark';
import { HtmlRenderer } from '@nestri/auth/ui/render';
import type { Screen } from '@nestri/auth/ui/screen';
import { THEME_NESTRI } from '../src/theme.js';
const PORT = Number(process.env.PORT ?? 1338);
const BOOT = crypto.randomUUID();
const renderer = HtmlRenderer({ theme: THEME_NESTRI });
// Matches `USER_CODE_LENGTH` in the issuer, which is local to it.
const DEVICE_CODE_LENGTH = 8;
const code = CodeUI({ sendCode: async () => {} });
const req = new Request(`http://localhost:${PORT}/`);
const email = 'someone@example.com';
const sent = {
type: 'code',
code: '123456',
claims: { email },
flow: 'x',
expires: 0
} satisfies CodeProviderState;
interface State {
id: string;
group: string;
label: string;
screen: () => Promise<Screen> | Screen;
}
const STATES: State[] = [
// Email code — what everyone sees.
{
id: 'email',
group: 'Email',
label: 'Enter email',
screen: () => code.request(req, { type: 'start' }, new FormData())
},
{
id: 'email-invalid',
group: 'Email',
label: 'Bad address',
screen: () =>
code.request(req, { type: 'start' }, new FormData(), {
type: 'invalid_claim',
key: 'email',
value: 'nope'
})
},
{
id: 'email-rate-limit',
group: 'Email',
label: 'Rate limited (email)',
screen: () =>
code.request(req, { type: 'start' }, new FormData(), {
type: 'rate_limit'
})
},
{
id: 'code',
group: 'Email',
label: 'Code sent',
screen: () => code.request(req, sent, new FormData())
},
{
id: 'code-resent',
group: 'Email',
label: 'Code resent',
screen: () => code.request(req, { ...sent, resend: true }, new FormData())
},
{
id: 'code-invalid',
group: 'Email',
label: 'Wrong code',
screen: () => code.request(req, sent, new FormData(), { type: 'invalid_code' })
},
{
id: 'code-rate-limit',
group: 'Email',
label: 'Rate limited (code)',
screen: () => code.request(req, sent, new FormData(), { type: 'rate_limit' })
},
// Device grant — a TV or a CLI signing in through a browser.
{
id: 'device-enter',
group: 'Device',
label: 'Enter device code',
screen: () => IssuerScreens.deviceEnter(DEVICE_CODE_LENGTH)
},
{
id: 'device-confirm',
group: 'Device',
label: 'Is this you?',
screen: () =>
IssuerScreens.deviceConfirm({
userCode: 'BCDF3467',
csrf: 'x'
})
},
{
id: 'device-approved',
group: 'Device',
label: 'Approved',
screen: () => IssuerScreens.deviceApproved()
},
{
id: 'device-denied',
group: 'Device',
label: 'Denied',
screen: () => IssuerScreens.deviceDenied()
},
{
id: 'device-invalid',
group: 'Device',
label: 'Code not valid',
screen: () => IssuerScreens.deviceInvalidCode()
},
{
id: 'device-too-many',
group: 'Device',
label: 'Too many tries',
screen: () => IssuerScreens.deviceTooManyTries()
},
{
id: 'device-expired',
group: 'Device',
label: 'Expired',
screen: () => IssuerScreens.deviceExpired()
},
{
id: 'device-answered',
group: 'Device',
label: 'Already answered',
screen: () => IssuerScreens.deviceAlreadyAnswered()
},
{
id: 'device-forged',
group: 'Device',
label: 'Forged form',
screen: () => IssuerScreens.deviceForgedForm()
},
// Reached only by accident in production, which is why they need looking at.
{
id: 'unknown-state',
group: 'Errors',
label: 'Sign-in expired',
screen: () => IssuerScreens.unknownState('The browser was in an unknown state.')
},
{
// Not shown today: with one provider the chooser redirects straight
// to it. Kept so a second provider does not arrive to an unstyled page.
id: 'choose',
group: 'Errors',
label: 'Provider chooser (unused)',
screen: () => ({
kind: 'choose',
options: [
{ href: '#', label: 'Continue with Email', mark: MARK_CODE },
{ href: '#', label: 'Continue with GitHub', mark: MARK_GITHUB },
{ href: '#', label: 'Continue with Google', mark: MARK_GOOGLE }
]
})
}
];
/** Polls for a restart of this process and reloads when it sees one. */
const RELOAD = `<script>(()=>{const boot=${JSON.stringify(BOOT)};setInterval(async()=>{try{const r=await fetch('/__boot',{cache:'no-store'});if((await r.text())!==boot)location.reload()}catch{}},400)})()</script>`;
async function page(state: State): Promise<Response> {
const res = renderer.render(await state.screen(), req);
const html = (await res.text()).replace('</body>', `${RELOAD}</body>`);
// Always 200, so a 400 screen is not mistaken for a broken preview.
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
function gallery(): Response {
const groups = [...new Set(STATES.map((s) => s.group))];
const tiles = groups
.map(
(group) =>
`<h2>${group}</h2><div class="grid">${STATES.filter((s) => s.group === group)
.map(
(s) =>
`<figure><figcaption><a href="/s/${s.id}" target="_blank">${s.label}</a><code>${s.id}</code></figcaption><div class="vp"><iframe src="/s/${s.id}" loading="lazy" title="${s.label}"></iframe></div></figure>`
)
.join('')}</div>`
)
.join('');
return new Response(
`<!doctype html><html><head><meta charset="utf-8"><title>Auth preview</title>
<style>
:root{color-scheme:dark;--w:1440;--h:900;--scale:.3}
body{margin:0;padding:16px 24px 48px;background:#111;color:#ddd;font:13px/1.4 system-ui,sans-serif}
header{position:sticky;top:0;z-index:1;display:flex;gap:8px;align-items:center;padding:8px 0;background:#111}
header b{margin-right:12px}
button{background:#222;color:#ddd;border:1px solid #333;border-radius:6px;padding:4px 10px;cursor:pointer}
button[aria-pressed=true]{background:#ddd;color:#111}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin:24px 0 8px}
.grid{display:flex;flex-wrap:wrap;gap:16px}
figure{margin:0}
figcaption{display:flex;gap:8px;align-items:baseline;margin-bottom:4px}
figcaption a{color:#ddd}
figcaption code{color:#666}
.vp{width:calc(var(--w)*var(--scale)*1px);height:calc(var(--h)*var(--scale)*1px);overflow:hidden;border:1px solid #2a2a2a;border-radius:4px}
iframe{width:calc(var(--w)*1px);height:calc(var(--h)*1px);border:0;transform:scale(var(--scale));transform-origin:0 0}
</style></head><body>
<header><b>Auth preview</b>
<button data-w="1440" data-h="900" data-s=".3">Desktop</button>
<button data-w="1440" data-h="900" data-s=".55">Desktop large</button>
<button data-w="390" data-h="844" data-s=".6">Phone</button>
<span style="color:#666;margin-left:auto">saves reload every frame · click a title to open full size</span></header>
${tiles}
<script>
const root=document.documentElement,buttons=[...document.querySelectorAll('header button')];
function pick(b){root.style.setProperty('--w',b.dataset.w);root.style.setProperty('--h',b.dataset.h);root.style.setProperty('--scale',b.dataset.s);buttons.forEach(x=>x.setAttribute('aria-pressed',x===b));try{localStorage.vp=buttons.indexOf(b)}catch{}}
buttons.forEach(b=>b.onclick=()=>pick(b));
let i=0;try{i=+localStorage.vp||0}catch{}pick(buttons[i]||buttons[0]);
</script>${RELOAD}</body></html>`,
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
Bun.serve({
port: PORT,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/__boot') return new Response(BOOT);
if (url.pathname === '/') return gallery();
const state = STATES.find((s) => url.pathname === `/s/${s.id}`);
if (state) return page(state);
// A form submitted inside a preview lands here; send it back.
return Response.redirect(request.headers.get('referer') ?? '/', 303);
}
});
console.log(`auth preview: http://localhost:${PORT} (${STATES.length} states)`);
+1 -27
View File
@@ -2,7 +2,6 @@ import type { Hyperdrive } from '@cloudflare/workers-types';
import { issuer } from '@nestri/auth/index';
import { CodeProvider } from '@nestri/auth/provider/code';
import { CodeUI } from '@nestri/auth/ui/code';
import type { Theme } from '@nestri/auth/ui/theme';
import { isDomainMatch } from '@nestri/auth/util';
import { Actor } from '@nestri/core/actor';
import { PostgresCodeStore } from '@nestri/core/auth/authorization-code';
@@ -17,6 +16,7 @@ import { Identity } from '@nestri/core/user/identity';
import { LinkedAccount } from '@nestri/core/user/linked-account';
import { sendVerificationCode } from './email.js';
import { THEME_NESTRI } from './theme.js';
/**
* Everything this issuer is handed, from a binding or from the environment.
@@ -170,32 +170,6 @@ export const allowClient = async (
return isDomainMatch(redirect, host);
};
/**
* The sign-in screen's theme.
*
* Almost everything that used to live here is now stated in
* `packages/auth/src/ui/css.ts`, which states the product's design language
* longhand. What is left here is the handful of values the
* issuer itself needs — and `primary`, which is the one colour the stylesheet
* reads back from the theme so the brand has a single source.
*
* There is no `background` and no light variant on purpose: the page is dark
* only, and the derived-colour scheme that made two schemes possible is
* exactly what rendered the sign-in field's text the colour of its own
* background.
*/
const THEME_NESTRI: Theme = {
title: 'Login | Nestri',
primary: 'hsl(12 84% 53%)',
favicon: 'https://nestri.io/images/favicon.ico',
// Mona Sans for the display line and the action, Geist for everything a
// person reads or types. Served from the Fontsource CDN because the
// self-hosted font packages need a bundler and nothing preprocesses this
// page — it is assembled as a string at request time. The family names must
// match the ones the stylesheet asks for.
css: `@import url('https://cdn.jsdelivr.net/fontsource/css/mona-sans:vf@latest/wght.css');@import url('https://cdn.jsdelivr.net/fontsource/css/geist:vf@latest/wght.css');`
};
export default {
async fetch(request: Request, env: Env, ctx?: ExecutionContext) {
Env.init(env as unknown as Record<string, unknown>);
+27
View File
@@ -0,0 +1,27 @@
import type { Theme } from '@nestri/auth/ui/theme';
/**
* The sign-in screen's theme.
*
* Almost everything that used to live here is now stated in
* `packages/auth/src/ui/css.ts`, which states the product's design language
* longhand. What is left here is the handful of values the
* issuer itself needs — and `primary`, which is the one colour the stylesheet
* reads back from the theme so the brand has a single source.
*
* There is no `background` and no light variant on purpose: the page is dark
* only, and the derived-colour scheme that made two schemes possible is
* exactly what rendered the sign-in field's text the colour of its own
* background.
*/
export const THEME_NESTRI: Theme = {
title: 'Login | Nestri',
primary: 'hsl(12 84% 53%)',
favicon: 'https://nestri.io/images/favicon.ico',
// Mona Sans for the display line and the action, Geist for everything a
// person reads or types. Served from the Fontsource CDN because the
// self-hosted font packages need a bundler and nothing preprocesses this
// page — it is assembled as a string at request time. The family names must
// match the ones the stylesheet asks for.
css: `@import url('https://cdn.jsdelivr.net/fontsource/css/mona-sans:vf@latest/wght.css');@import url('https://cdn.jsdelivr.net/fontsource/css/geist:vf@latest/wght.css');`
};
+11 -88
View File
@@ -231,6 +231,7 @@ import { DynamoStorage } from './storage/dynamo.js';
import { MemoryStorage } from './storage/memory.js';
import { Storage, StorageAdapter } from './storage/storage.js';
import { HtmlRenderer, type Renderer } from './ui/render.js';
import { IssuerScreens } from './ui/issuer.js';
import type { ChooseOption, Screen } from './ui/screen.js';
import type { Theme } from './ui/theme.js';
import { getRelativeUrl, isDomainMatch, lazy } from './util.js';
@@ -578,16 +579,7 @@ export function issuer<
const error =
input.error ??
function (err: UnknownStateError, req: Request) {
return renderer.render(
{
kind: 'message',
tone: 'danger',
heading: 'That sign-in has expired',
body: [err.message, 'Start again from wherever you were signing in.'],
status: 400
},
req
);
return renderer.render(IssuerScreens.unknownState(err.message), req);
};
const ttlAccess = input.ttl?.access ?? 60 * 60 * 24 * 30;
const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365;
@@ -938,31 +930,11 @@ export function issuer<
* three.
*/
function expired(): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'That sign-in request has expired',
body: ['Start it again from the app.'],
status: 400
};
return IssuerScreens.deviceExpired();
}
function deviceConfirmScreen(confirmation: DeviceConfirmation): Screen {
return {
kind: 'confirm',
heading: 'Is this you?',
verify: { code: confirmation.userCode, group: 4 },
body: [
`${confirmation.clientID} is asking to sign in to your account. The code above should match the one it is showing you.`,
'If it does not, or you did not start this on a device of your own, choose Deny. Nobody can sign in as you unless you approve here.'
],
action: '/device/confirm',
// The client id is escaped by the renderer like any other text. It
// is chosen by whoever started the grant, so it is never markup.
fields: [{ kind: 'hidden', name: 'csrf', value: confirmation.csrf }],
approve: { label: 'Approve', name: 'action', value: 'approve' },
deny: { label: 'Deny', name: 'action', value: 'deny' }
};
return IssuerScreens.deviceConfirm(confirmation);
}
async function getAuthorization(ctx: Context) {
@@ -1523,32 +1495,11 @@ export function issuer<
app.get('/device', async (c) => {
const raw = c.req.query('user_code');
if (!raw) {
return auth.screen(c, {
kind: 'form',
method: 'get',
action: '/device',
fields: [
{
kind: 'segments',
name: 'user_code',
label: 'Enter the code shown in the app',
length: USER_CODE_LENGTH,
autocomplete: 'off',
autofocus: true
}
],
submit: 'Continue'
});
return auth.screen(c, IssuerScreens.deviceEnter(USER_CODE_LENGTH));
}
if (!(await guessesLeft(c.req.raw))) {
return auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'Too many tries',
body: ['Wait a while, then start again from the app.'],
status: 429
});
return auth.screen(c, IssuerScreens.deviceTooManyTries());
}
const found = await deviceStore.byUserCode(canonicalUserCode(raw));
@@ -1557,13 +1508,7 @@ export function issuer<
// nothing, so a person mistyping once and then succeeding is not
// walking towards a lockout.
await chargeGuess(c.req.raw);
return auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'That code is not valid',
body: ['It may have expired, or already been used. Ask the app for a new one.'],
status: 400
});
return auth.screen(c, IssuerScreens.deviceInvalidCode());
}
const authorization: AuthorizationState = {
@@ -1602,43 +1547,21 @@ export function issuer<
const form = await c.req.formData().catch(() => null);
const csrf = form?.get('csrf')?.toString() ?? '';
if (!timingSafeCompare(confirmation.csrf, csrf)) {
return auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'That form was not the one we sent',
body: ['Start again from the app.'],
status: 400
});
return auth.screen(c, IssuerScreens.deviceForgedForm());
}
if (form?.get('action')?.toString() === 'deny') {
await deviceStore.deny(confirmation.deviceCode);
return auth.screen(c, {
kind: 'message',
tone: 'notice',
heading: 'Refused',
body: ['That sign-in request was refused. You can close this page.']
});
return auth.screen(c, IssuerScreens.deviceDenied());
}
// The store decides, not this code. If a refusal got here first the
// answer is already given and an approval must not overwrite it.
const approved = await deviceStore.approve(confirmation.deviceCode, confirmation.subject);
if (!approved) {
return auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'Already answered',
body: ['That sign-in request has already been answered.'],
status: 400
});
return auth.screen(c, IssuerScreens.deviceAlreadyAnswered());
}
return auth.screen(c, {
kind: 'message',
tone: 'notice',
heading: 'You are signed in',
body: ['You can close this page and go back to the app.']
});
return auth.screen(c, IssuerScreens.deviceApproved());
});
app.get('/authorize', async (c) => {
+1 -14
View File
@@ -79,20 +79,7 @@ export function Layout(
<div data-component="logo">
<LogoWord />
</div>
{props.headline ?? (
<h2 data-component="title">
One place for all the ways you play.{' '}
<strong>
Gather Around
<a
href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
rel="noopener noreferrer"
target="_blank">
.
</a>
</strong>
</h2>
)}
{props.headline}
<div data-component="actions">{props.children}</div>
</div>
</div>
+8 -6
View File
@@ -156,12 +156,14 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
if (error?.type === 'invalid_code')
alerts.push({ tone: 'danger', message: copy.code_invalid });
// Said after any error, so a person who mistyped still sees which
// address the code they are looking for actually went to.
alerts.push({
tone: 'success',
message: (state.resend ? copy.code_resent : copy.code_sent) + state.claims[mode]
});
// One banner, the newest thing that happened. "Code sent" under
// "Invalid code" reads as two answers to one question, and the
// address is still on the email itself.
if (alerts.length === 0)
alerts.push({
tone: 'success',
message: (state.resend ? copy.code_resent : copy.code_sent) + state.claims[mode]
});
return {
kind: 'form',
+146 -38
View File
@@ -41,9 +41,7 @@ export default `
--color-muted-foreground2: var(--color-gray-800);
--color-red-600: hsl(358 75% 59%);
--color-red-100: hsl(357 37% 12%);
--color-green-600: hsl(151 55% 42%);
--color-green-100: hsl(154 49% 9%);
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--font-mona: 'Mona Sans Variable', var(--font-sans);
@@ -252,7 +250,7 @@ body {
/* Stated rather than inherited: a form control does not take its parent's
colour, and leaving it to the UA put black glyphs on this field. */
color: var(--color-foreground);
caret-color: var(--color-brand);
caret-color: var(--color-foreground);
outline: none;
transition:
border-color 150ms,
@@ -267,9 +265,18 @@ body {
border-color: var(--color-gray-400);
}
/* Focus is white, never the brand colour: orange around a field reads as an
error, and the wordmark above is the only thing that should be orange. Grey
while the value cannot be submitted yet, white once it can — so the field
says it is done before the button does. */
[data-component='input']:focus {
border-color: var(--color-brand);
box-shadow: 0 0 0 1px var(--color-brand);
border-color: var(--color-gray-600);
box-shadow: 0 0 0 1px var(--color-gray-600);
}
[data-component='input']:focus:valid {
border-color: var(--color-gray-1000);
box-shadow: 0 0 0 1px var(--color-gray-1000);
}
/* Chrome paints its own background over an autofilled field and ignores
@@ -279,7 +286,7 @@ body {
[data-component='input']:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-foreground);
-webkit-box-shadow: 0 0 0 100px var(--color-background-100) inset;
caret-color: var(--color-brand);
caret-color: var(--color-foreground);
}
[data-component='button'] {
@@ -312,7 +319,21 @@ body {
[data-component='button']:focus-visible {
box-shadow:
0 0 0 2px var(--color-background-200),
0 0 0 4px var(--color-brand);
0 0 0 4px var(--color-gray-1000);
}
/* Dimmed until every field in its form would pass, using the browser's own
validity — \`type=email\`, \`required\`, the code's \`pattern\` — so there is
no script and no second definition of valid. Dimmed, not disabled: pressing
it still gets the browser's message saying what is missing. */
form:has([data-component='input']:invalid) [data-component='button'] {
opacity: 0.35;
cursor: not-allowed;
}
form:has([data-component='input']:invalid) [data-component='button']:hover {
background: var(--color-gray-1000);
scale: 1;
}
[data-component='button']:disabled {
@@ -359,46 +380,20 @@ body {
}
[data-component='form-alert'] {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.75rem;
border: 1px solid var(--color-red-600);
background: var(--color-red-100);
padding: 0.75rem 1rem;
margin: -0.25rem 0 0;
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--color-foreground);
font-weight: 500;
color: var(--color-red-600);
text-align: left;
}
[data-component='form-alert'][data-color='success'] {
border-color: var(--color-green-600);
background: var(--color-green-100);
}
[data-component='form-alert'] svg {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
}
[data-component='form-alert'] [data-slot='icon-success'] {
display: none;
color: var(--color-green-600);
}
[data-component='form-alert'] [data-slot='icon-danger'] {
display: block;
color: var(--color-red-600);
}
[data-component='form-alert'][data-color='success'] [data-slot='icon-success'] {
display: block;
}
[data-component='form-alert'][data-color='success'] [data-slot='icon-danger'] {
display: none;
[data-component='form-alert'] + [data-component='form-alert'] {
margin-top: -0.5rem;
}
/* The secondary action: a way in that is not the one being recommended, and
@@ -491,6 +486,110 @@ body {
font-variant-numeric: tabular-nums;
}
/* A code field drawn as one box per character, by \`segments.ts\`. The real
field is stretched invisibly over the boxes and takes every keystroke,
paste and autofill; the boxes only show what it holds. */
[data-component='segments'] {
position: relative;
width: 100%;
}
[data-component='segments'][data-enhanced] > [data-component='input'] {
position: absolute;
inset: 0;
z-index: 1;
height: 100%;
opacity: 0;
cursor: text;
}
[data-component='segments'] [data-slot='boxes'] {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
}
[data-component='input'][data-variant='box'] {
display: flex;
flex: 1 1 0%;
min-width: 0;
/* The height of a one-line field: line, padding and border. A box is a
div, and an empty div has no line to be the height of. */
height: calc(2rem + 1.8rem + 2px);
padding: 0;
align-items: center;
justify-content: center;
font-family: var(--font-mona);
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* The box the next character goes into: the focus ring, grey until the code
is whole and white once it is — the same rule as every other field. */
[data-component='input'][data-variant='box'][data-active] {
border-color: var(--color-gray-600);
box-shadow: 0 0 0 1px var(--color-gray-600);
}
[data-component='segments'][data-complete] [data-variant='box'][data-active] {
border-color: var(--color-gray-1000);
box-shadow: 0 0 0 1px var(--color-gray-1000);
}
/* The real caret is invisible with its field, so the empty box it is in
draws one. */
[data-component='input'][data-variant='box'][data-active]:empty::after {
content: '';
width: 2px;
height: 1.5rem;
background: var(--color-foreground);
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
[data-component='segments'] [data-slot='separator'] {
flex: none;
color: var(--color-muted-foreground2);
font-family: var(--font-mona);
font-size: 1.5rem;
font-weight: 700;
user-select: none;
}
/* A wrong answer shakes the field it came from, once, and rings it red while
it does. Every error is a new page, so an animation that runs on load runs
exactly when one arrives — no script needed to trigger it. \`segments.ts\`
plays the same two when a code field refuses a character. */
@keyframes shake {
0%, 100% { translate: 0; }
15%, 55% { translate: -0.5rem; }
35%, 75% { translate: 0.5rem; }
90% { translate: -0.2rem; }
}
/* No end frame, so it fades back to whatever border the field has now. */
@keyframes alarm {
0%, 60% {
border-color: var(--color-red-600);
box-shadow: 0 0 0 1px var(--color-red-600);
}
}
form:has([data-component='form-alert'][data-color='danger']) [data-component='input'] {
animation:
shake 400ms ease-in-out,
alarm 1s ease-out;
}
[data-component='input'][data-variant='code']::placeholder {
letter-spacing: normal;
font-size: 1rem;
@@ -526,5 +625,14 @@ body {
[data-component='button']:hover {
scale: 1;
}
/* The red stays: it is the message. Only the movement goes. */
form:has([data-component='form-alert'][data-color='danger']) [data-component='input'] {
animation: alarm 1s ease-out;
}
[data-component='input'][data-variant='box'][data-active]:empty::after {
animation: none;
}
}
`;
+150
View File
@@ -0,0 +1,150 @@
/**
* The screens the issuer draws itself, rather than a provider.
*
* They were written inline in the route handlers, which kept each next to the
* condition that reaches it but meant the only way to look at one was to
* arrange that condition — an expired cookie, a refused grant, a code guessed
* wrong ten times. Here each is a value that can be rendered on its own, which
* is what the preview in `apps/auth/script/preview.ts` does with every one.
*
* Copy and status only. How any of them looks is `render.tsx`'s business.
*
* @packageDocumentation
*/
import type { Screen } from './screen.js';
/** The confirmation a device grant asks for, before it is approved. */
export interface DeviceConfirmCopy {
userCode: string;
csrf: string;
}
export const IssuerScreens = {
/** The authorization state a request named is missing or unreadable. */
unknownState(message: string): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'That sign-in has expired',
body: [message, 'Start again from wherever you were signing in.'],
status: 400
};
},
/**
* What a device grant says once there is nothing left to answer.
*
* Written once because three different dead ends reach it — a cookie that
* timed out, a grant that expired, a confirmation that was already given —
* and the person on the other end can do the same one thing about all
* three.
*/
deviceExpired(): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'That sign-in request has expired',
body: ['Start it again from the app.'],
status: 400
};
},
deviceEnter(length: number): Screen {
return {
kind: 'form',
method: 'get',
action: '/device',
fields: [
{
kind: 'segments',
name: 'user_code',
label: 'Enter the code shown in the app',
length,
group: 4,
autocomplete: 'off',
autofocus: true
}
],
submit: 'Continue'
};
},
deviceTooManyTries(): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'Too many tries',
body: ['Wait a while, then start again from the app.'],
status: 429
};
},
deviceInvalidCode(): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'That code is not valid',
body: ['It may have expired, or already been used. Ask the app for a new one.'],
status: 400
};
},
deviceConfirm(confirmation: DeviceConfirmCopy): Screen {
return {
kind: 'confirm',
heading: 'Is this you?',
verify: { code: confirmation.userCode, group: 4 },
// Not the client id. It is chosen by whoever started the grant, so
// naming it would let a stranger's request introduce itself as
// anything it likes — and to the person reading, it is a device.
body: [
'A device is asking to sign in to your account. The code above should match the one it is showing you.'
],
action: '/device/confirm',
fields: [{ kind: 'hidden', name: 'csrf', value: confirmation.csrf }],
approve: { label: 'Approve', name: 'action', value: 'approve' },
deny: { label: 'Deny', name: 'action', value: 'deny' },
footer:
'If it does not, or you did not start this on a device of your own, choose Deny. Nobody can sign in as you unless you approve here.'
};
},
deviceForgedForm(): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'That form was not the one we sent',
body: ['Start again from the app.'],
status: 400
};
},
deviceDenied(): Screen {
return {
kind: 'message',
tone: 'notice',
heading: 'Refused',
body: ['That sign-in request was refused. You can close this page.']
};
},
deviceAlreadyAnswered(): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'Already answered',
body: ['That sign-in request has already been answered.'],
status: 400
};
},
deviceApproved(): Screen {
return {
kind: 'message',
tone: 'notice',
heading: 'You are signed in',
body: ['You can close this page and go back to the app.']
};
}
};
+61 -51
View File
@@ -17,6 +17,7 @@
/** @jsxImportSource hono/jsx */
import { Layout } from './base.js';
import segments from './segments.js';
import type {
Alert,
ChooseScreen,
@@ -52,7 +53,10 @@ export interface HtmlRendererOptions {
theme?: Theme;
}
/** The default renderer: server-rendered HTML, no client-side script. */
/**
* The default renderer: server-rendered HTML that works with script off. The
* one script, `segments.ts`, only redraws a code field as boxes.
*/
export function HtmlRenderer(options?: HtmlRendererOptions): Renderer {
const theme = options?.theme;
@@ -107,12 +111,12 @@ function Form(props: { theme?: Theme; screen: FormScreen }) {
return (
<Layout theme={props.theme}>
<form data-component="form" method={screen.method ?? 'post'} action={screen.action}>
{screen.alerts?.map((alert) => (
<Banner alert={alert} />
))}
{screen.fields.map((field) => (
<Input field={field} />
))}
{screen.alerts?.map((alert) => (
<Note alert={alert} />
))}
<button data-component="button">{screen.submit}</button>
</form>
@@ -179,6 +183,7 @@ function Confirm(props: { theme?: Theme; screen: ConfirmScreen }) {
{screen.deny.label}
</button>
</form>
{screen.footer && <Footer copy={screen.footer} />}
</Layout>
);
}
@@ -233,24 +238,47 @@ function Input(props: { field: Field }) {
}
if (field.kind === 'segments') {
// A grouped code is shown with a separator, `BCDF-3467`, and will be
// typed or pasted the way it was read. Room for the separators, and a
// pattern that allows them, or the box truncates a correct code to
// `BCDF-346` before the server — which ignores them — ever sees it.
const char = field.numeric ? '[0-9]' : '[A-Za-z0-9]';
const groups = field.group && field.group > 0 ? Math.ceil(field.length / field.group) : 1;
const sizes = Array.from({ length: groups }, (_, i) =>
Math.min(field.group ?? field.length, field.length - i * (field.group ?? field.length))
);
// One box per character once `segments.ts` has run; this single field
// until then, and for good if it never does.
return (
<input
data-component="input"
data-variant="code"
type="text"
name={field.name}
aria-label={field.label}
placeholder={field.label}
minLength={field.length}
maxLength={field.length}
size={field.length}
required
spellcheck={false}
autocapitalize="characters"
inputmode={field.numeric ? 'numeric' : 'text'}
autocomplete={field.autocomplete}
autofocus={field.autofocus}
/>
<>
<div
data-component="segments"
data-length={field.length}
data-group={field.group}
data-numeric={field.numeric ? 'true' : 'false'}>
<input
data-component="input"
data-variant="code"
type="text"
name={field.name}
aria-label={field.label}
placeholder={field.label}
minLength={field.length}
maxLength={field.length + groups - 1}
// Whole and well-formed, or `:invalid` — which is what the stylesheet
// dims the button on. `minLength` alone is not checked until edited.
pattern={sizes.map((n) => `${char}{${n}}`).join('[\\- ]?')}
size={field.length + groups - 1}
required
spellcheck={false}
autocapitalize="characters"
inputmode={field.numeric ? 'numeric' : 'text'}
autocomplete={field.autocomplete}
autofocus={field.autofocus}
/>
</div>
<script dangerouslySetInnerHTML={{ __html: segments }} />
</>
);
}
@@ -284,37 +312,19 @@ function Input(props: { field: Field }) {
);
}
function Banner(props: { alert: Alert }) {
/**
* A line of small text under the fields: red when something went wrong, green
* when something just happened. No box and no icon, so it reads as part of the
* input it is about rather than as a second thing on the page.
*/
function Note(props: { alert: Alert }) {
return (
<div data-component="form-alert" data-color={props.alert.tone}>
<svg
data-slot="icon-success"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<svg
data-slot="icon-danger"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<span data-slot="message">{props.alert.message}</span>
</div>
<p
data-component="form-alert"
data-color={props.alert.tone}
role={props.alert.tone === 'danger' ? 'alert' : 'status'}>
{props.alert.message}
</p>
);
}
+3 -1
View File
@@ -52,7 +52,7 @@ export interface Link {
external?: boolean;
}
/** The banner above a form saying what went wrong, or what just happened. */
/** The small line under a form's fields saying what went wrong, or what just happened. */
export interface Alert {
tone: 'danger' | 'success';
message: string;
@@ -167,6 +167,8 @@ export interface ConfirmScreen {
fields?: Field[];
approve: Action;
deny: Action;
/** The small print under the two answers. */
footer?: Copy;
status?: number;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Draws a code field as one box per character.
*
* An enhancement, not the field. The server sends a single plain input that
* is complete on its own — it validates, submits and autofills — and this
* draws boxes under it when script runs. With script off, or if this throws,
* the person gets the plain field and loses nothing but the look.
*
* The boxes are pictures. The real input is stretched invisibly over them and
* is the only thing that is ever typed into, pasted into or autofilled. This
* is deliberate, and replaced a version with one input per box: that one
* worked in a clean browser and broke in a real one, because a password
* manager or an autofill prompt that hooks a one-time-code field fights any
* script moving focus between six fields. One ordinary field gives them
* nothing to fight.
*
* Runs once per field: it is emitted directly after the input's wrapper and
* enhances `document.currentScript`'s previous sibling, so two code fields on
* one page each get their own copy and neither looks the other up.
*
* Plain ES2017 in a string, for the same reason `css.ts` is a string: nothing
* bundles this page.
*/
export default `(() => {
const root = document.currentScript && document.currentScript.previousElementSibling;
if (!root || root.getAttribute('data-component') !== 'segments') return;
const real = root.querySelector('input');
const length = Number(root.dataset.length);
const group = Number(root.dataset.group) || length;
const numeric = root.dataset.numeric === 'true';
if (!real || !length) return;
// What the field may hold. A pasted \`bcdf-3467\` is cleaned to its
// characters here, so the dash is something the boxes draw and never
// something typed.
const clean = (text) => {
const kept = numeric ? text.replace(/[^0-9]/g, '') : text.replace(/[^0-9a-zA-Z]/g, '');
return (numeric ? kept : kept.toUpperCase()).slice(0, length);
};
const boxes = [];
const row = document.createElement('div');
row.setAttribute('data-slot', 'boxes');
row.setAttribute('aria-hidden', 'true');
for (let i = 0; i < length; i++) {
if (i > 0 && i % group === 0) {
const dash = document.createElement('span');
dash.setAttribute('data-slot', 'separator');
dash.textContent = '-';
row.appendChild(dash);
}
const box = document.createElement('div');
box.setAttribute('data-component', 'input');
box.setAttribute('data-variant', 'box');
boxes.push(box);
row.appendChild(box);
}
// The caret lives at the end. Editing in the middle of a code is rarer than
// retyping its tail, and a caret that could sit anywhere would need its
// position drawn on boxes that do not have one.
const toEnd = () => {
const end = real.value.length;
if (real.selectionStart !== end || real.selectionEnd !== end) real.setSelectionRange(end, end);
};
// A refused character gets the same answer as a wrong code: the boxes
// shake and ring red. Silently dropping it looked exactly like a field
// that does not work. Played through script rather than a class, because
// the error page may already be running the stylesheet's own shake on
// these boxes, and toggling a class cannot restart an animation that is
// still named the same.
const refuse = () => {
const red = getComputedStyle(root).getPropertyValue('--color-red-600').trim() || 'red';
const ring = { borderColor: red, boxShadow: '0 0 0 1px ' + red };
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
boxes.forEach((box) => {
box.animate([ring, Object.assign({ offset: 0.6 }, ring)], { duration: 1000, easing: 'ease-out' });
if (!still)
box.animate(
[
{ translate: '0' },
{ translate: '-0.5rem', offset: 0.15 },
{ translate: '0.5rem', offset: 0.35 },
{ translate: '-0.5rem', offset: 0.55 },
{ translate: '0.5rem', offset: 0.75 },
{ translate: '-0.2rem', offset: 0.9 },
{ translate: '0' }
],
{ duration: 400, easing: 'ease-in-out' }
);
});
};
const render = () => {
const value = real.value;
const focused = document.activeElement === real;
boxes.forEach((box, i) => {
box.textContent = value[i] || '';
box.toggleAttribute('data-active', focused && i === Math.min(value.length, length - 1));
});
root.toggleAttribute('data-complete', value.length === length);
};
real.addEventListener('input', () => {
// Separators and spaces are part of how a code is written, not a
// mistake; anything else that \`clean\` drops is.
const written = real.value.replace(/[\\s-]/g, '');
const allowed = numeric ? /^[0-9]*$/ : /^[0-9a-zA-Z]*$/;
if (!allowed.test(written)) refuse();
const cleaned = clean(real.value);
if (cleaned !== real.value) real.value = cleaned;
toEnd();
render();
});
for (const event of ['focus', 'click', 'keyup', 'select']) real.addEventListener(event, () => (toEnd(), render()));
real.addEventListener('blur', render);
root.setAttribute('data-enhanced', '');
root.appendChild(row);
real.value = clean(real.value);
render();
})();`;
+8 -1
View File
@@ -313,7 +313,14 @@ describe('a code somebody else started', () => {
expect(reached.html).toContain(grant.user_code.slice(0, 4));
expect(reached.html).toContain(grant.user_code.slice(4));
expect(reached.html).toContain('desktop');
});
test('the page does not name the client, which named itself', async () => {
const grant = await started('Your bank');
const reached = await signInAndReachConfirmation(grant.user_code);
expect(reached.html).not.toContain('Your bank');
expect(reached.html).toContain('A device is asking');
});
test('a confirmation posted without the value from the cookie is refused', async () => {