mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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
1164 lines
46 KiB
TypeScript
1164 lines
46 KiB
TypeScript
'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>
|
||
)
|
||
}
|