mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12:39 +02:00
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"serve": "bun run src/server.ts",
|
||||
"preview": "bun --watch script/preview.ts",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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> | 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 = `<script>(()=>{const boot=${JSON.stringify(BOOT)};setInterval(async()=>{try{const r=await fetch('/__boot',{cache:'no-store'});if((await r.text())!==boot)location.reload()}catch{}},400)})()</script>`;
|
||||
|
||||
async function page(state: State): Promise<Response> {
|
||||
const res = renderer.render(await state.screen(), req);
|
||||
const html = (await res.text()).replace('</body>', `${RELOAD}</body>`);
|
||||
// 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) =>
|
||||
`<h2>${group}</h2><div class="grid">${STATES.filter((s) => s.group === group)
|
||||
.map(
|
||||
(s) =>
|
||||
`<figure><figcaption><a href="/s/${s.id}" target="_blank">${s.label}</a><code>${s.id}</code></figcaption><div class="vp"><iframe src="/s/${s.id}" loading="lazy" title="${s.label}"></iframe></div></figure>`
|
||||
)
|
||||
.join('')}</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return new Response(
|
||||
`<!doctype html><html><head><meta charset="utf-8"><title>Auth preview</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--w:1440;--h:900;--scale:.3}
|
||||
body{margin:0;padding:16px 24px 48px;background:#111;color:#ddd;font:13px/1.4 system-ui,sans-serif}
|
||||
header{position:sticky;top:0;z-index:1;display:flex;gap:8px;align-items:center;padding:8px 0;background:#111}
|
||||
header b{margin-right:12px}
|
||||
button{background:#222;color:#ddd;border:1px solid #333;border-radius:6px;padding:4px 10px;cursor:pointer}
|
||||
button[aria-pressed=true]{background:#ddd;color:#111}
|
||||
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin:24px 0 8px}
|
||||
.grid{display:flex;flex-wrap:wrap;gap:16px}
|
||||
figure{margin:0}
|
||||
figcaption{display:flex;gap:8px;align-items:baseline;margin-bottom:4px}
|
||||
figcaption a{color:#ddd}
|
||||
figcaption code{color:#666}
|
||||
.vp{width:calc(var(--w)*var(--scale)*1px);height:calc(var(--h)*var(--scale)*1px);overflow:hidden;border:1px solid #2a2a2a;border-radius:4px}
|
||||
iframe{width:calc(var(--w)*1px);height:calc(var(--h)*1px);border:0;transform:scale(var(--scale));transform-origin:0 0}
|
||||
</style></head><body>
|
||||
<header><b>Auth preview</b>
|
||||
<button data-w="1440" data-h="900" data-s=".3">Desktop</button>
|
||||
<button data-w="1440" data-h="900" data-s=".55">Desktop large</button>
|
||||
<button data-w="390" data-h="844" data-s=".6">Phone</button>
|
||||
<span style="color:#666;margin-left:auto">saves reload every frame · click a title to open full size</span></header>
|
||||
${tiles}
|
||||
<script>
|
||||
const root=document.documentElement,buttons=[...document.querySelectorAll('header button')];
|
||||
function pick(b){root.style.setProperty('--w',b.dataset.w);root.style.setProperty('--h',b.dataset.h);root.style.setProperty('--scale',b.dataset.s);buttons.forEach(x=>x.setAttribute('aria-pressed',x===b));try{localStorage.vp=buttons.indexOf(b)}catch{}}
|
||||
buttons.forEach(b=>b.onclick=()=>pick(b));
|
||||
let i=0;try{i=+localStorage.vp||0}catch{}pick(buttons[i]||buttons[0]);
|
||||
</script>${RELOAD}</body></html>`,
|
||||
{ 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)`);
|
||||
Reference in New Issue
Block a user