feat(security): nudge to sign out sessions unused for 3 months (#113)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 4m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m20s

Sessions of this account not seen for 90+ days (never the current one; a
session with no last-seen time is left alone) get a nudge in the existing
security strip, after the device-security ones and with the same rules
("Not now" snoozes 7 days, gone after 3 dismissals; a snoozed security
nudge no longer hides it): "You have 3 sessions you haven't used in 3
months. Signing them out keeps your account safe if an old device is
lost." [Review]

Review opens Settings -> Devices with those sessions ticked for sign-out
(one confirm, the usual re-auth incl. SSO); they are listed first and
marked "Inactive for 3+ months". OIDC accounts sign out on the
provider's dashboard, so nothing is ticked there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-24 23:52:16 -04:00
co-authored by Claude Opus 5.5
parent 02c852e194
commit c907b0ba99
6 changed files with 294 additions and 10 deletions
+54 -1
View File
@@ -9,12 +9,15 @@
* - setup-backup: no key backup at all (includes accounts that never set up
* cross-signing: the setup flow does both).
*
* - stale-sessions (#113, lowest priority): sessions of this account not used
* for 90+ days; Review opens Settings → Devices with them ticked to sign out.
*
* 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 NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup' | 'stale-sessions';
export type SecurityState = {
/** m.cross_signing.master account data present. */
@@ -38,6 +41,51 @@ export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
return s.backupActive ? undefined : 'connect-backup';
};
/** [Gitea #113] A session counts as stale after this long unused. */
export const STALE_SESSION_MS = 90 * 24 * 60 * 60 * 1000;
type SessionLike = { device_id: string; last_seen_ts?: number | null };
/**
* Pure: other sessions of this account not seen for STALE_SESSION_MS. Never
* the current session; a session with no last-seen time is left alone (we
* can't tell it's unused).
*/
export const staleSessions = <T extends SessionLike>(
devices: T[],
currentDeviceId: string | undefined,
now: number,
): T[] =>
devices.filter(
(d) =>
d.device_id !== currentDeviceId &&
typeof d.last_seen_ts === 'number' &&
now - d.last_seen_ts >= STALE_SESSION_MS,
);
/** "1 session" / "3 sessions you haven't used in 3 months". */
export const staleSessionsText = (count: number): string =>
`You have ${count === 1 ? 'a session' : `${count} sessions`} you haven't used in 3 months. Signing ${
count === 1 ? 'it' : 'them'
} out keeps your account safe if an old device is lost.`;
/**
* The nudge to show: the device-security one first, then stale sessions, each
* only if its own snooze allows (so a snoozed security nudge doesn't hide the
* stale-sessions one, and vice versa).
*/
export const pickNudge = (
security: NudgeKind | undefined,
staleCount: number,
record: NudgeRecord,
now: number,
): NudgeKind | undefined => {
const candidates: NudgeKind[] = [];
if (security) candidates.push(security);
if (staleCount > 0) candidates.push('stale-sessions');
return candidates.find((k) => shouldShowNudge(k, record, now));
};
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;
@@ -87,6 +135,11 @@ export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
action: 'Connect',
},
'stale-sessions': {
// The text depends on the count: see staleSessionsText.
text: '',
action: 'Review',
},
'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',