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
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:
co-authored by
Claude Opus 5.5
parent
02c852e194
commit
c907b0ba99
@@ -23,8 +23,11 @@ import {
|
||||
nudgeFor,
|
||||
NudgeRecord,
|
||||
parseNudgeRecord,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from '../../utils/securityNudge';
|
||||
import { preselectSignOutAtom } from '../../state/staleSessions';
|
||||
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
||||
|
||||
/** This device's backup connection: undefined until known, then true/false. */
|
||||
@@ -40,6 +43,35 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
|
||||
return active;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched
|
||||
* once sync has settled, then hourly; [] until known or on error.
|
||||
*/
|
||||
const useStaleSessionIds = (enabled: boolean): string[] => {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const load = () =>
|
||||
mx
|
||||
.getDevices()
|
||||
.then(({ devices }) => {
|
||||
if (alive())
|
||||
setIds(
|
||||
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map(
|
||||
(d) => d.device_id,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
load();
|
||||
const t = window.setInterval(load, 60 * 60 * 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [mx, enabled, alive]);
|
||||
return ids;
|
||||
};
|
||||
|
||||
const readRecord = (key: string): NudgeRecord => {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
@@ -84,12 +116,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
let deviceVerified: boolean | undefined;
|
||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||
const kind = nudgeFor({
|
||||
const securityKind = nudgeFor({
|
||||
crossSigning,
|
||||
deviceVerified,
|
||||
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
||||
backupActive,
|
||||
});
|
||||
const staleIds = useStaleSessionIds(settled);
|
||||
const setPreselect = useSetAtom(preselectSignOutAtom);
|
||||
const kind = pickNudge(securityKind, staleIds.length, record, Date.now());
|
||||
|
||||
// One strip at a time: outages, maintenance and a lost connection win.
|
||||
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||
@@ -97,10 +132,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const otherStrip =
|
||||
syncLost ||
|
||||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||
return null;
|
||||
if (!settled || !deviceId || !kind || otherStrip) return null;
|
||||
|
||||
const copy = NUDGE_COPY[kind];
|
||||
const copy =
|
||||
kind === 'stale-sessions'
|
||||
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
|
||||
: NUDGE_COPY[kind];
|
||||
const dismiss = () => {
|
||||
const next = dismissNudge(kind, record, Date.now());
|
||||
try {
|
||||
@@ -113,8 +150,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
return (
|
||||
<StatusStrip
|
||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||
icon={Icons.ShieldUser}
|
||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
||||
action={{
|
||||
label: copy.action,
|
||||
onClick: () => {
|
||||
// Review: the Devices page opens with these ticked for sign-out.
|
||||
if (kind === 'stale-sessions') setPreselect(staleIds);
|
||||
requestSettings('devices');
|
||||
},
|
||||
}}
|
||||
icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser}
|
||||
dismissLabel="Not now"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -15,6 +16,8 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||
import { withSearchParam } from '../../../pages/pathUtils';
|
||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { preselectSignOutAtom } from '../../../state/staleSessions';
|
||||
import { staleSessions } from '../../../utils/securityNudge';
|
||||
|
||||
type OtherDevicesProps = {
|
||||
devices: IMyDevice[];
|
||||
@@ -29,6 +32,23 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
|
||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||
|
||||
// [Gitea #113] The stale-sessions nudge's Review: tick those sessions for
|
||||
// sign-out (one confirm below). OIDC accounts sign out on the provider's
|
||||
// dashboard instead, so nothing is ticked there.
|
||||
const [preselect, setPreselect] = useAtom(preselectSignOutAtom);
|
||||
useEffect(() => {
|
||||
if (preselect.length === 0) return;
|
||||
if (!authMetadata) {
|
||||
const present = new Set(devices.map((d) => d.device_id));
|
||||
setDeleted(new Set(preselect.filter((id) => present.has(id))));
|
||||
}
|
||||
setPreselect([]);
|
||||
}, [preselect, setPreselect, devices, authMetadata]);
|
||||
|
||||
const staleIds = new Set(
|
||||
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id),
|
||||
);
|
||||
|
||||
const handleDashboardOIDC = useCallback(() => {
|
||||
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||
if (!authUrl) return;
|
||||
@@ -132,8 +152,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
{devices
|
||||
{staleIds.size > 0 && (
|
||||
<Text size="T200" priority="300">
|
||||
{staleIds.size === 1
|
||||
? "1 session hasn't been used in 3 months or more (marked Inactive)."
|
||||
: `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '}
|
||||
Sign out the ones you no longer use.
|
||||
</Text>
|
||||
)}
|
||||
{[...devices]
|
||||
// [Gitea #113] Inactive (90+ days) sessions first, then most recent.
|
||||
.sort((d1, d2) => {
|
||||
const s1 = staleIds.has(d1.device_id) ? 0 : 1;
|
||||
const s2 = staleIds.has(d2.device_id) ? 0 : 1;
|
||||
if (s1 !== s2) return s1 - s2;
|
||||
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
||||
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
|
||||
})
|
||||
@@ -145,6 +177,11 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
{staleIds.has(device.device_id) && (
|
||||
<Text size="L400" style={{ color: 'inherit' }}>
|
||||
Inactive for 3+ months
|
||||
</Text>
|
||||
)}
|
||||
<DeviceTile
|
||||
device={device}
|
||||
deleted={deleted.has(device.device_id)}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* [Gitea #113] Session ids the stale-sessions nudge's Review asked Settings →
|
||||
* Devices to tick for sign-out. Consumed (and cleared) by OtherDevices.
|
||||
*/
|
||||
export const preselectSignOutAtom = atom<string[]>([]);
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
parseNudgeRecord,
|
||||
SecurityState,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from './securityNudge';
|
||||
|
||||
const healthy: SecurityState = {
|
||||
@@ -79,3 +82,45 @@ test('stored record: round trip, and garbage starts a fresh grace period', () =>
|
||||
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('[#113] stale sessions: 90+ days unused, never the current one', () => {
|
||||
const now = Date.UTC(2026, 9, 25);
|
||||
const day = 86_400_000;
|
||||
const devices = [
|
||||
{ device_id: 'ME', last_seen_ts: now - 400 * day },
|
||||
{ device_id: 'OLD', last_seen_ts: now - 91 * day },
|
||||
{ device_id: 'EDGE', last_seen_ts: now - 90 * day },
|
||||
{ device_id: 'RECENT', last_seen_ts: now - 89 * day },
|
||||
{ device_id: 'UNKNOWN' },
|
||||
{ device_id: 'NULL', last_seen_ts: null },
|
||||
];
|
||||
assert.deepEqual(
|
||||
staleSessions(devices, 'ME', now).map((d) => d.device_id),
|
||||
['OLD', 'EDGE'],
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] wording follows the count', () => {
|
||||
assert.match(
|
||||
staleSessionsText(1),
|
||||
/^You have a session you haven't used in 3 months\. Signing it out/,
|
||||
);
|
||||
assert.match(
|
||||
staleSessionsText(3),
|
||||
/^You have 3 sessions you haven't used in 3 months\. Signing them out/,
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => {
|
||||
const now = 10 * 86_400_000;
|
||||
const fresh = { firstSeen: 0, dismissals: {} };
|
||||
assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device');
|
||||
assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions');
|
||||
assert.equal(pickNudge(undefined, 0, fresh, now), undefined);
|
||||
const snoozed = dismissNudge('verify-device', fresh, now);
|
||||
assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions');
|
||||
const bothSnoozed = dismissNudge('stale-sessions', snoozed, now);
|
||||
assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined);
|
||||
// inside the 24 h grace period nothing shows
|
||||
assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user