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