File size: 5,313 Bytes
0f4d9ea | 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 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | 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>
)
}
|