From 61eeecce437d002506a7e05b2910be9e5bea9631 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 18 Sep 2026 01:50:29 +0300 Subject: [PATCH 01/13] fix: Change wording --- packages/auth/src/ui/base.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth/src/ui/base.tsx b/packages/auth/src/ui/base.tsx index 508510a4..0fd3a30c 100644 --- a/packages/auth/src/ui/base.tsx +++ b/packages/auth/src/ui/base.tsx @@ -81,7 +81,7 @@ export function Layout( {props.headline ?? (

- One place for all the ways you play.{' '} + One place for all the ways we play.{' '} Gather Around Date: Fri, 2 Oct 2026 05:27:27 +0300 Subject: [PATCH 02/13] refactor(auth): make every issuer screen a value of its own The issuer's screens were written inline in its route handlers, so the only way to look at one was to reach the condition that produces it: an expired cookie, a refused grant, ten wrong guesses. They now live in ui/issuer.ts and the handlers call them, with no change to copy, status or markup. The app's theme moves to its own module for the same reason: it can be used without importing the issuer and its database stores. --- apps/auth/src/index.ts | 28 +------ apps/auth/src/theme.ts | 27 ++++++ packages/auth/src/issuer.ts | 99 +++------------------- packages/auth/src/ui/issuer.ts | 148 +++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 115 deletions(-) create mode 100644 apps/auth/src/theme.ts create mode 100644 packages/auth/src/ui/issuer.ts 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/issuer.ts b/packages/auth/src/ui/issuer.ts new file mode 100644 index 00000000..89794908 --- /dev/null +++ b/packages/auth/src/ui/issuer.ts @@ -0,0 +1,148 @@ +/** + * 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 { + clientID: string; + 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, + 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 }, + 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' } + }; + }, + + 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.'] + }; + } +}; From bd4029ba6e65e816009d31138f4145cb803e1ece Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 05:27:28 +0300 Subject: [PATCH 03/13] feat(auth): preview every sign-in screen state locally bun run preview (in apps/auth) serves a gallery of all 18 states the sign-in flow can show, at desktop and phone sizes, each one also viewable full size. It needs no database or mail: each state is the real screen value, drawn by the real renderer with the real theme. bun --watch restarts the server on any change to the renderer, the stylesheet or the copy, and every page polls for the restart and reloads itself, so styling is edit, save, look. --- apps/auth/README.md | 8 ++ apps/auth/package.json | 1 + apps/auth/script/preview.ts | 264 ++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 apps/auth/script/preview.ts 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..ad880443 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 --watch script/preview.ts", "deploy": "wrangler deploy" }, "dependencies": { diff --git a/apps/auth/script/preview.ts b/apps/auth/script/preview.ts new file mode 100644 index 00000000..07c4290a --- /dev/null +++ b/apps/auth/script/preview.ts @@ -0,0 +1,264 @@ +/** + * 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. + * + * `bun --watch` restarts this process when anything it imports changes, which + * includes `css.ts`, `render.tsx` and `base.tsx`. 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({ + clientID: 'nestri-cli', + 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}

` + ) + .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)`); From 36de16af7a70c902435a792e8ef72d63bcf6e185 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 05:42:11 +0300 Subject: [PATCH 04/13] fix(auth): say what went wrong under the input, and drop the default headline Alerts were a bordered, tinted box with an icon, above the fields. They are now a line of small text under the fields: red when something went wrong, green when something just happened, no box and no icon. The sign-in screens no longer carry the "One place for all the ways you play" heading. Screens that pass their own heading (device approve, deny and the rest) keep it. --- packages/auth/src/ui/base.tsx | 15 +---------- packages/auth/src/ui/css.ts | 38 ++++---------------------- packages/auth/src/ui/render.tsx | 48 +++++++++++---------------------- packages/auth/src/ui/screen.ts | 2 +- 4 files changed, 22 insertions(+), 81 deletions(-) diff --git a/packages/auth/src/ui/base.tsx b/packages/auth/src/ui/base.tsx index 0fd3a30c..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 we play.{' '} - - Gather Around - - . - - -

- )} + {props.headline}
{props.children}
diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index f866b85e..1889d9c9 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); @@ -359,46 +357,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 diff --git a/packages/auth/src/ui/render.tsx b/packages/auth/src/ui/render.tsx index 9f9e0317..b5910a7c 100644 --- a/packages/auth/src/ui/render.tsx +++ b/packages/auth/src/ui/render.tsx @@ -107,12 +107,12 @@ function Form(props: { theme?: Theme; screen: FormScreen }) { return (
- {screen.alerts?.map((alert) => ( - - ))} {screen.fields.map((field) => ( ))} + {screen.alerts?.map((alert) => ( + + ))} @@ -284,37 +284,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 ( -
- - - - - - - {props.alert.message} -
+

+ {props.alert.message} +

); } diff --git a/packages/auth/src/ui/screen.ts b/packages/auth/src/ui/screen.ts index 90c8f305..9634fe0b 100644 --- a/packages/auth/src/ui/screen.ts +++ b/packages/auth/src/ui/screen.ts @@ -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; From 97459421f8ff891e68bb512001dba19c38375f5a Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 05:48:09 +0300 Subject: [PATCH 05/13] style(auth): white focus, and a dim button until the form can be sent A focused field was ringed in the brand orange, which reads as an error and competes with the wordmark. Focus is now grey while the value is incomplete and white once it would pass, so the field says it is done. The submit button is dimmed while any field in its form is invalid. It uses the browser's own validity, so there is still no script, and the code field gains a pattern so a partly typed code counts as invalid. The button stays clickable, so pressing it early still gets the browser's message saying what is missing. --- packages/auth/src/ui/css.ts | 33 ++++++++++++++++++++++++++++----- packages/auth/src/ui/render.tsx | 3 +++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index 1889d9c9..d72dba46 100644 --- a/packages/auth/src/ui/css.ts +++ b/packages/auth/src/ui/css.ts @@ -250,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, @@ -265,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 @@ -277,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'] { @@ -310,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 { diff --git a/packages/auth/src/ui/render.tsx b/packages/auth/src/ui/render.tsx index b5910a7c..9c00f89a 100644 --- a/packages/auth/src/ui/render.tsx +++ b/packages/auth/src/ui/render.tsx @@ -243,6 +243,9 @@ function Input(props: { field: Field }) { placeholder={field.label} minLength={field.length} maxLength={field.length} + // Whole and well-formed, or `:invalid` — which is what the stylesheet + // dims the button on. `minLength` alone is not checked until edited. + pattern={`${field.numeric ? '[0-9]' : '[A-Za-z0-9]'}{${field.length}}`} size={field.length} required spellcheck={false} From 86394dd76900aa403b41fe50e400ba22d5880fa3 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 05:48:10 +0300 Subject: [PATCH 06/13] fix(auth): show one alert at a time on the code screen A wrong code showed "Invalid code" above "Code sent to ...", two answers to one question in two colours. An error now replaces the confirmation instead of joining it. --- packages/auth/src/ui/code.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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', From 5353b9a1db86407254674133de2fe2adec2594ac Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 05:48:10 +0300 Subject: [PATCH 07/13] fix(auth): reload the preview on every save, not just the first bun --watch watches each imported file, and many editors save by writing a new file and renaming it over the old one. The watch does not follow the rename, so the first such save reloaded the preview and every later one was silently ignored. The preview now runs under a small supervisor that watches the source directories and restarts the server, which sees a save however it was made. It also stops the server when it is stopped itself, so the port is not left held. --- apps/auth/package.json | 2 +- apps/auth/script/preview-watch.ts | 49 +++++++++++++++++++++++++++++++ apps/auth/script/preview.ts | 6 ++-- 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 apps/auth/script/preview-watch.ts diff --git a/apps/auth/package.json b/apps/auth/package.json index ad880443..28f7dcaa 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "wrangler dev", "serve": "bun run src/server.ts", - "preview": "bun --watch script/preview.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 index 07c4290a..9411411e 100644 --- a/apps/auth/script/preview.ts +++ b/apps/auth/script/preview.ts @@ -8,9 +8,9 @@ * 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. * - * `bun --watch` restarts this process when anything it imports changes, which - * includes `css.ts`, `render.tsx` and `base.tsx`. Each page polls for the - * restart and reloads itself, so saving a file is the whole loop. + * `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`. From 11f62c69aae51c8610a0532026f2f95e37d5e818 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 05:50:55 +0300 Subject: [PATCH 08/13] fix(auth): say "a device" on the confirm screen, and move the warning under it The confirm screen named the client asking to sign in. That name is chosen by whoever started the request, so it let a stranger's request introduce itself as anything at all; and to the person reading, it is just a device. It now says "A device is asking to sign in", and a test checks that a client's self-chosen name never appears. The warning about choosing Deny moves under the two buttons, as small print in the same style as the terms line on the email screen, so the question and its two answers read first. --- apps/auth/script/preview.ts | 1 - packages/auth/src/ui/issuer.ts | 13 +++++++------ packages/auth/src/ui/render.tsx | 1 + packages/auth/src/ui/screen.ts | 2 ++ packages/auth/test/device.test.ts | 9 ++++++++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/auth/script/preview.ts b/apps/auth/script/preview.ts index 9411411e..491e9f63 100644 --- a/apps/auth/script/preview.ts +++ b/apps/auth/script/preview.ts @@ -116,7 +116,6 @@ const STATES: State[] = [ label: 'Is this you?', screen: () => IssuerScreens.deviceConfirm({ - clientID: 'nestri-cli', userCode: 'BCDF3467', csrf: 'x' }) diff --git a/packages/auth/src/ui/issuer.ts b/packages/auth/src/ui/issuer.ts index 89794908..4a42fae4 100644 --- a/packages/auth/src/ui/issuer.ts +++ b/packages/auth/src/ui/issuer.ts @@ -16,7 +16,6 @@ import type { Screen } from './screen.js'; /** The confirmation a device grant asks for, before it is approved. */ export interface DeviceConfirmCopy { - clientID: string; userCode: string; csrf: string; } @@ -95,16 +94,18 @@ export const IssuerScreens = { 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: [ - `${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.' + 'A device is asking to sign in to your account. The code above should match the one it is showing you.' ], 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' } + 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.' }; }, diff --git a/packages/auth/src/ui/render.tsx b/packages/auth/src/ui/render.tsx index 9c00f89a..e6e579b8 100644 --- a/packages/auth/src/ui/render.tsx +++ b/packages/auth/src/ui/render.tsx @@ -179,6 +179,7 @@ function Confirm(props: { theme?: Theme; screen: ConfirmScreen }) { {screen.deny.label} + {screen.footer &&