mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.
Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.
Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).
@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
'use client'
|
||
// The parts every sign-in page is drawn from: the card a step sits in, its head,
|
||
// the lines under a control, and the two steps a held sign-in stops on — the
|
||
// second factor and the email address. The panel (components/auth/panel) and the
|
||
// pages beside it (/login/mfa, /login/oauth/device, /forget, /join) draw them
|
||
// the same way. IAM checks every code typed into them; nothing here decides.
|
||
//
|
||
// Every word a step says is the reader's (@hanzo/i18n): a part says its own
|
||
// through `t`, and what a page hands one (a title, a label, a line) the page has
|
||
// already said through its `t`. What IAM says when it refuses is IAM's sentence
|
||
// and is shown as IAM wrote it.
|
||
|
||
import { useId, useState, type FormEvent, type ReactNode } from 'react'
|
||
import { Refused, type Answer, type EmailRequired, type Factor } from '@hanzo/iam'
|
||
import { Button, Card, Checkbox, Input, Text, XStack, YStack } from '@hanzo/ui'
|
||
import { Action, More, TextLink } from '@hanzo/ui/marketing'
|
||
import { ArrowLeft } from 'lucide-react'
|
||
import { phrase } from '@hanzo/i18n'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
import { Field } from '@/components/account/form'
|
||
|
||
/**
|
||
* IAM's own sentence, or what went wrong in a few words. Ours are said through the
|
||
* page's `t`; a caller that passes none says them in English.
|
||
*/
|
||
export const said = (err: unknown, t: (source: string) => string = (source) => source): string =>
|
||
err instanceof Refused && err.code === NOT_PERMITTED
|
||
? t(ARCHIVED)
|
||
: err instanceof Error && err.message
|
||
? err.message
|
||
: t(FAILED)
|
||
|
||
/** What went wrong, when IAM says nothing. */
|
||
const FAILED = phrase('Something went wrong. Try again.')
|
||
|
||
/** IAM's `code` for a sign-in refused because the account may not sign in: archived by an operator (isForbidden), or deleted. */
|
||
const NOT_PERMITTED = 'account_not_permitted'
|
||
|
||
/** What an archived account is told, wherever its sign-in is refused (here, and /auth/callback for a provider's). */
|
||
export const ARCHIVED = phrase('This account has been archived, so it can’t sign in. Contact [email protected] to restore it.')
|
||
|
||
/** The address a refusal sends the person to, written in its sentence. */
|
||
const SUPPORT = '[email protected]'
|
||
|
||
/** A sentence as drawn: the support address in it is a link that opens a mail to it. */
|
||
export function Mailed({ text }: { text: string }) {
|
||
const at = text.indexOf(SUPPORT)
|
||
if (at < 0) return <>{text}</>
|
||
return (
|
||
<>
|
||
{text.slice(0, at)}
|
||
<TextLink href={`mailto:${SUPPORT}`}>{SUPPORT}</TextLink>
|
||
{text.slice(at + SUPPORT.length)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Every field a sign-in card draws is pay's field (hanzo-inc/pay `.pay-field`):
|
||
* 56px, 16px type, 12px corners, the muted tile. The value is the full ink and
|
||
* the sample the placeholder ink, both @hanzo/ui Input's own tokens (`$ink`,
|
||
* `$placeholderColor`), so the two never read alike. One rule for every card, so
|
||
* the landing's, /login's, /signup's and /join's fields are one field.
|
||
*/
|
||
const FIELD =
|
||
'[data-slot="signin"] .is_Input{min-height:56px;font-size:1rem;border-radius:9999px;padding-left:24px;padding-right:24px;background-color:var(--muted)}' +
|
||
// On a touch screen the library holds every button at the 44px thumb floor (@hanzo/ui theme.css, `pointer:coarse`, !important, inside @layer base), so the provider buttons and Continue are 44px there; the field takes the same height so the card's controls read as one size. A mouse keeps pay's 56px.
|
||
'@media (pointer:coarse){[data-slot="signin"] .is_Input{min-height:44px!important;height:44px!important}}'
|
||
|
||
/**
|
||
* The card a step sits in. It IS the region: every sign-in page sets it in the
|
||
* hero's copy column, under the page's own h1, so it carries no heading of its own.
|
||
* `data-slot="signin"` is how the header's Try Hanzo finds it on the landing.
|
||
*/
|
||
export function Sheet({ label, children }: { label: string; children: ReactNode }) {
|
||
return (
|
||
<Card
|
||
render="section"
|
||
aria-label={label}
|
||
data-slot="signin"
|
||
width="100%"
|
||
maxW={420}
|
||
mx="auto"
|
||
p={0}
|
||
gap="$4"
|
||
bg="transparent"
|
||
borderWidth={0}
|
||
borderColor="transparent"
|
||
boxShadow="none"
|
||
rounded={0}
|
||
$sm={{
|
||
p: '$6',
|
||
bg: '$panel',
|
||
borderWidth: 1,
|
||
borderColor: 'var(--border)',
|
||
rounded: 'var(--radius-2xl)',
|
||
boxShadow: 'var(--edge-highlight), var(--shadow-floating)',
|
||
'$platform-web': { borderRadius: '24px' },
|
||
}}
|
||
>
|
||
<style dangerouslySetInnerHTML={{ __html: FIELD }} />
|
||
{children}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The head of a step past the address: the way back to another address, the
|
||
* step's name when it has one, and the address it is for. The back arrow points
|
||
* to the line's start, so a right-to-left page turns it.
|
||
*/
|
||
export function Held({ address, title, onBack }: { address: string; title?: string; onBack: () => void }) {
|
||
const { t } = useI18n()
|
||
return (
|
||
<XStack items="center" gap="$2" minW={0}>
|
||
<Button type="button" variant="ghost" size="icon" marginInlineStart={-10} onClick={onBack} aria-label={t('Use another email')}>
|
||
<ArrowLeft size={16} aria-hidden data-i18n-mirror />
|
||
</Button>
|
||
<YStack minW={0}>
|
||
{title ? (
|
||
<Text fontSize="$3" fontWeight="600" color="$ink">
|
||
{title}
|
||
</Text>
|
||
) : null}
|
||
<Text fontSize="$2" color={title ? 'var(--muted-foreground)' : '$ink'} numberOfLines={1}>
|
||
{address}
|
||
</Text>
|
||
</YStack>
|
||
</XStack>
|
||
)
|
||
}
|
||
|
||
/** A line of the step's own words, centred above its control. */
|
||
export function Say({ children }: { children: ReactNode }) {
|
||
return (
|
||
<Text render="p" fontSize="$2" color="var(--muted-foreground)" text="center">
|
||
{children}
|
||
</Text>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* What IAM refused, or what is missing, said under the control in the error
|
||
* ink: red-300 on the dark ground (10:1), and on the light one red-600 darkened
|
||
* a fifth toward the ink (about red-700, 5.9:1 on the light card).
|
||
* `id` lets the field it is about name it (`aria-describedby`).
|
||
*/
|
||
export function Wrong({ text, id }: { text: string | null; id?: string }) {
|
||
return text ? (
|
||
<Text id={id} role="alert" fontSize="$3" lineHeight="1.25rem" color="var(--state-error-text)" $theme-light={{ color: 'color-mix(in srgb, var(--destructive) 80%, var(--foreground))' as `var(--${string})` }} text="center">
|
||
<Mailed text={text} />
|
||
</Text>
|
||
) : null
|
||
}
|
||
|
||
/** What happened that the person should know, said quietly under the control. */
|
||
export function Note({ text }: { text: string | null }) {
|
||
return text ? (
|
||
<Text role="status" fontSize="$2" color="var(--muted-foreground)" text="center">
|
||
{text}
|
||
</Text>
|
||
) : null
|
||
}
|
||
|
||
/** Another way through the same step: the quiet link, centred under the one filled control. */
|
||
export function Other({ onClick, disabled, children }: { onClick: () => void; disabled: boolean; children: ReactNode }) {
|
||
return (
|
||
<XStack justify="center">
|
||
<More render="button" type="button" onClick={onClick} disabled={disabled} $touchable={{ minH: 44, minW: 44 }}>
|
||
{children}
|
||
</More>
|
||
</XStack>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* A one-time code, typed or pasted, centred, and left to right in any language.
|
||
* `label` is the caller's, already in the reader's words.
|
||
*/
|
||
export function Code({
|
||
id,
|
||
value,
|
||
onChange,
|
||
label,
|
||
numeric = true,
|
||
}: {
|
||
id: string
|
||
value: string
|
||
onChange: (value: string) => void
|
||
label?: string
|
||
numeric?: boolean
|
||
}) {
|
||
const { t } = useI18n()
|
||
const named = label ?? t('Verification code')
|
||
return (
|
||
<Input
|
||
id={id}
|
||
size="lg"
|
||
fill
|
||
name="code"
|
||
data-i18n-ltr
|
||
aria-label={named}
|
||
inputMode={numeric ? 'numeric' : 'text'}
|
||
autoComplete={numeric ? 'one-time-code' : 'off'}
|
||
autoCapitalize="none"
|
||
placeholder={numeric ? t('Enter verification code') : named}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
text="center"
|
||
required
|
||
autoFocus
|
||
/>
|
||
)
|
||
}
|
||
|
||
/** The second factors a page takes: a code typed from an app, an email or a text. */
|
||
const TAKES = ['app', 'email', 'sms']
|
||
|
||
/** What a factor's code is, said where it is asked for. */
|
||
const ASK: Record<string, string> = {
|
||
app: phrase('Enter the 6-digit code from your authenticator app.'),
|
||
email: phrase('Enter the code we sent to your email.'),
|
||
sms: phrase('Enter the code we sent to your phone.'),
|
||
}
|
||
|
||
/** The way to switch to a factor, from another. */
|
||
const SWITCH: Record<string, string> = {
|
||
app: phrase('Use your authenticator app'),
|
||
email: phrase('Use the code sent to your email'),
|
||
sms: phrase('Use the code sent to your phone'),
|
||
}
|
||
|
||
/**
|
||
* The factors a held sign-in can be finished with on a page: offered by IAM,
|
||
* typed as a code, and for a delivered one, sent. The authenticator app first.
|
||
*/
|
||
export const usable = (offered: Factor[]): Factor[] =>
|
||
offered.filter((f) => TAKES.includes(f.type) && (f.type === 'app' || f.sent)).sort((a, b) => TAKES.indexOf(a.type) - TAKES.indexOf(b.type))
|
||
|
||
/**
|
||
* The second factor, asked for: the code of the factor being answered, a switch
|
||
* to each other one offered, and a recovery code in place of any. "Don't ask
|
||
* again" is offered when the organization keeps a window for it. `head` is the
|
||
* step's head, when the page has one to show.
|
||
*/
|
||
export function Factors({
|
||
offered,
|
||
busy,
|
||
wrong,
|
||
head,
|
||
onAnswer,
|
||
}: {
|
||
offered: Factor[]
|
||
busy: boolean
|
||
wrong: string | null
|
||
head?: ReactNode
|
||
onAnswer: (answer: Answer, remember: boolean) => void
|
||
}) {
|
||
const { t } = useI18n()
|
||
const [kind, setKind] = useState(offered[0]?.type ?? 'app')
|
||
const [recovery, setRecovery] = useState(false)
|
||
const [remember, setRemember] = useState(false)
|
||
const [code, setCode] = useState('')
|
||
const [missing, setMissing] = useState<string | null>(null)
|
||
const ids = { code: useId(), remember: useId() }
|
||
const hours = offered.find((f) => f.type === kind)?.remember ?? 0
|
||
|
||
function submit(e: FormEvent) {
|
||
e.preventDefault()
|
||
if (!code.trim()) {
|
||
setMissing(t(recovery ? 'Enter a recovery code.' : 'Enter the code.'))
|
||
return
|
||
}
|
||
setMissing(null)
|
||
onAnswer(recovery ? { recovery: code.trim() } : { type: kind, passcode: code.trim() }, remember)
|
||
}
|
||
|
||
/** Answer with another factor, or a recovery code, starting from an empty field. */
|
||
function choose(next: string | null) {
|
||
setRecovery(next === null)
|
||
if (next) setKind(next)
|
||
setCode('')
|
||
setMissing(null)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={submit} noValidate>
|
||
<YStack gap="$4">
|
||
{head}
|
||
<Say>{recovery ? t('Enter one of the recovery codes you saved when you set up two-step verification.') : t(ASK[kind])}</Say>
|
||
<Code
|
||
key={recovery ? 'recovery' : kind}
|
||
id={ids.code}
|
||
value={code}
|
||
onChange={setCode}
|
||
label={t(recovery ? 'Recovery code' : 'Verification code')}
|
||
numeric={!recovery}
|
||
/>
|
||
{hours > 0 && !recovery ? (
|
||
<XStack gap="$3" items="flex-start">
|
||
<Checkbox id={ids.remember} checked={remember} onCheckedChange={(v) => setRemember(v === true)} mt="$1" />
|
||
<Text render="label" htmlFor={ids.remember} fontSize="$2" color="var(--muted-foreground)" flex={1}>
|
||
{hours === 1 ? t("Don't ask again on this browser for an hour") : t("Don't ask again on this browser for {hours} hours", { hours })}
|
||
</Text>
|
||
</XStack>
|
||
) : null}
|
||
<Wrong text={missing ?? wrong} />
|
||
<Action render="button" type="submit" fill disabled={busy}>
|
||
{t(busy ? 'One moment…' : 'Verify')}
|
||
</Action>
|
||
<YStack gap="$1">
|
||
{offered
|
||
.filter((f) => recovery || f.type !== kind)
|
||
.map((f) => (
|
||
<Other key={f.type} onClick={() => choose(f.type)} disabled={busy}>
|
||
{t(SWITCH[f.type])}
|
||
</Other>
|
||
))}
|
||
{recovery ? null : (
|
||
<Other onClick={() => choose(null)} disabled={busy}>
|
||
{t('Use a recovery code')}
|
||
</Other>
|
||
)}
|
||
</YStack>
|
||
</YStack>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* A sign-in IAM holds for an email address: the code it sent to the address on
|
||
* file, a new one, or another address and the code sent there. With no address
|
||
* on file it starts by asking for one. `onSend` with the address shown is a
|
||
* code sent again. A new hold remounts it (`key`), so it opens on that hold's
|
||
* code.
|
||
*/
|
||
export function Confirm({
|
||
hold,
|
||
busy,
|
||
wrong,
|
||
note,
|
||
head,
|
||
onCode,
|
||
onSend,
|
||
}: {
|
||
hold: EmailRequired
|
||
busy: boolean
|
||
wrong: string | null
|
||
note: string | null
|
||
head?: ReactNode
|
||
onCode: (code: string) => void
|
||
onSend: (address: string) => void
|
||
}) {
|
||
const { t } = useI18n()
|
||
const [asking, setAsking] = useState(hold.email === '')
|
||
const [code, setCode] = useState('')
|
||
const [address, setAddress] = useState('')
|
||
const [missing, setMissing] = useState<string | null>(null)
|
||
const ids = { code: useId(), address: useId() }
|
||
|
||
function confirm(e: FormEvent) {
|
||
e.preventDefault()
|
||
if (!code.trim()) return setMissing(t('Enter the code from the email.'))
|
||
setMissing(null)
|
||
onCode(code.trim())
|
||
}
|
||
|
||
function send(e: FormEvent) {
|
||
e.preventDefault()
|
||
if (!address.trim().includes('@')) return setMissing(t('Enter your email address.'))
|
||
setMissing(null)
|
||
onSend(address.trim())
|
||
}
|
||
|
||
if (asking) {
|
||
return (
|
||
<form onSubmit={send} noValidate>
|
||
<YStack gap="$4">
|
||
{head}
|
||
<Say>{t("A confirmed email address is required to sign in. Enter yours and we'll send it a code.")}</Say>
|
||
<Field id={ids.address} label={t('Email')} center>
|
||
<Input
|
||
id={ids.address}
|
||
size="lg"
|
||
fill
|
||
type="email"
|
||
name="email"
|
||
autoComplete="email"
|
||
value={address}
|
||
onChange={(e) => setAddress(e.target.value)}
|
||
placeholder="[email protected]"
|
||
required
|
||
autoFocus
|
||
/>
|
||
</Field>
|
||
<Wrong text={missing ?? wrong} />
|
||
<Action render="button" type="submit" fill disabled={busy}>
|
||
{t(busy ? 'One moment…' : 'Send code')}
|
||
</Action>
|
||
{hold.email ? (
|
||
<Other
|
||
onClick={() => {
|
||
setMissing(null)
|
||
setAsking(false)
|
||
}}
|
||
disabled={busy}
|
||
>
|
||
{t('Enter the code sent to {email}', { email: hold.email })}
|
||
</Other>
|
||
) : null}
|
||
</YStack>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={confirm} noValidate>
|
||
<YStack gap="$4">
|
||
{head}
|
||
<Say>
|
||
{t('A confirmed email address is required to sign in. Enter the code we sent to{br}{email}', {
|
||
br: <br />,
|
||
email: (
|
||
<Text fontWeight="600" color="$ink">
|
||
{hold.email}
|
||
</Text>
|
||
),
|
||
})}
|
||
</Say>
|
||
<Code id={ids.code} value={code} onChange={setCode} />
|
||
<Wrong text={missing ?? wrong} />
|
||
<Note text={note} />
|
||
<Action render="button" type="submit" fill disabled={busy}>
|
||
{t(busy ? 'One moment…' : 'Confirm email address')}
|
||
</Action>
|
||
<YStack gap="$1">
|
||
<Other onClick={() => onSend(hold.email)} disabled={busy}>
|
||
{t('Send a new code')}
|
||
</Other>
|
||
<Other
|
||
onClick={() => {
|
||
setMissing(null)
|
||
setAddress('')
|
||
setAsking(true)
|
||
}}
|
||
disabled={busy}
|
||
>
|
||
{t('Use a different email')}
|
||
</Other>
|
||
</YStack>
|
||
</YStack>
|
||
</form>
|
||
)
|
||
}
|