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.
192 lines
6.9 KiB
TypeScript
192 lines
6.9 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* /join?org=<owner>&invite=<code>[&client_id=<app>] — the page an emailed
|
|
* invitation opens (hanzoai/iam internal/invitations `PathJoin`).
|
|
*
|
|
* An invitation is a row the organization's admin wrote in IAM; this page carries
|
|
* its code to IAM's one door for it, as the person signed in here:
|
|
*
|
|
* signed out the sign-in panel, which signs in or makes the account, and
|
|
* comes back to this invitation
|
|
* signed in Join (@hanzo/iam `acceptInvite`, with this page's access token,
|
|
* which IAM admits for a platform application only and which
|
|
* joins that token's own account). An invitation pinned to an
|
|
* address also wants a code: IAM sends one to the account's
|
|
* address itself and says so, and the page sends it back.
|
|
*
|
|
* The organization is named from the link and never looked up, so a guessed code
|
|
* learns nothing; IAM decides at the door and its refusal is shown as written.
|
|
* `client_id` names the application the invitation was sent from; the account
|
|
* signs in here as this site's own.
|
|
*/
|
|
|
|
import { useEffect, useId, useState, type FormEvent } from 'react'
|
|
import { Refused } from '@hanzo/iam'
|
|
import { useIam } from '@hanzo/iam/react'
|
|
import { Text, YStack } from '@hanzo/ui'
|
|
import { Action, TextLink } from '@hanzo/ui/marketing'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
import Hero from '@/components/home/Hero'
|
|
import { Panel } from '@/components/auth/panel'
|
|
import { Code, Other, said, Say, Sheet, Wrong } from '@/components/auth/steps'
|
|
import { aim } from '@hanzo/app/lib/destination'
|
|
import { useHydrated } from '@hanzo/app/lib/hydrated'
|
|
import { useClient, useSignOut } from '@hanzo/app/lib/signout'
|
|
import { useQuery } from '@/lib/auth/query'
|
|
|
|
/** IAM's reasons for an invitation that wants the code it sent to the account's address. */
|
|
const ASKS = new Set(['email_code_sent', 'email_code_required'])
|
|
|
|
export default function Page() {
|
|
const { t } = useI18n()
|
|
const iam = useClient()
|
|
const signOut = useSignOut()
|
|
const { isLoading, isAuthenticated, user } = useIam()
|
|
const q = useQuery()
|
|
const hydrated = useHydrated()
|
|
const owner = q.get('org') ?? ''
|
|
const code = q.get('invite') ?? ''
|
|
const app = q.get('client_id')
|
|
// This page again, rebuilt from the link's own fields, for a sign-in to come back to.
|
|
const here = `/join?${new URLSearchParams({ ...(app ? { client_id: app } : {}), invite: code, org: owner })}`
|
|
// IAM's sentence about the code it sent, once it asked for one.
|
|
const [asked, setAsked] = useState<string | null>(null)
|
|
const [emailCode, setEmailCode] = useState('')
|
|
const [joined, setJoined] = useState<string | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
const [wrong, setWrong] = useState<string | null>(null)
|
|
const ids = { code: useId() }
|
|
|
|
// Signed out, the sign-in that follows comes back to this invitation.
|
|
useEffect(() => {
|
|
if (owner && code && !isLoading && !isAuthenticated) aim(here)
|
|
}, [owner, code, here, isLoading, isAuthenticated])
|
|
|
|
/**
|
|
* One call for every step. Without the code it asks to join, which for a pinned
|
|
* invitation is also how IAM sends one; with it, it answers that code.
|
|
*/
|
|
function join(withCode: boolean) {
|
|
if (withCode && !emailCode.trim()) {
|
|
setWrong(t('Enter the code from the email.'))
|
|
return
|
|
}
|
|
setBusy(true)
|
|
setWrong(null)
|
|
iam
|
|
.acceptInvite(owner, code, withCode ? emailCode.trim() : undefined)
|
|
.then((org) => setJoined(org))
|
|
.catch((err: unknown) => {
|
|
if (err instanceof Refused && ASKS.has(err.code)) {
|
|
setAsked(err.message)
|
|
setEmailCode('')
|
|
return
|
|
}
|
|
setWrong(said(err, t))
|
|
})
|
|
.finally(() => setBusy(false))
|
|
}
|
|
|
|
/** Sign out, and sign in as the account the invitation was sent to; the sign-in comes back here. */
|
|
function other() {
|
|
aim(here)
|
|
void signOut()
|
|
}
|
|
|
|
let card
|
|
if (isLoading || !hydrated) {
|
|
card = (
|
|
<Sheet label={t('Join an organization')}>
|
|
<Say>{t('One moment…')}</Say>
|
|
</Sheet>
|
|
)
|
|
} else if (!owner || !code) {
|
|
card = (
|
|
<Sheet label={t('Join an organization')}>
|
|
<Wrong text={t('This invite link is incomplete.')} />
|
|
<Say>
|
|
{t('It is missing the organization or the invitation code. Ask the person who invited you to send it again, or {signin}.', {
|
|
signin: <TextLink href="/login">{t('sign in to Hanzo')}</TextLink>,
|
|
})}
|
|
</Say>
|
|
</Sheet>
|
|
)
|
|
} else if (!isAuthenticated) {
|
|
card = <Panel mode="login" />
|
|
} else if (joined) {
|
|
card = (
|
|
<Sheet label={t('You joined {org}', { org: joined })}>
|
|
<Say>
|
|
{t('You are a member of {org}.', {
|
|
org: (
|
|
<Text fontWeight="600" color="$ink">
|
|
{joined}
|
|
</Text>
|
|
),
|
|
})}
|
|
</Say>
|
|
<Action href="/" fill>
|
|
{t('Continue')}
|
|
</Action>
|
|
</Sheet>
|
|
)
|
|
} else if (asked !== null) {
|
|
card = (
|
|
<Sheet label={t('Join {org}', { org: owner })}>
|
|
<form
|
|
onSubmit={(e: FormEvent) => {
|
|
e.preventDefault()
|
|
join(true)
|
|
}}
|
|
noValidate
|
|
>
|
|
<YStack gap="$4">
|
|
<Say>{asked}</Say>
|
|
<Code id={ids.code} value={emailCode} onChange={setEmailCode} />
|
|
<Wrong text={wrong} />
|
|
<Action render="button" type="submit" fill disabled={busy}>
|
|
{busy ? t('One moment…') : t('Join {org}', { org: owner })}
|
|
</Action>
|
|
<Other onClick={() => join(false)} disabled={busy}>
|
|
{t('Send a new code')}
|
|
</Other>
|
|
</YStack>
|
|
</form>
|
|
</Sheet>
|
|
)
|
|
} else {
|
|
card = (
|
|
<Sheet label={t('Join {org}', { org: owner })}>
|
|
<Say>
|
|
{t('Signed in as {who}.', {
|
|
who: (
|
|
<Text fontWeight="600" color="$ink">
|
|
{user?.email ?? user?.name ?? t('your account')}
|
|
</Text>
|
|
),
|
|
})}
|
|
</Say>
|
|
<Wrong text={wrong} />
|
|
<Action render="button" type="button" fill disabled={busy} onClick={() => join(false)}>
|
|
{busy ? t('One moment…') : t('Join {org}', { org: owner })}
|
|
</Action>
|
|
<Other onClick={other} disabled={busy}>
|
|
{t('Use another account')}
|
|
</Other>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
// Signed out with a whole link, the panel asks: the lede says what for, and the terms are stated.
|
|
const asking = Boolean(owner && code) && !isLoading && !isAuthenticated
|
|
return (
|
|
<Hero
|
|
title={owner ? t('Join {org}', { org: owner }) : t('Join an organization')}
|
|
line={`${owner ? t('You were invited to join {org} on Hanzo.', { org: owner }) : t('Accept an invitation to an organization on Hanzo.')}${asking ? ` ${t('Sign in, or create your account, to accept it.')}` : ''}`}
|
|
terms={asking ? undefined : null}
|
|
signin={card}
|
|
/>
|
|
)
|
|
}
|