diff --git a/apps/auth/README.md b/apps/auth/README.md index eb34f58f..98f402a8 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -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). diff --git a/apps/auth/package.json b/apps/auth/package.json index e19d85cb..28f7dcaa 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -4,6 +4,7 @@ "scripts": { "dev": "wrangler dev", "serve": "bun run src/server.ts", + "preview": "bun script/preview-watch.ts", "deploy": "wrangler deploy" }, "dependencies": { diff --git a/apps/auth/script/preview-watch.ts b/apps/auth/script/preview-watch.ts new file mode 100644 index 00000000..7578728e --- /dev/null +++ b/apps/auth/script/preview-watch.ts @@ -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 | 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); + }); +} diff --git a/apps/auth/script/preview.ts b/apps/auth/script/preview.ts new file mode 100644 index 00000000..491e9f63 --- /dev/null +++ b/apps/auth/script/preview.ts @@ -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; +} + +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 = ``; + +async function page(state: State): Promise { + const res = renderer.render(await state.screen(), req); + const html = (await res.text()).replace('', `${RELOAD}`); + // 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) => + `

${group}

${STATES.filter((s) => s.group === group) + .map( + (s) => + `
${s.label}${s.id}
` + ) + .join('')}
` + ) + .join(''); + + return new Response( + `Auth preview + +
Auth preview + + + +saves reload every frame · click a title to open full size
+${tiles} +${RELOAD}`, + { 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)`); diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index ad7a3afa..3772db92 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -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); diff --git a/apps/auth/src/theme.ts b/apps/auth/src/theme.ts new file mode 100644 index 00000000..4f71ac53 --- /dev/null +++ b/apps/auth/src/theme.ts @@ -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');` +}; diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index c27b5cb8..d0c6d4b6 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -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) => { diff --git a/packages/auth/src/ui/base.tsx b/packages/auth/src/ui/base.tsx index 508510a4..839341a6 100644 --- a/packages/auth/src/ui/base.tsx +++ b/packages/auth/src/ui/base.tsx @@ -79,20 +79,7 @@ export function Layout(
- {props.headline ?? ( -

- One place for all the ways you play.{' '} - - Gather Around - - . - - -

- )} + {props.headline}
{props.children}
diff --git a/packages/auth/src/ui/code.ts b/packages/auth/src/ui/code.ts index f0058cef..6dcca5e2 100644 --- a/packages/auth/src/ui/code.ts +++ b/packages/auth/src/ui/code.ts @@ -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', diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index f866b85e..78778f55 100644 --- a/packages/auth/src/ui/css.ts +++ b/packages/auth/src/ui/css.ts @@ -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; + } } `; diff --git a/packages/auth/src/ui/issuer.ts b/packages/auth/src/ui/issuer.ts new file mode 100644 index 00000000..5cbe7e09 --- /dev/null +++ b/packages/auth/src/ui/issuer.ts @@ -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.'] + }; + } +}; diff --git a/packages/auth/src/ui/render.tsx b/packages/auth/src/ui/render.tsx index 9f9e0317..9a55d10b 100644 --- a/packages/auth/src/ui/render.tsx +++ b/packages/auth/src/ui/render.tsx @@ -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 (
- {screen.alerts?.map((alert) => ( - - ))} {screen.fields.map((field) => ( ))} + {screen.alerts?.map((alert) => ( + + ))} @@ -179,6 +183,7 @@ function Confirm(props: { theme?: Theme; screen: ConfirmScreen }) { {screen.deny.label} + {screen.footer &&