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:
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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`) {
|
||||||
|
|||||||
Reference in New Issue
Block a user