Files
zeekay 59014e789c login: the first screen has no "Sign in with password" link
A known account's password stays a press away on the code step, where the
link was sent; the first screen is Google, GitHub and the address.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:30:04 -07:00

1164 lines
46 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'
// Signing in and signing up, drawn on hanzo.ai's own page (/login, /signup).
//
// IAM is the authority and the only one: it says whether an address has an
// account, checks the password or the code, creates the account, mints the
// PKCE-bound code and, at the callback, the tokens. This file is where the
// person types, and nothing more — no password is kept, no session is made
// here, and the callback (/auth/callback) finishes every way in exactly as it
// finishes the others.
//
// a social provider a top-level hop through IAM to the provider and back to
// /auth/callback. IAM's own page is never drawn; the
// provider's is, because only the provider can ask.
// an email address alone on the first screen, and IAM asked whether an
// account holds it. Every address is sent its link and
// code at once (`sendLoginCode`, then `loginWithCode`; the
// link is lib/auth/link). A known one's password is one
// press away on the page that waits for them
// (`loginWithPassword`). On /login a new one goes to
// /signup before anything is sent. On /signup a new one
// is made once the link or its code proves the address:
// with no password after the terms
// (`createAccountWithCode`), or with one chosen, typed
// twice, beside the terms while the link waits
// (`signup`). An app that sends no code makes the account
// from the password and the terms (`signup`), and signs
// in by password alone.
//
// The credential calls are answered on this origin (lib/auth/client, universe
// static-sites `iam-on-hanzo-ai`, and the dev server's /v1 proxy), so no IAM
// route is opened cross-origin, and IAM keeps its session cookie here: the
// session a later visit is recognised by (`Gate`). `methods` is a public read
// from the issuer. A host that answers none of the calls sends the
// person to hanzo.ai's own /login or /signup.
//
// A sign-in IAM holds is finished here too, because IAM holds it in a host-only
// challenge cookie on the host the credential was posted to — this one:
//
// a second factor (`SecondFactor`, NextMfa) the code from the
// authenticator app, or the one IAM sent by email or
// text, or a recovery code (`verifyFactor`). A wrong one
// spends the challenge, so the credential is asked again.
// an address (`EmailRequired`) the code IAM sent to the address on
// file (`confirmEmail`), or another address and the code
// sent there (`changeEmail`). A wrong code keeps the step.
//
// A factor this page cannot take finishes on the issuer's page, the one case
// still sent there (`issuer`): enrolling one (RequiredMfa), and a passkey, whose
// credential is bound to the issuer's host (IAM's WebAuthn relying party is the
// issuer, hanzo.id), so no ceremony for it can run on hanzo.ai.
//
// Every word is the reader's (@hanzo/i18n `t`), one sentence to a call: an
// address, a provider's name or a site is an argument, and a sentence with a link
// in it is one string with the link as an argument. What is said after a press
// (`wrong`, `note`) is said when it is set. IAM's own refusals are IAM's words.
import { useEffect, useId, useRef, useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { EmailRequired, Refused, SecondFactor, SignupRequired, type Methods } from '@hanzo/iam'
import { useIam } from '@hanzo/iam/react'
import { Checkbox, Input, Separator, Text, View, XStack, YStack } from '@hanzo/ui'
import { Mailbox, type LucideIcon } from 'lucide-react'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { Code, Confirm, Factors, Held, Note, Other, said, Say, Sheet, usable, Wrong } from '@/components/auth/steps'
import { Terms } from '@/components/onboarding/terms'
import { BAR_HEIGHT, LARGE } from '@/components/shell/focus'
import { Apple, Github, Google } from '@/components/brand'
import { foreign, useLeave, useSignin } from '@/lib/auth/signin'
import { asker, ours } from '@/lib/auth/asker'
import { first } from '@/lib/auth/first'
import { useTap } from '@/components/auth/tap'
import { CHANNEL, mark, names, unmark } from '@/lib/auth/link'
import { countSignup, markMethod, markSignupIntent } from '@hanzo/app/lib/intent'
import { authorize, track } from '@hanzo/app/lib/tags'
import { aim, choseFree, door, handoff, LOGIN, recognize, signUp, takeDestination } from '@hanzo/app/lib/destination'
import { asked, unasked } from '@hanzo/app/lib/session'
import { here, useClient } from '@hanzo/app/lib/signout'
import { guest, SIGNIN, visits } from '@hanzo/app/lib/host'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
import { Action, More, TextLink } from '@hanzo/ui/marketing'
/** A provider's door: its own ground, a hairline and a lift, and it holds them
* under the pointer — the brand's button, not the page's. */
const PROVIDER = {
borderColor: 'var(--border)',
color: 'var(--foreground)',
boxShadow: '0 2px 8px -2px color-mix(in srgb, var(--pure-black) 25%, transparent), inset 0 1px 0 0 var(--white-08)',
} as const
const GOOGLE = { ...PROVIDER, bg: 'var(--background)' } as const
const GITHUB = { ...PROVIDER, bg: 'color-mix(in srgb, var(--card) 90%, transparent)' } as const
/**
* A whole address: something, an @, a domain with a dot in it and a name after
* the last dot, and no space anywhere. Asked before IAM is, so "ada@" is never
* sent a code and never reaches the password step as an address no code can reach.
*/
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@.]{2,}$/
export type Mode = 'login' | 'signup'
type Step = 'email' | 'code' | 'password' | 'create' | 'terms' | 'known' | 'new' | 'factor' | 'confirm'
/** The versions a person agrees to, recorded on the account IAM makes. */
const POLICY = { terms: TERMS_VERSION, aup: AUP_VERSION }
/** IAM's floor for a new password (signup `passwordPolicyError`), said before anything is sent. */
const FLOOR = 8
/** Said under the second password when it is not the first. */
const MISMATCH = phrase('The passwords don’t match.')
/**
* How long /signup says an address already has an account before its sign-in
* leaves, in ms. Long enough to read the card; far inside the life of the
* authorization code the sign-in carries, which a held press outlived.
*/
const KNOWN = 2000
/**
* How long the page with the link waits for it to sign in here before it turns
* to the code, in ms: a link opened in this browser signs in well inside it.
*/
const LINGER = 5000
/** The tab's session key an address is handed from /login to /signup under. */
const HANDED = 'signin.address'
/** What an application offers until IAM says: the two providers Hanzo runs, and every method. */
const FIRST: Methods = {
password: true,
code: true,
signup: true,
providers: [
{ name: 'provider-google', type: 'Google' },
{ name: 'provider-github', type: 'GitHub' },
],
}
/** A provider's mark, by its kind. A kind with none is drawn as its name alone. */
const MARKS: Record<string, LucideIcon> = { apple: Apple, google: Google, github: Github }
/** The providers in the order they are offered, from the first paint and after IAM answers: Apple, Google, GitHub, then any other. */
const RANK = ['apple', 'google', 'github']
const inOrder = (providers: Methods['providers']): Methods['providers'] => {
const rank = (type: string) => {
const i = RANK.indexOf(type.toLowerCase())
return i < 0 ? RANK.length : i
}
return [...providers].sort((a, b) => rank(a.type) - rank(b.type))
}
function GoogleColorIcon({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M23.745 12.27c0-.7-.06-1.4-.19-2.07H12v4.51h6.6c-.29 1.52-1.14 2.8-2.4 3.65v3.03h3.88c2.27-2.09 3.66-5.17 3.66-9.12z"
/>
<path
fill="#34A853"
d="M12 24c3.24 0 5.95-1.08 7.93-2.91l-3.88-3.03c-1.08.72-2.45 1.16-4.05 1.16-3.12 0-5.77-2.1-6.72-4.93H1.25v3.13C3.26 21.36 7.36 24 12 24z"
/>
<path
fill="#FBBC05"
d="M5.28 14.29c-.25-.72-.38-1.49-.38-2.29s.14-1.57.38-2.29V6.57H1.25C.45 8.16 0 9.96 0 12s.45 3.84 1.25 5.43l4.03-3.14z"
/>
<path
fill="#EA4335"
d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.42-3.42C17.95 1.19 15.24 0 12 0 7.36 0 3.26 2.64 1.25 6.57l4.03 3.14c.95-2.83 3.6-4.96 6.72-4.96z"
/>
</svg>
)
}
/**
* `tap` offers Google One Tap over the first screen: /login and /signup say it
* (`Gate`), the landing's hero, which draws the same panel, does not.
*/
export function Panel({ mode, tap = false }: { mode: Mode; tap?: boolean }) {
const { t } = useI18n()
const { user } = useIam()
const login = useSignin()
const leave = useLeave()
const act = LARGE
const [methods, setMethods] = useState<Methods>(FIRST)
const [step, setStep] = useState<Step>('email')
const [name, setName] = useState('')
const [email, setEmail] = useState(user?.email ?? '')
const [password, setPassword] = useState('')
// The password typed again, for an account being made.
const [repeat, setRepeat] = useState('')
const [code, setCode] = useState('')
const [agree, setAgree] = useState(false)
const [showCode, setShowCode] = useState(false)
const [busy, setBusy] = useState(false)
const [wrong, setWrong] = useState<string | null>(null)
const [note, setNote] = useState<string | null>(null)
// The person was already told, at the address, that it has an account (`known`).
const told = useRef(false)
// The link waits for an account being made here.
const [fresh, setFresh] = useState(false)
// The account may sign in with a password: the page with the link offers it.
const [keyed, setKeyed] = useState(true)
// A link is on its way to the address.
const [mailed, setMailed] = useState(false)
// A sign-in IAM is holding. `holds` counts the address holds, so each new one
// opens its own step.
const [factor, setFactor] = useState<SecondFactor | null>(null)
const [hold, setHold] = useState<EmailRequired | null>(null)
const [holds, setHolds] = useState(0)
const ids = { name: useId(), email: useId(), password: useId(), repeat: useId(), code: useId(), terms: useId(), wrong: useId() }
const local = here()
const iam = useClient()
// Another app's sign-in (lib/auth/signin `foreign`): every sign-in here mints
// its code, and a finished one hands it to the app instead of leaving.
const [app] = useState(foreign)
const [handed, setHanded] = useState(false)
useEffect(() => iam.ask(app), [iam, app])
useEffect(() => {
// A host that answers no IAM call hands the reader to hanzo.ai (`Gate`),
// so it asks IAM nothing, not even which providers it offers.
if (!here()) return
let live = true
iam
.methods()
.then((m) => {
// A list with no provider in it is IAM saying none; one that failed to
// arrive keeps the ones Hanzo runs.
if (live) setMethods(m)
})
.catch(() => {})
return () => {
live = false
}
}, [iam])
// An address /login handed to /signup in this tab (`HANDED`): one IAM said has
// no account. It arrives typed, and the page says why it is here. It rides in
// the tab's session store, never the URL, and is read once.
useEffect(() => {
let hint: string | null = null
try {
hint = sessionStorage.getItem(HANDED)
sessionStorage.removeItem(HANDED)
} catch {
/* no store: the address is typed again */
}
if (!hint || mode !== 'signup') return
setEmail(hint)
setFresh(true)
}, [mode])
const signup = mode === 'signup'
// What follows the address where IAM says nothing of it (lib/auth/first), and
// whether an account is made with no code to prove its address.
const ask = first(methods, signup)
const direct = !methods.code
/** /signup, the page that makes accounts, with the address carried to it. */
const register = () => {
try {
sessionStorage.setItem(HANDED, email.trim())
} catch {
/* no store: /signup asks for the address again */
}
window.location.assign(signUp())
}
/**
* The person chose a way in: remembered for the callback, and where it asks
* for an account (`asks`) counted as the account asked for
* (signup_submitted), with the intent mark that tells the callback a new
* account from a returning login.
*/
function commit(method: string, asks = signup) {
markMethod(method)
if (!asks) return
markSignupIntent()
track('signup_submitted', { method })
}
function social(provider: string, kind: string, hint?: string, idTokenHint?: string, idpNonce?: string) {
commit(kind.toLowerCase())
setBusy(true)
void login({
additionalParams: {
provider,
...(hint ? { login_hint: hint } : {}),
...(idTokenHint ? { id_token_hint: idTokenHint } : {}),
...(idpNonce ? { idp_nonce: idpNonce } : {}),
...(signup ? { signup: 'true' } : {}),
},
}).catch((err: unknown) => {
setWrong(said(err, t))
setBusy(false)
})
}
// Google One Tap, only on the first screen, only while IAM offers Google, and
// withdrawn by any press. A tapped account is the Google hop with its address
// as the hint (components/auth/tap).
const google = methods.providers.find((p) => p.type.toLowerCase() === 'google')
useTap(
tap && step === 'email' && !busy && !!google,
// A credential that names no address is not one of Google's: IAM is not handed it.
(email, credential, idpNonce) => google && social(google.name, google.type, email, email ? credential : undefined, email ? idpNonce : undefined),
)
/** The issuer's page, for what cannot be done here, with the address filled in. */
const issuer = () => login({ additionalParams: { login_hint: email.trim(), ...(signup ? { signup: 'true' } : {}) } })
/** Back to the first screen, the address kept, saying why. */
function restart(why: string) {
setFactor(null)
setHold(null)
setName('')
setPassword('')
setRepeat('')
setFresh(false)
setMailed(false)
setCode('')
setStep('email')
setWrong(why)
}
/**
* Hold the sign-in for its address. A hold naming the address it already named
* is a code sent there again.
*/
function keep(next: EmailRequired) {
setNote(hold && next.email && next.email === hold.email ? t('A new code is on its way to {email}.', { email: next.email }) : null)
setHold(next)
setHolds((n) => n + 1)
setStep('confirm')
}
/** Run one call to IAM, saying its refusal, and leave when it answers with where to. */
async function run(call: () => Promise<string | void>) {
setBusy(true)
setWrong(null)
setNote(null)
try {
const to = await call()
if (to && app) {
// The app's own callback: the browser hands it the code. A native app's
// page stays and says so; a site on the web is simply left for. The
// session it was answered from is this site's too, so this browser is
// one that has signed in here (`recognize`): hanzo.ai asks for it next
// time instead of offering sign-up.
recognize()
window.location.assign(to)
if (!asker(app.redirectUri)) {
setHanded(true)
setBusy(false)
}
return
}
// Leaving for the callback: what the stream holds (signup_submitted) is
// sent first, while this page can still send it (tags `authorize`).
if (to) window.location.assign(authorize(to))
else setBusy(false)
} catch (err) {
// A factor this page cannot take finishes on the issuer's page.
if (err instanceof SecondFactor && (err.directive !== 'NextMfa' || !usable(err.factors).length)) {
await issuer()
return
}
setBusy(false)
if (err instanceof SecondFactor) {
setFactor(err)
setStep('factor')
return
}
if (err instanceof EmailRequired) {
keep(err)
return
}
// A second factor answered wrong spends its challenge, and a held address
// can end: either way the sign-in starts again from the credential.
if (step === 'factor' || (step === 'confirm' && err instanceof Refused && err.code === 'login_required')) {
restart(said(err, t))
return
}
setWrong(said(err, t))
}
}
/** On to a step, with nothing typed for it yet. */
function open(at: Step) {
setCode('')
setShowCode(false)
setStep(at)
}
/** The link and its code to the address, and the page that waits for them. This browser is marked as the one that asked (lib/auth/link). */
async function mail() {
await iam.sendLoginCode(email.trim())
mark(email.trim())
setMailed(true)
open('code')
}
/** Between the steps that follow the address, saying nothing from the last one. */
function go(at: Step) {
setWrong(null)
setNote(null)
setStep(at)
}
/**
* The address, whole, on a host that answers IAM; else null, with why said, or
* the person sent to hanzo.ai's own page: with its own request where this host
* keeps a session (`visits`), as itself where it signs nobody in.
*/
function sound(): string | null {
const address = email.trim()
if (!address) {
setWrong(t('Enter your email address.'))
return null
}
if (!ADDRESS.test(address)) {
setWrong(t('Enter a full email address, like [email protected].'))
return null
}
if (!local) {
if (visits(window.location.hostname)) void leave(signup ? 'signup' : 'login')
else window.location.assign(`${SIGNIN}${signup ? signUp() : LOGIN}`)
return null
}
return address
}
/**
* Whether an account holds the address, from IAM, where the application makes
* accounts; null where it makes none, or IAM is unreachable or refuses the
* question, and the credential is tried as it would be without the answer.
*/
async function identify(address: string) {
return methods.signup ? await iam.identify(address).catch(() => null) : null
}
/**
* An address IAM says no account holds, on a page that is not /signup: to
* /signup, typed for it, from /login and from the sign-up the home page
* carries (components/home/Start), which reads as a login and never says it
* makes an account; made here where the login panel stands for an invitation
* (/join), so the sign-in still comes back to where it began.
*/
function unknown() {
if (signup || window.location.pathname === LOGIN) return register()
setStep('new')
}
/** The application's own sign-up: an account from the password and the terms, with no code to prove the address. */
function create(address: string) {
made('password')
return iam.signup({
email: address,
password,
code: '',
...(name.trim() ? { name: name.trim() } : {}),
terms: POLICY,
})
}
/**
* Continue with email: the address alone, and IAM asked about it. Every address
* the application can mail is sent its link at once (owner, 2026-10-22): a known
* one with "Enter password instead" beside the link when the account has a
* password, a new one with none. The password comes first only where no link
* can be sent: an application that sends no code, and one that makes no
* accounts (below).
*/
function next(e: FormEvent) {
e.preventDefault()
const address = sound()
if (!address) return
told.current = false
void run(async () => {
const who = await identify(address)
if (who?.account) {
if (!methods.code) return open('password')
setKeyed(methods.password && who.password !== false)
return mail()
}
// An application that makes no accounts is never asked about an address
// (admin.hanzo.ai's admin-console), and its own first step decides: its
// password where it has passwords, since its org (admin) has no email
// provider and a link would never arrive. Everywhere else an address IAM
// gave no answer for is mailed its link, like any other (owner, 2026-10-22).
if (!who && !methods.signup && ask === 'password') return open('password')
// New account: send magic link directly (no redirect, no password setup, no code entry needed)
setFresh(signup)
setKeyed(Boolean(methods.password))
await mail()
})
}
/**
* A password for the account being made, chosen and typed again, and the
* terms agreed to beside it. Creates the account immediately with IAM.
*/
function make(e: FormEvent) {
e.preventDefault()
if (password.length < FLOOR) return setWrong(t('Use at least {n} characters.', { n: FLOOR }))
if (repeat !== password) return setWrong(t(MISMATCH))
if (!agree) return setWrong(t('Agree to the terms to continue.'))
void run(async () => {
return create(email.trim())
})
}
/** Back to the link with no password chosen. */
function skip() {
setPassword('')
setRepeat('')
setAgree(false)
go('code')
}
/** The password, in place of the link. */
function enter(e: FormEvent) {
e.preventDefault()
if (!password) return setWrong(t('Enter your password.'))
commit('password', false)
void run(() => iam.loginWithPassword(email.trim(), password))
}
function finish(e: FormEvent) {
e.preventDefault()
spend(code)
}
/**
* The code, typed here or handed over by the link opened in this browser. It
* signs in on either page. An address no account holds is made: at once with
* the password and terms chosen before the link, else after the terms. Only
* the inbox's holder learns either way: the code proved the address first.
*/
function spend(typed: string) {
const address = email.trim()
const sent = typed.trim()
if (!sent) return setWrong(t('Enter the code from the email.'))
commit('code', false)
void run(async () => {
try {
const to = await iam.loginWithCode(address, sent)
unmark()
// /signup with an address an account holds: said, and then the sign-in
// leaves on its own. It is held for the beat it takes to read (`KNOWN`)
// and no longer, since the code it carries expires if held for a press.
if (signup && to && !told.current) {
setStep('known')
await new Promise((done) => setTimeout(done, KNOWN))
}
return to
} catch (err) {
if (!(err instanceof SignupRequired)) throw err
unmark()
if (fresh && agree && password.length >= FLOOR) {
made('password')
return iam.signup({ email: address, password, code: sent, terms: POLICY })
}
if (window.location.pathname === '/' || window.location.pathname === '') {
made('email-code')
return iam.createAccountWithCode(address, sent, POLICY)
}
setStep('terms')
}
})
}
// No sign-in in this tab a few seconds after the link went out: it was most
// likely opened on another device, so the page turns to its code on its own.
useEffect(() => {
if (step !== 'code' || showCode || busy) return
const later = setTimeout(() => setShowCode(true), LINGER)
return () => clearTimeout(later)
}, [step, showCode, busy])
// The link opened in this browser hands its code to the page waiting on it
// (lib/auth/link), which spends it as if it were typed, and says it took it.
const latest = useRef(spend)
useEffect(() => {
latest.current = spend
})
useEffect(() => {
if (step !== 'code' || typeof BroadcastChannel === 'undefined') return
const channel = new BroadcastChannel(CHANNEL)
channel.onmessage = (e: MessageEvent) => {
if (!names(e.data, email)) return
channel.postMessage({ taken: e.data.code })
setCode(e.data.code)
latest.current(e.data.code)
}
return () => channel.close()
}, [step, email])
/** A new account is asked for and its terms agreed to, by the way it is made. */
function made(method: 'password' | 'email-code') {
commit('email', true)
track('terms_accepted', { method })
countSignup('email')
}
const back = () => {
setStep('email')
setWrong(null)
setNote(null)
setName('')
setPassword('')
setRepeat('')
setFresh(false)
setMailed(false)
setCode('')
setAgree(false)
setShowCode(false)
setFactor(null)
setHold(null)
}
/** Another code to the same address, said when it is on its way. */
const again = () =>
void run(async () => {
await iam.sendLoginCode(email.trim())
setCode('')
setNote(t('A new code is on its way.'))
})
// Where the link went: one sentence, the address set on its own line, in bold.
const sentTo = t('To continue, click the link sent to{br}{email}', {
br: <br />,
email: (
<Text fontWeight="600" color="$ink">
{email.trim()}
</Text>
),
})
// The second password's refusal, as `wrong` holds it.
const mismatch = t(MISMATCH)
if (step === 'terms') {
// The next page of the same flow, laid over the one it was reached from and
// UNDER THE BAR: it is drawn in place, inside the page's column, whose layer
// (components/shell/focus) sits below the bar's, so the wordmark and the
// account stay at the top as on every other page. Portalled to <body> above
// the modals it covered the bar, the wordmark with it, and left the font
// scope, so its title fell to the inherited 14px. Its top is the bar's
// height, and its foot clears the home indicator in an installed app.
return (
<View
display="block"
position="fixed"
inset={0}
z="var(--z-raised)"
overflowY="auto"
bg="var(--background)"
pt={BAR_HEIGHT}
pb="var(--safe-bottom)"
>
<Terms
email={email.trim()}
busy={busy}
wrong={wrong}
onOther={back}
{...(signup
? {}
: {
title: t('No account for this email yet'),
say: t("We couldn't find a Hanzo account for {email}. Agree to the terms below to create one.", { email: email.trim() }),
})}
onCreate={() => {
// An address the code proved with no password chosen: made with none.
made('email-code')
void run(() => iam.createAccountWithCode(email.trim(), code.trim(), POLICY))
}}
/>
</View>
)
}
// The account the link went to may sign in with a password instead.
const passworded = Boolean(methods.password)
// The reset, with the address as its hint.
const forget = `/forget?${new URLSearchParams({ login_hint: email.trim() })}`
if (handed) return <Handed />
return (
// `/`, /login and /signup all set it in the hero's copy column, under the
// page's own h1, and the hero's line under it states the terms for every mode.
<Sheet label={t(signup ? 'Sign up' : 'Log in')}>
{step === 'email' ? (
<>
{methods.providers.length ? (
<>
<YStack gap="$3" width="100%">
{inOrder(methods.providers).map((p) => {
const isGoogle = p.type.toLowerCase() === 'google'
const isApple = p.type.toLowerCase() === 'apple'
const isGithub = p.type.toLowerCase() === 'github'
const Mark = MARKS[p.type.toLowerCase()]
return (
<Action
render="button"
key={p.name}
type="button"
onClick={() => social(p.name, p.type)}
disabled={busy}
width="100%"
display="flex"
items="center"
justify="center"
gap={12}
{...act}
fontWeight="600"
cursor={busy ? 'not-allowed' : 'pointer'}
transition="all 0.15s ease-in-out"
{...(isGoogle || isApple ? GOOGLE : isGithub ? GITHUB : null)}
>
{isGoogle ? <GoogleColorIcon size={18} /> : Mark ? <Mark size={18} /> : null}
{t('Continue with {provider}', { provider: p.type })}
</Action>
)
})}
</YStack>
{/* The word between the providers and the address, on a hairline either side. */}
<XStack items="center" gap="$3" my="$1">
<Separator />
<Text fontSize="$1" color="var(--muted-foreground)">
{t('or')}
</Text>
<Separator />
</XStack>
</>
) : null}
<form onSubmit={next} noValidate>
<YStack gap="$4">
<Input
id={ids.email}
size="lg"
fill
type="email"
aria-label={t('Email')}
name="email"
autoComplete={signup ? 'email' : 'username'}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
required
autoFocus
aria-invalid={wrong ? true : undefined}
aria-describedby={wrong ? ids.wrong : undefined}
/>
<Wrong id={ids.wrong} text={wrong} />
<Note text={note} />
<Action render="button" type="submit" fill disabled={busy} {...act}>
{t(busy ? 'One moment…' : 'Continue with email')}
</Action>
</YStack>
</form>
</>
) : step === 'code' ? (
<form onSubmit={finish} noValidate>
{showCode ? (
// The code form: the link was opened elsewhere, or its code was asked for.
<YStack gap="$4">
<Say>{sentTo}</Say>
<Text render="p" fontSize="$1" fontStyle="italic" color="var(--muted-foreground)" text="center">
{t('If the link shows a verification code instead of signing you in, enter it here.')}
</Text>
<Say>
{t('Not seeing the email in your inbox? {again} Wrong email? {change}', {
again: (
<TextLink onPress={again} disabled={busy}>
{t('Try sending again.')}
</TextLink>
),
change: (
<TextLink onPress={back} disabled={busy}>
{t('Change email address')}
</TextLink>
),
})}
</Say>
<Code id={ids.code} value={code} onChange={setCode} />
<Wrong text={wrong} />
<Note text={note} />
<Action render="button" type="submit" fill width="100%" disabled={busy} {...act}>
{t(busy ? 'One moment…' : 'Verify email address')}
</Action>
{passworded ? (
<Other onClick={() => go('password')} disabled={busy}>
{t('Enter password instead')}
</Other>
) : null}
</YStack>
) : (
// Where the link went, then one column of ways on.
<YStack gap="$5" items="center">
<Mailbox size={28} strokeWidth={1.6} color="var(--foreground)" aria-hidden />
<Say>{sentTo}</Say>
<Wrong text={wrong} />
<Note text={note} />
<YStack items="center" gap="$2">
<TextLink onPress={() => setShowCode(true)} fontSize="$2">
{t('Enter verification code')}
</TextLink>
<TextLink onPress={again} disabled={busy} fontSize="$2">
{t('Resend email')}
</TextLink>
<TextLink onPress={back} disabled={busy} fontSize="$2">
{t('Use a different email')}
</TextLink>
{passworded ? (
<TextLink onPress={() => go('password')} disabled={busy} fontSize="$2">
{t('Enter password instead')}
</TextLink>
) : null}
</YStack>
</YStack>
)}
</form>
) : step === 'password' ? (
<form onSubmit={enter} noValidate>
<YStack gap="$4">
<Held address={email.trim()} title={t('Enter your password')} onBack={back} />
{/* The address the password belongs to, for the browser's password manager. */}
<input type="email" name="email" autoComplete="username" value={email.trim()} readOnly hidden />
<Input
id={ids.password}
size="lg"
fill
type="password"
aria-label={t('Password')}
name="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('Password')}
required
autoFocus
aria-invalid={wrong ? true : undefined}
aria-describedby={wrong ? ids.wrong : undefined}
/>
<Wrong id={ids.wrong} text={wrong} />
<Note text={note} />
<Action render="button" type="submit" fill disabled={busy} {...act}>
{t(busy ? 'One moment…' : 'Continue')}
</Action>
{methods.code ? (
<Other onClick={() => (mailed ? go('code') : void run(mail))} disabled={busy}>
{t(mailed ? 'Use the emailed link instead' : 'Email me a link instead')}
</Other>
) : null}
{/* The reset is hanzo.ai's own page (/forget), the address carried as its hint. */}
<XStack justify="center">
<More href={forget} $touchable={{ minH: 44, minW: 44 }}>
{t('Forgot password?')}
</More>
</XStack>
</YStack>
</form>
) : step === 'create' ? (
<form onSubmit={make} noValidate>
<YStack gap="$4">
<Held address={email.trim()} title={t('Set a password')} onBack={back} />
<input type="email" name="email" autoComplete="username" value={email.trim()} readOnly hidden />
<Input
id={ids.name}
size="lg"
fill
type="text"
aria-label={t('Full name')}
name="name"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('Full name')}
autoFocus
/>
<Input
id={ids.password}
size="lg"
fill
type="password"
aria-label={t('Password')}
name="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('Password ({n}+ characters)', { n: FLOOR })}
required
aria-invalid={wrong && wrong !== mismatch ? true : undefined}
aria-describedby={wrong && wrong !== mismatch ? ids.wrong : undefined}
/>
<Input
id={ids.repeat}
size="lg"
fill
type="password"
aria-label={t('Confirm password')}
name="repeat"
autoComplete="new-password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
placeholder={t('Confirm password')}
required
aria-invalid={wrong === mismatch ? true : undefined}
aria-describedby={wrong === mismatch ? ids.wrong : undefined}
/>
<XStack gap="$3" items="flex-start">
<Checkbox id={ids.terms} checked={agree} onCheckedChange={(v) => setAgree(v === true)} mt="$1" />
<Text render="label" htmlFor={ids.terms} fontSize="$2" color="var(--muted-foreground)" flex={1}>
{t("I agree to Hanzo's {terms} and {aup} and confirm that I am at least 18 years of age.", {
terms: <TextLink href="/terms">{t('Terms of Service')}</TextLink>,
aup: <TextLink href="/aup">{t('Acceptable Use Policy')}</TextLink>,
})}
</Text>
</XStack>
<Wrong id={ids.wrong} text={wrong} />
<Note text={note} />
<Action render="button" type="submit" fill disabled={busy || !agree} {...act}>
{t(busy ? 'One moment…' : 'Create account')}
</Action>
{methods.code ? (
<Other onClick={() => void run(mail)} disabled={busy}>
{t('Email me a sign-in link instead')}
</Other>
) : null}
</YStack>
</form>
) : step === 'known' ? (
// /signup with an address its code proved an account holds: said while it logs in.
<YStack gap="$4">
<Held address={email.trim()} title={t('You already have an account')} onBack={back} />
<Say>{t("This email is already registered, so we're logging you in.")}</Say>
<Wrong text={wrong} />
</YStack>
) : step === 'new' ? (
// The login panel off /login (/join's invitation) with an address IAM says
// no account holds: said before any code, and made here — the code, then
// the terms (`terms`) — so the sign-in comes back to the invitation.
<YStack gap="$4">
<Held address={email.trim()} title={t('No account for this email yet')} onBack={back} />
<Say>{t("This email isn't registered with Hanzo. Create an account with it, or use a different email.")}</Say>
<Action
render="button"
type="button"
fill
disabled={busy}
onClick={() => {
setFresh(true)
void run(mail)
}}
{...act}
>
{t(busy ? 'One moment…' : 'Create an account')}
</Action>
<Wrong text={wrong} />
<Other onClick={back} disabled={busy}>
{t('Use a different email')}
</Other>
</YStack>
) : step === 'factor' && factor ? (
<Factors
offered={usable(factor.factors)}
busy={busy}
wrong={wrong}
head={<Held address={email.trim()} title={t('Two-step verification')} onBack={back} />}
onAnswer={(answer, remember) => void run(() => iam.verifyFactor(factor, answer, remember))}
/>
) : step === 'confirm' && hold ? (
<Confirm
key={holds}
hold={hold}
busy={busy}
wrong={wrong}
note={note}
head={<Held address={email.trim()} title={t('Confirm your email')} onBack={back} />}
onCode={(sent) => void run(() => iam.confirmEmail(hold, sent))}
onSend={(address) => void run(async () => keep(await iam.changeEmail(hold, address)))}
/>
) : null}
</Sheet>
)
}
/**
* The page around the panel: /login and /signup are this and a mode, and the
* home's hero carries it.
*
* a guest host /login and /signup: the same address on hanzo.ai, which
* can sign in; anywhere else, nothing until Continue
* signed in on to where the sign-in was headed (or `to`)
* not asked yet IAM is asked first, with no credential and no navigation
* (@hanzo/iam `resume`, lib/auth/session `unasked`): a
* session on this origin signs the reader in with nothing
* typed, and anything else draws the form. Asked only by
* a browser that has held a session here (destination
* `door`, `hz_known`): a stranger's holds no session to
* find, and IAM's "no" is a 400 its console would log on
* every page. Not on the page a sign-out lands on
* (`?from=logout`).
* otherwise the panel, counted as signup_viewed with its `mode`: the
* panel is where an account is made, and /login's
* provider buttons make one too
*
* `to` is where a completed sign-in goes when the page knows better than the
* page it started from: a paid plan chosen on /signup goes on to /pay.
*/
export function Gate({ mode, to, onShown }: { mode: Mode; to?: string; onShown?: () => void }) {
const router = useRouter()
const { isLoading, isAuthenticated } = useIam()
const iam = useClient()
const [app] = useState(foreign)
const leave = useLeave()
const [other, setOther] = useState(false)
const [pass, setPass] = useState(false)
const passing = useRef(false)
useEffect(() => {
if (isLoading) return
const { pathname, search } = window.location
// /login and /signup ARE the sign-in, so a guest host moves them to hanzo.ai on
// arrival. Anywhere else the panel is part of the page (the home's hero), and a
// guest host's panel asks IAM nothing and hands off on Continue (Panel `next`).
const signin = pathname === LOGIN || pathname === signUp()
if (signin ? handoff(`${pathname}${search}`) : guest(window.location.hostname)) return
// Another app's sign-in. A Hanzo site on the web (`ours`) is answered from
// the session here on arrival: the person signed in on hanzo.ai passes
// straight through, and a session IAM will not spend for that app draws the
// panel. IAM's session is asked even when this page holds no tokens of its
// own — they expire long before the session does, and a person signed in
// here yesterday must not type a password for platform.hanzo.ai today — but
// only by a browser that has signed in here (`door`), not from the page a
// sign-out lands on. Anything else is finished by the person, never on
// arrival: a code minted silently would go to whichever app on this machine
// claims the callback's scheme. Signed in, they press Continue; otherwise
// the panel.
if (app) {
if (!ours(app.redirectUri) || passing.current) return
if (!isAuthenticated && (door() !== LOGIN || new URLSearchParams(search).get('from') === 'logout')) return
passing.current = true
setPass(true)
iam.ask(app)
void iam.resume().then((next) => {
if (!next) {
setPass(false)
setOther(true)
return
}
markMethod('session')
recognize()
window.location.replace(next)
})
return
}
if (to) aim(to)
if (isAuthenticated) {
choseFree()
const dest = takeDestination()
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI__ || (window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__)
const onward = (isDesktop && (!dest || dest === '/home' || dest === '/')) ? '/chat' : dest
router.replace(onward)
return
}
// A host that signs in on hanzo.ai (`visits`) sends its own request there from
// /login and /signup, and asks IAM nothing here; elsewhere its panel leaves on
// Continue.
if (visits(window.location.hostname)) {
// Not from the page a sign-out lands on: hanzo.ai answers this host from its
// session, so leaving would sign the person straight back in.
if (signin && new URLSearchParams(search).get('from') !== 'logout') void leave(mode === 'signup' ? 'signup' : 'login')
return
}
if (unasked() && door() === LOGIN && new URLSearchParams(search).get('from') !== 'logout') {
asked()
void iam.resume().then((next) => {
if (!next) return
markMethod('session')
window.location.replace(authorize(next))
})
return
}
}, [isLoading, isAuthenticated, iam, router, to, app, leave, mode])
// The panel seen, in the mode it is seen in: again when a checkout's page
// switches in place, so a buyer who turns to making an account is counted as one.
useEffect(() => {
track('signup_viewed', { mode })
}, [mode])
useEffect(() => {
onShown?.()
// Once, when the panel first appears.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (pass) return <Passing />
if (app && isAuthenticated && !other) return <Continue onOther={() => setOther(true)} />
return <Panel mode={mode} tap={true} />
}
/** The account this browser is signed in as, offered to the app that asked. */
function Continue({ onOther }: { onOther: () => void }) {
const { t } = useI18n()
const { user } = useIam()
const iam = useClient()
const [app] = useState(foreign)
const [busy, setBusy] = useState(false)
const [handed, setHanded] = useState(false)
const who = user?.email ?? user?.name
// A site on the web is named and left for; a native app is handed the code.
const site = asker(app?.redirectUri)
async function go() {
setBusy(true)
iam.ask(app)
const to = await iam.resume()
// No session IAM can spend on this origin: the panel asks for the credential.
if (!to) return onOther()
markMethod('session')
window.location.assign(to)
if (!site) setHanded(true)
}
if (handed) return <Handed />
return (
<Sheet label={t('Continue')}>
<Say>{site ? t('{site} asks to sign you in.', { site }) : t('An app on this device asked to sign in.')}</Say>
<Action render="button" type="button" fill disabled={busy} onClick={() => void go()}>
{who ? t('Continue as {who}', { who }) : t('Continue as your account')}
</Action>
<TextLink onPress={onOther}>{t('Use another account')}</TextLink>
</Sheet>
)
}
/** A Hanzo site's sign-in, answered from the session here: the browser is on its way back. */
function Passing() {
const { t } = useI18n()
return (
<Sheet label={t('Signing in')}>
<Say>{t('Signing you in…')}</Say>
</Sheet>
)
}
/** The code went to the app that asked; this tab has nothing left to do. */
function Handed() {
const { t } = useI18n()
return (
<Sheet label={t('Signed in')}>
<Say>{t('Signed in. Return to the app; you can close this tab.')}</Say>
</Sheet>
)
}