mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
The screen was still the upstream template's: its font, its accent, its logo, and a theme that tried to serve a light and a dark scheme from one set of colours by deriving each one from the background's lightness. That derivation is replaced with stated values, and the page is dark only. Black, a neutral grey ramp, one brand accent, Mona Sans for the display line and Geist for anything read or typed; two dashed bands closing into a box on a wide screen, a dashed vertical either side of the column, the wordmark, and the line of copy the rest of the product opens with. The email field keeps the fix from the previous commit and takes the brand colour on focus, which is the only place it appears besides the wordmark. Measured against a render of the same design built from its own source: the band rules, the column rules, the wordmark box and the button height land on identical pixels.
230 lines
6.0 KiB
TypeScript
230 lines
6.0 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
};
|
|
}
|