mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +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.
173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* /forget — a new password for an account that cannot sign in.
|
||
*
|
||
* The address first (`?login_hint=` fills it in), and IAM sends a code to it
|
||
* (@hanzo/iam `sendLoginCode`, POST /v1/iam/verification-codes on this origin).
|
||
* Then the code and the new password together (@hanzo/iam `resetPassword`, PUT
|
||
* /v1/iam/password): IAM spends the code against the account that address
|
||
* signs in as, checks the password against its organization's rules, and writes
|
||
* it. It signs nobody in and says nothing about which addresses have accounts;
|
||
* the person signs in afterwards like anybody else, second factor included.
|
||
* Signing in goes on to `?next=` (a client's sign-in this page was opened from)
|
||
* or to /login.
|
||
*/
|
||
|
||
import { useId, useState, type FormEvent } from 'react'
|
||
import { useI18n } from '@hanzo/i18n/react'
|
||
import { Input, Text, YStack } from '@hanzo/ui'
|
||
import { Action, TextLink } from '@hanzo/ui/marketing'
|
||
import Hero from '@/components/home/Hero'
|
||
import { Field } from '@/components/account/form'
|
||
import { Code, Held, Note, Other, said, Say, Sheet, Wrong } from '@/components/auth/steps'
|
||
import { onward, LOGIN } from '@hanzo/app/lib/destination'
|
||
import { useClient } from '@hanzo/app/lib/signout'
|
||
import { useQuery } from '@/lib/auth/query'
|
||
|
||
/** IAM's floor for a password (`passwordPolicyError`), said before anything is sent. */
|
||
const FLOOR = 8
|
||
|
||
type Step = 'email' | 'code' | 'done'
|
||
|
||
export default function Page() {
|
||
const { t } = useI18n()
|
||
const iam = useClient()
|
||
const q = useQuery()
|
||
const [step, setStep] = useState<Step>('email')
|
||
// The address as typed, or the hint the page was opened with until something is.
|
||
const [typed, setEmail] = useState<string | null>(null)
|
||
const email = typed ?? q.get('login_hint') ?? ''
|
||
const [code, setCode] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [wrong, setWrong] = useState<string | null>(null)
|
||
const [note, setNote] = useState<string | null>(null)
|
||
const ids = { email: useId(), code: useId(), password: useId() }
|
||
// Where signing in goes once the password is set.
|
||
const next = onward(`?${q}`) ?? LOGIN
|
||
|
||
/** Run one call to IAM, saying its refusal, and go on to `then` (saying `told`) when it answers. */
|
||
function run(call: () => Promise<void>, then: Step, told?: string) {
|
||
setBusy(true)
|
||
setWrong(null)
|
||
setNote(null)
|
||
call()
|
||
.then(() => {
|
||
setStep(then)
|
||
if (told) setNote(told)
|
||
})
|
||
.catch((err: unknown) => setWrong(said(err, t)))
|
||
.finally(() => setBusy(false))
|
||
}
|
||
|
||
function send(e: FormEvent) {
|
||
e.preventDefault()
|
||
if (!email.trim().includes('@')) {
|
||
setWrong(t('Enter your email address.'))
|
||
return
|
||
}
|
||
run(() => iam.sendLoginCode(email.trim()), 'code')
|
||
}
|
||
|
||
function reset(e: FormEvent) {
|
||
e.preventDefault()
|
||
if (!code.trim()) {
|
||
setWrong(t('Enter the code from the email.'))
|
||
return
|
||
}
|
||
if (password.length < FLOOR) {
|
||
setWrong(t('Use at least {n} characters.', { n: FLOOR }))
|
||
return
|
||
}
|
||
run(() => iam.resetPassword(email.trim(), code.trim(), password), 'done')
|
||
}
|
||
|
||
const back = () => {
|
||
setStep('email')
|
||
setCode('')
|
||
setPassword('')
|
||
setWrong(null)
|
||
setNote(null)
|
||
}
|
||
|
||
return (
|
||
<Hero
|
||
title={t('Reset your password')}
|
||
line={t('We’ll send a code to your email to set a new password.')}
|
||
switcher={
|
||
<Text render="p" m={0} fontSize="$2" color="var(--muted-foreground)" text="center">
|
||
{t('Remembered it? {signin}', { signin: <TextLink href={next}>{t('Sign in')}</TextLink> })}
|
||
</Text>
|
||
}
|
||
terms={null}
|
||
signin={
|
||
<Sheet label={t('Reset your password')}>
|
||
{step === 'email' ? (
|
||
<form onSubmit={send} noValidate>
|
||
<YStack gap="$4">
|
||
<Field id={ids.email} label={t('Email')} center>
|
||
<Input
|
||
id={ids.email}
|
||
size="lg"
|
||
fill
|
||
type="email"
|
||
name="email"
|
||
autoComplete="username"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder="[email protected]"
|
||
required
|
||
autoFocus
|
||
/>
|
||
</Field>
|
||
<Wrong text={wrong} />
|
||
<Action render="button" type="submit" fill disabled={busy}>
|
||
{busy ? t('One moment…') : t('Send code')}
|
||
</Action>
|
||
</YStack>
|
||
</form>
|
||
) : step === 'code' ? (
|
||
<form onSubmit={reset} noValidate>
|
||
<YStack gap="$4">
|
||
<Held address={email.trim()} title={t('Choose a new password')} onBack={back} />
|
||
<Say>{t('If an account uses this address, we sent it a code. Enter it with your new password.')}</Say>
|
||
<Code id={ids.code} value={code} onChange={setCode} />
|
||
<Field id={ids.password} label={t('New password')} center>
|
||
<Input
|
||
id={ids.password}
|
||
size="lg"
|
||
fill
|
||
type="password"
|
||
name="password"
|
||
autoComplete="new-password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder={t('At least {n} characters', { n: FLOOR })}
|
||
required
|
||
/>
|
||
</Field>
|
||
<Wrong text={wrong} />
|
||
<Note text={note} />
|
||
<Action render="button" type="submit" fill disabled={busy}>
|
||
{busy ? t('One moment…') : t('Set password')}
|
||
</Action>
|
||
<Other onClick={() => run(() => iam.sendLoginCode(email.trim()), 'code', t('A new code is on its way.'))} disabled={busy}>
|
||
{t('Send a new code')}
|
||
</Other>
|
||
</YStack>
|
||
</form>
|
||
) : (
|
||
<YStack gap="$4">
|
||
<Say>{t('Your password is set. Sign in with it to continue.')}</Say>
|
||
<Action href={next} fill>
|
||
{t('Sign in')}
|
||
</Action>
|
||
</YStack>
|
||
)}
|
||
</Sheet>
|
||
}
|
||
/>
|
||
)
|
||
}
|