refactor(auth): describe sign-in screens as data, draw them in one place (#339)

Providers stop returning `Response`. Each says what it needs from the
person — an address, a pin, a yes-or-no — as a `Screen`, and one
`Renderer` draws it.

The old shape made every provider a small web framework: it had to know
about markup, about the stylesheet's attribute names, about how a page
is assembled. So each grew its own callback signature, its own copy, and
its own `new Response(jsx.toString())` — and there was no shared
vocabulary left to style.

### What that had already cost

- **The device flow never got a design.** Its two pages were built by
concatenating HTML strings, with an inline `style` on the user code. Six
more replies were `text/plain` — including the one a person gets when
their sign-in cookie expires.
- **The password screens were already broken.** They render
`data-component="input"`, `data-component="link"`,
`data-component="form-footer"` against a stylesheet that was rewritten
for the code flow. Nobody noticed because password sign-in is not
switched on. They are deleted here, not repaired.
- **A provider could not be named or marked without editing the
library.** `DiscordProvider` has existed all along; wiring it up
rendered **"Continue with discord"**, lowercase, no icon, because the
marks and display names were two hardcoded `const` records inside the
code that drew the chooser.

### What changed

`ui/screen.ts` — four screen kinds (`choose`, `form`, `confirm`,
`message`) and a field vocabulary, as plain data. No JSX, no hono.

`ui/render.tsx` — the only file that knows what a button looks like.
`Renderer` is one method, so replacing the presentation layer wholesale
means implementing that and nothing else.

`Provider.display` — each provider carries its own name and mark
(`ui/mark.ts`, raw SVG strings so `provider/*.ts` never imports a
rendering library). The chooser is built from what providers declare;
`issuer({ chooser })` is left with only the two decisions a deployment
makes that a provider cannot — whether to offer it, and what to put
first.

The theme global is gone. It was `globalThis`, with a comment conceding
as much: every component depended on something invisible at the call
site, untestable in isolation, and shared mutable state on a runtime
that keeps one module instance across requests. It is a closure argument
now, and `Theme` shrinks to the values a deployment sets that the
stylesheet cannot.

`kind: 'segments'` is named by meaning rather than widget — a code read
off one screen and typed into another. The emailed pin and the device
user code are the same field now; they used to describe it separately,
in different files.

### Adding things, after

```ts
providers: {
  code: CodeProvider({ ... }),
  discord: DiscordProvider({ clientID, clientSecret }),   // icon and name included
  password: PasswordProvider(PasswordUI({ ... })),        // in the design, because it has none of its own
}
```

Neither touches CSS. Neither touches the renderer.

### Notes

- **No Tailwind.** I floated it and then dropped it: `packages/auth`
deploys straight from `src/` both ways (`wrangler.jsonc` points `main`
at `src/index.ts`; `server.ts` runs the same handler under Bun), so
adding a CSS toolchain would fight both. The stringly-typed
`data-component` problem is solved by the typed components instead —
nobody adding a screen writes one. The stylesheet grew ~95 lines for the
new primitives and that is the last CSS this change needs.
- **Design tokens stay independent** of the website rather than shared,
since auth is a separate Worker on a separate hostname with its own
release cadence. The brand values are copied, with a comment naming the
site as the source. Easy to reverse if you'd rather couple them.
- `src/provider/oauth2.ts` has a pre-existing `TS2578: Unused
'@ts-expect-error'` on `dev`. Untouched — verified it fails the same way
without this branch.

### Verification

- `packages/auth`: 66/66 pass. The 27 device tests are **unmodified**
and still pass, which is the behaviour argument — statuses, cookies and
the confirmation step are unchanged.
- `apps/auth`: 23/23 pass.
- `tsc --noEmit` clean on both, apart from the pre-existing error above.
- Every screen rendered and eyeballed in a browser.

Net −757 lines.
This commit is contained in:
Wanjohi
2026-09-17 22:52:42 +00:00
committed by GitHub
37 changed files with 1546 additions and 1452 deletions

View File

@@ -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",

View File

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

View File

@@ -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<boolean>;
/**
* 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<string, string>, req: Request): Promise<Response>;
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<Providers, Subjects, Result>) {
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
/**
* 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 (
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">` +
`<title>Confirm sign-in</title>` +
`<h1>Is this you?</h1>` +
`<p><strong>${client}</strong> is asking to sign in to your account.</p>` +
`<p>The code it is showing you should be:</p>` +
`<p><code style="font-size:2em;letter-spacing:.2em">${code.slice(0, 4)}-${code.slice(4)}</code></p>` +
`<p>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.</p>` +
`<form method="post" action="/device/confirm">` +
`<input type="hidden" name="csrf" value="${escapeHtml(confirmation.csrf)}">` +
`<button type="submit" name="action" value="approve">Approve</button> ` +
`<button type="submit" name="action" value="deny">Deny</button>` +
`</form>`
);
/**
* 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(
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">` +
`<title>Sign in to a device</title>` +
`<form method="get" action="/device">` +
`<label for="user_code">Enter the code shown in the app</label>` +
`<input id="user_code" name="user_code" autocomplete="off" autofocus>` +
`<button type="submit">Continue</button>` +
`</form>`
);
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) => {

View File

@@ -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'
});
}

View File

@@ -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<Response>;
) => Promise<Screen>;
/**
* Callback to send the pin code to the user.
*
@@ -242,6 +239,7 @@ export function CodeProvider<Claims extends Record<string, string> = Record<stri
return {
type: 'code',
display: { name: 'Email', icon: MARK_CODE },
init(routes, ctx) {
async function transition(
c: Context,
@@ -253,8 +251,7 @@ export function CodeProvider<Claims extends Record<string, string> = Record<stri
// Twenty-four hours, which is what this was, made a six-digit
// pin usable for a day.
await ctx.set<CodeProviderState>(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<Claims extends Record<string, string> = Record<stri
return transition(c, { type: 'start' }, fd);
});
}
};
};
}
/**

View File

@@ -65,6 +65,7 @@ export function CognitoProvider(config: CognitoConfig) {
return Oauth2Provider({
type: 'cognito',
display: { name: 'Cognito' },
...config,
endpoint: {
authorization: `https://${domain}/oauth2/authorize`,

View File

@@ -17,6 +17,7 @@
* @packageDocumentation
*/
import { MARK_DISCORD } from '../ui/mark.js';
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
export interface DiscordConfig extends Oauth2WrappedConfig {}
@@ -36,6 +37,7 @@ export interface DiscordConfig extends Oauth2WrappedConfig {}
export function DiscordProvider(config: DiscordConfig) {
return Oauth2Provider({
type: 'discord',
display: { name: 'Discord', icon: MARK_DISCORD },
...config,
endpoint: {
authorization: 'https://discord.com/oauth2/authorize',

View File

@@ -33,6 +33,7 @@
* @packageDocumentation
*/
import { MARK_FACEBOOK } from '../ui/mark.js';
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
@@ -55,6 +56,7 @@ export function FacebookProvider(config: FacebookConfig) {
return Oauth2Provider({
...config,
type: 'facebook',
display: { name: 'Facebook', icon: MARK_FACEBOOK },
endpoint: {
authorization: 'https://www.facebook.com/v12.0/dialog/oauth',
token: 'https://graph.facebook.com/v12.0/oauth/access_token'
@@ -79,6 +81,7 @@ export function FacebookOidcProvider(config: FacebookOidcConfig) {
return OidcProvider({
...config,
type: 'facebook',
display: { name: 'Facebook', icon: MARK_FACEBOOK },
issuer: 'https://graph.facebook.com'
});
}

View File

@@ -17,6 +17,7 @@
* @packageDocumentation
*/
import { MARK_GITHUB } from '../ui/mark.js';
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
export interface GithubConfig extends Oauth2WrappedConfig {}
@@ -37,6 +38,7 @@ export function GithubProvider(config: GithubConfig) {
return Oauth2Provider({
...config,
type: 'github',
display: { name: 'GitHub', icon: MARK_GITHUB },
endpoint: {
authorization: 'https://github.com/login/oauth/authorize',
token: 'https://github.com/login/oauth/access_token'

View File

@@ -33,6 +33,7 @@
* @packageDocumentation
*/
import { MARK_GOOGLE } from '../ui/mark.js';
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
@@ -55,6 +56,7 @@ export function GoogleProvider(config: GoogleConfig) {
return Oauth2Provider({
...config,
type: 'google',
display: { name: 'Google', icon: MARK_GOOGLE },
endpoint: {
authorization: 'https://accounts.google.com/o/oauth2/v2/auth',
token: 'https://oauth2.googleapis.com/token',
@@ -80,6 +82,7 @@ export function GoogleOidcProvider(config: GoogleOidcConfig) {
return OidcProvider({
...config,
type: 'google',
display: { name: 'Google', icon: MARK_GOOGLE },
issuer: 'https://accounts.google.com'
});
}

View File

@@ -36,6 +36,7 @@ export interface JumpCloudConfig extends Oauth2WrappedConfig {}
export function JumpCloudProvider(config: JumpCloudConfig) {
return Oauth2Provider({
type: 'jumpcloud',
display: { name: 'JumpCloud' },
...config,
endpoint: {
authorization: 'https://oauth.id.jumpcloud.com/oauth2/auth',

View File

@@ -1,9 +1,11 @@
import { MARK_LINKEDIN } from '../ui/mark.js';
import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js';
export function LinkedInAdapter(config: Oauth2WrappedConfig) {
return Oauth2Provider({
...config,
type: 'linkedin',
display: { name: 'LinkedIn', icon: MARK_LINKEDIN },
endpoint: {
authorization: 'https://www.linkedin.com/oauth/v2/authorization',
token: 'https://www.linkedin.com/oauth/v2/accessToken'

View File

@@ -34,6 +34,7 @@
* @packageDocumentation
*/
import { MARK_MICROSOFT } from '../ui/mark.js';
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
@@ -71,6 +72,7 @@ export function MicrosoftProvider(config: MicrosoftConfig) {
return Oauth2Provider({
...config,
type: 'microsoft',
display: { name: 'Microsoft', icon: MARK_MICROSOFT },
endpoint: {
authorization: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/authorize`,
token: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/token`
@@ -95,6 +97,7 @@ export function MicrosoftOidcProvider(config: MicrosoftOidcConfig) {
return OidcProvider({
...config,
type: 'microsoft',
display: { name: 'Microsoft', icon: MARK_MICROSOFT },
issuer: 'https://graph.microsoft.com/oidc/userinfo'
});
}

View File

@@ -27,9 +27,16 @@ import { createRemoteJWKSet, jwtVerify } from 'jose';
import { OauthError } from '../error.js';
import { generatePKCE } from '../pkce.js';
import { getRelativeUrl } from '../util.js';
import { Provider } from './provider.js';
import { Provider, type ProviderDisplay } from './provider.js';
export interface Oauth2Config {
/**
* How this provider is named and marked on the chooser.
*
* @internal
*/
display?: ProviderDisplay;
/**
* @internal
*/
@@ -216,6 +223,7 @@ export function Oauth2Provider(
return {
type: config.type || 'oauth2',
display: config.display,
init(routes, ctx) {
routes.get('/authorize', async (c) => {
const state = crypto.randomUUID();

View File

@@ -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 = {

View File

@@ -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<Response>
* register: (req, state, form, error) => Promise<Response>
* change: (req, state, form, error) => Promise<Response>
* login: (req, form, error) => Promise<Screen>
* register: (req, state, form, error) => Promise<Screen>
* change: (req, state, form, error) => Promise<Screen>
* })
* ```
*
* 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<Response>;
login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise<Screen>;
/**
* 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<Response>;
) => Promise<Screen>;
/**
* 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<Response>;
) => Promise<Screen>;
/**
* 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<PasswordRegisterState>(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<PasswordChangeState>(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') {

View File

@@ -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<Properties = any> {
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<Properties>) => void;
client?: (input: {
clientID: string;
@@ -24,6 +49,15 @@ export interface ProviderOptions<Properties> {
}
) => Promise<Response>;
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: <T>(ctx: Context, key: string, maxAge: number, value: T) => Promise<void>;
get: <T>(ctx: Context, key: string) => Promise<T>;
unset: (ctx: Context, key: string) => Promise<void>;

View File

@@ -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'

View File

@@ -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'

View File

@@ -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');

View File

@@ -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',

View File

@@ -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'

View File

@@ -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'

View File

@@ -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(
<div data-component="logo">
<LogoWord />
</div>
<h2 data-component="title">
One place for all the ways you play.{' '}
<strong>
Gather Around
<a
href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
rel="noopener noreferrer"
target="_blank">
.
</a>
</strong>
</h2>
{props.headline ?? (
<h2 data-component="title">
One place for all the ways you play.{' '}
<strong>
Gather Around
<a
href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
rel="noopener noreferrer"
target="_blank">
.
</a>
</strong>
</h2>
)}
<div data-component="actions">{props.children}</div>
</div>
</div>

View File

@@ -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 Nestris ',
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<string, string>, code: string) => Promise<void>;
/**
* Custom copy for the UI.
*/
copy?: Partial<CodeUICopy>;
/**
* 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<Screen> => {
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' }
]
}
};
}
};
}

View File

@@ -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<string, string>, code: string) => Promise<void>;
/**
* Custom copy for the UI.
*/
copy?: Partial<CodeUICopy>;
/**
* 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<Response> => {
if (state.type === 'start') {
const jsx = (
<Layout>
<form data-component="form" method="post">
{error?.type === 'invalid_claim' && <FormAlert message={copy.email_invalid} />}
{error?.type === 'rate_limit' && <FormAlert message={copy.rate_limited} />}
<input type="hidden" name="action" value="request" />
<input
data-component="input"
autofocus
type={mode === 'email' ? 'email' : 'tel'}
name={mode === 'email' ? 'email' : 'phone'}
inputmode={mode === 'email' ? 'email' : 'numeric'}
required
placeholder={copy.email_placeholder}
/>
<button data-component="button">{copy.button_continue}</button>
</form>
<p data-component="form-footer">
{copy.terms_before}
<a href={copy.terms_url} rel="noopener noreferrer" target="_blank">
{copy.terms_label}
</a>
{copy.terms_between}
<a href={copy.privacy_url} rel="noopener noreferrer" target="_blank">
{copy.privacy_label}
</a>
{copy.terms_after}
</p>
</Layout>
);
return new Response(jsx.toString(), {
headers: {
'Content-Type': 'text/html'
}
});
}
if (state.type === 'code') {
const jsx = (
<Layout>
<form data-component="form" class="form" method="post">
{error?.type === 'invalid_code' && <FormAlert message={copy.code_invalid} />}
{error?.type === 'rate_limit' && <FormAlert message={copy.rate_limited} />}
{state.type === 'code' && (
<FormAlert
message={(state.resend ? copy.code_resent : copy.code_sent) + state.claims.email}
color="success"
/>
)}
<input type="hidden" name="action" value="verify" />
<input
data-component="input"
autofocus
minLength={6}
maxLength={6}
type="text"
name="code"
required
inputmode="numeric"
autocomplete="one-time-code"
placeholder={copy.code_placeholder}
/>
<button data-component="button">{copy.button_continue}</button>
</form>
<form method="post">
{Object.entries(state.claims).map(([key, value]) => (
<input key={key} type="hidden" name={key} value={value} className="hidden" />
))}
<input type="hidden" name="action" value="request" />
<div data-component="form-footer">
<span>
{copy.code_didnt_get} <button data-component="link">{copy.code_resend}</button>
</span>
</div>
</form>
</Layout>
);
return new Response(jsx.toString(), {
headers: {
'Content-Type': 'text/html'
}
});
}
throw new UnknownStateError();
}
};
}

View File

@@ -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;

View File

@@ -1,35 +0,0 @@
/** @jsxImportSource hono/jsx */
export function FormAlert(props: { message?: string; color?: 'danger' | 'success' }) {
return (
<div data-component="form-alert" data-color={props.color}>
<svg
data-slot="icon-success"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<svg
data-slot="icon-danger"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<span data-slot="message">{props.message}</span>
</div>
);
}

View File

@@ -1,86 +0,0 @@
/** @jsxImportSource hono/jsx */
export const ICON_GITHUB = (
<svg
viewBox="0 0 256 250"
width="256"
height="250"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid">
<path d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0Zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931Zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66Zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08Zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27Zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622Zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868Zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403Z" />
</svg>
);
export const ICON_GOOGLE = (
<svg
width="256"
height="262"
viewBox="0 0 256 262"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid">
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
/>
<path
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
);
export const ICON_EMAIL = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
/>
</svg>
);
export const ICON_SLACK = (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.79948 0C7.47279 0.000978593 6.39909 1.07547 6.40007 2.39951C6.39909 3.72355 7.47377 4.79804 8.80046 4.79902H11.2009V2.40049C11.2018 1.07645 10.1271 0.00195719 8.79948 0ZM8.79948 6.4H2.40039C1.07371 6.40098 -0.000977873 7.47547 2.67973e-06 8.79951C-0.00195842 10.1235 1.07273 11.198 2.39941 11.2H8.79948C10.1262 11.199 11.2009 10.1245 11.1999 8.80049C11.2009 7.47547 10.1262 6.40098 8.79948 6.4Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M24.0007 8.79951C24.0016 7.47547 22.9269 6.40098 21.6003 6.4C20.2736 6.40098 19.1989 7.47547 19.1999 8.79951V11.2H21.6003C22.9269 11.199 24.0016 10.1245 24.0007 8.79951ZM17.6006 8.79951V2.39951C17.6016 1.07645 16.5279 0.00195719 15.2012 0C13.8745 0.000978593 12.7998 1.07547 12.8008 2.39951V8.79951C12.7988 10.1235 13.8735 11.198 15.2002 11.2C16.5269 11.199 17.6016 10.1245 17.6006 8.79951Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.1992 23.9998C16.5259 23.9988 17.6006 22.9243 17.5996 21.6003C17.6006 20.2763 16.5259 19.2018 15.1992 19.2008H12.7988V21.6003C12.7978 22.9234 13.8725 23.9978 15.1992 23.9998ZM15.1992 17.5988H21.5993C22.926 17.5978 24.0007 16.5234 23.9997 15.1993C24.0016 13.8753 22.927 12.8008 21.6003 12.7988H15.2002C13.8735 12.7998 12.7988 13.8743 12.7998 15.1983C12.7988 16.5234 13.8725 17.5978 15.1992 17.5988Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 15.1993C-0.000979882 16.5234 1.07371 17.5978 2.40039 17.5988C3.72708 17.5978 4.80177 16.5234 4.80079 15.1993V12.7998H2.40039C1.07371 12.8008 -0.000979882 13.8753 0 15.1993ZM6.40007 15.1993V21.5993C6.3981 22.9234 7.47279 23.9978 8.79948 23.9998C10.1262 23.9988 11.2009 22.9243 11.1999 21.6003V15.2013C11.2018 13.8772 10.1271 12.8027 8.80046 12.8008C7.47279 12.8008 6.39909 13.8753 6.40007 15.1993Z"
fill="currentColor"
/>
</g>
</svg>
);

View File

@@ -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 = `<svg viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M8.55,36.91A6.55,6.55,0,1,1,2,43.45,6.54,6.54,0,0,1,8.55,36.91Zm17.45,0a6.55,6.55,0,1,1-6.55,6.54A6.55,6.55,0,0,1,26,36.91Zm17.45,0a6.55,6.55,0,1,1-6.54,6.54A6.54,6.54,0,0,1,43.45,36.91ZM8.55,19.45A6.55,6.55,0,1,1,2,26,6.55,6.55,0,0,1,8.55,19.45Zm17.45,0A6.55,6.55,0,1,1,19.45,26,6.56,6.56,0,0,1,26,19.45Zm17.45,0A6.55,6.55,0,1,1,36.91,26,6.55,6.55,0,0,1,43.45,19.45ZM8.55,2A6.55,6.55,0,1,1,2,8.55,6.54,6.54,0,0,1,8.55,2ZM26,2a6.55,6.55,0,1,1-6.55,6.55A6.55,6.55,0,0,1,26,2ZM43.45,2a6.55,6.55,0,1,1-6.54,6.55A6.55,6.55,0,0,1,43.45,2Z"/></svg>`;
/** A padlock: the mark for the password provider. */
export const MARK_PASSWORD = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z" clip-rule="evenodd"/></svg>`;
export const MARK_DISCORD = `<svg viewBox="0 0 640 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`;
export const MARK_GITHUB = `<svg viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/></svg>`;
export const MARK_GOOGLE = `<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24s.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>`;
export const MARK_APPLE = `<svg viewBox="0 0 814 1000" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>`;
export const MARK_X = `<svg viewBox="0 0 1200 1227" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" d="M714.163 519.284 1160.89 0h-105.86L667.137 450.887 357.328 0H0l468.492 681.821L0 1226.37h105.866l409.625-476.152 327.181 476.152H1200L714.137 519.284h.026ZM569.165 687.828l-47.468-67.894-377.686-540.24h162.604l304.797 435.991 47.468 67.894 396.2 566.721H892.476L569.165 687.854v-.026Z"/></svg>`;
export const MARK_FACEBOOK = `<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><linearGradient x1="50%" x2="50%" y1="97.078%" y2="0%" id="nestri-mark-facebook"><stop offset="0%" stop-color="#0062E0"/><stop offset="100%" stop-color="#19AFFF"/></linearGradient></defs><path fill="url(#nestri-mark-facebook)" d="M15 35.8C6.5 34.3 0 26.9 0 18 0 8.1 8.1 0 18 0s18 8.1 18 18c0 8.9-6.5 16.3-15 17.8l-1-.8h-4l-1 .8z"/><path fill="#FFF" d="m25 23 .8-5H21v-3.5c0-1.4.5-2.5 2.7-2.5H26V7.4c-1.3-.2-2.7-.4-4-.4-4.1 0-7 2.5-7 7v4h-4.5v5H15v12.7c1 .2 2 .3 3 .3s2-.1 3-.3V23h4z"/></svg>`;
export const MARK_MICROSOFT = `<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#F1511B" d="M121.666 121.666H0V0h121.666z"/><path fill="#80CC28" d="M256 121.666H134.335V0H256z"/><path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z"/><path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z"/></svg>`;
export const MARK_TWITCH = `<svg viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" d="M40.1 32L10 108.9v314.3h107V480h60.2l56.8-56.8h87l117-117V32H40.1zm357.8 254.1L331 353H224l-56.8 56.8V353H76.9V72.1h321v214zM331 149v116.9h-40.1V149H331zm-107 0v116.9h-40.1V149H224z"/></svg>`;
export const MARK_SLACK = `<svg viewBox="0 0 2447.6 2452.5" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g clip-rule="evenodd" fill-rule="evenodd"><path fill="#36c5f0" d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"/><path fill="#2eb67d" d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"/><path fill="#ecb22e" d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"/><path fill="#e01e5a" d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"/></g></svg>`;
export const MARK_SPOTIFY = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#1ED760" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>`;
export const MARK_LINKEDIN = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z"/></svg>`;
export const MARK_STEAM = `<svg viewBox="0 0 496 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="currentColor" d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>`;
export const MARK_YAHOO = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#5F01D1" d="M0 6.1h4.7l2.7 6.9 2.8-6.9h4.6l-6.9 16.6H3.3l1.9-4.4zM17.6 12.2h-5.1L17 1.3h5.1zM18.3 13.1c1.6 0 2.9 1.3 2.9 2.9s-1.3 2.9-2.9 2.9-2.9-1.3-2.9-2.9 1.3-2.9 2.9-2.9z"/></svg>`;

View File

@@ -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<string, string>;
export type PasswordUICopy = typeof DEFAULT_COPY;
export interface PasswordUIOptions extends Pick<PasswordConfig, 'sendCode' | 'validatePassword'> {
/**
* Custom copy for the UI.
*/
copy?: Partial<PasswordUICopy>;
}
/**
* 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<Screen> => ({
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<Screen> => {
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<Screen> => {
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' } }]
};
}
};
}

View File

@@ -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<string, string>;
type PasswordUICopy = typeof DEFAULT_COPY;
/**
* Configure the password UI.
*/
export interface PasswordUIOptions extends Pick<PasswordConfig, 'sendCode' | 'validatePassword'> {
/**
* Custom copy for the UI.
*/
copy?: Partial<PasswordUICopy>;
}
/**
* 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<Response> => {
const jsx = (
<Layout>
<form data-component="form" method="post">
<FormAlert message={error?.type && copy?.[`error_${error.type}`]} />
<input
data-component="input"
type="email"
name="email"
required
placeholder={copy.input_email}
autofocus={!error}
value={form?.get('email')?.toString()}
/>
<input
data-component="input"
autofocus={error?.type === 'invalid_password'}
required
type="password"
name="password"
placeholder={copy.input_password}
autoComplete="current-password"
/>
<button data-component="button">{copy.button_continue}</button>
<div data-component="form-footer">
<span>
{copy.register_prompt}{' '}
<a data-component="link" href="register">
{copy.register}
</a>
</span>
<a data-component="link" href="change">
{copy.change_prompt}
</a>
</div>
</form>
</Layout>
);
return new Response(jsx.toString(), {
status: error ? 401 : 200,
headers: {
'Content-Type': 'text/html'
}
});
},
register: async (_req, state, form, error): Promise<Response> => {
const emailError = ['invalid_email', 'email_taken'].includes(error?.type || '');
const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes(
error?.type || ''
);
const jsx = (
<Layout>
<form data-component="form" method="post">
<FormAlert
message={
error?.type
? error.type === 'validation_error'
? (error.message ?? copy?.[`error_${error.type}`])
: copy?.[`error_${error.type}`]
: undefined
}
/>
{state.type === 'start' && (
<>
<input type="hidden" name="action" value="register" />
<input
data-component="input"
autofocus={!error || emailError}
type="email"
name="email"
value={!emailError ? form?.get('email')?.toString() : ''}
required
placeholder={copy.input_email}
/>
<input
data-component="input"
autofocus={passwordError}
type="password"
name="password"
placeholder={copy.input_password}
required
value={!passwordError ? form?.get('password')?.toString() : ''}
autoComplete="new-password"
/>
<input
data-component="input"
type="password"
name="repeat"
required
autofocus={passwordError}
placeholder={copy.input_repeat}
autoComplete="new-password"
/>
<button data-component="button">{copy.button_continue}</button>
<div data-component="form-footer">
<span>
{copy.login_prompt}{' '}
<a data-component="link" href="authorize">
{copy.login}
</a>
</span>
</div>
</>
)}
{state.type === 'code' && (
<>
<input type="hidden" name="action" value="verify" />
<input
data-component="input"
autofocus
name="code"
minLength={6}
maxLength={6}
required
placeholder={copy.input_code}
autoComplete="one-time-code"
/>
<button data-component="button">{copy.button_continue}</button>
</>
)}
</form>
</Layout>
) as string;
return new Response(jsx.toString(), {
headers: {
'Content-Type': 'text/html'
}
});
},
change: async (_req, state, form, error): Promise<Response> => {
const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes(
error?.type || ''
);
const jsx = (
<Layout>
<form data-component="form" method="post" replace>
<FormAlert
message={
error?.type
? error.type === 'validation_error'
? (error.message ?? copy?.[`error_${error.type}`])
: copy?.[`error_${error.type}`]
: undefined
}
/>
{state.type === 'start' && (
<>
<input type="hidden" name="action" value="code" />
<input
data-component="input"
autofocus
type="email"
name="email"
required
value={form?.get('email')?.toString()}
placeholder={copy.input_email}
/>
</>
)}
{state.type === 'code' && (
<>
<input type="hidden" name="action" value="verify" />
<input
data-component="input"
autofocus
name="code"
minLength={6}
maxLength={6}
required
placeholder={copy.input_code}
autoComplete="one-time-code"
/>
</>
)}
{state.type === 'update' && (
<>
<input type="hidden" name="action" value="update" />
<input
data-component="input"
autofocus
type="password"
name="password"
placeholder={copy.input_password}
required
value={!passwordError ? form?.get('password')?.toString() : ''}
autoComplete="new-password"
/>
<input
data-component="input"
type="password"
name="repeat"
required
value={!passwordError ? form?.get('password')?.toString() : ''}
placeholder={copy.input_repeat}
autoComplete="new-password"
/>
</>
)}
<button data-component="button">{copy.button_continue}</button>
</form>
{state.type === 'code' && (
<form method="post">
<input type="hidden" name="action" value="code" />
<input type="hidden" name="email" value={state.email} />
{state.type === 'code' && (
<div data-component="form-footer">
<span>
{copy.code_return}{' '}
<a data-component="link" href="authorize">
{copy.login.toLowerCase()}
</a>
</span>
<button data-component="link">{copy.code_resend}</button>
</div>
)}
</form>
)}
</Layout>
);
return new Response(jsx.toString(), {
status: error ? 400 : 200,
headers: {
'Content-Type': 'text/html'
}
});
}
};
}

View File

@@ -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 <Choose theme={theme} screen={screen} />;
case 'form':
return <Form theme={theme} screen={screen} />;
case 'confirm':
return <Confirm theme={theme} screen={screen} />;
case 'message':
return <Message theme={theme} screen={screen} />;
}
})();
// 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(`<!doctype html>${body.toString()}`, {
status: screen.status ?? 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
};
}
/* -------------------------------------------------------------------------- */
/* Screens */
/* -------------------------------------------------------------------------- */
function Choose(props: { theme?: Theme; screen: ChooseScreen }) {
return (
<Layout theme={props.theme}>
<div data-component="form">
{props.screen.options.map((option) => (
<a href={option.href} data-component="button" data-color="ghost">
{option.mark && <Glyph mark={option.mark} />}
{option.label}
</a>
))}
</div>
{props.screen.footer && <Footer copy={props.screen.footer} />}
</Layout>
);
}
function Form(props: { theme?: Theme; screen: FormScreen }) {
const screen = props.screen;
return (
<Layout theme={props.theme}>
<form data-component="form" method={screen.method ?? 'post'} action={screen.action}>
{screen.alerts?.map((alert) => (
<Banner alert={alert} />
))}
{screen.fields.map((field) => (
<Input field={field} />
))}
<button data-component="button">{screen.submit}</button>
</form>
{screen.links && screen.links.length > 0 && (
<div data-component="form-footer">
{screen.links.map((entry) => (
<span>
{entry.prompt ? `${entry.prompt} ` : ''}
<Anchor
href={entry.link.href}
external={entry.link.external}
label={entry.link.label}
/>
</span>
))}
</div>
)}
{screen.aside && (
<form method={screen.method ?? 'post'} action={screen.action}>
{screen.aside.fields?.map((field) => (
<Input field={field} />
))}
<div data-component="form-footer">
<span>
{screen.aside.prompt ? `${screen.aside.prompt} ` : ''}
<button data-component="link">{screen.aside.submit}</button>
</span>
</div>
</form>
)}
{screen.footer && <Footer copy={screen.footer} />}
</Layout>
);
}
function Confirm(props: { theme?: Theme; screen: ConfirmScreen }) {
const screen = props.screen;
return (
<Layout theme={props.theme} headline={<Headline text={screen.heading} />}>
{screen.verify && (
<p data-component="verify">{group(screen.verify.code, screen.verify.group)}</p>
)}
<div data-component="prose">
{screen.body.map((line) => (
<p>
<Prose copy={line} />
</p>
))}
</div>
<form data-component="form" method="post" action={screen.action}>
{screen.fields?.map((field) => (
<Input field={field} />
))}
<button data-component="button" name={screen.approve.name} value={screen.approve.value}>
{screen.approve.label}
</button>
<button
data-component="button"
data-color="ghost"
name={screen.deny.name}
value={screen.deny.value}>
{screen.deny.label}
</button>
</form>
</Layout>
);
}
function Message(props: { theme?: Theme; screen: MessageScreen }) {
const screen = props.screen;
return (
<Layout theme={props.theme} headline={<Headline text={screen.heading} />}>
<div data-component="prose" data-tone={screen.tone}>
{screen.body.map((line) => (
<p>
<Prose copy={line} />
</p>
))}
</div>
{screen.link && (
<div data-component="form">
<a href={screen.link.href} data-component="button" data-color="ghost">
{screen.link.label}
</a>
</div>
)}
</Layout>
);
}
/* -------------------------------------------------------------------------- */
/* Pieces */
/* -------------------------------------------------------------------------- */
function Headline(props: { text: string }) {
return (
<h2 data-component="title">
<strong>{props.text}</strong>
</h2>
);
}
/**
* A field, drawn according to what it means rather than what it is.
*
* `segments` is the one that earns its own case: a code read off one screen and
* typed into another wants to be wide, tracked out and unambiguous, and it is
* the same treatment whether the code arrived by email or is showing on a
* television. Both used to describe that separately, in different files.
*/
function Input(props: { field: Field }) {
const field = props.field;
if (field.kind === 'hidden') {
return <input type="hidden" name={field.name} value={field.value} />;
}
if (field.kind === 'segments') {
return (
<input
data-component="input"
data-variant="code"
type="text"
name={field.name}
aria-label={field.label}
placeholder={field.label}
minLength={field.length}
maxLength={field.length}
size={field.length}
required
spellcheck={false}
autocapitalize="characters"
inputmode={field.numeric ? 'numeric' : 'text'}
autocomplete={field.autocomplete}
autofocus={field.autofocus}
/>
);
}
if (field.kind === 'password') {
return (
<input
data-component="input"
type="password"
name={field.name}
aria-label={field.label}
placeholder={field.label}
required={field.required ?? true}
autocomplete={field.autocomplete}
autofocus={field.autofocus}
/>
);
}
return (
<input
data-component="input"
type={field.kind === 'email' ? 'email' : field.kind === 'tel' ? 'tel' : 'text'}
name={field.name}
aria-label={field.label}
placeholder={field.label}
inputmode={field.kind === 'email' ? 'email' : field.kind === 'tel' ? 'numeric' : undefined}
required={field.required ?? true}
autocomplete={field.autocomplete}
autofocus={field.autofocus}
/>
);
}
function Banner(props: { alert: Alert }) {
return (
<div data-component="form-alert" data-color={props.alert.tone}>
<svg
data-slot="icon-success"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<svg
data-slot="icon-danger"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
<span data-slot="message">{props.alert.message}</span>
</div>
);
}
function Footer(props: { copy: Copy }) {
return (
<p data-component="form-footer">
<Prose copy={props.copy} />
</p>
);
}
/** One line of copy, with its links kept inside the sentence they belong to. */
function Prose(props: { copy: Copy }) {
if (typeof props.copy === 'string') return <>{props.copy}</>;
return (
<>
{props.copy.map((run) =>
typeof run === 'string' ? (
<>{run}</>
) : (
<Anchor href={run.href} external={run.external} label={run.text} />
)
)}
</>
);
}
function Anchor(props: { href: string; label: string; external?: boolean }) {
return (
<a
href={props.href}
{...(props.external ? { rel: 'noopener noreferrer', target: '_blank' } : {})}>
{props.label}
</a>
);
}
/**
* A brand mark.
*
* The markup comes from a library constant in `mark.ts` and never from anything
* a request carries, which is what makes injecting it here safe. A provider
* naming its own mark is the reason marks are strings at all.
*/
function Glyph(props: { mark: Mark }) {
return <i data-slot="icon" dangerouslySetInnerHTML={{ __html: props.mark }} />;
}
/** `ABCD1234` shown as `ABCD-1234`, so it can be read aloud and compared. */
function group(code: string, size?: number): string {
if (!size || size <= 0 || code.length <= size) return code;
const parts: string[] = [];
for (let at = 0; at < code.length; at += size) parts.push(code.slice(at, at + size));
return parts.join('-');
}

View File

@@ -0,0 +1,189 @@
/**
* What a sign-in page asks for, described as data.
*
* Nothing here renders anything. A provider says *what it needs from the
* person* — an address, a pin, a yes-or-no — and something else decides what
* that looks like. That split is the whole point of this file, and it is worth
* saying why, because the shape it replaced is the more obvious one.
*
* Previously each provider was handed a callback and asked to return a
* `Response`. That makes every provider a small web framework: it has to know
* about markup, about the stylesheet's class names, about how a page is
* assembled. So each one grew its own callback signature, its own copy, its own
* `new Response(jsx.toString())` — and there was no shared vocabulary left to
* style, so adding a provider meant writing a page and adding a screen meant
* writing CSS.
*
* With screens as data there is exactly one thing that knows about markup, and
* a new provider describes itself in a dozen lines. The other half of the same
* trade: a renderer can be swapped whole, because {@link Screen} is the entire
* contract between the two halves.
*
* @packageDocumentation
*/
/**
* Raw SVG markup for a brand mark.
*
* A string and not JSX so that `provider/*.ts` can name its own mark without
* any of them importing a rendering library — a provider describing itself
* must not drag in the thing that draws it. These are library constants,
* written here, never assembled from anything a request carries.
*/
export type Mark = string;
/**
* A fragment of a sentence, which may be a link.
*
* Copy that contains a link is a list of these rather than a string with
* markup in it, because the alternative is either HTML in a translatable
* string or a sentence glued together from fragments in the markup. Both put
* the sentence somewhere a translator cannot see it whole.
*/
export type Run = string | { text: string; href: string; external?: boolean };
/** One line of prose, with or without links in it. */
export type Copy = string | Run[];
/** A link, as a person reads it. */
export interface Link {
label: string;
href: string;
external?: boolean;
}
/** The banner above a form saying what went wrong, or what just happened. */
export interface Alert {
tone: 'danger' | 'success';
message: string;
}
/** A button that submits, and the form value it carries when it does. */
export interface Action {
label: string;
name?: string;
value?: string;
}
/**
* Something the person is asked to type.
*
* `kind` is the *meaning*, not the widget: `segments` is "a code read off one
* screen and typed into another", which is both the emailed pin and the device
* user code. Naming it by meaning is what lets the two share a treatment
* without either one describing it.
*/
export type Field =
| { kind: 'hidden'; name: string; value: string }
| {
kind: 'email' | 'tel' | 'text';
name: string;
label: string;
autocomplete?: string;
autofocus?: boolean;
required?: boolean;
}
| {
kind: 'password';
name: string;
label: string;
autocomplete?: string;
autofocus?: boolean;
required?: boolean;
}
| {
kind: 'segments';
name: string;
label: string;
/** How many characters the code has, in total. */
length: number;
/** Insert a visual break every `group` characters. */
group?: number;
/** Digits only, which also brings up the numeric keypad. */
numeric?: boolean;
autocomplete?: string;
autofocus?: boolean;
};
/** One way in, on the screen that offers a choice of them. */
export interface ChooseOption {
href: string;
label: string;
mark?: Mark;
}
/**
* Pick a way to sign in.
*
* The options are built from what the providers declare about themselves, so
* this screen has no list of known providers in it and adding one does not
* touch this file. That list used to live in the rendering code as two
* hardcoded records, which meant a provider could not be added without editing
* the library that drew it.
*/
export interface ChooseScreen {
kind: 'choose';
options: ChooseOption[];
footer?: Copy;
status?: number;
}
/** Ask for some values and submit them. */
export interface FormScreen {
kind: 'form';
method?: 'get' | 'post';
action?: string;
alerts?: Alert[];
fields: Field[];
submit: string;
/**
* A second, smaller form under the first.
*
* This exists for one shape and should stay that narrow: an action that is
* a sentence rather than a button — "Didn't get code? Resend" — and that
* has to be its own form because it submits different values.
*/
aside?: { prompt?: string; fields?: Field[]; submit: string };
/** Links under the form: "Already have an account? Login". */
links?: { prompt?: string; link: Link }[];
footer?: Copy;
status?: number;
}
/**
* Say what is about to happen and ask whether to do it.
*
* Distinct from a form with two buttons because the question is distinct: a
* form collects something the person knows, and this one asks them to check a
* fact in front of them and answer for it. `verify` is that fact — a code
* shown back so it can be compared against the one on the device.
*/
export interface ConfirmScreen {
kind: 'confirm';
heading: string;
body: Copy[];
verify?: { code: string; group?: number };
action?: string;
fields?: Field[];
approve: Action;
deny: Action;
status?: number;
}
/**
* A dead end that says so.
*
* Every plain-text error reply is one of these. They were `c.text(...)`, which
* is how a person following a link off a television ends up looking at
* unstyled black-on-white in the middle of signing in.
*/
export interface MessageScreen {
kind: 'message';
tone: 'danger' | 'notice';
heading: string;
body: Copy[];
link?: Link;
status?: number;
}
export type Screen = ChooseScreen | FormScreen | ConfirmScreen | MessageScreen;

View File

@@ -1,201 +0,0 @@
/**
* The UI that's displayed when loading the root page of the OpenAuth server. You can configure
* which providers should be displayed in the select UI.
*
* ```ts
* import { Select } from "@openauthjs/openauth/ui/select"
*
* export default issuer({
* select: Select({
* providers: {
* github: {
* hide: true
* },
* google: {
* display: "Google"
* }
* }
* })
* // ...
* })
* ```
*
* @packageDocumentation
*/
/** @jsxImportSource hono/jsx */
import { Layout } from './base.js';
import { ICON_GITHUB, ICON_GOOGLE } from './icon.js';
export interface SelectProps {
/**
* An object with all the providers and their config; where the key is the provider name.
*
* @example
* ```ts
* {
* github: {
* hide: true
* },
* google: {
* display: "Google"
* }
* }
* ```
*/
providers?: Record<
string,
{
/**
* Whether to hide the provider from the select UI.
* @default false
*/
hide?: boolean;
/**
* The display name of the provider.
*/
display?: string;
}
>;
}
export function Select(props?: SelectProps) {
return async (providers: Record<string, string>, _req: Request): Promise<Response> => {
const jsx = (
<Layout>
<div data-component="form">
{Object.entries(providers).map(([key, type]) => {
const match = props?.providers?.[key];
if (match?.hide) return;
const icon = ICON[key];
return (
<a href={`/${key}/authorize`} data-component="button" data-color="ghost">
{icon && <i data-slot="icon">{icon}</i>}
Continue with {match?.display || DISPLAY[type] || type}
</a>
);
})}
</div>
</Layout>
);
return new Response(jsx.toString(), {
headers: {
'Content-Type': 'text/html'
}
});
};
}
const DISPLAY: Record<string, string> = {
twitch: 'Twitch',
google: 'Google',
github: 'GitHub',
apple: 'Apple',
x: 'X',
facebook: 'Facebook',
microsoft: 'Microsoft',
slack: 'Slack'
};
const ICON: Record<string, any> = {
code: (
<svg
fill="currentColor"
viewBox="0 0 52 52"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg">
<path
d="M8.55,36.91A6.55,6.55,0,1,1,2,43.45,6.54,6.54,0,0,1,8.55,36.91Zm17.45,0a6.55,6.55,0,1,1-6.55,6.54A6.55,6.55,0,0,1,26,36.91Zm17.45,0a6.55,6.55,0,1,1-6.54,6.54A6.54,6.54,0,0,1,43.45,36.91ZM8.55,19.45A6.55,6.55,0,1,1,2,26,6.55,6.55,0,0,1,8.55,19.45Zm17.45,0A6.55,6.55,0,1,1,19.45,26,6.56,6.56,0,0,1,26,19.45Zm17.45,0A6.55,6.55,0,1,1,36.91,26,6.55,6.55,0,0,1,43.45,19.45ZM8.55,2A6.55,6.55,0,1,1,2,8.55,6.54,6.54,0,0,1,8.55,2ZM26,2a6.55,6.55,0,1,1-6.55,6.55A6.55,6.55,0,0,1,26,2ZM43.45,2a6.55,6.55,0,1,1-6.54,6.55A6.55,6.55,0,0,1,43.45,2Z"
fill-rule="evenodd"
/>
</svg>
),
password: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fill-rule="evenodd"
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
clip-rule="evenodd"
/>
</svg>
),
twitch: (
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path
fill="currentColor"
d="M40.1 32L10 108.9v314.3h107V480h60.2l56.8-56.8h87l117-117V32H40.1zm357.8 254.1L331 353H224l-56.8 56.8V353H76.9V72.1h321v214zM331 149v116.9h-40.1V149H331zm-107 0v116.9h-40.1V149H224z"></path>
</svg>
),
google: ICON_GOOGLE,
github: ICON_GITHUB,
apple: (
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 814 1000">
<path
fill="currentColor"
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z "
/>
</svg>
),
x: (
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1227">
<path
fill="currentColor"
d="M714.163 519.284 1160.89 0h-105.86L667.137 450.887 357.328 0H0l468.492 681.821L0 1226.37h105.866l409.625-476.152 327.181 476.152H1200L714.137 519.284h.026ZM569.165 687.828l-47.468-67.894-377.686-540.24h162.604l304.797 435.991 47.468 67.894 396.2 566.721H892.476L569.165 687.854v-.026Z"
/>
</svg>
),
microsoft: (
<svg
role="img"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid">
<path fill="#F1511B" d="M121.666 121.666H0V0h121.666z" />
<path fill="#80CC28" d="M256 121.666H134.335V0H256z" />
<path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z" />
<path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z" />
</svg>
),
facebook: (
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" fill="url(#a)">
<defs>
<linearGradient x1="50%" x2="50%" y1="97.078%" y2="0%" id="a">
<stop offset="0%" stop-color="#0062E0" />
<stop offset="100%" stop-color="#19AFFF" />
</linearGradient>
</defs>
<path d="M15 35.8C6.5 34.3 0 26.9 0 18 0 8.1 8.1 0 18 0s18 8.1 18 18c0 8.9-6.5 16.3-15 17.8l-1-.8h-4l-1 .8z" />
<path
fill="#FFF"
d="m25 23 .8-5H21v-3.5c0-1.4.5-2.5 2.7-2.5H26V7.4c-1.3-.2-2.7-.4-4-.4-4.1 0-7 2.5-7 7v4h-4.5v5H15v12.7c1 .2 2 .3 3 .3s2-.1 3-.3V23h4z"
/>
</svg>
),
slack: (
<svg
role="img"
enable-background="new 0 0 2447.6 2452.5"
viewBox="0 0 2447.6 2452.5"
xmlns="http://www.w3.org/2000/svg">
<g clip-rule="evenodd" fill-rule="evenodd">
<path
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
fill="#36c5f0"
/>
<path
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
fill="#2eb67d"
/>
<path
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
fill="#ecb22e"
/>
<path
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
fill="#e01e5a"
/>
</g>
</svg>
)
};

View File

@@ -1,318 +1,50 @@
/**
* Use one of the built-in themes.
* The handful of values a deployment sets that the stylesheet cannot.
*
* @example
* Deliberately small. This used to be the *only* way to influence how a sign-in
* page looked — a fixed struct of colours, a radius and a font family — which
* meant any design it could not express had to be written around it, and this
* one was, in `css.ts`. Customising the pages now means supplying a
* {@link Renderer}; what is left here is the per-deployment trim.
*
* ```ts
* import { THEME_SST } from "@openauthjs/openauth/ui/theme"
* import type { Theme } from "@nestri/auth/ui/theme"
*
* export default issuer({
* theme: THEME_SST,
* // ...
* })
* ```
*
* Or define your own.
*
* ```ts
* import type { Theme } from "@openauthjs/openauth/ui/theme"
*
* const MY_THEME: Theme = {
* title: "Acne",
* radius: "none",
* favicon: "https://www.example.com/favicon.svg",
* // ...
* const THEME: Theme = {
* title: "Login | Example",
* primary: "hsl(12 84% 53%)",
* favicon: "https://example.com/favicon.ico"
* }
*
* export default issuer({
* theme: MY_THEME,
* // ...
* })
* ```
*
* @packageDocumentation
*/
/**
* A type to define values for light and dark mode.
*
* @example
* ```ts
* {
* light: "#FFF",
* dark: "#000"
* }
* ```
*/
/** A value that differs between light and dark mode. */
export interface ColorScheme {
/**
* The value for dark mode.
*/
dark: string;
/**
* The value for light mode.
*/
light: string;
}
/**
* A type to define your custom theme.
*/
export interface Theme {
/**
* The name of your app. Also used as the title of the page.
*
* @example
* ```ts
* {
* title: "Acne"
* }
* ```
*/
/** The page title. */
title?: string;
/**
* A URL to the favicon of your app.
*
* @example
* ```ts
* {
* favicon: "https://www.example.com/favicon.svg"
* }
* ```
*/
/** A URL to the favicon. */
favicon?: string;
/**
* The border radius of the UI elements.
* The brand colour.
*
* @example
* ```ts
* {
* radius: "none"
* }
* ```
*/
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
/**
* The primary color of the theme.
*
* Takes a color or both light and dark colors.
*
* @example
* ```ts
* {
* primary: "#FF5E00"
* }
* ```
* The one value the stylesheet reads back out of the theme, so that the
* accent has a single source rather than being stated twice.
*/
primary: string | ColorScheme;
/**
* The background color of the theme.
*
* Takes a color or both light and dark colors.
*
* @example
* ```ts
* {
* background: "#FFF"
* }
* ```
*/
background?: string | ColorScheme;
/**
* A URL to the logo of your app.
*
* Takes a single image or both light and dark mode versions.
*
* @example
* ```ts
* {
* logo: "https://www.example.com/logo.svg"
* }
* ```
*/
/** A URL to the logo, if the built-in wordmark is not wanted. */
logo?: string | ColorScheme;
/**
* The font family and scale of the theme.
*/
font?: {
/**
* The font family of the theme.
*
* @example
* ```ts
* {
* font: {
* family: "Geist Mono, monospace"
* }
* }
* ```
*/
family?: string;
/**
* The font scale of the theme. Can be used to increase or decrease the font sizes across
* the UI.
*
* @default "1"
* @example
* ```ts
* {
* font: {
* scale: "1.25"
* }
* }
* ```
*/
scale?: string;
};
/**
* Custom CSS that's added to the page in a `<style>` tag.
* Extra CSS, added in a `<style>` tag.
*
* This can be used to import custom fonts.
*
* @example
* ```ts
* {
* css: `@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@100;200;300;400;500;600;700;800;900&display=swap');`
* }
* ```
* This is for `@import`ing a font and little else. A design expressed here
* is a design fighting the stylesheet; write a {@link Renderer} instead.
*/
css?: string;
}
/**
* Built-in default OpenAuth theme.
*/
export const THEME_OPENAUTH: Theme = {
title: 'OpenAuth',
radius: 'none',
background: {
dark: 'black',
light: 'white'
},
primary: {
dark: 'white',
light: 'black'
},
font: {
family: 'IBM Plex Sans, sans-serif'
},
css: `
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&display=swap');
`
};
/**
* Built-in theme based on [Terminal](https://terminal.shop).
*/
export const THEME_TERMINAL: Theme = {
title: 'terminal',
radius: 'none',
favicon: 'https://www.terminal.shop/favicon.svg',
logo: {
dark: 'https://www.terminal.shop/images/logo-white.svg',
light: 'https://www.terminal.shop/images/logo-black.svg'
},
primary: '#ff5e00',
background: {
dark: 'rgb(0, 0, 0)',
light: 'rgb(255, 255, 255)'
},
font: {
family: 'Geist Mono, monospace'
},
css: `
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100;200;300;400;500;600;700;800;900&display=swap');
`
};
/**
* Built-in theme based on [SST](https://sst.dev).
*/
export const THEME_SST: Theme = {
title: 'SST',
favicon: 'https://sst.dev/favicon.svg',
logo: {
dark: 'https://sst.dev/favicon.svg',
light: 'https://sst.dev/favicon.svg'
},
background: {
dark: '#1a1a2d',
light: 'rgb(255, 255, 255)'
},
primary: '#f3663f',
font: {
family: 'Rubik, sans-serif'
},
css: `
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@100;200;300;400;500;600;700;800;900&display=swap');
`
};
/**
* Built-in theme based on [Supabase](https://supabase.com).
*/
export const THEME_SUPABASE: Theme = {
title: 'Supabase',
logo: {
dark: 'https://supabase.com/dashboard/_next/image?url=%2Fdashboard%2Fimg%2Fsupabase-dark.svg&w=128&q=75',
light:
'https://supabase.com/dashboard/_next/image?url=%2Fdashboard%2Fimg%2Fsupabase-light.svg&w=128&q=75'
},
background: {
dark: '#171717',
light: '#f8f8f8'
},
primary: {
dark: '#006239',
light: '#72e3ad'
},
font: {
family: 'Varela Round, sans-serif'
},
css: `
@import url('https://fonts.googleapis.com/css2?family=Varela+Round:wght@100;200;300;400;500;600;700;800;900&display=swap');
`
};
/**
* Built-in theme based on [Vercel](https://vercel.com).
*/
export const THEME_VERCEL: Theme = {
title: 'Vercel',
logo: {
dark: 'https://vercel.com/mktng/_next/static/media/vercel-logotype-dark.e8c0a742.svg',
light: 'https://vercel.com/mktng/_next/static/media/vercel-logotype-light.700a8d26.svg'
},
background: {
dark: 'black',
light: 'white'
},
primary: {
dark: 'white',
light: 'black'
},
font: {
family: 'Geist, sans-serif'
},
css: `
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
`
};
// i really don't wanna use async local storage for this so get over it
/**
* @internal
*/
export function setTheme(value: Theme) {
// @ts-ignore
globalThis.OPENAUTH_THEME = value;
}
/**
* @internal
*/
export function getTheme() {
// @ts-ignore
return globalThis.OPENAUTH_THEME || THEME_OPENAUTH;
}

View File

@@ -15,17 +15,22 @@ const auth = issuer({
storage: MemoryStorage(),
subjects,
allow: async () => true,
// Screens back as JSON instead of HTML, which is the whole of what it takes
// to replace the presentation layer — and is why these tests can assert on
// what the flow decided rather than on the markup it happened to produce.
renderer: { render: (screen) => Response.json(screen) },
providers: {
code: CodeProvider({
maxAttempts: 3,
maxSends: 2,
sendWindow: 3600,
resendInterval: 0,
request: async (_req, _state, _form, error) =>
new Response(JSON.stringify({ error: error?.type ?? null }), {
status: 200,
headers: { 'content-type': 'application/json' }
}),
request: async (_req, _state, _form, error) => ({
kind: 'message',
tone: 'danger',
heading: error?.type ?? 'none',
body: []
}),
sendCode: async (claims, code) => {
if (!claims.email?.includes('@')) {
return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' };
@@ -92,7 +97,8 @@ async function ask(email: string) {
/** What the stub UI reported, so a test can name the error rather than a status. */
async function errorOf(response: Response) {
return ((await response.clone().json()) as { error: string | null }).error;
const screen = (await response.clone().json()) as { heading: string };
return screen.heading === 'none' ? null : screen.heading;
}
beforeEach(() => {