From 72c5ecdf2cc794f66ceff2453f7a20c954e1de50 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:52:06 -0500 Subject: [PATCH] fix: select onboarding by available client capabilities --- docs/FEATURES.md | 1 + web/src/__tests__/desktop-onboarding.test.tsx | 91 ++++++++++++++++++- web/src/components/auth/DesktopOnboarding.tsx | 10 +- web/src/components/auth/SetupScreen.tsx | 31 ++++++- 4 files changed, 128 insertions(+), 5 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 89a67b45..835bcc96 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -122,6 +122,7 @@ The Kanban board is the central interface — a drag-and-drop workspace that ref - **Mobile shell controls** — Compact navigation uses bounded labels and full accessible names; Board Chat stays fixed above the bottom navigation and device safe area - **Resizable Workbench** — Board Chat and Squad Chat open in one bounded right-side dock, preserve the active conversation when switching channels, and clamp their width to keep the application shell recoverable - **Bulk operations** — Select multiple tasks to move, archive, or delete in batch; select-all toggle +- **Client-aware first setup** — Browsers secure the connected server without native readiness checks or promises of local storage creation. The desktop app retains its native setup paths. Existing server or desktop data requires review before password creation. - **Keyboard shortcuts** — Navigate tasks (j/k, arrows), open (Enter), close (Esc), create (c), move to column (1-4), help (?) - **Loading skeleton** — Shimmer placeholders while the board loads - **Blocked column** — Dedicated column for blocked tasks with categorized reasons (waiting on feedback, technical snag, prerequisite, other) diff --git a/web/src/__tests__/desktop-onboarding.test.tsx b/web/src/__tests__/desktop-onboarding.test.tsx index 8bcff7d7..9082756c 100644 --- a/web/src/__tests__/desktop-onboarding.test.tsx +++ b/web/src/__tests__/desktop-onboarding.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; import { renderWithProviders } from './test-utils'; @@ -26,6 +26,21 @@ function diagnostics() { } describe('desktop onboarding', () => { + beforeEach(() => { + Object.defineProperty(window, 'veritasDesktop', { + configurable: true, + value: { + getSetupDiagnostics: vi.fn(async () => diagnostics()), + validateConnectionConfig: vi.fn(async () => ({ + mode: 'local', + valid: true, + normalizedServerUrl: null, + errors: [], + warnings: [], + })), + }, + }); + }); afterEach(() => { cleanup(); window.localStorage.clear(); @@ -34,6 +49,78 @@ describe('desktop onboarding', () => { vi.unstubAllGlobals(); }); + it.each([false, true])( + 'uses server setup in a browser, including existing data: %s', + async (existing) => { + delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop; + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + needsSetup: true, + authenticated: false, + sessionExpiry: null, + authEnabled: true, + setupContext: { + hasExistingData: existing, + storageMode: 'sqlite', + counts: { tasks: existing ? 2 : 0 }, + }, + }) + ) + ) + ); + renderWithProviders( + + + + ); + if (existing) { + await screen.findByText('Secure Existing Server Data'); + expect(screen.queryByLabelText('Password')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Secure Existing Data' })); + } + await screen.findByText('Secure Your Board'); + expect(screen.getByText(/Your board data stays on that server/)).toBeDefined(); + expect(screen.queryByText('Desktop Setup')).toBeNull(); + expect(screen.queryByText('Desktop Bridge')).toBeNull(); + expect(screen.queryByText(/create a new local SQLite/)).toBeNull(); + expect(window.localStorage.getItem(DESKTOP_ONBOARDING_STORAGE_KEY)).toBeNull(); + } + ); + + it.each([false, true])('handles restricted storage with native bridge: %s', async (native) => { + if (!native) delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Storage denied', 'SecurityError'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Storage denied', 'SecurityError'); + }); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ needsSetup: true, authenticated: false, authEnabled: true }) + ) + ) + ); + renderWithProviders( + + + + ); + if (native) { + await screen.findByText('Desktop Setup'); + fireEvent.click(screen.getByRole('button', { name: 'Continue to Password' })); + } + expect(await screen.findByText('Secure Your Board')).toBeDefined(); + expect(screen.queryByText('Desktop Bridge')).toBeNull(); + }); + it('shows the board-only first-run path before password setup', async () => { vi.stubGlobal( 'fetch', @@ -186,6 +273,7 @@ describe('desktop onboarding', () => { }); it('labels browser-only remote checks as URL validation', async () => { + delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop; renderWithProviders(); fireEvent.click(screen.getByTestId('setup-mode-remote')); @@ -199,6 +287,7 @@ describe('desktop onboarding', () => { }); it('blocks browser-only remote checks for local destinations', async () => { + delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop; renderWithProviders(); fireEvent.click(screen.getByTestId('setup-mode-remote')); diff --git a/web/src/components/auth/DesktopOnboarding.tsx b/web/src/components/auth/DesktopOnboarding.tsx index 1cab4ae8..84829e26 100644 --- a/web/src/components/auth/DesktopOnboarding.tsx +++ b/web/src/components/auth/DesktopOnboarding.tsx @@ -162,8 +162,16 @@ export function markDesktopOnboardingComplete(): void { } } +export function hasDesktopSetupCapabilities(): boolean { + const bridge = getDesktopBridge(); + return ( + typeof bridge?.getSetupDiagnostics === 'function' && + typeof bridge?.validateConnectionConfig === 'function' + ); +} + export function shouldShowDesktopOnboarding(): boolean { - return !readOnboardingComplete(); + return hasDesktopSetupCapabilities() && !readOnboardingComplete(); } function stateLabel(state: HealthState): string { diff --git a/web/src/components/auth/SetupScreen.tsx b/web/src/components/auth/SetupScreen.tsx index 41e8c49c..2f975b05 100644 --- a/web/src/components/auth/SetupScreen.tsx +++ b/web/src/components/auth/SetupScreen.tsx @@ -2,7 +2,11 @@ import { useState } from 'react'; import { Button, Checkbox, PasswordInput } from '@mantine/core'; import { useAuth } from '@/hooks/useAuth'; import { Copy, Download, Check, Shield, Key } from 'lucide-react'; -import { DesktopOnboardingScreen, shouldShowDesktopOnboarding } from './DesktopOnboarding'; +import { + DesktopOnboardingScreen, + shouldShowDesktopOnboarding, + hasDesktopSetupCapabilities, +} from './DesktopOnboarding'; // Password strength calculation function getPasswordStrength(password: string): { score: number; label: string; color: string } { @@ -22,6 +26,7 @@ function getPasswordStrength(password: string): { score: number; label: string; export function SetupScreen() { const { setup, status } = useAuth(); + const isDesktopSetup = hasDesktopSetupCapabilities(); const [showOnboarding, setShowOnboarding] = useState(() => shouldShowDesktopOnboarding()); const [existingDataAcknowledged, setExistingDataAcknowledged] = useState(false); const [password, setPassword] = useState(''); @@ -42,7 +47,7 @@ export function SetupScreen() { const requiresExistingDataReview = status?.setupContext?.hasExistingData === true && !existingDataAcknowledged; - if (showOnboarding || requiresExistingDataReview) { + if (isDesktopSetup && (showOnboarding || requiresExistingDataReview)) { return ( { @@ -54,6 +59,24 @@ export function SetupScreen() { ); } + if (requiresExistingDataReview) { + return ( +
+
+

Secure Existing Server Data

+

+ This server already contains board data. Creating a password protects that existing + data; it does not import, replace, or migrate it. +

+

+ Continue only if you administer this server. Keep your recovery key in a safe place. +

+ +
+
+ ); + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!isValid || isSubmitting) return; @@ -167,7 +190,9 @@ export function SetupScreen() {

Secure Your Board

- Create a password to protect your Veritas Kanban board. + {isDesktopSetup + ? 'Create a password to protect your Veritas Kanban board.' + : 'Create a password to protect the Veritas Kanban server you are connected to. Your board data stays on that server.'}