Merge pull request 'fix(security): a dismissed status announcement no longer hides the security nudge (#307)' (#391) from fix/nudge-dismissed-strip into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
This commit was merged in pull request #391.
This commit is contained in:
@@ -29,6 +29,32 @@ async function settled(page: Page) {
|
|||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
|
||||||
|
async function mockAnnouncement(page: Page) {
|
||||||
|
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
const cfg = await res.json();
|
||||||
|
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
|
||||||
|
await route.fulfill({ response: res, json: cfg });
|
||||||
|
});
|
||||||
|
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
|
||||||
|
const cors = { 'access-control-allow-origin': '*' };
|
||||||
|
if (route.request().url().includes('/heartbeat/')) {
|
||||||
|
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
headers: cors,
|
||||||
|
json: {
|
||||||
|
config: { slug: 'matrix' },
|
||||||
|
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
|
||||||
|
maintenanceList: [],
|
||||||
|
publicGroupList: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('security nudge (#110, #123)', () => {
|
test.describe('security nudge (#110, #123)', () => {
|
||||||
let user: TestUser;
|
let user: TestUser;
|
||||||
|
|
||||||
@@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => {
|
|||||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||||
|
test.setTimeout(200_000);
|
||||||
|
await mockAnnouncement(page);
|
||||||
|
await loginUI(page, user);
|
||||||
|
await pastGrace(page);
|
||||||
|
await settled(page);
|
||||||
|
// The announcement wins the one strip slot until it's dismissed.
|
||||||
|
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
|
||||||
|
await expect(announcement).toBeVisible();
|
||||||
|
await expect(strip(page)).toHaveCount(0);
|
||||||
|
await announcement.getByRole('button', { name: 'Dismiss' }).click();
|
||||||
|
await page.reload();
|
||||||
|
await settled(page);
|
||||||
|
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
|
||||||
|
});
|
||||||
|
|
||||||
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
||||||
test.setTimeout(200_000);
|
test.setTimeout(200_000);
|
||||||
await loginUI(page, user);
|
await loginUI(page, user);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
|
|||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||||
import { pickBanner } from '../../utils/kumaStatus';
|
import { pickBanner } from '../../utils/kumaStatus';
|
||||||
import {
|
import {
|
||||||
dismissNudge,
|
dismissNudge,
|
||||||
@@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
const backupInfo = useKeyBackupInfo(crypto);
|
const backupInfo = useKeyBackupInfo(crypto);
|
||||||
const backupActive = useBackupActive(crypto);
|
const backupActive = useBackupActive(crypto);
|
||||||
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
||||||
|
const statusDismissed = useAtomValue(statusDismissedAtom);
|
||||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||||
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
||||||
// never stacks under the "Connecting…" strip at startup.
|
// never stacks under the "Connecting…" strip at startup.
|
||||||
@@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// One strip at a time: outages, maintenance and a lost connection win.
|
// One strip at a time: outages, maintenance and a lost connection win.
|
||||||
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
|
// [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 || !shouldShowNudge(kind, record, Date.now()))
|
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||||
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
@@ -7,22 +7,10 @@ import { useClientConfig } from '../../hooks/useClientConfig';
|
|||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { useServerStatus } from '../../hooks/useServerStatus';
|
import { useServerStatus } from '../../hooks/useServerStatus';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||||
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
||||||
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
|
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
|
||||||
|
|
||||||
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
|
||||||
const DISMISS_MAX = 50;
|
|
||||||
|
|
||||||
const readDismissed = (): string[] => {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
|
||||||
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bannerIcon = (b: StripContent): IconSrc => {
|
const bannerIcon = (b: StripContent): IconSrc => {
|
||||||
if (b.kind === 'maintenance') return Icons.Setting;
|
if (b.kind === 'maintenance') return Icons.Setting;
|
||||||
if (b.tone === 'Primary') return Icons.Info;
|
if (b.tone === 'Primary') return Icons.Info;
|
||||||
@@ -179,22 +167,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
|||||||
/** The logged-in app's status strip. */
|
/** The logged-in app's status strip. */
|
||||||
export function ServerStatusBanner() {
|
export function ServerStatusBanner() {
|
||||||
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
||||||
const [dismissed, setDismissed] = useState(readDismissed);
|
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
|
||||||
const banner = pickBanner(status, {
|
const banner = pickBanner(status, {
|
||||||
syncLost,
|
syncLost,
|
||||||
where: 'client',
|
where: 'client',
|
||||||
dismissed: new Set(dismissed),
|
dismissed: new Set(dismissed),
|
||||||
});
|
});
|
||||||
if (!banner) return null;
|
if (!banner) return null;
|
||||||
const dismiss = () => {
|
const dismiss = () => dismissKey(banner.key);
|
||||||
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
|
||||||
} catch {
|
|
||||||
// storage unavailable — dismissed for this session only
|
|
||||||
}
|
|
||||||
setDismissed(next);
|
|
||||||
};
|
|
||||||
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{
|
|||||||
syncLost: false,
|
syncLost: false,
|
||||||
lostAt: null,
|
lostAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
||||||
|
const DISMISS_MAX = 50;
|
||||||
|
|
||||||
|
const readDismissed = (): string[] => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
||||||
|
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseDismissedAtom = atom<string[]>(readDismissed());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
|
||||||
|
* the security nudge knows a dismissed announcement isn't on screen.
|
||||||
|
* Persisted to localStorage; session-only if storage is unavailable.
|
||||||
|
*/
|
||||||
|
export const statusDismissedAtom = atom(
|
||||||
|
(get) => get(baseDismissedAtom),
|
||||||
|
(get, set, key: string) => {
|
||||||
|
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// storage unavailable — dismissed for this session only
|
||||||
|
}
|
||||||
|
set(baseDismissedAtom, next);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user