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('
+Auth preview
+
+
+
+saves reload every frame · click a title to open full size
+${tiles}
+${RELOAD}
', `${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) =>
+ `