feat: device-security nudge — verify this device / key backup (#110, #123)
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8e30c73e2f
commit
6a7627fa26
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* [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',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user