diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 08054944..017584fa 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -37,6 +37,11 @@ export namespace MachineApi { schema: Result( z.object({ machineId: z.string().meta({ example: Examples.Machine.id }), + slug: z.string().meta({ + description: + 'The name this host is reached at. Its hostname is this label under the box zone, and the id never appears in one.', + example: Examples.Machine.slug + }), secret: z.string().meta({ description: 'Shown once. Store it on the box; it cannot be retrieved.' }) @@ -111,7 +116,13 @@ export namespace MachineApi { label }); - return c.json({ data: { machineId: registered.id, secret: registered.secret } }); + return c.json({ + data: { + machineId: registered.id, + slug: registered.slug, + secret: registered.secret + } + }); } ) .patch( diff --git a/packages/auth/package.json b/packages/auth/package.json index 70729b14..9ba38a22 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -7,9 +7,9 @@ "type": "module", "sideEffects": false, "exports": { - "./ui/code": { - "types": "./src/ui/code.tsx", - "import": "./src/ui/code.tsx" + "./ui/render": { + "types": "./src/ui/render.tsx", + "import": "./src/ui/render.tsx" }, "./*": { "types": "./src/*.ts", diff --git a/packages/auth/script/build.ts b/packages/auth/script/build.ts index dd0a5b7f..f184f326 100644 --- a/packages/auth/script/build.ts +++ b/packages/auth/script/build.ts @@ -18,6 +18,10 @@ await Bun.build({ outdir: 'dist/esm', external: [...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies)], root: 'src', - entrypoints: ['./src/ui/base.tsx'] + // The renderer, bundled with the layout and stylesheet it pulls in. It is + // the one entry point whose imports must be followed rather than left + // external, because a consumer replacing the pages still imports this to + // build on it. + entrypoints: ['./src/ui/render.tsx'] }); await $`tsc --outDir dist/types --declaration --emitDeclarationOnly --declarationMap`; diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index ab59f241..c27b5cb8 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -199,39 +199,40 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { compactDecrypt, CompactEncrypt, jwtVerify, SignJWT } from 'jose'; -import { - MissingParameterError, - OauthError, - UnauthorizedClientError, - UnknownStateError -} from './error.js'; -import { encryptionKeys, signingKeys } from './keys.js'; -import { type KeyStore, StorageKeyStore } from './key.js'; import { type AuthorizationCodeRecord, type CodeStore, hashAuthorizationCode, StorageCodeStore } from './authorization-code.js'; -import { - hashRefreshToken, - type RefreshRecord, - type RefreshStore, - StorageRefreshStore -} from './refresh.js'; import { type DeviceGrantSubject, type DeviceStore, hashDeviceCode, MemoryDeviceStore } from './device.js'; +import { + MissingParameterError, + OauthError, + UnauthorizedClientError, + UnknownStateError +} from './error.js'; +import { type KeyStore, StorageKeyStore } from './key.js'; +import { encryptionKeys, signingKeys } from './keys.js'; import { validatePKCE } from './pkce.js'; import { generateUnbiasedString, timingSafeCompare } from './random.js'; +import { + hashRefreshToken, + type RefreshRecord, + type RefreshStore, + StorageRefreshStore +} from './refresh.js'; import { DynamoStorage } from './storage/dynamo.js'; import { MemoryStorage } from './storage/memory.js'; import { Storage, StorageAdapter } from './storage/storage.js'; -import { Select } from './ui/select.js'; -import { setTheme, Theme } from './ui/theme.js'; +import { HtmlRenderer, type Renderer } from './ui/render.js'; +import type { ChooseOption, Screen } from './ui/screen.js'; +import type { Theme } from './ui/theme.js'; import { getRelativeUrl, isDomainMatch, lazy } from './util.js'; /** @internal */ @@ -319,37 +320,31 @@ export interface IssuerInput< */ providers: Providers; /** - * The theme you want to use for the UI. + * Per-deployment trim for the built-in screens: title, favicon, brand + * colour, and any stylesheet needed to load a font. * - * This includes the UI the user sees when selecting a provider. And the `PasswordUI` and - * `CodeUI` that are used by the `PasswordProvider` and `CodeProvider`. - * - * @example - * ```ts title="issuer.ts" - * import { THEME_SST } from "@openauthjs/openauth/ui/theme" - * - * issuer({ - * theme: THEME_SST - * // ... - * }) - * ``` - * - * Or define your own. + * Ignored when {@link IssuerInput.renderer} is supplied, because a renderer + * that was handed a theme would have two sources for the same values. * * ```ts title="issuer.ts" - * import type { Theme } from "@openauthjs/openauth/ui/theme" - * - * const MY_THEME: Theme = { - * // ... - * } - * * issuer({ - * theme: MY_THEME + * theme: { title: "Login | Example", primary: "hsl(12 84% 53%)" } * // ... * }) * ``` */ theme?: Theme; + /** + * Draws every screen this issuer serves. + * + * The whole presentation layer behind one method. Supply this to replace + * the built-in pages outright — it is the only thing that has to change, + * because providers describe what they need as data and never render + * anything themselves. + * + * @default HtmlRenderer({ theme }) + */ + renderer?: Renderer; /** * Set the TTL, in seconds, for access and refresh tokens. * @@ -474,26 +469,30 @@ export interface IssuerInput< */ allowDeviceClient?(clientID: string, req: Request): Promise; /** - * Optionally, configure the UI that's displayed when the user visits the root URL of the - * of the OpenAuth server. + * Which providers appear on the screen offering a choice of them, and in + * what order. + * + * What each one is *called*, and the mark beside it, comes from the + * provider itself — so adding one needs nothing here. This is only for the + * two decisions a deployment makes that a provider cannot: whether to offer + * it at all, and what to put first. * * ```ts title="issuer.ts" - * import { Select } from "@openauthjs/openauth/ui/select" - * * issuer({ - * select: Select({ - * providers: { - * github: { hide: true }, - * google: { display: "Google" } - * } - * }) + * chooser: { hide: ["steam"], order: ["code", "discord"] } * // ... * }) * ``` - * - * @default Select() */ - select?(providers: Record, req: Request): Promise; + chooser?: { + /** Providers to leave off the screen, by their key in `providers`. */ + hide?: string[]; + /** + * Providers to put first, by key. Anything not named keeps its order + * from `providers` and follows. + */ + order?: string[]; + }; /** * @internal */ @@ -578,13 +577,17 @@ export function issuer< >(input: IssuerInput) { const error = input.error ?? - function (err) { - return new Response(err.message, { - status: 400, - headers: { - 'Content-Type': 'text/plain' - } - }); + 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 + ); }; const ttlAccess = input.ttl?.access ?? 60 * 60 * 24 * 30; const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365; @@ -602,11 +605,42 @@ export function issuer< req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? undefined); - if (input.theme) { - setTheme(input.theme); - } + const renderer = input.renderer ?? HtmlRenderer({ theme: input.theme }); - const select = lazy(() => input.select ?? Select()); + /** + * The screen offering a choice of providers. + * + * Built from what each provider says about itself. Nothing here knows the + * name of a single provider, which is the property worth keeping: this was + * two hardcoded records in the rendering code, and a provider missing from + * them appeared as its own bare identifier with no way to fix it short of + * editing the library. + */ + function chooseScreen(): Screen { + const hidden = new Set(input.chooser?.hide ?? []); + const first = input.chooser?.order ?? []; + const options: ChooseOption[] = Object.keys(input.providers) + .filter((key) => !hidden.has(key)) + // Stable, so anything `order` does not name keeps the order it was + // declared in rather than being shuffled by the comparator. + .sort((a, b) => { + const ai = first.indexOf(a); + const bi = first.indexOf(b); + if (ai === bi) return 0; + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }) + .map((key) => { + const provider = input.providers[key]!; + return { + href: `/${key}/authorize`, + label: `Continue with ${provider.display?.name ?? provider.type}`, + mark: provider.display?.icon + }; + }); + return { kind: 'choose', options }; + } const allow = lazy( () => input.allow ?? @@ -674,10 +708,7 @@ export function issuer< await auth.unset(ctx, 'authorization'); const grant = await deviceStore.byDeviceCode(authorization.device_code); if (!grant || grant.status !== 'pending' || grant.expires <= Date.now()) { - return ctx.text( - 'That sign-in request has expired. Start it again from the app.', - 400 - ); + return auth.screen(ctx, expired()); } // Carried in an encrypted cookie rather than written to @@ -700,7 +731,7 @@ export function issuer< } }; await auth.set(ctx, 'device_confirm', ttlDevice, confirmation); - return ctx.html(deviceConfirmPage(confirmation)); + return auth.screen(ctx, deviceConfirmScreen(confirmation)); } if (authorization) { if (authorization.response_type === 'token') { @@ -784,6 +815,12 @@ export function issuer< Object.fromEntries(response.headers.entries()) ); }, + screen(ctx, screen) { + // Forwarded rather than returned directly so that cookies set + // earlier in the handler survive onto the response. Every page this + // issuer serves goes through here. + return auth.forward(ctx, renderer.render(screen, ctx.req.raw)); + }, async set(ctx, key, maxAge, value) { setCookie(ctx, key, await encrypt(value), { maxAge, @@ -858,12 +895,7 @@ export function issuer< bucket && bucket.resetAt > now ? { count: bucket.count + 1, resetAt: bucket.resetAt } : { count: 1, resetAt: now + deviceGuessWindow * 1000 }; - await Storage.set( - storage!, - key, - next, - Math.max(1, Math.ceil((next.resetAt - now) / 1000)) - ); + await Storage.set(storage!, key, next, Math.max(1, Math.ceil((next.resetAt - now) / 1000))); return next.count <= deviceGuessLimit; } @@ -888,16 +920,6 @@ export function issuer< return raw.replace(/[^0-9a-zA-Z]/g, '').toUpperCase(); } - /** Enough escaping to put an attacker-chosen client name on a page safely. */ - function escapeHtml(raw: string) { - return raw - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - } - /** * The page that asks the only question that authorizes anything. * @@ -907,27 +929,43 @@ export function issuer< * link. Approving is a POST carrying a value that was put in the cookie * alongside it, so a page on another site cannot submit it on their behalf. */ - function deviceConfirmPage(confirmation: DeviceConfirmation) { - const code = escapeHtml(confirmation.userCode); - const client = escapeHtml(confirmation.clientID); - return ( - `` + - `Confirm sign-in` + - `

Is this you?

` + - `

${client} is asking to sign in to your account.

` + - `

The code it is showing you should be:

` + - `

${code.slice(0, 4)}-${code.slice(4)}

` + - `

If those do not match, or you did not start this on a device of your own, ` + - `choose Deny. Nobody can sign in as you unless you approve here.

` + - `
` + - `` + - ` ` + - `` + - `
` - ); + /** + * 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. + */ + function expired(): Screen { + return { + kind: 'message', + tone: 'danger', + heading: 'That sign-in request has expired', + body: ['Start it again from the app.'], + status: 400 + }; } - async function getAuthorization(ctx: Context) { + 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' } + }; + } + + async function getAuthorization(ctx: Context) { const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization'); if (!match) throw new UnknownStateError(); return match as AuthorizationState; @@ -1257,10 +1295,7 @@ export function issuer< 400 ); if (!clientID) - return c.json( - { error: 'invalid_request', error_description: 'Missing client_id' }, - 400 - ); + return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400); const hash = await hashDeviceCode(deviceCode); const grant = await deviceStore.byDeviceCode(hash); @@ -1282,7 +1317,10 @@ export function issuer< // carrying is whatever the last caller claimed. if (grant.clientID !== clientID) { return c.json( - { error: 'invalid_grant', error_description: 'That device code belongs to another client' }, + { + error: 'invalid_grant', + error_description: 'That device code belongs to another client' + }, 400 ); } @@ -1428,10 +1466,7 @@ export function issuer< if (!clientID) return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400); if (input.allowDeviceClient && !(await input.allowDeviceClient(clientID, c.req.raw))) - return c.json( - { error: 'invalid_client', error_description: 'Unknown client_id' }, - 400 - ); + return c.json({ error: 'invalid_client', error_description: 'Unknown client_id' }, 400); // Not `randomUUID`: a device code is the credential the tokens are // handed to, so it gets the same treatment as one — full-width @@ -1488,19 +1523,32 @@ export function issuer< app.get('/device', async (c) => { const raw = c.req.query('user_code'); if (!raw) { - return c.html( - `` + - `Sign in to a device` + - `
` + - `` + - `` + - `` + - `
` - ); + 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' + }); } if (!(await guessesLeft(c.req.raw))) { - return c.text('Too many codes tried. Wait a while and start again from the app.', 429); + return auth.screen(c, { + kind: 'message', + tone: 'danger', + heading: 'Too many tries', + body: ['Wait a while, then start again from the app.'], + status: 429 + }); } const found = await deviceStore.byUserCode(canonicalUserCode(raw)); @@ -1509,7 +1557,13 @@ export function issuer< // nothing, so a person mistyping once and then succeeding is not // walking towards a lockout. await chargeGuess(c.req.raw); - return c.text('That code is not valid any more. Ask the app for a new one.', 400); + 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 + }); } const authorization: AuthorizationState = { @@ -1523,15 +1577,7 @@ export function issuer< if (provider) return c.redirect(`/${provider}/authorize`); const providers = Object.keys(input.providers); if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); - return auth.forward( - c, - await select()( - Object.fromEntries( - Object.entries(input.providers).map(([key, value]) => [key, value.type]) - ), - c.req.raw - ) - ); + return auth.screen(c, chooseScreen()); }); // The step that actually authorizes, and the reason there is one. @@ -1549,31 +1595,53 @@ export function issuer< app.post('/device/confirm', async (c) => { const confirmation = (await auth.get(c, 'device_confirm')) as DeviceConfirmation | undefined; if (!confirmation) { - return c.text('That sign-in request has expired. Start it again from the app.', 400); + return auth.screen(c, expired()); } await auth.unset(c, 'device_confirm'); const form = await c.req.formData().catch(() => null); const csrf = form?.get('csrf')?.toString() ?? ''; if (!timingSafeCompare(confirmation.csrf, csrf)) { - return c.text('That form was not the one we sent. Start again from the app.', 400); + 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 + }); } if (form?.get('action')?.toString() === 'deny') { await deviceStore.deny(confirmation.deviceCode); - return c.text('That sign-in request was refused. You can close this page.'); + return auth.screen(c, { + kind: 'message', + tone: 'notice', + heading: 'Refused', + body: ['That sign-in request was refused. You can close this page.'] + }); } // 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 c.text('That sign-in request has already been answered.', 400); + return auth.screen(c, { + kind: 'message', + tone: 'danger', + heading: 'Already answered', + body: ['That sign-in request has already been answered.'], + status: 400 + }); } - return c.text('You are signed in. You can close this page and go back to the app.'); + 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.'] + }); }); - app.get('/authorize', async (c) => { + app.get('/authorize', async (c) => { const provider = c.req.query('provider'); const response_type = c.req.query('response_type'); const redirect_uri = c.req.query('redirect_uri'); @@ -1629,15 +1697,7 @@ export function issuer< if (provider) return c.redirect(`/${provider}/authorize`); const providers = Object.keys(input.providers); if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); - return auth.forward( - c, - await select()( - Object.fromEntries( - Object.entries(input.providers).map(([key, value]) => [key, value.type]) - ), - c.req.raw - ) - ); + return auth.screen(c, chooseScreen()); }); app.get('/userinfo', async (c) => { diff --git a/packages/auth/src/provider/apple.ts b/packages/auth/src/provider/apple.ts index ed6a9530..e9fa84cd 100644 --- a/packages/auth/src/provider/apple.ts +++ b/packages/auth/src/provider/apple.ts @@ -51,6 +51,7 @@ * @packageDocumentation */ +import { MARK_APPLE } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; import { OidcProvider, OidcWrappedConfig } from './oidc.js'; @@ -96,6 +97,7 @@ export function AppleProvider(config: AppleConfig) { return Oauth2Provider({ ...restConfig, type: 'apple' as const, + display: { name: 'Apple', icon: MARK_APPLE }, endpoint: { authorization: 'https://appleid.apple.com/auth/authorize', token: 'https://appleid.apple.com/auth/token', @@ -122,6 +124,7 @@ export function AppleOidcProvider(config: AppleOidcConfig) { return OidcProvider({ ...config, type: 'apple' as const, + display: { name: 'Apple', icon: MARK_APPLE }, issuer: 'https://appleid.apple.com' }); } diff --git a/packages/auth/src/provider/code.ts b/packages/auth/src/provider/code.ts index 7b3a2dd5..a55bd1df 100644 --- a/packages/auth/src/provider/code.ts +++ b/packages/auth/src/provider/code.ts @@ -56,6 +56,8 @@ import { Context } from 'hono'; import { generateUnbiasedDigits, generateUnbiasedString, timingSafeCompare } from '../random.js'; import { Storage } from '../storage/storage.js'; +import { MARK_CODE } from '../ui/mark.js'; +import type { Screen } from '../ui/screen.js'; import { Provider } from './provider.js'; export interface CodeProviderConfig< @@ -120,23 +122,18 @@ export interface CodeProviderConfig< */ resendInterval?: number; /** - * The request handler to generate the UI for the code flow. + * What to ask for at each step of the flow. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * - * Also passes in the current `state` of the flow and any `error` that occurred. - * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a {@link Screen} describing the question, not a rendered page. + * Which one is drawn follows from `state`, and `error` says what to say + * above it. */ request: ( req: Request, state: CodeProviderState, form?: FormData, error?: CodeProviderError - ) => Promise; + ) => Promise; /** * Callback to send the pin code to the user. * @@ -242,6 +239,7 @@ export function CodeProvider = Record = Record(c, 'provider', ttl, next); - const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err)); - return resp; + return ctx.screen(c, await config.request(c.req.raw, next, fd, err)); } routes.get('/authorize', async (c) => { @@ -383,7 +380,7 @@ export function CodeProvider = Record { const state = crypto.randomUUID(); diff --git a/packages/auth/src/provider/oidc.ts b/packages/auth/src/provider/oidc.ts index 15d46a8b..b13266c9 100644 --- a/packages/auth/src/provider/oidc.ts +++ b/packages/auth/src/provider/oidc.ts @@ -24,9 +24,16 @@ import { createLocalJWKSet, JSONWebKeySet, jwtVerify } from 'jose'; import { WellKnown } from '../client.js'; import { OauthError } from '../error.js'; import { getRelativeUrl, lazy } from '../util.js'; -import { Provider } from './provider.js'; +import { Provider, type ProviderDisplay } from './provider.js'; export interface OidcConfig { + /** + * How this provider is named and marked on the chooser. + * + * @internal + */ + display?: ProviderDisplay; + /** * @internal */ @@ -123,6 +130,7 @@ export function OidcProvider(config: OidcConfig): Provider<{ id: JWTPayload; cli return { type: config.type || 'oidc', + display: config.display, init(routes, ctx) { routes.get('/authorize', async (c) => { const provider: ProviderState = { diff --git a/packages/auth/src/provider/password.ts b/packages/auth/src/provider/password.ts index e5a9e766..ca765a5c 100644 --- a/packages/auth/src/provider/password.ts +++ b/packages/auth/src/provider/password.ts @@ -5,8 +5,8 @@ import { v1 } from '@standard-schema/spec'; * paired with the `PasswordUI`. * * ```ts - * import { PasswordUI } from "@openauthjs/openauth/ui/password" - * import { PasswordProvider } from "@openauthjs/openauth/provider/password" + * import { PasswordUI } from "@nestri/auth/ui/password" + * import { PasswordProvider } from "@nestri/auth/provider/password" * * export default issuer({ * providers: { @@ -23,25 +23,26 @@ import { v1 } from '@standard-schema/spec'; * }) * ``` * - * Behind the scenes, the `PasswordProvider` expects callbacks that implements request handlers - * that generate the UI for the following. + * Behind the scenes, the `PasswordProvider` asks its config what to put on + * each screen. Each callback returns a `Screen` — a description of what is + * being asked for — and the issuer's renderer decides how it is drawn. * * ```ts * PasswordProvider({ * // ... - * login: (req, form, error) => Promise - * register: (req, state, form, error) => Promise - * change: (req, state, form, error) => Promise + * login: (req, form, error) => Promise + * register: (req, state, form, error) => Promise + * change: (req, state, form, error) => Promise * }) * ``` * - * This allows you to create your own UI for each of these screens. - * * @packageDocumentation */ import { UnknownStateError } from '../error.js'; import { generateUnbiasedDigits, timingSafeCompare } from '../random.js'; import { Storage } from '../storage/storage.js'; +import { MARK_PASSWORD } from '../ui/mark.js'; +import type { Screen } from '../ui/screen.js'; import { Provider } from './provider.js'; /** @@ -64,52 +65,37 @@ export interface PasswordConfig { /** * The request handler to generate the UI for the login screen. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * * In case of an error, this is called again with the `error`. * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a `Screen` describing what to ask for, not a rendered page. */ - login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise; + login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise; /** * The request handler to generate the UI for the register screen. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * * Also passes in the current `state` of the flow and any `error` that occurred. * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a `Screen` describing what to ask for, not a rendered page. */ register: ( req: Request, state: PasswordRegisterState, form?: FormData, error?: PasswordRegisterError - ) => Promise; + ) => Promise; /** * The request handler to generate the UI for the change password screen. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * * Also passes in the current `state` of the flow and any `error` that occurred. * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a `Screen` describing what to ask for, not a rendered page. */ change: ( req: Request, state: PasswordChangeState, form?: FormData, error?: PasswordChangeError - ) => Promise; + ) => Promise; /** * Callback to send the confirmation pin code to the user. * @@ -268,13 +254,14 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri } return { type: 'password', + display: { name: 'Password', icon: MARK_PASSWORD }, init(routes, ctx) { - routes.get('/authorize', async (c) => ctx.forward(c, await config.login(c.req.raw))); + routes.get('/authorize', async (c) => ctx.screen(c, await config.login(c.req.raw))); routes.post('/authorize', async (c) => { const fd = await c.req.formData(); async function error(err: PasswordLoginError) { - return ctx.forward(c, await config.login(c.req.raw, fd, err)); + return ctx.screen(c, await config.login(c.req.raw, fd, err)); } const email = fd.get('email')?.toString()?.toLowerCase(); if (!email) return error({ type: 'invalid_email' }); @@ -300,7 +287,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri type: 'start' }; await ctx.set(c, 'provider', 60 * 60 * 24, state); - return ctx.forward(c, await config.register(c.req.raw, state)); + return ctx.screen(c, await config.register(c.req.raw, state)); }); routes.post('/register', async (c) => { @@ -311,7 +298,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri async function transition(next: PasswordRegisterState, err?: PasswordRegisterError) { await ctx.set(c, 'provider', 60 * 60 * 24, next); - return ctx.forward(c, await config.register(c.req.raw, next, fd, err)); + return ctx.screen(c, await config.register(c.req.raw, next, fd, err)); } if (action === 'register' && provider.type === 'start') { @@ -386,7 +373,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri redirect }; await ctx.set(c, 'provider', 60 * 60 * 24, state); - return ctx.forward(c, await config.change(c.req.raw, state)); + return ctx.screen(c, await config.change(c.req.raw, state)); }); routes.post('/change', async (c) => { @@ -397,7 +384,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri async function transition(next: PasswordChangeState, err?: PasswordChangeError) { await ctx.set(c, 'provider', 60 * 60 * 24, next); - return ctx.forward(c, await config.change(c.req.raw, next, fd, err)); + return ctx.screen(c, await config.change(c.req.raw, next, fd, err)); } if (action === 'code') { diff --git a/packages/auth/src/provider/provider.ts b/packages/auth/src/provider/provider.ts index 41ed5cf9..fa441f23 100644 --- a/packages/auth/src/provider/provider.ts +++ b/packages/auth/src/provider/provider.ts @@ -1,11 +1,36 @@ import type { Context, Hono } from 'hono'; import { StorageAdapter } from '../storage/storage.js'; +import type { Mark, Screen } from '../ui/screen.js'; export type ProviderRoute = Hono; +/** + * How a provider is offered to a person choosing one. + * + * Declared by the provider rather than looked up by whatever draws the + * chooser. That used to be two hardcoded records inside the rendering code, so + * a provider the library had not been told about rendered as its own lowercase + * identifier with no mark beside it — and there was no way to fix it from + * outside the library. + */ +export interface ProviderDisplay { + /** The name as a person reads it: `GitHub`, not `github`. */ + name: string; + /** Raw SVG for the brand mark, from `ui/mark.ts`. */ + icon?: Mark; +} + export interface Provider { type: string; + /** + * What to call this provider, and what to draw beside it. + * + * Optional because a provider nobody picks from a list — one reached + * directly, or one with no browser in the flow at all — has nothing to + * display. Falling back to `type` is correct there and only there. + */ + display?: ProviderDisplay; init: (route: ProviderRoute, options: ProviderOptions) => void; client?: (input: { clientID: string; @@ -24,6 +49,15 @@ export interface ProviderOptions { } ) => Promise; forward: (ctx: Context, response: Response) => Response; + /** + * Draw a screen and return it as this request's response. + * + * The only way a provider produces a page. It cannot reach the renderer + * itself, which is the point: a provider says what it needs to ask and + * never how it looks, so there is exactly one place that has to agree with + * the stylesheet. + */ + screen: (ctx: Context, screen: Screen) => Response; set: (ctx: Context, key: string, maxAge: number, value: T) => Promise; get: (ctx: Context, key: string) => Promise; unset: (ctx: Context, key: string) => Promise; diff --git a/packages/auth/src/provider/slack.ts b/packages/auth/src/provider/slack.ts index a053bf38..f1d6231f 100644 --- a/packages/auth/src/provider/slack.ts +++ b/packages/auth/src/provider/slack.ts @@ -19,6 +19,7 @@ * @packageDocumentation */ +import { MARK_SLACK } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface SlackConfig extends Oauth2WrappedConfig { @@ -59,6 +60,7 @@ export function SlackProvider(config: SlackConfig) { return Oauth2Provider({ ...config, type: 'slack', + display: { name: 'Slack', icon: MARK_SLACK }, endpoint: { authorization: 'https://slack.com/openid/connect/authorize', token: 'https://slack.com/api/openid.connect.token' diff --git a/packages/auth/src/provider/spotify.ts b/packages/auth/src/provider/spotify.ts index 266fcf68..6326d3db 100644 --- a/packages/auth/src/provider/spotify.ts +++ b/packages/auth/src/provider/spotify.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_SPOTIFY } from '../ui/mark.js'; import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js'; export interface SpotifyConfig extends Oauth2WrappedConfig {} @@ -37,6 +38,7 @@ export function SpotifyProvider(config: SpotifyConfig) { return Oauth2Provider({ ...config, type: 'spotify', + display: { name: 'Spotify', icon: MARK_SPOTIFY }, endpoint: { authorization: 'https://accounts.spotify.com/authorize', token: 'https://accounts.spotify.com/api/token' diff --git a/packages/auth/src/provider/steam.ts b/packages/auth/src/provider/steam.ts index cb565dfd..f5fb0152 100644 --- a/packages/auth/src/provider/steam.ts +++ b/packages/auth/src/provider/steam.ts @@ -1,3 +1,4 @@ +import { MARK_STEAM } from '../ui/mark.js'; import { getRelativeUrl } from '../util.js'; import { Provider } from './provider.js'; @@ -6,6 +7,7 @@ const STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login'; export function SteamProvider(): Provider<{ steamid: string }> { return { type: 'steam', + display: { name: 'Steam', icon: MARK_STEAM }, init(routes, ctx) { routes.get('/authorize', async (c) => { const returnUrl = getRelativeUrl(c, './callback'); diff --git a/packages/auth/src/provider/twitch.ts b/packages/auth/src/provider/twitch.ts index 2523b36c..603206ad 100644 --- a/packages/auth/src/provider/twitch.ts +++ b/packages/auth/src/provider/twitch.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_TWITCH } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface TwitchConfig extends Oauth2WrappedConfig {} @@ -36,6 +37,7 @@ export interface TwitchConfig extends Oauth2WrappedConfig {} export function TwitchProvider(config: TwitchConfig) { return Oauth2Provider({ type: 'twitch', + display: { name: 'Twitch', icon: MARK_TWITCH }, ...config, endpoint: { authorization: 'https://id.twitch.tv/oauth2/authorize', diff --git a/packages/auth/src/provider/x.ts b/packages/auth/src/provider/x.ts index 398cdfe0..c48e3963 100644 --- a/packages/auth/src/provider/x.ts +++ b/packages/auth/src/provider/x.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_X } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface XProviderConfig extends Oauth2WrappedConfig {} @@ -37,6 +38,7 @@ export function XProvider(config: XProviderConfig) { return Oauth2Provider({ ...config, type: 'x', + display: { name: 'X', icon: MARK_X }, endpoint: { authorization: 'https://twitter.com/i/oauth2/authorize', token: 'https://api.x.com/2/oauth2/token' diff --git a/packages/auth/src/provider/yahoo.ts b/packages/auth/src/provider/yahoo.ts index 84058709..0abfa05c 100644 --- a/packages/auth/src/provider/yahoo.ts +++ b/packages/auth/src/provider/yahoo.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_YAHOO } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface YahooConfig extends Oauth2WrappedConfig {} @@ -37,6 +38,7 @@ export function YahooProvider(config: YahooConfig) { return Oauth2Provider({ ...config, type: 'yahoo', + display: { name: 'Yahoo', icon: MARK_YAHOO }, endpoint: { authorization: 'https://api.login.yahoo.com/oauth2/request_auth', token: 'https://api.login.yahoo.com/oauth2/get_token' diff --git a/packages/auth/src/ui/base.tsx b/packages/auth/src/ui/base.tsx index 2b312d15..508510a4 100644 --- a/packages/auth/src/ui/base.tsx +++ b/packages/auth/src/ui/base.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren } from 'hono/jsx'; import css from './css.js'; -import { getTheme } from './theme.js'; +import type { Theme } from './theme.js'; /** * The page every sign-in screen is drawn inside. @@ -20,12 +20,25 @@ import { getTheme } from './theme.js'; */ export function Layout( props: PropsWithChildren<{ + theme?: Theme; size?: 'small'; + /** + * Replaces the product tagline above the content. + * + * A screen that asks its own question — "Is this you?", "That code has + * expired" — says it here, because the tagline is the wrong line to + * read above an answer somebody has to give. + */ + headline?: unknown; }> ) { - const theme = getTheme(); + // Passed in rather than read from a module global. It was a global — with a + // comment conceding as much — which made every component here depend on + // something invisible at the call site: untestable in isolation, and shared + // mutable state on a runtime that keeps one module instance across requests. + const theme = props.theme; - function get(key: 'primary' | 'background' | 'logo', mode: 'light' | 'dark') { + function get(key: 'primary' | 'logo', mode: 'light' | 'dark') { if (!theme) return; if (!theme[key]) return; if (typeof theme[key] === 'string') return theme[key]; @@ -66,18 +79,20 @@ export function Layout(
-

- One place for all the ways you play.{' '} - - Gather Around - - . - - -

+ {props.headline ?? ( +

+ One place for all the ways you play.{' '} + + Gather Around + + . + + +

+ )}
{props.children}
diff --git a/packages/auth/src/ui/code.ts b/packages/auth/src/ui/code.ts new file mode 100644 index 00000000..f0058cef --- /dev/null +++ b/packages/auth/src/ui/code.ts @@ -0,0 +1,195 @@ +/** + * The screens for the pin code provider. + * + * ```ts + * import { CodeUI } from "@nestri/auth/ui/code" + * import { CodeProvider } from "@nestri/auth/provider/code" + * + * export default issuer({ + * providers: { + * code: CodeProvider( + * CodeUI({ + * copy: { code_info: "We'll send a pin code to your email" }, + * sendCode: (claims, code) => console.log(claims.email, code) + * }) + * ) + * } + * }) + * ``` + * + * What this file contains is copy and flow — which alert belongs to which + * error, what the two steps ask for. It contains no markup, because how a + * screen is drawn is the renderer's business and describing it here is what + * made every provider its own little design system. + * + * @packageDocumentation + */ + +import type { CodeProviderOptions } from '../provider/code.js'; +import type { Alert, Copy, Screen } from './screen.js'; + +const DEFAULT_COPY = { + /** + * Copy for the email input. + */ + email_placeholder: 'Email', + /** + * Error message when the email is invalid. + */ + email_invalid: 'Email address is not valid', + /** + * Copy for the continue button. + */ + button_continue: 'Continue', + /** + * Copy informing that the pin code will be emailed. + */ + code_info: "We'll send a pin code to your email.", + /** + * Copy for the pin code input. + */ + code_placeholder: 'Code', + /** + * Error message when the code is invalid. + */ + code_invalid: 'Invalid code', + /** + * Copy for when the code was sent. + */ + code_sent: 'Code sent to ', + /** + * Copy for when the code was resent. + */ + code_resent: 'Code resent to ', + /** + * Copy for the link to resend the code. + */ + code_didnt_get: "Didn't get code?", + /** + * Copy for the resend button. + */ + code_resend: 'Resend', + /** + * Error message when too many codes have been asked for, or too many + * guesses made. Deliberately one message for both: which of the two it was + * is a fact about somebody else's mailbox. + */ + rate_limited: 'Too many attempts. Wait a moment and start again.', + /** + * The consent line under the action, split around its two links so the + * sentence stays one translatable run rather than being glued together + * from fragments in the markup. + */ + terms_before: + 'By continuing, you acknowledge that you have read and understood, and agree to Nestri’s ', + terms_label: 'Terms & Conditions', + terms_url: 'https://nestri.io/terms', + terms_between: ' and ', + privacy_label: 'Privacy Policy', + privacy_url: 'https://nestri.io/privacy', + terms_after: '.' +}; + +export type CodeUICopy = typeof DEFAULT_COPY; + +export interface CodeUIOptions { + /** + * Callback to send the pin code to the user. + * + * The `claims` object contains the email or phone number of the user. + */ + sendCode: (claims: Record, code: string) => Promise; + /** + * Custom copy for the UI. + */ + copy?: Partial; + /** + * The mode to use for the input. + * @default "email" + */ + mode?: 'email' | 'phone'; +} + +/** + * Creates the screens for the code provider flow. + * @param props - Configure the screens. + */ +export function CodeUI(props: CodeUIOptions): CodeProviderOptions { + const copy = { ...DEFAULT_COPY, ...props.copy }; + const mode = props.mode ?? 'email'; + + const terms: Copy = [ + copy.terms_before, + { text: copy.terms_label, href: copy.terms_url, external: true }, + copy.terms_between, + { text: copy.privacy_label, href: copy.privacy_url, external: true }, + copy.terms_after + ]; + + return { + sendCode: props.sendCode, + length: 6, + request: async (_req, state, _form, error): Promise => { + const alerts: Alert[] = []; + if (error?.type === 'invalid_claim') + alerts.push({ tone: 'danger', message: copy.email_invalid }); + if (error?.type === 'rate_limit') alerts.push({ tone: 'danger', message: copy.rate_limited }); + + if (state.type === 'start') { + return { + kind: 'form', + alerts, + fields: [ + { kind: 'hidden', name: 'action', value: 'request' }, + { + kind: mode === 'email' ? 'email' : 'tel', + name: mode === 'email' ? 'email' : 'phone', + label: copy.email_placeholder, + autocomplete: mode === 'email' ? 'email' : 'tel', + autofocus: true + } + ], + submit: copy.button_continue, + footer: terms + }; + } + + 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] + }); + + return { + kind: 'form', + alerts, + fields: [ + { kind: 'hidden', name: 'action', value: 'verify' }, + { + kind: 'segments', + name: 'code', + label: copy.code_placeholder, + length: 6, + numeric: true, + autocomplete: 'one-time-code', + autofocus: true + } + ], + submit: copy.button_continue, + aside: { + prompt: copy.code_didnt_get, + submit: copy.code_resend, + fields: [ + ...Object.entries(state.claims).map( + ([name, value]) => ({ kind: 'hidden', name, value }) as const + ), + { kind: 'hidden', name: 'action', value: 'request' } + ] + } + }; + } + }; +} diff --git a/packages/auth/src/ui/code.tsx b/packages/auth/src/ui/code.tsx deleted file mode 100644 index 9d0561ae..00000000 --- a/packages/auth/src/ui/code.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Configure the UI that's used by the Code provider. - * - * ```ts {1,7-12} - * import { CodeUI } from "@openauthjs/openauth/ui/code" - * import { CodeProvider } from "@openauthjs/openauth/provider/code" - * - * export default issuer({ - * providers: { - * code: CodeAdapter( - * CodeUI({ - * copy: { - * code_info: "We'll send a pin code to your email" - * }, - * sendCode: (claims, code) => console.log(claims.email, code) - * }) - * ) - * }, - * // ... - * }) - * ``` - * - * @packageDocumentation - */ -/** @jsxImportSource hono/jsx */ - -import { UnknownStateError } from '../error.js'; -import { CodeProviderOptions } from '../provider/code.js'; -import { Layout } from './base.js'; -import { FormAlert } from './form.js'; - -const DEFAULT_COPY = { - /** - * Copy for the email input. - */ - email_placeholder: 'Email', - /** - * Error message when the email is invalid. - */ - email_invalid: 'Email address is not valid', - /** - * Copy for the continue button. - */ - button_continue: 'Continue', - /** - * Copy informing that the pin code will be emailed. - */ - code_info: "We'll send a pin code to your email.", - /** - * Copy for the pin code input. - */ - code_placeholder: 'Code', - /** - * Error message when the code is invalid. - */ - code_invalid: 'Invalid code', - /** - * Copy for when the code was sent. - */ - code_sent: 'Code sent to ', - /** - * Copy for when the code was resent. - */ - code_resent: 'Code resent to ', - /** - * Copy for the link to resend the code. - */ - code_didnt_get: "Didn't get code?", - /** - * Copy for the resend button. - */ - code_resend: 'Resend', - /** - * Error message when too many codes have been asked for, or too many - * guesses made. Deliberately one message for both: which of the two it was - * is a fact about somebody else's mailbox. - */ - rate_limited: 'Too many attempts. Wait a moment and start again.', - /** - * The consent line under the action, split around its two links so the - * sentence stays one translatable run rather than being glued together - * from fragments in the markup. - */ - terms_before: - 'By continuing, you acknowledge that you have read and understood, and agree to Nestri\u2019s ', - terms_label: 'Terms & Conditions', - terms_url: 'https://nestri.io/terms', - terms_between: ' and ', - privacy_label: 'Privacy Policy', - privacy_url: 'https://nestri.io/privacy', - terms_after: '.' -}; - -export type CodeUICopy = typeof DEFAULT_COPY; - -/** - * Configure the password UI. - */ -export interface CodeUIOptions { - /** - * Callback to send the pin code to the user. - * - * The `claims` object contains the email or phone number of the user. You can send the code - * using this. - * - * @example - * ```ts - * async (claims, code) => { - * // Send the code via the claim - * } - * ``` - */ - sendCode: (claims: Record, code: string) => Promise; - /** - * Custom copy for the UI. - */ - copy?: Partial; - /** - * The mode to use for the input. - * @default "email" - */ - mode?: 'email' | 'phone'; -} - -/** - * Creates a UI for the Code provider flow. - * @param props - Configure the UI. - */ -export function CodeUI(props: CodeUIOptions): CodeProviderOptions { - const copy = { - ...DEFAULT_COPY, - ...props.copy - }; - - const mode = props.mode ?? 'email'; - - return { - sendCode: props.sendCode, - length: 6, - request: async (_req, state, _form, error): Promise => { - if (state.type === 'start') { - const jsx = ( - -
- {error?.type === 'invalid_claim' && } - {error?.type === 'rate_limit' && } - - - - -

- {copy.terms_before} - - {copy.terms_label} - - {copy.terms_between} - - {copy.privacy_label} - - {copy.terms_after} -

-
- ); - return new Response(jsx.toString(), { - headers: { - 'Content-Type': 'text/html' - } - }); - } - - if (state.type === 'code') { - const jsx = ( - -
- {error?.type === 'invalid_code' && } - {error?.type === 'rate_limit' && } - {state.type === 'code' && ( - - )} - - - - -
- {Object.entries(state.claims).map(([key, value]) => ( - - ))} - -
- - {copy.code_didnt_get} - -
-
-
- ); - return new Response(jsx.toString(), { - headers: { - 'Content-Type': 'text/html' - } - }); - } - - throw new UnknownStateError(); - } - }; -} diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index cba715e0..f866b85e 100644 --- a/packages/auth/src/ui/css.ts +++ b/packages/auth/src/ui/css.ts @@ -401,6 +401,102 @@ body { display: none; } +/* The secondary action: a way in that is not the one being recommended, and + the only button that does not want to be the brightest thing on the page. */ +[data-component='button'][data-color='ghost'] { + background: var(--color-background-100); + border: 1px solid var(--color-gray-300); + color: var(--color-foreground); + text-decoration: none; + gap: 0.625rem; +} + +[data-component='button'][data-color='ghost']:hover { + background: var(--color-gray-100); + border-color: var(--color-gray-400); +} + +[data-component='button'] [data-slot='icon'] { + display: flex; + height: 1.25rem; + width: 1.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; +} + +[data-component='button'] [data-slot='icon'] svg { + height: 100%; + width: 100%; +} + +/* A code shown back to be compared against one on another screen. Tracked out + and monospaced because the whole job of this line is that two people looking + at two devices can tell whether the characters are the same. */ +[data-component='verify'] { + margin: 0 0 1.25rem; + width: 100%; + text-align: center; + font-family: var(--font-mona); + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 700; + letter-spacing: 0.15em; + font-variant-numeric: tabular-nums; + color: var(--color-foreground); + pointer-events: auto; + user-select: text; +} + +[data-component='prose'] { + width: 100%; + margin-bottom: 1.5rem; + text-align: center; + text-wrap: pretty; + color: var(--color-muted-foreground); + pointer-events: auto; + user-select: text; +} + +[data-component='prose'] p { + margin: 0 0 0.75rem; +} + +[data-component='prose'] p:last-child { + margin-bottom: 0; +} + +[data-component='prose'] a { + color: var(--color-foreground); + text-decoration: underline; + text-underline-offset: 0.125rem; +} + +[data-component='prose'][data-tone='danger'] { + color: var(--color-muted-foreground); +} + +/* The same field whether the code arrived by email or is showing on a + television across the room: wide, tracked out, and never autocorrected. */ +[data-component='input'][data-variant='code'] { + text-align: center; + font-family: var(--font-mona); + font-size: 1.5rem; + line-height: 2rem; + font-weight: 700; + letter-spacing: 0.35em; + /* Tracking adds a trailing gap after the last glyph, which pushes the run + visibly off-centre; half the tracking back as padding cancels it. */ + padding-left: calc(1.25rem + 0.35em); + font-variant-numeric: tabular-nums; +} + +[data-component='input'][data-variant='code']::placeholder { + letter-spacing: normal; + font-size: 1rem; + font-weight: 400; +} + @media (min-width: 40rem) { [data-component='stack'] { padding: 2.5rem; diff --git a/packages/auth/src/ui/form.tsx b/packages/auth/src/ui/form.tsx deleted file mode 100644 index 90b35a88..00000000 --- a/packages/auth/src/ui/form.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** @jsxImportSource hono/jsx */ - -export function FormAlert(props: { message?: string; color?: 'danger' | 'success' }) { - return ( -
- - - - - - - {props.message} -
- ); -} diff --git a/packages/auth/src/ui/icon.tsx b/packages/auth/src/ui/icon.tsx deleted file mode 100644 index 1005bdd7..00000000 --- a/packages/auth/src/ui/icon.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** @jsxImportSource hono/jsx */ - -export const ICON_GITHUB = ( - - - -); - -export const ICON_GOOGLE = ( - - - - - - -); - -export const ICON_EMAIL = ( - - - -); - -export const ICON_SLACK = ( - - - - - - - - -); diff --git a/packages/auth/src/ui/mark.ts b/packages/auth/src/ui/mark.ts new file mode 100644 index 00000000..48b6f189 --- /dev/null +++ b/packages/auth/src/ui/mark.ts @@ -0,0 +1,51 @@ +/** + * Brand marks, as raw SVG. + * + * Each provider names its own mark from `provider/*.ts`, which is why these are + * strings rather than JSX: a provider says what it is called and what it looks + * like, and does it without importing a rendering library. Before this, the + * marks lived in a record inside the code that drew the chooser, so a provider + * that record had never heard of rendered as a bare lowercase word — and the + * only way to fix that was to edit the library doing the drawing. + * + * `fill="currentColor"` wherever the mark is monochrome, so it takes the colour + * of the control it sits in. The few that are not — the ones whose brand *is* + * the colours — keep theirs. + * + * These are constants written here and never assembled from anything a request + * carries, which is what makes them safe to inject as markup. + * + * @packageDocumentation + */ + +/** A keypad: the mark for the pin code provider. */ +export const MARK_CODE = ``; + +/** A padlock: the mark for the password provider. */ +export const MARK_PASSWORD = ``; + +export const MARK_DISCORD = ``; + +export const MARK_GITHUB = ``; + +export const MARK_GOOGLE = ``; + +export const MARK_APPLE = ``; + +export const MARK_X = ``; + +export const MARK_FACEBOOK = ``; + +export const MARK_MICROSOFT = ``; + +export const MARK_TWITCH = ``; + +export const MARK_SLACK = ``; + +export const MARK_SPOTIFY = ``; + +export const MARK_LINKEDIN = ``; + +export const MARK_STEAM = ``; + +export const MARK_YAHOO = ``; diff --git a/packages/auth/src/ui/password.ts b/packages/auth/src/ui/password.ts new file mode 100644 index 00000000..e49be6b9 --- /dev/null +++ b/packages/auth/src/ui/password.ts @@ -0,0 +1,249 @@ +/** + * The screens for the password provider. + * + * ```ts + * import { PasswordUI } from "@nestri/auth/ui/password" + * import { PasswordProvider } from "@nestri/auth/provider/password" + * + * export default issuer({ + * providers: { + * password: PasswordProvider( + * PasswordUI({ + * copy: { error_email_taken: "This email is already taken." }, + * sendCode: (email, code) => console.log(email, code) + * }) + * ) + * } + * }) + * ``` + * + * Six screens across three flows, and not one of them mentions a colour, a + * class name or a tag. That is the difference the {@link Screen} boundary + * makes: this file was markup for six pages, drifting from the design language + * every time the design language moved, and none of it was noticed because + * nobody had turned password sign-in on yet. + * + * @packageDocumentation + */ + +import type { + PasswordChangeError, + PasswordChangeState, + PasswordConfig, + PasswordLoginError, + PasswordRegisterError, + PasswordRegisterState +} from '../provider/password.js'; +import type { Alert, Field, Screen } from './screen.js'; + +const DEFAULT_COPY = { + /** Error message when email is already taken. */ + error_email_taken: 'There is already an account with this email.', + /** Error message when the confirmation code is incorrect. */ + error_invalid_code: 'Code is incorrect.', + /** Error message when the email is invalid. */ + error_invalid_email: 'Email is not valid.', + /** Error message when the password is incorrect. */ + error_invalid_password: 'Password is incorrect.', + /** Error message when the passwords do not match. */ + error_password_mismatch: 'Passwords do not match.', + /** Error message when the user enters a password that fails validation. */ + error_validation_error: 'Password does not meet requirements.', + /** Copy for the register button. */ + register: 'Register', + /** Copy for the register link. */ + register_prompt: "Don't have an account?", + /** Copy for the login link. */ + login_prompt: 'Already have an account?', + /** Copy for the login button. */ + login: 'Login', + /** Copy for the forgot password link. */ + change_prompt: 'Forgot password?', + /** Copy for the resend code button. */ + code_resend: 'Resend code', + /** Copy for the "Back to" link. */ + code_return: 'Back to', + /** Copy for the email input. */ + input_email: 'Email', + /** Copy for the password input. */ + input_password: 'Password', + /** Copy for the code input. */ + input_code: 'Code', + /** Copy for the repeat password input. */ + input_repeat: 'Repeat password', + /** Copy for the continue button. */ + button_continue: 'Continue' +} satisfies { + [key in `error_${ + | PasswordLoginError['type'] + | PasswordRegisterError['type'] + | PasswordChangeError['type']}`]: string; +} & Record; + +export type PasswordUICopy = typeof DEFAULT_COPY; + +export interface PasswordUIOptions extends Pick { + /** + * Custom copy for the UI. + */ + copy?: Partial; +} + +/** + * Creates the screens for the password provider flow. + * @param input - Configure the screens. + */ +export function PasswordUI(input: PasswordUIOptions): PasswordConfig { + const copy = { ...DEFAULT_COPY, ...input.copy }; + + /** + * The banner for whatever just went wrong. + * + * One function for all three flows because the error types overlap almost + * entirely, and a `validation_error` carries its own message — the only + * case where the provider knows better than the copy table what to say. + */ + function alerts( + error?: PasswordLoginError | PasswordRegisterError | PasswordChangeError + ): Alert[] { + if (!error) return []; + if (error.type === 'validation_error') { + return [{ tone: 'danger', message: error.message || copy.error_validation_error }]; + } + return [{ tone: 'danger', message: copy[`error_${error.type}`] }]; + } + + const codeField: Field = { + kind: 'segments', + name: 'code', + label: copy.input_code, + length: 6, + numeric: true, + autocomplete: 'one-time-code', + autofocus: true + }; + + return { + validatePassword: input.validatePassword, + sendCode: input.sendCode, + + login: async (_req, _form, error): Promise => ({ + kind: 'form', + alerts: alerts(error), + fields: [ + { + kind: 'email', + name: 'email', + label: copy.input_email, + autocomplete: 'email', + autofocus: true + }, + { + kind: 'password', + name: 'password', + label: copy.input_password, + autocomplete: 'current-password' + } + ], + submit: copy.button_continue, + links: [ + { prompt: copy.register_prompt, link: { label: copy.register, href: 'register' } }, + { link: { label: copy.change_prompt, href: 'change' } } + ] + }), + + register: async (_req, state: PasswordRegisterState, _form, error): Promise => { + if (state.type === 'code') { + return { + kind: 'form', + alerts: alerts(error), + fields: [{ kind: 'hidden', name: 'action', value: 'verify' }, codeField], + submit: copy.button_continue, + links: [{ prompt: copy.code_return, link: { label: copy.login, href: 'authorize' } }] + }; + } + + return { + kind: 'form', + alerts: alerts(error), + fields: [ + { kind: 'hidden', name: 'action', value: 'register' }, + { + kind: 'email', + name: 'email', + label: copy.input_email, + autocomplete: 'email', + autofocus: true + }, + { + kind: 'password', + name: 'password', + label: copy.input_password, + autocomplete: 'new-password' + }, + { + kind: 'password', + name: 'repeat', + label: copy.input_repeat, + autocomplete: 'new-password' + } + ], + submit: copy.button_continue, + links: [{ prompt: copy.login_prompt, link: { label: copy.login, href: 'authorize' } }] + }; + }, + + change: async (_req, state: PasswordChangeState, _form, error): Promise => { + if (state.type === 'code') { + return { + kind: 'form', + alerts: alerts(error), + fields: [{ kind: 'hidden', name: 'action', value: 'verify' }, codeField], + submit: copy.button_continue, + links: [{ prompt: copy.code_return, link: { label: copy.login, href: 'authorize' } }] + }; + } + + if (state.type === 'update') { + return { + kind: 'form', + alerts: alerts(error), + fields: [ + { kind: 'hidden', name: 'action', value: 'update' }, + { + kind: 'password', + name: 'password', + label: copy.input_password, + autocomplete: 'new-password', + autofocus: true + }, + { + kind: 'password', + name: 'repeat', + label: copy.input_repeat, + autocomplete: 'new-password' + } + ], + submit: copy.button_continue + }; + } + + return { + kind: 'form', + alerts: alerts(error), + fields: [ + { kind: 'hidden', name: 'action', value: 'code' }, + { + kind: 'email', + name: 'email', + label: copy.input_email, + autocomplete: 'email', + autofocus: true + } + ], + submit: copy.button_continue, + links: [{ prompt: copy.code_return, link: { label: copy.login, href: 'authorize' } }] + }; + } + }; +} diff --git a/packages/auth/src/ui/password.tsx b/packages/auth/src/ui/password.tsx deleted file mode 100644 index d345a0ad..00000000 --- a/packages/auth/src/ui/password.tsx +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Configure the UI that's used by the Password provider. - * - * ```ts {1,7-12} - * import { PasswordUI } from "@openauthjs/openauth/ui/password" - * import { PasswordProvider } from "@openauthjs/openauth/provider/password" - * - * export default issuer({ - * providers: { - * password: PasswordAdapter( - * PasswordUI({ - * copy: { - * error_email_taken: "This email is already taken." - * }, - * sendCode: (email, code) => console.log(email, code) - * }) - * ) - * }, - * // ... - * }) - * ``` - * - * @packageDocumentation - */ -/** @jsxImportSource hono/jsx */ - -import { - PasswordChangeError, - PasswordConfig, - PasswordLoginError, - PasswordRegisterError -} from '../provider/password.js'; -import { Layout } from './base.js'; -import './form.js'; -import { FormAlert } from './form.js'; - -const DEFAULT_COPY = { - /** - * Error message when email is already taken. - */ - error_email_taken: 'There is already an account with this email.', - /** - * Error message when the confirmation code is incorrect. - */ - error_invalid_code: 'Code is incorrect.', - /** - * Error message when the email is invalid. - */ - error_invalid_email: 'Email is not valid.', - /** - * Error message when the password is incorrect. - */ - error_invalid_password: 'Password is incorrect.', - /** - * Error message when the passwords do not match. - */ - error_password_mismatch: 'Passwords do not match.', - /** - * Error message when the user enters a password that fails validation. - */ - error_validation_error: 'Password does not meet requirements.', - /** - * Title of the register page. - */ - register_title: 'Welcome to the app', - /** - * Description of the register page. - */ - register_description: 'Sign in with your email', - /** - * Title of the login page. - */ - login_title: 'Welcome to the app', - /** - * Description of the login page. - */ - login_description: 'Sign in with your email', - /** - * Copy for the register button. - */ - register: 'Register', - /** - * Copy for the register link. - */ - register_prompt: "Don't have an account?", - /** - * Copy for the login link. - */ - login_prompt: 'Already have an account?', - /** - * Copy for the login button. - */ - login: 'Login', - /** - * Copy for the forgot password link. - */ - change_prompt: 'Forgot password?', - /** - * Copy for the resend code button. - */ - code_resend: 'Resend code', - /** - * Copy for the "Back to" link. - */ - code_return: 'Back to', - /** - * Copy for the logo. - * @internal - */ - logo: 'A', - /** - * Copy for the email input. - */ - input_email: 'Email', - /** - * Copy for the password input. - */ - input_password: 'Password', - /** - * Copy for the code input. - */ - input_code: 'Code', - /** - * Copy for the repeat password input. - */ - input_repeat: 'Repeat password', - /** - * Copy for the continue button. - */ - button_continue: 'Continue' -} satisfies { - [key in `error_${ - | PasswordLoginError['type'] - | PasswordRegisterError['type'] - | PasswordChangeError['type']}`]: string; -} & Record; - -type PasswordUICopy = typeof DEFAULT_COPY; - -/** - * Configure the password UI. - */ -export interface PasswordUIOptions extends Pick { - /** - * Custom copy for the UI. - */ - copy?: Partial; -} - -/** - * Creates a UI for the Password provider flow. - * @param input - Configure the UI. - */ -export function PasswordUI(input: PasswordUIOptions): PasswordConfig { - const copy = { - ...DEFAULT_COPY, - ...input.copy - }; - return { - validatePassword: input.validatePassword, - sendCode: input.sendCode, - login: async (_req, form, error): Promise => { - const jsx = ( - -
- - - - -
- - {copy.register_prompt}{' '} - - {copy.register} - - - - {copy.change_prompt} - -
- -
- ); - return new Response(jsx.toString(), { - status: error ? 401 : 200, - headers: { - 'Content-Type': 'text/html' - } - }); - }, - register: async (_req, state, form, error): Promise => { - const emailError = ['invalid_email', 'email_taken'].includes(error?.type || ''); - const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes( - error?.type || '' - ); - const jsx = ( - -
- - {state.type === 'start' && ( - <> - - - - - -
- - {copy.login_prompt}{' '} - - {copy.login} - - -
- - )} - - {state.type === 'code' && ( - <> - - - - - )} - -
- ) as string; - return new Response(jsx.toString(), { - headers: { - 'Content-Type': 'text/html' - } - }); - }, - change: async (_req, state, form, error): Promise => { - const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes( - error?.type || '' - ); - const jsx = ( - -
- - {state.type === 'start' && ( - <> - - - - )} - {state.type === 'code' && ( - <> - - - - )} - {state.type === 'update' && ( - <> - - - - - )} - - - {state.type === 'code' && ( -
- - - {state.type === 'code' && ( -
- - {copy.code_return}{' '} - - {copy.login.toLowerCase()} - - - -
- )} -
- )} -
- ); - return new Response(jsx.toString(), { - status: error ? 400 : 200, - headers: { - 'Content-Type': 'text/html' - } - }); - } - }; -} diff --git a/packages/auth/src/ui/render.tsx b/packages/auth/src/ui/render.tsx new file mode 100644 index 00000000..9f9e0317 --- /dev/null +++ b/packages/auth/src/ui/render.tsx @@ -0,0 +1,372 @@ +/** + * The one place that turns a {@link Screen} into markup. + * + * Everything that knows what a button looks like is in this file. A provider + * describes what it needs, this decides how it is drawn, and the two are + * swappable independently — which is the property the previous arrangement did + * not have, because each provider returned a finished `Response` and therefore + * had an opinion about markup. + * + * The components below are deliberately not exported. A `data-component` + * attribute is a contract with the stylesheet and nothing else should be + * writing one: it is a string, so a typo in it is silent, and the whole reason + * to have typed components is that nobody adding a screen ever types one again. + * + * @packageDocumentation + */ +/** @jsxImportSource hono/jsx */ + +import { Layout } from './base.js'; +import type { + Alert, + ChooseScreen, + ConfirmScreen, + Copy, + Field, + FormScreen, + Mark, + MessageScreen, + Screen +} from './screen.js'; +import type { Theme } from './theme.js'; + +/** + * Draws a screen. + * + * One method, on purpose. It is the entire boundary between what the auth flow + * needs to ask and how it is presented, so replacing the presentation wholesale + * means implementing this and nothing else. + */ +export interface Renderer { + render(screen: Screen, req: Request): Response; +} + +export interface HtmlRendererOptions { + /** + * Page title, favicon, brand colour and any extra stylesheet. + * + * Held in the closure rather than in a module global, so two renderers with + * two themes can exist at once and a component can be rendered in a test + * without arranging global state first. + */ + theme?: Theme; +} + +/** The default renderer: server-rendered HTML, no client-side script. */ +export function HtmlRenderer(options?: HtmlRendererOptions): Renderer { + const theme = options?.theme; + + return { + render(screen, _req) { + const body = (() => { + switch (screen.kind) { + case 'choose': + return ; + case 'form': + return
; + case 'confirm': + return ; + case 'message': + return ; + } + })(); + + // The doctype is prepended rather than being part of the tree + // because the JSX runtime will not emit one, and without it every + // one of these pages renders in quirks mode. + return new Response(`${body.toString()}`, { + status: screen.status ?? 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + }; +} + +/* -------------------------------------------------------------------------- */ +/* Screens */ +/* -------------------------------------------------------------------------- */ + +function Choose(props: { theme?: Theme; screen: ChooseScreen }) { + return ( + +
+ {props.screen.options.map((option) => ( + + {option.mark && } + {option.label} + + ))} +
+ {props.screen.footer &&