Files
zeekay 0f5536b707 hanzo.ai reads in the reader's language, and Arabic right to left
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.
2026-10-22 19:54:31 -07:00

455 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}