| 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' |
|
|
| |
| |
| |
| 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> |
| ) |
| } |
|
|