amanpay / web /src /components /ConsentControls.tsx
MHamdan's picture
CI deploy d3d1cf1
0f4d9ea verified
Raw
History Blame Contribute Delete
5.31 kB
import { useEffect, useState } from 'react'
import { useI18n } from '../i18n'
import { Callout, Spinner } from './ui'
import { ApiRequestError } from '../api/client'
import {
deleteProfile, profileStatus, setConsent,
type ConsentPurpose, type DeleteProfileResult, type ProfileStatus,
} from '../api/ai'
/** Customer privacy/consent controls (PR C4). Advisory only — these choices never approve, deny or
* change a payment. Required security processing is shown as informational (not a consent toggle).
* Identity is the authenticated principal (session token); no user id is sent. */
export function ConsentControls() {
const { t } = useI18n()
const [status, setStatus] = useState<ProfileStatus | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState(false)
const [busy, setBusy] = useState<ConsentPurpose | 'delete' | null>(null)
const [notice, setNotice] = useState<{ tone: 'ok' | 'warn'; msg: string } | null>(null)
const [deleted, setDeleted] = useState<DeleteProfileResult | null>(null)
async function refresh(signal?: AbortSignal) {
setLoading(true)
try {
setStatus(await profileStatus(signal))
} catch {
setErr(true)
} finally {
setLoading(false)
}
}
useEffect(() => {
const c = new AbortController()
refresh(c.signal)
return () => c.abort()
}, [])
async function toggle(purpose: ConsentPurpose, grant: boolean) {
if (!status) return
setBusy(purpose); setNotice(null)
try {
const next = await setConsent(purpose, grant, status.record_version)
setStatus(next); setNotice({ tone: 'ok', msg: t('consent.saved') })
} catch (e) {
if (e instanceof ApiRequestError && e.status === 409) {
await refresh(); setNotice({ tone: 'warn', msg: t('consent.conflict') })
} else {
setErr(true)
}
} finally {
setBusy(null)
}
}
async function onDelete() {
if (!window.confirm(t('consent.delete.confirm'))) return
setBusy('delete'); setNotice(null)
try {
const res = await deleteProfile()
setDeleted(res)
await refresh()
setNotice({ tone: 'ok', msg: t('consent.delete.done') })
} catch {
setErr(true)
} finally {
setBusy(null)
}
}
if (loading && !status) return <Spinner label={t('common.loading')} />
if (err && !status) return <Callout tone="warn">{t('home.security.unavailable')}</Callout>
if (!status) return null
const optionalRow = (purpose: ConsentPurpose, titleKey: string, explainKey: string) => {
const meta = status.purposes[purpose]
const granted = meta.status === 'granted'
return (
<div className="consent-row" data-testid={`consent-${purpose}`}>
<div className="consent-row-text">
<strong>{t(titleKey as never)}</strong>
<p className="muted small">{t(explainKey as never)}</p>
</div>
<div className="consent-row-control">
<span className={`badge ${granted ? 'tone-ok' : 'tone-info'}`} data-testid={`consent-state-${purpose}`}>
{granted ? t('consent.on') : t('consent.off')}
</span>
<button className="btn" disabled={busy === purpose}
onClick={() => toggle(purpose, !granted)}
data-testid={`consent-toggle-${purpose}`}>
{granted ? t('consent.withdraw') : t('consent.grant')}
</button>
</div>
</div>
)
}
return (
<div className="consent-controls">
{/* Required security processing — informational, NOT a consent toggle, not "granted". */}
<div className="consent-row" data-testid="consent-service_essential">
<div className="consent-row-text">
<strong>{t('required.title')} <span className="badge tone-warn">{t('required.badge')}</span></strong>
<p className="muted small">{t('required.explain')}</p>
</div>
<span className="badge tone-neutral" data-testid="required-state">{t('required.state')}</span>
</div>
{optionalRow('optional_personalization', 'consent.personalization.title', 'consent.personalization.explain')}
{optionalRow('optional_federation', 'consent.federation.title', 'consent.federation.explain')}
{status.personalization_without_shared_learning && (
<Callout tone="info"><span data-testid="pwsl">{t('consent.pwsl')}</span></Callout>
)}
{notice && <Callout tone={notice.tone}>{notice.msg}</Callout>}
<div className="consent-delete">
<strong>{t('consent.delete.title')}</strong>
<p className="muted small">{t('consent.delete.explain')}</p>
<button className="btn danger" disabled={busy === 'delete'}
onClick={onDelete} data-testid="consent-delete">
{t('consent.delete.button')}
</button>
{deleted && (
<div className="deletion-receipt" data-testid="deletion-receipt">
<p className="small"><strong>{t('consent.delete.deletedLabel')}:</strong> {deleted.deleted_categories.join(', ')}</p>
<p className="small"><strong>{t('consent.delete.retainedLabel')}:</strong> {deleted.retained_categories.join(', ')}</p>
<p className="muted small">{t('consent.delete.notUnlearning')}</p>
</div>
)}
</div>
</div>
)
}