Merge branch 'feat/legacy-impact-panel' into 'main'

Hardening shows who still uses legacy mail apps (LP-15)

See merge request inbuxa/inbuxa-admin!4
This commit was merged in pull request #4.
This commit is contained in:
2026-09-21 13:31:40 -07:00
3 changed files with 168 additions and 1 deletions
@@ -25,8 +25,10 @@ import { useAccountStore } from '@/stores/accountStore';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
ago,
CONFIRM_PHRASE, CONFIRM_PHRASE,
describeListener, describeListener,
impactEntries,
fetchProtocolPolicy, fetchProtocolPolicy,
phraseMatches, phraseMatches,
PolicyUnavailable, PolicyUnavailable,
@@ -35,6 +37,7 @@ import {
type PolicyListener, type PolicyListener,
type ProtocolPolicy, type ProtocolPolicy,
type ProtocolRow, type ProtocolRow,
type RecentUse,
} from './protocolPolicy'; } from './protocolPolicy';
type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string };
@@ -156,6 +159,8 @@ export function LegacyProtocolsPage() {
<ProtocolTable rows={protocolRows(policy, listeners)} off={off} /> <ProtocolTable rows={protocolRows(policy, listeners)} off={off} />
{!off && policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
{(off || confirming) && <Statement listeners={listeners} />} {(off || confirming) && <Statement listeners={listeners} />}
{!off && canUpdate && !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 (
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
{t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')}
</p>
);
}
return (
<section className="space-y-2 rounded-xl border p-4 text-sm">
<p>
<strong>
{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.',
})}
</strong>{' '}
{t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')}
</p>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{entries.map((entry) => (
<li key={entry.name} className="flex flex-wrap gap-x-2">
<span className="font-medium">{entry.name}</span>
<span className="text-muted-foreground">
{entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)}
</span>
</li>
))}
</ul>
</section>
);
}
/** The statement (LP-16), at server scope, with the firewall note (LP-20). */ /** The statement (LP-16), at server scope, with the firewall note (LP-20). */
function Statement({ listeners }: { listeners: PolicyListener[] }) { function Statement({ listeners }: { listeners: PolicyListener[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
+39 -1
View File
@@ -5,7 +5,15 @@
*/ */
import { describe, expect, it } from 'vitest'; 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. // As inbuxa:ProtocolPolicy/get sends it: listeners keyed by the policy's own property names.
const WIRE = { const WIRE = {
@@ -82,3 +90,33 @@ describe('phraseMatches (LP-17)', () => {
expect(phraseMatches('')).toBe(false); 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: '[email protected]', protocol: 'submission', lastUsedAt: 100 },
{ accountId: 'a', name: '[email protected]', protocol: 'imap', lastUsedAt: 300 },
{ accountId: 'b', name: '[email protected]', protocol: 'pop3', lastUsedAt: 200 },
{ accountId: 'c', name: 'bad' },
],
}).recentLegacyUse!;
expect(impactEntries(recent)).toEqual([
{ name: '[email protected]', protocols: ['IMAP', 'SMTP submission'], lastUsedAt: 300 },
{ name: '[email protected]', 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');
});
});
+82
View File
@@ -38,6 +38,27 @@ export interface ProtocolPolicy {
lockedProtocols: string[]; lockedProtocols: string[];
/** What turning the switch off would close, whichever way it is set now (LP-16). */ /** What turning the switch off would close, whichever way it is set now (LP-16). */
wouldClose: PolicyListener[]; 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<string, unknown>): ProtocolPolicy {
? raw.lockedProtocols.filter((p): p is string => typeof p === 'string') ? raw.lockedProtocols.filter((p): p is string => typeof p === 'string')
: [], : [],
wouldClose: parseListeners(raw.wouldClose), 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<string, unknown>;
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<string, string> = {
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<string, ImpactEntry>();
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. */ /** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */
export class PolicyUnavailable extends Error {} export class PolicyUnavailable extends Error {}
export async function fetchProtocolPolicy(signal?: AbortSignal): Promise<ProtocolPolicy> { export async function fetchProtocolPolicy(signal?: AbortSignal): Promise<ProtocolPolicy> {
const accountId = getAccountId('x:NetworkListener'); 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 responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
const [name, result] = responses[0] ?? []; const [name, result] = responses[0] ?? [];
if (name !== `${OBJECT}/get`) { if (name !== `${OBJECT}/get`) {