From bd4029ba6e65e816009d31138f4145cb803e1ece Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 05:27:28 +0300 Subject: [PATCH] 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}

${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)`);