CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m38s
One "security" strip, in the same top slot and style as the status banner (#124), for this device, in priority order: - "Verify this device" — cross-signing exists but this device isn't verified (can't unlock backed-up history, untrusted to others). First, because verifying with the recovery key also connects the backup. - "Connect this device to your key backup" — a backup exists, this device isn't using it. - "Set up key backup" — no backup at all (includes accounts without cross-signing; the setup flow does both). The button opens Settings → Devices (existing flows); "Not now" snoozes. Rules: nothing in a device's first 24 h; "Not now" snoozes that nudge 7 days; 3 dismissals stop it; never on a healthy device; waits until sync has settled (never under "Connecting…"); outage/maintenance/connection strips win. Per-device record in localStorage, wiped on logout. Mounted inside the Matrix client context and an error boundary (an early version outside the context crashed the app on load — caught before pushing). Also: the status strip wraps its text on phones instead of truncating it when there's no Details button (benefits #124's strips too). Design approved on #123 (real-client screenshots there). Tests: 4 unit (the #123 state table, loading, timing, stored record); e2e: nothing during the grace period, "Set up key backup" → Settings → Devices, "Not now" holds across a reload. Unit 1319, Playwright 29 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
/**
|
|
* [Gitea #110, #123] One "security" nudge for this device, in priority order:
|
|
*
|
|
* - verify-device: the account has cross-signing but THIS device isn't signed.
|
|
* It can't unlock backed-up history and shows as untrusted to others.
|
|
* Verifying with the recovery key also connects the backup, so it comes first.
|
|
* - connect-backup: a key backup exists on the server but this device isn't
|
|
* backing up to it (`getActiveSessionBackupVersion()` null).
|
|
* - setup-backup: no key backup at all (includes accounts that never set up
|
|
* cross-signing: the setup flow does both).
|
|
*
|
|
* Anything not yet known (crypto still loading) → no nudge. Timing: not in
|
|
* the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three
|
|
* dismissals of a nudge stop it for good; a healthy device never sees one.
|
|
*/
|
|
|
|
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup';
|
|
|
|
export type SecurityState = {
|
|
/** m.cross_signing.master account data present. */
|
|
crossSigning: boolean;
|
|
/** This device cross-signing-verified; undefined while crypto isn't ready. */
|
|
deviceVerified: boolean | undefined;
|
|
/** A key backup version on the server; undefined while loading. */
|
|
backupOnServer: boolean | undefined;
|
|
/** This device is backing up to it; undefined while loading. */
|
|
backupActive: boolean | undefined;
|
|
};
|
|
|
|
export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
|
|
if (s.crossSigning) {
|
|
if (s.deviceVerified === undefined) return undefined;
|
|
if (!s.deviceVerified) return 'verify-device';
|
|
}
|
|
if (s.backupOnServer === undefined) return undefined;
|
|
if (!s.backupOnServer) return 'setup-backup';
|
|
if (s.backupActive === undefined) return undefined;
|
|
return s.backupActive ? undefined : 'connect-backup';
|
|
};
|
|
|
|
export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
export const NUDGE_MAX_DISMISSALS = 3;
|
|
|
|
export type NudgeRecord = {
|
|
/** When this device was first seen by the nudge (ms). */
|
|
firstSeen: number;
|
|
dismissals: Partial<Record<NudgeKind, { count: number; last: number }>>;
|
|
};
|
|
|
|
export const shouldShowNudge = (kind: NudgeKind, record: NudgeRecord, now: number): boolean => {
|
|
if (now - record.firstSeen < NUDGE_GRACE_MS) return false;
|
|
const d = record.dismissals[kind];
|
|
if (!d) return true;
|
|
return d.count < NUDGE_MAX_DISMISSALS && now - d.last >= NUDGE_SNOOZE_MS;
|
|
};
|
|
|
|
export const dismissNudge = (kind: NudgeKind, record: NudgeRecord, now: number): NudgeRecord => ({
|
|
...record,
|
|
dismissals: {
|
|
...record.dismissals,
|
|
[kind]: { count: (record.dismissals[kind]?.count ?? 0) + 1, last: now },
|
|
},
|
|
});
|
|
|
|
export const NUDGE_KEY_PREFIX = 'lotus-security-nudge-';
|
|
|
|
/** Parse a stored record; anything unexpected starts a fresh one (grace period from now). */
|
|
export const parseNudgeRecord = (raw: string | null, now: number): NudgeRecord => {
|
|
try {
|
|
const v = JSON.parse(raw ?? '');
|
|
if (v && typeof v.firstSeen === 'number' && v.dismissals && typeof v.dismissals === 'object') {
|
|
return { firstSeen: v.firstSeen, dismissals: v.dismissals };
|
|
}
|
|
} catch {
|
|
/* fresh record below */
|
|
}
|
|
return { firstSeen: now, dismissals: {} };
|
|
};
|
|
|
|
export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
|
|
'verify-device': {
|
|
text: 'Verify this device so you can read your older encrypted messages and others see it as trusted.',
|
|
action: 'Verify',
|
|
},
|
|
'connect-backup': {
|
|
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
|
|
action: 'Connect',
|
|
},
|
|
'setup-backup': {
|
|
text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.",
|
|
action: 'Set up',
|
|
},
|
|
};
|