amanpay / web /src /pages /D2OperatorPage.tsx
MHamdan's picture
CI deploy 59e9772 (part 2)
67756a5 verified
Raw
History Blame Contribute Delete
14 kB
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 {
/* clipboard unavailable — user can select manually */
}
}
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)
// Invitations
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)
// Accounts
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]) // eslint-disable-line react-hooks/exhaustive-deps
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>
)
}