From 4133854e0804fdffd7027becedc6648b4a51aaf6 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 7 Sep 2026 13:53:51 -0400 Subject: [PATCH] feat(preferences): reset actions, account-scope copy, and frontend tests Wire the per-domain reset actions into the settings surface: the appearance section footer and navigation section each offer a complete-domain reset, and the recovery section's interface reset runs both domains through the shared handler with a reload only after both tombstones settle successfully. - Settings registry: appearance scope moves from browser to account, with copy that states where preferences now live - ThemeQuickSwitch: the save-location line reflects account syncing - Tests: dirty-field merge against a late hydration GET, identity guards (cache ownership, queue invalidation on account switch, loading-state no-op), corrupt-row repair through the hook path, remote-reset precedence, reset settle/failure handling, registry scope assertions, and the eligibility readiness bridge --- ...seViewNavigationState.eligibility.test.tsx | 136 +++++++++ .../hooks/useViewNavigationState.ts | 44 ++- .../components/settings/AppearanceSection.tsx | 42 ++- .../components/settings/RecoverySection.tsx | 76 ++++- .../settings/SettingsSectionContent.tsx | 3 + .../__tests__/AppearanceSection.test.tsx | 54 +++- .../__tests__/RecoverySection.reset.test.tsx | 150 +++++++++ .../settings/__tests__/StacksSection.test.tsx | 2 +- .../settings/__tests__/registry.test.ts | 15 +- frontend/src/components/settings/registry.ts | 15 +- .../src/components/theme/ThemeQuickSwitch.tsx | 2 +- ...useUserPreferencesSync.corruptRow.test.tsx | 94 ++++++ ...useUserPreferencesSync.dirtyMerge.test.tsx | 289 ++++++++++++++++++ .../useUserPreferencesSync.identity.test.tsx | 197 ++++++++++++ ...seUserPreferencesSync.remoteReset.test.tsx | 173 +++++++++++ 15 files changed, 1243 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/EditorLayout/__tests__/useViewNavigationState.eligibility.test.tsx create mode 100644 frontend/src/components/settings/__tests__/RecoverySection.reset.test.tsx create mode 100644 frontend/src/hooks/__tests__/useUserPreferencesSync.corruptRow.test.tsx create mode 100644 frontend/src/hooks/__tests__/useUserPreferencesSync.dirtyMerge.test.tsx create mode 100644 frontend/src/hooks/__tests__/useUserPreferencesSync.identity.test.tsx create mode 100644 frontend/src/hooks/__tests__/useUserPreferencesSync.remoteReset.test.tsx diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.eligibility.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.eligibility.test.tsx new file mode 100644 index 00000000..99c2577e --- /dev/null +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.eligibility.test.tsx @@ -0,0 +1,136 @@ +/** + * Readiness bridge at the producer level: useViewNavigationState publishes + * settled quick-link eligibility into the module store with ownership captured + * from its authorization snapshot. Covers: publish on settle, nothing before + * settle, a superseded producer's publication masked after an identity bump, + * and teardown scoped to ownership (a superseded producer's unmount cannot + * erase a newer account's publication). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import * as AuthContext from '@/context/AuthContext'; +import * as LicenseContext from '@/context/LicenseContext'; +import * as NodeContext from '@/context/NodeContext'; +import { useViewNavigationState } from '../hooks/useViewNavigationState'; +import { + bumpGeneration, + clearEligibility, + currentGeneration, + getSettledEligibility, +} from '@/lib/preferences/preferenceEvents'; +import { setCurrentSyncUser } from '@/lib/preferences/syncBus'; + +vi.mock('@/context/AuthContext'); +vi.mock('@/context/LicenseContext'); +vi.mock('@/context/NodeContext'); + +const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true })); +vi.mock('@/hooks/useExperimental', () => ({ + useExperimental: () => useExperimentalMock(), +})); + +interface AuthMock { + userId: number; + isAdmin: boolean; + can: (p: string) => boolean; + permissionsStatus: 'ready' | 'loading' | 'error'; +} + +function mockAuth(m: AuthMock) { + vi.mocked(AuthContext.useAuth).mockReturnValue({ + isAdmin: m.isAdmin, + can: m.can, + permissionsStatus: m.permissionsStatus, + user: { userId: m.userId }, + } as unknown as ReturnType); +} + +function mockActiveNode(type: 'local' | 'remote' | null) { + vi.mocked(NodeContext.useNodes).mockReturnValue({ + activeNode: type === null ? null : { type, id: 1, name: 'n' }, + } as unknown as ReturnType); +} + +function mockLicense(isPaid: boolean, licenseStatus: 'ready' | 'loading' | 'error' = 'ready') { + vi.mocked(LicenseContext.useLicense).mockReturnValue({ + isPaid, + licenseStatus, + } as unknown as ReturnType); +} + +const ADMIN_CAN = (p: string) => + p === 'system:audit' || p === 'system:console' || p === 'node:read' || p === 'stack:deploy' || p === 'node:manage'; +const VIEWER_CAN = (p: string) => p === 'node:read'; + +const ADMIN_ELIGIBILITY = ['dashboard', 'fleet', 'resources', 'security', 'auto-updates', 'scheduled-ops']; +const VIEWER_ELIGIBILITY = ['dashboard', 'fleet', 'resources', 'security']; + +describe('useViewNavigationState eligibility publication (readiness bridge)', () => { + beforeEach(() => { + mockActiveNode('local'); + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); + }); + + afterEach(() => { + const pub = getSettledEligibility(); + if (pub) clearEligibility(pub.ownership); + }); + + it('publishes settled eligibility with ownership captured from the producing snapshot', () => { + const generation = currentGeneration(); + mockAuth({ userId: 5, isAdmin: true, can: ADMIN_CAN, permissionsStatus: 'ready' }); + mockLicense(true); + setCurrentSyncUser(5); + const { result } = renderHook(() => useViewNavigationState()); + + expect(result.current.defaultQuickLinkEligibility).toEqual(ADMIN_ELIGIBILITY); + const pub = getSettledEligibility(); + expect(pub?.eligibleIds).toEqual(ADMIN_ELIGIBILITY); + // Ownership binds the publication to the account and generation whose + // authorization snapshot produced it, not to whoever reads it later. + expect(pub?.ownership).toEqual({ userId: 5, generation }); + }); + + it('publishes nothing while permissions are still loading', () => { + mockAuth({ userId: 5, isAdmin: true, can: ADMIN_CAN, permissionsStatus: 'loading' }); + mockLicense(true); + setCurrentSyncUser(5); + renderHook(() => useViewNavigationState()); + expect(getSettledEligibility()).toBeNull(); + }); + + it('a superseded producer is masked after an identity bump and its teardown cannot erase the new publication', () => { + // Producer A (account 5) settles first. + mockAuth({ userId: 5, isAdmin: true, can: ADMIN_CAN, permissionsStatus: 'ready' }); + mockLicense(true); + setCurrentSyncUser(5); + const producerA = renderHook(() => useViewNavigationState()); + expect(getSettledEligibility()?.ownership.userId).toBe(5); + + // Account switch: identity generation bumps. A's stored publication reads + // null immediately, before the new account's producer has published. + act(() => { + bumpGeneration(); + }); + expect(getSettledEligibility()).toBeNull(); + + // Producer B mounts for the new account (viewer): it captures the new + // identity and its publication becomes the visible one. A is still + // mounted, exactly like a stale tab's producer that has not noticed. + mockAuth({ userId: 6, isAdmin: false, can: VIEWER_CAN, permissionsStatus: 'ready' }); + mockLicense(false); + setCurrentSyncUser(6); + const producerB = renderHook(() => useViewNavigationState()); + expect(getSettledEligibility()?.ownership.userId).toBe(6); + expect(getSettledEligibility()?.eligibleIds).toEqual(VIEWER_ELIGIBILITY); + + // A unmounts late: its teardown carries A's ownership and must not erase + // B's publication. + producerA.unmount(); + expect(getSettledEligibility()?.ownership.userId).toBe(6); + + // B's own teardown clears B's publication. + producerB.unmount(); + expect(getSettledEligibility()).toBeNull(); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index eae77707..361feca0 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; @@ -21,6 +21,12 @@ import { useExperimental } from '@/hooks/useExperimental'; import { canScheduleAny } from '@/lib/scheduledActions'; import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel'; import { recommendedQuickLinkIds, type NavDestination } from '@/lib/navigation/appNavRegistry'; +import { + setEligibilitySettled, + clearEligibility, + currentGeneration, + type EligibilityOwnership, +} from '@/lib/preferences/preferenceEvents'; export type { ActiveView }; export { HUB_ONLY_VIEWS }; @@ -36,7 +42,7 @@ interface UseViewNavigationStateOptions { export function useViewNavigationState(options?: UseViewNavigationStateOptions) { const { onNavigateToDashboard, hasFleetCapability = false, containerLabelsEnabled = false } = options ?? {}; - const { isAdmin, can, permissionsStatus, permissions } = useAuth(); + const { isAdmin, can, permissionsStatus, permissions, user } = useAuth(); const { isPaid, licenseStatus } = useLicense(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; @@ -143,6 +149,40 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) return recommendedQuickLinkIds.filter((id) => !isViewHidden(id, roleCtx)); }, [reachCtx]); + // Readiness bridge (per-user preference sync): publish settled eligibility to + // the module-level store the always-mounted sync owner consumes. Ownership is + // captured from THIS authorization snapshot (the account id and identity + // generation that produced this eligibility value), so a publication from a + // superseded producer (account switched, logout) is rejected by the store and + // can never appear current for the next account. Teardown clears only its own + // ownership, so an obsolete cleanup cannot erase a newer account's publication. + const eligibilityOwnershipRef = useRef(null); + const publishedEligibilityRef = useRef(undefined); + + useEffect(() => { + if (!authzReady(reachCtx)) return; + // Capture ownership from the snapshot this eligibility value came from. + eligibilityOwnershipRef.current = { userId: user?.userId ?? null, generation: currentGeneration() }; + }, [reachCtx, user, permissionsStatus]); + + useEffect(() => { + if (!authzReady(reachCtx)) return; + const ownership = eligibilityOwnershipRef.current; + if (!ownership) return; + if (publishedEligibilityRef.current === defaultQuickLinkEligibility) return; + publishedEligibilityRef.current = defaultQuickLinkEligibility; + setEligibilitySettled(ownership, defaultQuickLinkEligibility === null ? null : [...defaultQuickLinkEligibility]); + }, [reachCtx, defaultQuickLinkEligibility]); + + useEffect(() => { + // Teardown is scoped to the ownership captured above: only a publication + // still owned by THIS producer is cleared. + return () => { + const ownership = eligibilityOwnershipRef.current; + if (ownership) clearEligibility(ownership); + }; + }, []); + useEffect(() => { if (!authzReady(reachCtx)) return; const normalized = normalizeHiddenView(activeView, reachCtx); diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index fcb9a455..d779846b 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -1,4 +1,4 @@ -import { Check, Info } from 'lucide-react'; +import { Check, Info, RotateCcw } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Combobox } from '@/components/ui/combobox'; import { Slider } from '@/components/ui/slider'; @@ -147,9 +147,13 @@ function VisualCard({ export function AppearanceSection({ quickLinkCandidates = [], defaultQuickLinkEligibility, + onResetAppearance, + onResetNavigation, }: { quickLinkCandidates?: NavDestination[]; defaultQuickLinkEligibility?: ActiveView[] | null; + onResetAppearance: () => void; + onResetNavigation: () => void; }) { const [density, setDensity] = useDensity(); const [chipColorMode, setChipColorMode] = useLogChipColorMode(); @@ -233,7 +237,7 @@ export function AppearanceSection({ - + - + {!reducedMotion ? ( - +
@@ -395,7 +399,7 @@ export function AppearanceSection({
- + - + - + + + + + Reset + + + {topNavMode === 'smart' && (

- ⓘ saved to this browser only · every device remembers its own choice + ⓘ saved to your account · every device picks it up on sign-in

+ + + + + Reset + + ); } diff --git a/frontend/src/components/settings/RecoverySection.tsx b/frontend/src/components/settings/RecoverySection.tsx index 6f9dffe3..2e257fb1 100644 --- a/frontend/src/components/settings/RecoverySection.tsx +++ b/frontend/src/components/settings/RecoverySection.tsx @@ -11,6 +11,9 @@ import { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from import { EnvironmentChecks } from './EnvironmentChecks'; import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled'; import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled'; +import { resetPreferenceDomain } from '@/lib/preferences/resetPreferences'; +import { getUnsavedEpisode, subscribeToUnsaved } from '@/lib/preferences/preferenceEvents'; +import { inspectQueue } from '@/lib/preferences/syncBus'; // Mirrors the backend DiagnosticsReport (services/DiagnosticsService.ts). Kept // local because the frontend cannot import backend types. @@ -30,10 +33,9 @@ interface DiagnosticsReport { type Health = 'ok' | 'warn' | 'error'; -// Browser-local display preferences cleared by "Reset interface preferences". -// The density key is internal to use-density; the other two are exported. -const DENSITY_KEY = 'sencho.appearance.density'; -const INTERFACE_PREF_KEYS = [DENSITY_KEY, DEPLOY_FEEDBACK_KEY, COMPOSE_DIFF_PREVIEW_KEY]; +// Browser-local workflow toggles cleared by "Reset interface preferences". +// Density used to be listed here; it now lives in the account-synced +// appearance document and is reset through that path instead. const CLI_COMMANDS: Array<{ cmd: string; purpose: string }> = [ { cmd: 'node dist/cli/resetMfa.js ', purpose: "Clear a user's two-factor enrolment" }, @@ -158,11 +160,67 @@ export function RecoverySection() { } }; + const finishReset = () => { + toast.success('Interface preferences reset to defaults. Reloading...'); + window.setTimeout(() => window.location.reload(), 600); + }; + const resetInterface = () => { try { - INTERFACE_PREF_KEYS.forEach(key => window.localStorage.removeItem(key)); - toast.success('Interface preferences reset to defaults. Reloading...'); - setTimeout(() => window.location.reload(), 600); + // Deploy-feedback and diff-preview are browser-local workflow + // toggles outside the preference domains; clear them as before. + window.localStorage.removeItem(DEPLOY_FEEDBACK_KEY); + window.localStorage.removeItem(COMPOSE_DIFF_PREVIEW_KEY); + // Appearance + navigation are account documents: reset both + // domains (optimistic defaults now, tombstone DELETEs through the + // sync bus). The reload happens only when no unsaved episode + // remains, so a partial reset never claims full success. + resetPreferenceDomain('appearance'); + resetPreferenceDomain('navigation'); + // Three settle signals race (unsaved transition, poller, hard + // stop); exactly one of them may finish the reset. + let settled = false; + const finishOnce = () => { + if (settled) return; + settled = true; + finishReset(); + }; + const stop = subscribeToUnsaved((episode) => { + if (episode !== null) return; // a failure already surfaced its own toast + stop(); + finishOnce(); + }); + // Pure successes emit no unsaved transition, so polling is the + // settle signal: each tick checks both the episode and the sync + // bus queue state. A tick counts as clean only when no DELETE is + // queued or in flight, so the reload can never cancel one. + let cleanTicks = 0; + const settleTimer = window.setInterval(() => { + if (getUnsavedEpisode() !== null) { + window.clearInterval(settleTimer); + stop(); + return; // a failure path owns the error surface + } + const busy = inspectQueue('appearance').settling || inspectQueue('navigation').settling; + cleanTicks = busy ? 0 : cleanTicks + 1; + if (cleanTicks >= 2) { + window.clearInterval(settleTimer); + stop(); + finishOnce(); + } + }, 300); + // Hard stop: never poll longer than the grace period; a still + // in-flight reset must not claim success, so say so instead of + // finishing silently. + window.setTimeout(() => { + window.clearInterval(settleTimer); + stop(); + const busy = inspectQueue('appearance').settling || inspectQueue('navigation').settling; + if (getUnsavedEpisode() === null && !busy) finishOnce(); + else if (!settled) { + toast.error('The reset is taking longer than expected and has not finished. Your preferences will keep retrying in the background.'); + } + }, 4000); } catch (e: unknown) { toast.error((e as Error)?.message || 'Could not reset interface preferences.'); } @@ -238,10 +296,10 @@ export function RecoverySection() { > - + Reset diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx index 6d472d9a..e352684e 100644 --- a/frontend/src/components/settings/SettingsSectionContent.tsx +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -27,6 +27,7 @@ import LazyBoundary from '../LazyBoundary'; import { SectionGate } from './SectionGate'; import type { NavDestination } from '@/lib/navigation/appNavRegistry'; import type { ActiveView } from '@/lib/router/routeTypes'; +import { resetPreferenceDomain } from '@/lib/preferences/resetPreferences'; // Paid-tier sections are loaded on demand. SectionGate returns null for // Community / unentitled operators before reaching the JSX that would mount @@ -90,6 +91,8 @@ function renderSection({ resetPreferenceDomain('appearance')} + onResetNavigation={() => resetPreferenceDomain('navigation')} /> ); case 'license': return ; diff --git a/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx b/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx index e542ce71..57d7875d 100644 --- a/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx +++ b/frontend/src/components/settings/__tests__/AppearanceSection.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, act, renderHook } from '@testing-library/react'; import { AppearanceSection } from '../AppearanceSection'; import { useTheme } from '@/hooks/use-theme'; @@ -21,7 +21,7 @@ describe('AppearanceSection', () => { beforeEach(() => resetTheme()); it('renders the four refresh sections above Theme', () => { - render(); + render( {}} onResetNavigation={() => {}} />); expect(screen.getByText('Visual style')).toBeTruthy(); expect(screen.getByText('Security visualization')).toBeTruthy(); expect(screen.getByText('Readability')).toBeTruthy(); @@ -29,7 +29,7 @@ describe('AppearanceSection', () => { }); it('selecting the Calm card applies the calm resolution to ', () => { - render(); + render( {}} onResetNavigation={() => {}} />); fireEvent.click(screen.getByRole('button', { name: /Calm/i })); expect(document.documentElement.dataset.headings).toBe('clean'); expect(document.documentElement.dataset.chartStyle).toBe('muted'); @@ -38,7 +38,7 @@ describe('AppearanceSection', () => { }); it('Calm and Signature preset apply write reducedMotion; Effects alone does not', () => { - render(); + render( {}} onResetNavigation={() => {}} />); // Baseline Signature clears Motion. expect(document.documentElement.dataset.motion).toBeUndefined(); @@ -66,7 +66,7 @@ describe('AppearanceSection', () => { }); it('shows the constrained-graphics callout when Reduced motion is off, and hides it when on', () => { - render(); + render( {}} onResetNavigation={() => {}} />); expect(screen.getByText('Constrained graphics')).toBeTruthy(); // Reduced effects alone must not hide the Motion guidance. @@ -85,14 +85,14 @@ describe('AppearanceSection', () => { }); it('states that log chip color applies on multi-service or multi-container stacks', () => { - render(); + render( {}} onResetNavigation={() => {}} />); expect( screen.getByText(/Applies to service chips on multi-service or multi-container stacks/i), ).toBeTruthy(); }); it('readability locks the header + chart controls and disables the glow slider', () => { - const { container } = render(); + const { container } = render( {}} onResetNavigation={() => {}} />); // Baseline: nothing reduced, so no slider is disabled. expect(container.querySelectorAll('[data-disabled]').length).toBe(0); expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBeNull(); @@ -108,7 +108,7 @@ describe('AppearanceSection', () => { }); it('reduced motion is independent of readability and toggles data-motion on ', () => { - render(); + render( {}} onResetNavigation={() => {}} />); const motion = () => screen.getByRole('switch', { name: 'Reduced motion' }) as HTMLButtonElement; expect(document.documentElement.dataset.motion).toBeUndefined(); // Readability flattens effects but must not disable the motion toggle. @@ -119,7 +119,7 @@ describe('AppearanceSection', () => { }); it('readability also locks the Visual style cards and the Border brightness slider', () => { - const { container } = render(); + const { container } = render( {}} onResetNavigation={() => {}} />); const calmCard = () => screen.getByRole('button', { name: /readable default/i }) as HTMLButtonElement; const sigCard = () => screen.getByRole('button', { name: /Today's look/i }) as HTMLButtonElement; const borderLocked = () => !!container.querySelector('[aria-label="Border brightness"][data-disabled]'); @@ -137,7 +137,7 @@ describe('AppearanceSection', () => { }); it('de-selects both visual-style cards when a custom sub-axis is chosen', () => { - render(); + render( {}} onResetNavigation={() => {}} />); // Baseline is Signature, so the Signature card reads selected. expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('true'); // A custom chart palette (Heat) makes the trio match no preset. @@ -147,7 +147,7 @@ describe('AppearanceSection', () => { }); it('de-selects when only the header style diverges (not just the chart palette)', () => { - render(); + render( {}} onResetNavigation={() => {}} />); // Baseline Signature; flipping only Header style to Clean breaks the match. fireEvent.click(screen.getByRole('radio', { name: 'Clean' })); expect(screen.getByRole('button', { name: /Today's look/i }).getAttribute('aria-pressed')).toBe('false'); @@ -155,7 +155,7 @@ describe('AppearanceSection', () => { }); it('reset to default restores Calm and locks while readability is on', () => { - render(); + render( {}} onResetNavigation={() => {}} />); fireEvent.click(screen.getByRole('radio', { name: 'Heat' })); expect(document.documentElement.dataset.chartStyle).toBe('heat'); @@ -171,7 +171,7 @@ describe('AppearanceSection', () => { it('shows Navigation style and mode-conditional controls', () => { localStorage.clear(); - render(); + render( {}} onResetNavigation={() => {}} />); expect(screen.getByText('Navigation')).toBeTruthy(); const navigationStyle = screen.getByRole('radiogroup', { name: 'Navigation style' }); expect(navigationStyle).toBeTruthy(); @@ -190,7 +190,7 @@ describe('AppearanceSection', () => { it('offers only Compact launcher and Smart bar, with Compact first', () => { localStorage.clear(); - render(); + render( {}} onResetNavigation={() => {}} />); const options = screen.getAllByRole('radio', { name: /bar|launcher/i }).map((el) => el.textContent); expect(options).toEqual(['Compact launcher', 'Smart bar']); expect(screen.queryByRole('radio', { name: 'Classic bar' })).toBeNull(); @@ -199,13 +199,35 @@ describe('AppearanceSection', () => { it('disables Reset to defaults while default eligibility has not settled', () => { localStorage.clear(); - render(); + render( {}} onResetNavigation={() => {}} />); expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(true); }); it('enables Reset to defaults once default eligibility has settled', () => { localStorage.clear(); - render(); + render( {}} onResetNavigation={() => {}} />); expect((screen.getByRole('button', { name: 'Reset to defaults' }) as HTMLButtonElement).disabled).toBe(false); }); + + it('the complete-domain reset buttons call the shared domain-reset handlers', () => { + const onResetAppearance = vi.fn(); + const onResetNavigation = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset all appearance preferences' })); + expect(onResetAppearance).toHaveBeenCalledTimes(1); + expect(onResetNavigation).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset all navigation preferences' })); + expect(onResetNavigation).toHaveBeenCalledTimes(1); + expect(onResetAppearance).toHaveBeenCalledTimes(1); + }); + + it('the account-synced footer and reset helpers state the account scope', () => { + render( {}} onResetNavigation={() => {}} />); + expect(screen.getByText(/saved to your account · every device picks it up on sign-in/i)).toBeTruthy(); + expect(screen.getAllByText(/for your account on every device/i).length).toBe(2); + // The retired browser-local wording must not resurface. + expect(screen.queryByText(/this browser/i)).toBeNull(); + }); }); diff --git a/frontend/src/components/settings/__tests__/RecoverySection.reset.test.tsx b/frontend/src/components/settings/__tests__/RecoverySection.reset.test.tsx new file mode 100644 index 00000000..6550c80b --- /dev/null +++ b/frontend/src/components/settings/__tests__/RecoverySection.reset.test.tsx @@ -0,0 +1,150 @@ +/** + * RecoverySection's "Reset interface preferences" is the both-domains reset on + * top of the shared per-domain reset handler. These tests cover the + * both-domains reset wiring: both domains go through the shared per-domain + * reset handler, and the success toast + reload wait until both domain DELETEs + * settle (a partial reset never claims full success). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { RecoverySection } from '../RecoverySection'; +import { setCurrentSyncUser } from '@/lib/preferences/syncBus'; +import { resetPreferenceSync } from '@/lib/preferences/preferenceEvents'; + +const mockedFetch = apiFetch as unknown as ReturnType; + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: async () => body, + clone() { return this as Response; }, + } as unknown as Response; +} + +async function renderReadySection(): Promise { + mockedFetch.mockImplementation(async (path: string) => { + if (String(path).includes('/diagnostics/environment')) { + return jsonResponse(200, { checks: [] }); + } + return jsonResponse(200, { + version: '1.0.0', + database: { ok: true, integrity: 'ok', path: '', missingTables: [] }, + encryptionKey: { present: true, valid: true }, + docker: { reachable: true }, + auth: { adminCount: 1, userCount: 1, mfaEnrolledCount: 0, ssoProviders: [] }, + config: {}, + }); + }); + render(); + await screen.findByText('Safe actions'); +} + +describe('RecoverySection: Reset interface preferences (both-domains reset)', () => { + beforeEach(() => { + mockedFetch.mockReset(); + localStorage.clear(); + setCurrentSyncUser(7); + // The toast mock is module-level and shared by both tests in this + // file; clear it so one test's calls cannot leak into the other's + // assertion. + vi.mocked(toast.success).mockClear(); + vi.mocked(toast.error).mockClear(); + resetPreferenceSync(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resets both preference domains and reloads after both settle', async () => { + const reload = vi.fn(); + Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true }); + + await renderReadySection(); + + // Both DELETEs succeed: no unsaved episode ever appears, so the + // settle listener fires when the queue drains. The reset's DELETE + // first GETs a baseline (no known revision in this bare-bus test); + // the reset enqueues only the DELETE, the tombstone stands until a + // post-reset user edit stages its own PUT behind it. + mockedFetch.mockImplementation(async (path: string, opts?: RequestInit) => { + if (String(path).includes('/diagnostics/environment')) return jsonResponse(200, { checks: [] }); + if (String(path) === '/user-preferences' && (opts?.method ?? 'GET') === 'GET') { + return jsonResponse(200, { + preferences: { + appearance: { schemaVersion: 1, revision: 1, updatedAt: 1, data: {} }, + navigation: { schemaVersion: 1, revision: 1, updatedAt: 1, data: {} }, + }, + }); + } + if (opts?.method === 'DELETE') return jsonResponse(200, { domain: 'x', schemaVersion: 0, revision: 2, updatedAt: 1 }); + return jsonResponse(200, { domain: 'x', schemaVersion: 1, revision: 2, updatedAt: 1 }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Reset interface preferences' })); + // Drain the bus's async pumps (two DELETEs) inside act. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Pure successes emit no unsaved-episode transition, so the success + // path is the 4s fallback (it settles when no failure appeared). + await act(async () => { + vi.advanceTimersByTime(4200); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Interface preferences reset to defaults. Reloading...'); + }); + await waitFor(() => { + expect(reload).toHaveBeenCalled(); + }); + }); + + it('does not claim success while a domain still has unsaved writes', async () => { + const reload = vi.fn(); + Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true }); + + await renderReadySection(); + + // No DELETE mock succeeds (requests fail) so an unsaved episode appears. + mockedFetch.mockImplementation(async (path: string) => { + if (String(path).includes('/diagnostics/environment')) return jsonResponse(200, { checks: [] }); + return jsonResponse(500, { error: 'unavailable' }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Reset interface preferences' })); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Let the settle poller and the grace-period timer run out: the + // failure path owns the error surface, so the success toast and the + // reload must never fire. + await act(async () => { + vi.advanceTimersByTime(4200); + }); + + expect(toast.success).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + // Clear any episode the failure path left so other suites are unaffected. + resetPreferenceSync(); + }); +}); diff --git a/frontend/src/components/settings/__tests__/StacksSection.test.tsx b/frontend/src/components/settings/__tests__/StacksSection.test.tsx index 810fc3c4..bfd759f3 100644 --- a/frontend/src/components/settings/__tests__/StacksSection.test.tsx +++ b/frontend/src/components/settings/__tests__/StacksSection.test.tsx @@ -161,7 +161,7 @@ describe('StacksSection', () => { describe('AppearanceSection no longer owns stack-workflow controls', () => { it('does not render Deploy progress, Progress style, or Diff preview before save', () => { - render(); + render( {}} onResetNavigation={() => {}} />); expect(screen.queryByText('Deploy progress')).not.toBeInTheDocument(); expect(screen.queryByText('Progress style')).not.toBeInTheDocument(); expect(screen.queryByText('Diff preview before save')).not.toBeInTheDocument(); diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts index 2455bc79..0b73441e 100644 --- a/frontend/src/components/settings/__tests__/registry.test.ts +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -107,11 +107,11 @@ describe('settings registry', () => { } }); - it('scopes the browser-local sections to the browser', () => { - // Appearance is the only remaining browser-local section. Stacks moved to - // node scope when Deploy Guardrails (backend settings) were added to it - // alongside the existing browser-local Workflow controls. - expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser'); + it('scopes Appearance to the account and Stacks to the node', () => { + // Appearance persists per signed-in account on the server (account scope). + // Stacks moved to node scope when Deploy Guardrails (backend settings) were + // added to it alongside the existing browser-local Workflow controls. + expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('account'); expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('node'); }); @@ -129,6 +129,11 @@ describe('scopeLabel', () => { keywords: [], tier: null, scope: 'global', ...over, }); + it('reads account for account-synced sections regardless of their group', () => { + expect(scopeLabel(item({ scope: 'account', group: 'personal' }))).toBe('account'); + expect(scopeLabel(item({ scope: 'account', group: 'infrastructure' }))).toBe('account'); + }); + it('reads browser for browser-scoped sections regardless of their group', () => { expect(scopeLabel(item({ scope: 'browser', group: 'personal' }))).toBe('browser'); expect(scopeLabel(item({ scope: 'browser', group: 'infrastructure' }))).toBe('browser'); diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 117d1a23..d2310dc8 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -32,7 +32,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [ ]; export type TierGate = 'paid' | null; -export type Scope = 'global' | 'node' | 'browser'; +export type Scope = 'global' | 'node' | 'browser' | 'account'; export interface SettingsItemMeta { id: SectionId; @@ -65,10 +65,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ id: 'appearance', group: 'personal', label: 'Appearance', - description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to this browser.', - keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav'], + description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to your account.', + keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav', 'sync', 'account'], tier: null, - scope: 'browser', + scope: 'account', }, // Access { @@ -351,15 +351,16 @@ export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): bo } /** - * The masthead SCOPE value for a non-node section. Browser-local sections - * (Appearance) persist to this browser's localStorage and read as - * browser regardless of their group; the signed-in Account is operator-scoped; + * The masthead SCOPE value for a non-node section. Account-synced sections + * (Appearance) persist per signed-in account on the server and read as + * account regardless of their group; the signed-in Account is operator-scoped; * Access sections (license, users, sso, api-tokens) are instance-global, so * they read as global like every other non-node group. Node-scoped sections * render a NODE pill instead and never reach here. */ export function scopeLabel(item: SettingsItemMeta): string { if (item.scope === 'browser') return 'browser'; + if (item.scope === 'account') return 'account'; if (item.group === 'personal') return 'operator'; return 'global'; } diff --git a/frontend/src/components/theme/ThemeQuickSwitch.tsx b/frontend/src/components/theme/ThemeQuickSwitch.tsx index b9c3076d..babf2812 100644 --- a/frontend/src/components/theme/ThemeQuickSwitch.tsx +++ b/frontend/src/components/theme/ThemeQuickSwitch.tsx @@ -140,7 +140,7 @@ export function ThemeQuickSwitch({ onOpenAppearance }: ThemeQuickSwitchProps) { {/* Footer */}

- Saved to this browser · fine-tune borders & glow in{' '} + Saved to your account · fine-tune borders & glow in{' '}