| import { useEffect, useState } from 'react' |
| import { useI18n } from '../i18n' |
| import { localizeError } from '../utils/errors' |
| import { Button, Field, Callout, Spinner } from '../components/ui' |
| import { PasskeyEnvWarnings, OpenInNewTabBanner } from '../components/d2' |
| import { PasskeyCancelled, PasskeyUnsupported } from '../auth/passkey' |
| import { signIn, demoQuickSession, getIdentityStatus } from '../api/identity' |
|
|
| type Busy = 'passkey' | 'handle' | 'demo' | null |
| type Msg = { tone: 'ok' | 'bad' | 'info'; text: string } | null |
|
|
| export function D2SignInPage({ onNav }: { onNav: (r: string) => void }) { |
| const { t } = useI18n() |
| const [busy, setBusy] = useState<Busy>(null) |
| const [msg, setMsg] = useState<Msg>(null) |
| const [showHandle, setShowHandle] = useState(false) |
| const [handle, setHandle] = useState('') |
| const [demoOpen, setDemoOpen] = useState(false) |
|
|
| useEffect(() => { |
| let alive = true |
| |
| Promise.resolve(getIdentityStatus?.()) |
| .then((s) => { if (alive && s) setDemoOpen(!!s.demo_open_enroll) }) |
| .catch(() => {}) |
| return () => { alive = false } |
| }, []) |
|
|
| async function runDemo(role: 'customer' | 'operator' = 'customer') { |
| setBusy('demo'); setMsg(null) |
| try { |
| |
| const r = await demoQuickSession(role) |
| setMsg({ tone: 'ok', text: t('d2.signin.success', { name: r.account.display_alias }) }) |
| onNav(role === 'operator' ? 'id/operator' : 'finance') |
| } catch (e) { |
| setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.signin.error') }) |
| } finally { |
| setBusy(null) |
| } |
| } |
|
|
| async function run(which: 'passkey' | 'handle') { |
| if (which === 'handle' && !handle.trim()) { |
| setMsg({ tone: 'info', text: t('d2.signin.needHandle') }); return |
| } |
| setBusy(which); setMsg(null) |
| try { |
| const r = await signIn(which === 'handle' ? handle.trim() : undefined) |
| setMsg({ tone: 'ok', text: t('d2.signin.success', { name: r.account.display_alias }) }) |
| onNav('id/account') |
| } catch (e) { |
| if (e instanceof PasskeyUnsupported) setMsg({ tone: 'info', text: t('d2.unsupported') }) |
| else if (e instanceof PasskeyCancelled) setMsg({ tone: 'info', text: t('d2.cancelled') }) |
| else setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.signin.error') }) |
| } finally { |
| setBusy(null) |
| } |
| } |
|
|
| return ( |
| <section className="card" aria-labelledby="d2signin-h"> |
| <h2 id="d2signin-h">{t('d2.signin.title')}</h2> |
| <p className="hint">{t('d2.signin.intro')}</p> |
| <OpenInNewTabBanner /> |
| <PasskeyEnvWarnings /> |
| |
| {demoOpen && ( |
| <fieldset className="action-group"> |
| <legend>{t('d2.signin.demoTitle')}</legend> |
| <Callout tone="info">{t('d2.signin.demoHint')}</Callout> |
| <Button onClick={() => runDemo('customer')} disabled={busy !== null}> |
| {busy === 'demo' ? <Spinner label={t('d2.working')} /> : t('d2.signin.demo')} |
| </Button> |
| <Button onClick={() => runDemo('operator')} disabled={busy !== null}> |
| {t('d2.signin.demoOperator')} |
| </Button> |
| </fieldset> |
| )} |
| |
| <fieldset className="action-group"> |
| <legend>{t('d2.signin.withPasskey')}</legend> |
| <Button onClick={() => run('passkey')} disabled={busy !== null}> |
| {busy === 'passkey' ? <Spinner label={t('d2.working')} /> : t('d2.signin.withPasskey')} |
| </Button> |
| <p className="hint"> |
| <button type="button" className="link" onClick={() => setShowHandle((v) => !v)} aria-expanded={showHandle}> |
| {t('d2.signin.handleToggle')} |
| </button> |
| </p> |
| {showHandle && ( |
| <> |
| <Field id="d2_signin_handle" label={t('d2.signin.handleLabel')} value={handle} |
| onChange={(e) => setHandle(e.target.value)} autoComplete="username" /> |
| <p className="hint">{t('d2.signin.handleHint')}</p> |
| <Button onClick={() => run('handle')} disabled={busy !== null}> |
| {busy === 'handle' ? <Spinner label={t('d2.working')} /> : t('d2.signin.signInHandle')} |
| </Button> |
| </> |
| )} |
| </fieldset> |
|
|
| <div className="row"> |
| <button type="button" className="link" onClick={() => onNav('id/enroll')}>{t('d2.signin.noAccount')}</button> |
| <button type="button" className="link" onClick={() => onNav('id/recovery')}>{t('d2.signin.recover')}</button> |
| </div> |
|
|
| <p aria-live="polite" className="sr-live">{busy ? t('d2.working') : ''}</p> |
| {msg && <Callout tone={msg.tone}>{msg.text}</Callout>} |
| </section> |
| ) |
| } |
|
|