Files
cinny/src/app/features/security-nudge/SecurityBanner.tsx
T
Lotus CIandClaude Opus 5.5 c907b0ba99
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
feat(security): nudge to sign out sessions unused for 3 months (#113)
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
2026-10-24 23:52:16 -04:00

187 lines
6.4 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { SyncState } from 'matrix-js-sdk';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import { Icons } from 'folds';
import { ErrorBoundary } from 'react-error-boundary';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../../hooks/useDeviceVerificationStatus';
import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBackup';
import { useAlive } from '../../hooks/useAlive';
import { useSyncState } from '../../hooks/useSyncState';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { pickBanner } from '../../utils/kumaStatus';
import {
dismissNudge,
NUDGE_COPY,
NUDGE_KEY_PREFIX,
nudgeFor,
NudgeRecord,
parseNudgeRecord,
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. */
const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
const alive = useAlive();
const [active, setActive] = useState<boolean>();
useEffect(() => {
crypto.getActiveSessionBackupVersion().then((v) => {
if (alive()) setActive(typeof v === 'string');
});
}, [crypto, alive]);
useKeyBackupStatusChange(useCallback((enabled: boolean) => setActive(enabled), []));
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 {
raw = localStorage.getItem(key);
} catch {
/* storage unavailable */
}
const record = parseNudgeRecord(raw, Date.now());
if (!raw) {
try {
localStorage.setItem(key, JSON.stringify(record));
} catch {
/* best effort */
}
}
return record;
};
function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const mx = useMatrixClient();
const deviceId = mx.getDeviceId() ?? undefined;
const crossSigning = useCrossSigningActive();
const status = useDeviceVerificationStatus(crypto, mx.getSafeUserId(), deviceId);
const backupInfo = useKeyBackupInfo(crypto);
const backupActive = useBackupActive(crypto);
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
const statusDismissed = useAtomValue(statusDismissedAtom);
const requestSettings = useSetAtom(settingsRequestAtom);
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
// never stacks under the "Connecting…" strip at startup.
const [settled, setSettled] = useState(false);
useSyncState(
mx,
useCallback((state: SyncState | null, prev?: SyncState | null) => {
setSettled(state === SyncState.Syncing && prev === SyncState.Syncing);
}, []),
);
const key = `${NUDGE_KEY_PREFIX}${deviceId ?? 'unknown'}`;
const [record, setRecord] = useState(() => readRecord(key));
let deviceVerified: boolean | undefined;
if (status === VerificationStatus.Verified) deviceVerified = true;
else if (status === VerificationStatus.Unverified) deviceVerified = false;
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
// on screen and must not keep the nudge hidden for as long as it's posted.
const otherStrip =
syncLost ||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
if (!settled || !deviceId || !kind || otherStrip) return null;
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 {
localStorage.setItem(key, JSON.stringify(next));
} catch {
/* dismissed for this session only */
}
setRecord(next);
};
return (
<StatusStrip
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
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}
/>
);
}
/**
* [Gitea #110, #123] The device-security nudge: verify this device, connect
* it to the key backup, or set up key backup. See utils/securityNudge.ts.
*/
function SecurityBannerInner() {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
if (!crypto) return null;
return <SecurityBannerFor crypto={crypto} />;
}
// A nudge must never take the app down: any error just hides it.
export function SecurityBanner() {
return (
<ErrorBoundary fallback={null}>
<SecurityBannerInner />
</ErrorBoundary>
);
}