diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx index 7b3add4..0ada5a3 100644 --- a/src/features/hardening/LegacyProtocolsPage.tsx +++ b/src/features/hardening/LegacyProtocolsPage.tsx @@ -25,8 +25,10 @@ import { useAccountStore } from '@/stores/accountStore'; import { toast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; import { + ago, CONFIRM_PHRASE, describeListener, + impactEntries, fetchProtocolPolicy, phraseMatches, PolicyUnavailable, @@ -35,6 +37,7 @@ import { type PolicyListener, type ProtocolPolicy, type ProtocolRow, + type RecentUse, } from './protocolPolicy'; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; @@ -156,6 +159,8 @@ export function LegacyProtocolsPage() { + {!off && policy.recentLegacyUse && } + {(off || confirming) && } {!off && canUpdate && !confirming && ( @@ -316,6 +321,48 @@ function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) { ); } +/** + * The impact panel (LP-15): who would notice, shown before anything can + * change. With nobody, it says so in one line. + */ +function ImpactPanel({ recent }: { recent: RecentUse[] }) { + const { t, i18n } = useTranslation(); + const entries = impactEntries(recent); + // Read once, when the panel appears: "2 days ago" needn't tick. + const [now] = useState(() => Date.now()); + if (entries.length === 0) { + return ( +

+ {t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')} +

+ ); + } + return ( +
+

+ + {t('legacyProtocols.impactCount', { + count: entries.length, + defaultValue_one: '1 account used a legacy mail app in the last 30 days.', + defaultValue_other: '{{count}} accounts used a legacy mail app in the last 30 days.', + })} + {' '} + {t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')} +

+
    + {entries.map((entry) => ( +
  • + {entry.name} + + {entry.protocols.join(', ')} ยท {ago(entry.lastUsedAt, now, i18n.language)} + +
  • + ))} +
+
+ ); +} + /** The statement (LP-16), at server scope, with the firewall note (LP-20). */ function Statement({ listeners }: { listeners: PolicyListener[] }) { const { t } = useTranslation(); diff --git a/src/features/hardening/protocolPolicy.test.ts b/src/features/hardening/protocolPolicy.test.ts index 46ecfc0..6bd822f 100644 --- a/src/features/hardening/protocolPolicy.test.ts +++ b/src/features/hardening/protocolPolicy.test.ts @@ -5,7 +5,15 @@ */ import { describe, expect, it } from 'vitest'; -import { CONFIRM_PHRASE, parsePolicy, phraseMatches, protocolRows, type ProtocolPolicy } from './protocolPolicy'; +import { + ago, + CONFIRM_PHRASE, + impactEntries, + parsePolicy, + phraseMatches, + protocolRows, + type ProtocolPolicy, +} from './protocolPolicy'; // As inbuxa:ProtocolPolicy/get sends it: listeners keyed by the policy's own property names. const WIRE = { @@ -82,3 +90,33 @@ describe('phraseMatches (LP-17)', () => { expect(phraseMatches('')).toBe(false); }); }); + +describe('the impact panel (LP-15)', () => { + it('reads nothing from a server too old to say, and an empty list as nobody', () => { + expect(parsePolicy(WIRE).recentLegacyUse).toBeNull(); + expect(parsePolicy({ ...WIRE, recentLegacyUse: [] }).recentLegacyUse).toEqual([]); + }); + + it('shows each account once, with every protocol it used and its latest use', () => { + const recent = parsePolicy({ + ...WIRE, + recentLegacyUse: [ + { accountId: 'a', name: 'maria@example.org', protocol: 'submission', lastUsedAt: 100 }, + { accountId: 'a', name: 'maria@example.org', protocol: 'imap', lastUsedAt: 300 }, + { accountId: 'b', name: 'ada@example.org', protocol: 'pop3', lastUsedAt: 200 }, + { accountId: 'c', name: 'bad' }, + ], + }).recentLegacyUse!; + expect(impactEntries(recent)).toEqual([ + { name: 'maria@example.org', protocols: ['IMAP', 'SMTP submission'], lastUsedAt: 300 }, + { name: 'ada@example.org', protocols: ['POP3'], lastUsedAt: 200 }, + ]); + }); + + it('says how long ago in words', () => { + const now = Date.UTC(2026, 8, 21); + expect(ago(now - 2 * 86400_000, now, 'en')).toBe('2 days ago'); + expect(ago(now - 3 * 3600_000, now, 'en')).toBe('3 hours ago'); + expect(ago(now - 10_000, now, 'en')).toBe('this minute'); + }); +}); diff --git a/src/features/hardening/protocolPolicy.ts b/src/features/hardening/protocolPolicy.ts index 7f76e19..126f701 100644 --- a/src/features/hardening/protocolPolicy.ts +++ b/src/features/hardening/protocolPolicy.ts @@ -38,6 +38,27 @@ export interface ProtocolPolicy { lockedProtocols: string[]; /** What turning the switch off would close, whichever way it is set now (LP-16). */ wouldClose: PolicyListener[]; + /** + * Who signed in over a legacy protocol in the last 30 days (LP-15), or null + * from a server too old to say -- which is not the same as nobody. + */ + recentLegacyUse: RecentUse[] | null; +} + +/** One account's last sign-in over one legacy protocol, as the server reports it. */ +export interface RecentUse { + accountId: string; + name: string; + protocol: string; + /** Milliseconds since the epoch. */ + lastUsedAt: number; +} + +/** One account on the impact panel: every protocol it used, and when it last did. */ +export interface ImpactEntry { + name: string; + protocols: string[]; + lastUsedAt: number; } /** @@ -71,14 +92,75 @@ export function parsePolicy(raw: Record): ProtocolPolicy { ? raw.lockedProtocols.filter((p): p is string => typeof p === 'string') : [], wouldClose: parseListeners(raw.wouldClose), + recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null, }; } +function parseRecent(raw: unknown[]): RecentUse[] { + return raw.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return []; + const r = entry as Record; + if (typeof r.name !== 'string' || typeof r.protocol !== 'string' || typeof r.lastUsedAt !== 'number') return []; + return [ + { + accountId: typeof r.accountId === 'string' ? r.accountId : '', + name: r.name, + protocol: r.protocol, + lastUsedAt: r.lastUsedAt, + }, + ]; + }); +} + +const PROTOCOL_LABELS: Record = { + imap: 'IMAP', + pop3: 'POP3', + manageSieve: 'ManageSieve', + submission: 'SMTP submission', +}; + +/** + * The impact panel's lines (LP-15): one per account, naming every protocol it + * used and when it last used any, most recent first. + */ +export function impactEntries(recent: RecentUse[]): ImpactEntry[] { + const byAccount = new Map(); + for (const use of recent) { + const key = use.accountId || use.name; + const entry = byAccount.get(key) ?? { name: use.name, protocols: [], lastUsedAt: 0 }; + const label = PROTOCOL_LABELS[use.protocol] ?? use.protocol; + if (!entry.protocols.includes(label)) entry.protocols.push(label); + entry.lastUsedAt = Math.max(entry.lastUsedAt, use.lastUsedAt); + byAccount.set(key, entry); + } + const order = Object.values(PROTOCOL_LABELS); + return [...byAccount.values()] + .map((e) => ({ ...e, protocols: e.protocols.sort((a, b) => order.indexOf(a) - order.indexOf(b)) })) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.name.localeCompare(b.name)); +} + +/** "2 days ago", "3 hours ago", "just now", in the reader's language. */ +export function ago(at: number, now: number, locale?: string): string { + const seconds = Math.round((at - now) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const steps: [Intl.RelativeTimeFormatUnit, number][] = [ + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ]; + for (const [unit, size] of steps) { + if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit); + } + return rtf.format(0, 'minute'); +} + /** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */ export class PolicyUnavailable extends Error {} export async function fetchProtocolPolicy(signal?: AbortSignal): Promise { const accountId = getAccountId('x:NetworkListener'); + // No `properties`: the server answers with all of them, recentLegacyUse + // included where it has it. const responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]); const [name, result] = responses[0] ?? []; if (name !== `${OBJECT}/get`) {