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
138 lines
4.7 KiB
TypeScript
138 lines
4.7 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 } from '../../state/serverStatus';
|
|
import { pickBanner } from '../../utils/kumaStatus';
|
|
import {
|
|
dismissNudge,
|
|
NUDGE_COPY,
|
|
NUDGE_KEY_PREFIX,
|
|
nudgeFor,
|
|
NudgeRecord,
|
|
parseNudgeRecord,
|
|
shouldShowNudge,
|
|
} from '../../utils/securityNudge';
|
|
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;
|
|
};
|
|
|
|
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 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 kind = nudgeFor({
|
|
crossSigning,
|
|
deviceVerified,
|
|
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
|
backupActive,
|
|
});
|
|
|
|
// One strip at a time: outages, maintenance and a lost connection win.
|
|
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
|
|
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
|
return null;
|
|
|
|
const copy = 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 }}
|
|
icon={Icons.ShieldUser}
|
|
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
|
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>
|
|
);
|
|
}
|