| import { useEffect, useState } from 'react' |
| import { useI18n } from '../i18n' |
| import { localizeError } from '../utils/errors' |
| import { Button, Field, Callout, Spinner } from '../components/ui' |
| import { ConfirmDialog, statusLabel, roleLabel } from '../components/d2' |
| import { useD2Session } from '../hooks/useD2Session' |
| import { |
| createInvitations, getOperatorAccount, listInvitations, listOperatorAccounts, |
| operatorDeleteAccount, operatorPauseAccount, operatorReactivateAccount, operatorRecoveryInvitation, |
| revokeInvitation, |
| type D2AccountEvent, type D2Invitation, type D2OperatorAccount, |
| } from '../api/identity' |
|
|
| type Msg = { tone: 'ok' | 'bad' | 'info'; text: string } | null |
| type Tab = 'invite' | 'accounts' |
| type ActionKind = 'pause' | 'reactivate' | 'delete' | 'recovery' |
|
|
| async function copy(text: string): Promise<void> { |
| try { |
| await navigator.clipboard?.writeText(text) |
| } catch { |
| |
| } |
| } |
|
|
| export function D2OperatorPage({ onNav }: { onNav: (r: string) => void }) { |
| const { t } = useI18n() |
| const { isOperator } = useD2Session() |
| const [tab, setTab] = useState<Tab>('invite') |
| const [busy, setBusy] = useState(false) |
| const [msg, setMsg] = useState<Msg>(null) |
|
|
| |
| const [role, setRole] = useState('customer') |
| const [count, setCount] = useState('1') |
| const [ttlHours, setTtlHours] = useState('24') |
| const [newCodes, setNewCodes] = useState<{ id: string; code: string }[] | null>(null) |
| const [invitations, setInvitations] = useState<D2Invitation[] | null>(null) |
|
|
| |
| const [accounts, setAccounts] = useState<D2OperatorAccount[] | null>(null) |
| const [detail, setDetail] = useState<{ account: D2OperatorAccount; events: D2AccountEvent[] } | null>(null) |
| const [recoveryCode, setRecoveryCode] = useState<string | null>(null) |
| const [confirm, setConfirm] = useState<{ kind: ActionKind; id: string } | null>(null) |
|
|
| async function loadInvitations() { |
| try { setInvitations(await listInvitations()) } |
| catch (e) { setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.operator.error') }) } |
| } |
| async function loadAccounts() { |
| try { setAccounts(await listOperatorAccounts()) } |
| catch (e) { setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.operator.error') }) } |
| } |
|
|
| useEffect(() => { |
| if (!isOperator) return |
| if (tab === 'invite') void loadInvitations() |
| else void loadAccounts() |
| }, [tab, isOperator]) |
|
|
| if (!isOperator) { |
| return ( |
| <section className="card" aria-labelledby="d2op-h"> |
| <h2 id="d2op-h">{t('d2.operator.title')}</h2> |
| <Callout tone="warn">{t('d2.operator.onlyOperator')}</Callout> |
| <Button onClick={() => onNav('id/account')}>{t('d2.back')}</Button> |
| </section> |
| ) |
| } |
|
|
| async function onCreate() { |
| setBusy(true); setMsg(null) |
| try { |
| const n = Math.max(1, Number(count) || 1) |
| const ttl = Math.max(1, Number(ttlHours) || 1) * 3600 |
| const codes = await createInvitations(role, n, ttl) |
| setNewCodes(codes) |
| await loadInvitations() |
| } catch (e) { |
| setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.operator.error') }) |
| } finally { |
| setBusy(false) |
| } |
| } |
|
|
| async function onRevokeInvite(id: string) { |
| setBusy(true); setMsg(null); setConfirm(null) |
| try { |
| await revokeInvitation(id) |
| await loadInvitations() |
| } catch (e) { |
| setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.operator.error') }) |
| } finally { |
| setBusy(false) |
| } |
| } |
|
|
| async function openDetail(id: string) { |
| setBusy(true); setMsg(null); setRecoveryCode(null) |
| try { |
| setDetail(await getOperatorAccount(id)) |
| } catch (e) { |
| setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.operator.error') }) |
| } finally { |
| setBusy(false) |
| } |
| } |
|
|
| async function runAction(kind: ActionKind, id: string) { |
| setBusy(true); setMsg(null); setConfirm(null) |
| try { |
| if (kind === 'pause') await operatorPauseAccount(id) |
| else if (kind === 'reactivate') await operatorReactivateAccount(id) |
| else if (kind === 'delete') await operatorDeleteAccount(id) |
| else if (kind === 'recovery') { setRecoveryCode(await operatorRecoveryInvitation(id)) } |
| setMsg({ tone: 'ok', text: t('d2.operator.done') }) |
| if (kind === 'delete') { setDetail(null); await loadAccounts() } |
| else await openDetail(id) |
| } catch (e) { |
| setMsg({ tone: 'bad', text: localizeError(e, t, 'd2.operator.error') }) |
| } finally { |
| setBusy(false) |
| } |
| } |
|
|
| const confirmMsgKey: Record<ActionKind, 'd2.operator.pauseConfirm' | 'd2.operator.reactivateConfirm' |
| | 'd2.operator.deleteConfirm' | 'd2.operator.recoveryConfirm'> = { |
| pause: 'd2.operator.pauseConfirm', reactivate: 'd2.operator.reactivateConfirm', |
| delete: 'd2.operator.deleteConfirm', recovery: 'd2.operator.recoveryConfirm', |
| } |
|
|
| return ( |
| <section className="card" aria-labelledby="d2op-h"> |
| <h2 id="d2op-h">{t('d2.operator.title')}</h2> |
| |
| <div className="tabs" role="tablist" aria-label={t('d2.operator.title')}> |
| <button role="tab" aria-selected={tab === 'invite'} className={`tab ${tab === 'invite' ? 'active' : ''}`} |
| onClick={() => { setDetail(null); setTab('invite') }}>{t('d2.operator.tab.invite')}</button> |
| <button role="tab" aria-selected={tab === 'accounts'} className={`tab ${tab === 'accounts' ? 'active' : ''}`} |
| onClick={() => { setDetail(null); setTab('accounts') }}>{t('d2.operator.tab.accounts')}</button> |
| </div> |
| |
| {tab === 'invite' && ( |
| <> |
| <fieldset className="action-group"> |
| <legend>{t('d2.operator.newInvite')}</legend> |
| <div className="field"> |
| <label htmlFor="op_role">{t('d2.operator.inviteRole')}</label> |
| <select id="op_role" className="d2-select" value={role} onChange={(e) => setRole(e.target.value)}> |
| <option value="customer">{t('d2.role.customer')}</option> |
| <option value="operator">{t('d2.role.operator')}</option> |
| </select> |
| </div> |
| <Field id="op_count" label={t('d2.operator.inviteCount')} type="number" min={1} value={count} |
| onChange={(e) => setCount(e.target.value)} /> |
| <Field id="op_ttl" label={t('d2.operator.inviteTtl')} type="number" min={1} value={ttlHours} |
| onChange={(e) => setTtlHours(e.target.value)} /> |
| <Button onClick={onCreate} disabled={busy}> |
| {busy ? <Spinner label={t('d2.working')} /> : t('d2.operator.create')} |
| </Button> |
| </fieldset> |
| |
| {newCodes && ( |
| <div className="action-group" role="region" aria-label={t('d2.operator.codesTitle')}> |
| <strong>{t('d2.operator.codesTitle')}</strong> |
| <Callout tone="warn">{t('d2.operator.codesWarn')}</Callout> |
| <ul className="d2-list"> |
| {newCodes.map((c) => ( |
| <li key={c.id} className="d2-item"> |
| <code className="mono">{c.code}</code> |
| <Button onClick={() => copy(c.code)}>{t('d2.copy')}</Button> |
| </li> |
| ))} |
| </ul> |
| <Button onClick={() => copy(newCodes.map((c) => c.code).join('\n'))}>{t('d2.copyAll')}</Button> |
| </div> |
| )} |
| |
| <h3>{t('d2.operator.inviteList')}</h3> |
| {invitations === null ? ( |
| <Spinner label={t('d2.loading')} /> |
| ) : invitations.length === 0 ? ( |
| <p className="muted empty-state">{t('d2.operator.inviteEmpty')}</p> |
| ) : ( |
| <ul className="d2-list"> |
| {invitations.map((inv) => ( |
| <li key={inv.id} className="d2-item"> |
| <div className="d2-item-main"> |
| <strong>{roleLabel(t, inv.role)}</strong> |
| <div className="d2-meta"> |
| <span>{t('d2.operator.inviteUses')}: {inv.use_count}/{inv.max_uses}</span> |
| <span>{t('d2.operator.inviteExpires')}: {inv.expires_at}</span> |
| {inv.revoked && <span className="badge tone-bad">{t('d2.operator.inviteRevoked')}</span>} |
| {inv.claimed && <span className="badge tone-info">{t('d2.operator.inviteClaimed')}</span>} |
| </div> |
| </div> |
| {!inv.revoked && ( |
| <div className="d2-item-actions"> |
| <Button className="btn danger" onClick={() => setConfirm({ kind: 'pause', id: `inv:${inv.id}` })} |
| disabled={busy}>{t('d2.operator.inviteRevoke')}</Button> |
| </div> |
| )} |
| </li> |
| ))} |
| </ul> |
| )} |
| </> |
| )} |
|
|
| {tab === 'accounts' && !detail && ( |
| <> |
| <h3>{t('d2.operator.accountList')}</h3> |
| {accounts === null ? ( |
| <Spinner label={t('d2.loading')} /> |
| ) : accounts.length === 0 ? ( |
| <p className="muted empty-state">{t('d2.operator.accountEmpty')}</p> |
| ) : ( |
| <ul className="d2-list"> |
| {accounts.map((a) => ( |
| <li key={a.id} className="d2-item"> |
| <div className="d2-item-main"> |
| <strong>{a.display_alias}</strong> |
| <div className="d2-meta"> |
| <span>{t('d2.operator.colHandle')}: {a.public_handle}</span> |
| <span>{t('d2.operator.colRole')}: {roleLabel(t, a.role)}</span> |
| <span>{t('d2.operator.colStatus')}: {statusLabel(t, a.status)}</span> |
| <span>{t('d2.operator.colPasskeys')}: {a.passkey_count}</span> |
| <span>{t('d2.operator.colSessions')}: {a.active_session_count}</span> |
| </div> |
| </div> |
| <div className="d2-item-actions"> |
| <Button onClick={() => openDetail(a.id)} disabled={busy}>{t('d2.operator.view')}</Button> |
| </div> |
| </li> |
| ))} |
| </ul> |
| )} |
| </> |
| )} |
|
|
| {tab === 'accounts' && detail && ( |
| <> |
| <Button onClick={() => { setDetail(null); setRecoveryCode(null) }}>{t('d2.operator.backToList')}</Button> |
| <h3>{t('d2.operator.detailTitle')}</h3> |
| <dl className="summary-grid"> |
| <dt>{t('d2.operator.colHandle')}</dt><dd>{detail.account.public_handle}</dd> |
| <dt>{t('d2.operator.colAlias')}</dt><dd>{detail.account.display_alias}</dd> |
| <dt>{t('d2.operator.colRole')}</dt><dd>{roleLabel(t, detail.account.role)}</dd> |
| <dt>{t('d2.operator.colStatus')}</dt><dd>{statusLabel(t, detail.account.status)}</dd> |
| <dt>{t('d2.operator.colPasskeys')}</dt><dd>{detail.account.passkey_count}</dd> |
| <dt>{t('d2.operator.colSessions')}</dt><dd>{detail.account.active_session_count}</dd> |
| </dl> |
| |
| <div className="quick-actions"> |
| {detail.account.status === 'active' ? ( |
| <Button className="btn danger" onClick={() => setConfirm({ kind: 'pause', id: detail.account.id })} |
| disabled={busy}>{t('d2.operator.pause')}</Button> |
| ) : ( |
| <Button onClick={() => setConfirm({ kind: 'reactivate', id: detail.account.id })} |
| disabled={busy}>{t('d2.operator.reactivate')}</Button> |
| )} |
| <Button onClick={() => setConfirm({ kind: 'recovery', id: detail.account.id })} |
| disabled={busy}>{t('d2.operator.recovery')}</Button> |
| <Button className="btn danger" onClick={() => setConfirm({ kind: 'delete', id: detail.account.id })} |
| disabled={busy}>{t('d2.operator.delete')}</Button> |
| </div> |
| |
| {recoveryCode && ( |
| <div className="action-group" role="region" aria-label={t('d2.operator.recoveryTitle')}> |
| <strong>{t('d2.operator.recoveryTitle')}</strong> |
| <Callout tone="warn">{t('d2.operator.recoveryWarn')}</Callout> |
| <div className="row"> |
| <code className="mono">{recoveryCode}</code> |
| <Button onClick={() => copy(recoveryCode)}>{t('d2.copy')}</Button> |
| </div> |
| </div> |
| )} |
| |
| <h3>{t('d2.operator.events')}</h3> |
| {detail.events.length === 0 ? ( |
| <p className="muted empty-state">{t('d2.operator.eventsEmpty')}</p> |
| ) : ( |
| <ul className="d2-list"> |
| {detail.events.map((ev) => ( |
| <li key={ev.id} className="d2-item"> |
| <div className="d2-item-main"> |
| <strong>{ev.kind}</strong> |
| <div className="d2-meta"> |
| <span>{ev.at}</span> |
| {ev.detail && <span>{ev.detail}</span>} |
| </div> |
| </div> |
| </li> |
| ))} |
| </ul> |
| )} |
| </> |
| )} |
|
|
| <p aria-live="polite" className="sr-live">{busy ? t('d2.working') : ''}</p> |
| {msg && <Callout tone={msg.tone}>{msg.text}</Callout>} |
|
|
| {confirm && confirm.id.startsWith('inv:') && ( |
| <ConfirmDialog |
| message={t('d2.operator.inviteRevokeConfirm')} confirmLabel={t('d2.operator.inviteRevoke')} busy={busy} |
| onConfirm={() => onRevokeInvite(confirm.id.slice(4))} onCancel={() => setConfirm(null)} |
| /> |
| )} |
| {confirm && !confirm.id.startsWith('inv:') && ( |
| <ConfirmDialog |
| message={t(confirmMsgKey[confirm.kind])} |
| confirmLabel={t(`d2.operator.${confirm.kind}` as 'd2.operator.pause')} |
| danger={confirm.kind === 'delete' || confirm.kind === 'pause'} |
| busy={busy} |
| onConfirm={() => runAction(confirm.kind, confirm.id)} onCancel={() => setConfirm(null)} |
| /> |
| )} |
| </section> |
| ) |
| } |
|
|