File size: 4,753 Bytes
68eaf46
67756a5
 
 
134ecb7
67756a5
fab9291
67756a5
68eaf46
67756a5
 
 
 
 
 
 
 
68eaf46
 
 
 
 
 
 
 
 
 
 
518b0c2
68eaf46
 
fab9291
 
68eaf46
518b0c2
68eaf46
fab9291
68eaf46
 
 
 
67756a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134ecb7
67756a5
 
68eaf46
 
 
 
518b0c2
68eaf46
 
518b0c2
 
 
68eaf46
 
 
67756a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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
    // Tolerate a mocked/absent status call (undefined return) as well as a 404 (D2/demo off).
    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 {
      // No-passkey demo session: works in the HF iframe and on any device (no WebAuthn).
      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>
  )
}