Merge remote-tracking branch 'origin/main' into docs/web-to-mac-desktop-migration

This commit is contained in:
Brad Groux 2026-07-23 09:25:14 -05:00
commit 772b9c9b90
26 changed files with 660 additions and 65 deletions

View file

@ -70,6 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Made startup, setup, password, recovery, and login surfaces draggable in the
frameless desktop window, and made first-run setup detect populated desktop
SQLite data, default to a non-destructive **Use Existing Data** path, show
concise migrated-data counts, and reserve backup restore for recovery (#901).
- Serialized agent launch and terminalization ownership per task and attempt so
concurrent starts, stop requests, provider exits, and callbacks cannot launch
or finalize the same run twice (#887).

View file

@ -19,17 +19,21 @@ describe('desktop status pages CSP', () => {
it('allows the generated status page style block by hash instead of unsafe-inline', () => {
const html = statusPage('Starting', 'Preparing runtime');
const csp = html.match(/content="([^"]+)"/)?.[1] || '';
const style = inlineStyle(html);
expect(csp).toContain(`style-src ${styleHash(inlineStyle(html))}`);
expect(csp).toContain(`style-src ${styleHash(style)}`);
expect(csp).not.toContain("'unsafe-inline'");
expect(style).toContain('-webkit-app-region: drag');
});
it('allows the static renderer startup style block by hash instead of unsafe-inline', () => {
const testDir = dirname(fileURLToPath(import.meta.url));
const html = readFileSync(resolve(testDir, '../../renderer/index.html'), 'utf-8');
const csp = html.match(/content="([^"]+)"/)?.[1] || '';
const style = inlineStyle(html);
expect(csp).toContain(`style-src ${styleHash(inlineStyle(html))}`);
expect(csp).toContain(`style-src ${styleHash(style)}`);
expect(csp).not.toContain("'unsafe-inline'");
expect(style).toContain('-webkit-app-region: drag');
});
});

View file

@ -13,6 +13,7 @@ const STATUS_PAGE_CSS = `
min-height: 100vh;
display: grid;
place-items: center;
-webkit-app-region: drag;
}
main {
width: min(720px, calc(100vw - 48px));
@ -37,6 +38,7 @@ const STATUS_PAGE_CSS = `
padding: 16px;
color: #d8deea;
font-size: 12px;
-webkit-app-region: no-drag;
}
`;

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'sha256-73uqRU+Tsu2ddgLhX3AMG2sgAbLckUuCsmHkbBY3NnM='; img-src data:; script-src 'none';"
content="default-src 'none'; style-src 'sha256-xvSit7g7hrRoyQ9jZajpAVwT+zh5N9+6jMWpB9vorxQ='; img-src data:; script-src 'none';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Veritas Kanban</title>
@ -25,6 +25,7 @@
min-height: 100vh;
display: grid;
place-items: center;
-webkit-app-region: drag;
}
main {
width: min(680px, calc(100vw - 48px));

29
pnpm-lock.yaml generated
View file

@ -9,10 +9,11 @@ overrides:
'@modelcontextprotocol/sdk>express-rate-limit': 8.5.2
'@xmldom/xmldom': 0.8.13
esbuild@<0.28.1: 0.28.1
fast-uri: '>=3.1.2'
fast-uri@<3.1.4: 3.1.4
form-data@<4.0.6: 4.0.6
hono: '>=4.12.18'
ip-address: '>=10.1.1'
js-yaml@<4.3.0: 4.3.0
postcss: '>=8.5.10'
qs: ^6.14.2
uuid@^8.3.0: 11.1.1
@ -2730,8 +2731,8 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
@ -3284,8 +3285,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.2.0:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
jsdom@29.1.1:
@ -5280,7 +5281,7 @@ snapshots:
'@apidevtools/json-schema-ref-parser@14.0.1':
dependencies:
'@types/json-schema': 7.0.15
js-yaml: 4.2.0
js-yaml: 4.3.0
'@apidevtools/openapi-schemas@2.1.0': {}
@ -6589,14 +6590,14 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.2
fast-uri: 3.1.4
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.2
fast-uri: 3.1.4
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@ -6647,7 +6648,7 @@ snapshots:
hosted-git-info: 4.1.0
isbinaryfile: 5.0.7
jiti: 2.7.0
js-yaml: 4.2.0
js-yaml: 4.3.0
json5: 2.2.3
lazy-val: 1.0.5
minimatch: 10.2.5
@ -6909,7 +6910,7 @@ snapshots:
fs-extra: 10.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
js-yaml: 4.2.0
js-yaml: 4.3.0
sanitize-filename: 1.6.4
source-map-support: 0.5.21
stat-mode: 1.0.0
@ -7287,7 +7288,7 @@ snapshots:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
builder-util: 26.15.3
fs-extra: 10.1.0
js-yaml: 4.2.0
js-yaml: 4.3.0
transitivePeerDependencies:
- electron-builder-squirrel-windows
- supports-color
@ -7426,7 +7427,7 @@ snapshots:
dependencies:
builder-util-runtime: 9.7.0
fs-extra: 10.1.0
js-yaml: 4.2.0
js-yaml: 4.3.0
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
@ -7829,7 +7830,7 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-uri@3.1.2: {}
fast-uri@3.1.4: {}
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
@ -8427,7 +8428,7 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.2.0:
js-yaml@4.3.0:
dependencies:
argparse: 2.0.1

View file

@ -18,10 +18,11 @@ overrides:
'@modelcontextprotocol/sdk>express-rate-limit': 8.5.2
'@xmldom/xmldom': 0.8.13
'esbuild@<0.28.1': 0.28.1
fast-uri: '>=3.1.2'
'fast-uri@<3.1.4': 3.1.4
'form-data@<4.0.6': 4.0.6
hono: '>=4.12.18'
ip-address: '>=10.1.1'
'js-yaml@<4.3.0': 4.3.0
postcss: '>=8.5.10'
qs: ^6.14.2
'uuid@^8.3.0': 11.1.1

View file

@ -0,0 +1,142 @@
import { afterEach, describe, expect, it } from 'vitest';
import { getDesktopSetupContext } from '../services/desktop-setup-context-service.js';
import {
getStorage,
initStorage,
shutdownStorage,
SqliteStorageProvider,
} from '../storage/index.js';
import { createTestSqliteDatabase } from '../storage/sqlite/test-helpers.js';
import { SqliteSetupContextRepository } from '../storage/sqlite/setup-context-repository.js';
describe('desktop setup context', () => {
const fixtures: ReturnType<typeof createTestSqliteDatabase>[] = [];
afterEach(async () => {
await shutdownStorage();
fixtures.splice(0).forEach((fixture) => fixture.cleanup());
delete process.env.VERITAS_DESKTOP_RUNTIME;
delete process.env.VERITAS_STORAGE;
});
function createFixture() {
const fixture = createTestSqliteDatabase();
fixture.database.open();
fixtures.push(fixture);
return fixture;
}
it('classifies a newly initialized SQLite database as empty', () => {
const fixture = createFixture();
expect(new SqliteSetupContextRepository(fixture.database).getSetupContext()).toEqual({
storageMode: 'sqlite',
hasExistingData: false,
counts: {
tasks: 0,
squadMessages: 0,
telemetryEvents: 0,
workflowDefinitions: 0,
workflowRuns: 0,
},
});
});
it('reports concise counts for a populated desktop database', () => {
const fixture = createFixture();
const database = fixture.database.getConnection();
const now = '2026-07-23T13:18:26.000Z';
database
.prepare(
`INSERT INTO tasks (
id, workspace_id, storage_state, title, description, type, status, priority,
task_json, created_at, updated_at
) VALUES (?, 'local', 'active', ?, '', 'task', 'todo', 'medium', ?, ?, ?)`
)
.run(
'task_existing',
'Existing task',
JSON.stringify({ id: 'task_existing', title: 'Existing task' }),
now,
now
);
database
.prepare(
`INSERT INTO squad_messages (
id, workspace_id, agent, message, timestamp, message_json
) VALUES (?, 'local', ?, ?, ?, ?)`
)
.run(
'message_existing',
'VERITAS',
'Existing message',
now,
JSON.stringify({ id: 'message_existing' })
);
database
.prepare(
`INSERT INTO telemetry_events (
id, workspace_id, type, payload_json, created_at
) VALUES (?, 'local', ?, ?, ?)`
)
.run('event_existing', 'task.created', '{}', now);
database
.prepare(
`INSERT INTO workflow_definitions (
id, workspace_id, name, version, workflow_json, created_at, updated_at
) VALUES (?, 'local', ?, 1, ?, ?, ?)`
)
.run('workflow_existing', 'Existing workflow', '{}', now, now);
database
.prepare(
`INSERT INTO workflow_runs (
id, workspace_id, workflow_id, workflow_version, status, run_json, started_at
) VALUES (?, 'local', ?, 1, 'completed', ?, ?)`
)
.run('run_existing', 'workflow_existing', '{}', now);
expect(new SqliteSetupContextRepository(fixture.database).getSetupContext()).toEqual({
storageMode: 'sqlite',
hasExistingData: true,
counts: {
tasks: 1,
squadMessages: 1,
telemetryEvents: 1,
workflowDefinitions: 1,
workflowRuns: 1,
},
});
});
it('exposes setup context only from the active desktop SQLite runtime', async () => {
const fixture = createTestSqliteDatabase();
fixtures.push(fixture);
process.env.VERITAS_STORAGE = 'sqlite';
await initStorage('sqlite', {
database: { databasePath: fixture.databasePath },
});
const storage = getStorage();
expect(storage).toBeInstanceOf(SqliteStorageProvider);
await storage.tasks.create({
id: 'task_runtime',
title: 'Existing runtime task',
description: '',
type: 'task',
status: 'todo',
priority: 'medium',
created: '2026-07-23T13:18:26.000Z',
updated: '2026-07-23T13:18:26.000Z',
});
expect(getDesktopSetupContext()).toBeUndefined();
process.env.VERITAS_DESKTOP_RUNTIME = '1';
expect(getDesktopSetupContext()).toMatchObject({
storageMode: 'sqlite',
hasExistingData: true,
counts: { tasks: 1 },
});
});
});

View file

@ -17,6 +17,7 @@ import crypto from 'crypto';
let testConfigDir: string;
let testSecurityFile: string;
let securityConfig: any = {};
let desktopSetupContext: Record<string, unknown> | undefined;
// Mock security module
vi.mock('../../config/security.js', () => {
@ -51,6 +52,10 @@ vi.mock('../../config/security.js', () => {
};
});
vi.mock('../../services/desktop-setup-context-service.js', () => ({
getDesktopSetupContext: () => desktopSetupContext,
}));
// Import auth route after mocking
import authRouter from '../../routes/auth.js';
import { errorHandler } from '../../middleware/error-handler.js';
@ -62,6 +67,7 @@ import {
import { createTestSqliteDatabase } from '../../storage/sqlite/test-helpers.js';
import { SqliteDeviceSessionRepository } from '../../storage/sqlite/device-session-repository.js';
import { SqliteIdentityRepository } from '../../storage/sqlite/identity-repository.js';
import { resetIdentityServiceForTests } from '../../services/identity-service.js';
describe('Auth Routes', () => {
let app: express.Express;
@ -74,6 +80,7 @@ describe('Auth Routes', () => {
jwtSecret: 'test-secret-key-for-jwt-signing-12345678',
sessionVersion: 0,
};
desktopSetupContext = undefined;
app = express();
app.use(express.json());
@ -84,8 +91,10 @@ describe('Auth Routes', () => {
afterEach(() => {
resetDeviceSessionServiceForTests();
resetIdentityServiceForTests();
delete process.env.VERITAS_JWT_SECRET;
delete process.env.VERITAS_SQLITE_PATH;
delete process.env.VERITAS_STORAGE;
});
describe('GET /api/auth/status', () => {
@ -96,6 +105,25 @@ describe('Auth Routes', () => {
expect(res.body.authenticated).toBe(false);
});
it('includes populated desktop data context while setup is needed', async () => {
desktopSetupContext = {
storageMode: 'sqlite',
hasExistingData: true,
counts: {
tasks: 2236,
squadMessages: 74196,
telemetryEvents: 98,
workflowDefinitions: 2,
workflowRuns: 3,
},
};
const res = await request(app).get('/api/auth/status');
expect(res.status).toBe(200);
expect(res.body.setupContext).toEqual(desktopSetupContext);
});
it('should indicate setup complete when password exists', async () => {
securityConfig.passwordHash = await bcrypt.hash('test-password', 4);
securityConfig.authEnabled = true;
@ -103,6 +131,7 @@ describe('Auth Routes', () => {
const res = await request(app).get('/api/auth/status');
expect(res.status).toBe(200);
expect(res.body.needsSetup).toBe(false);
expect(res.body.setupContext).toBeUndefined();
});
it('should detect valid JWT cookie', async () => {
@ -229,6 +258,33 @@ describe('Auth Routes', () => {
expect(res.body.message).toContain('Password set');
});
it('preserves migrated local identity metadata during password setup', async () => {
const fixture = createTestSqliteDatabase();
fixture.database.open();
const identities = new SqliteIdentityRepository(fixture.database);
identities.ensureLocalOwner({
displayName: 'Migrated Owner',
email: 'owner@example.test',
});
process.env.VERITAS_STORAGE = 'sqlite';
process.env.VERITAS_SQLITE_PATH = fixture.databasePath;
try {
const res = await request(app)
.post('/api/auth/setup')
.send({ password: 'strongpassword123' });
expect(res.status).toBe(200);
expect(identities.getUser('local-user')).toMatchObject({
displayName: 'Migrated Owner',
email: 'owner@example.test',
});
} finally {
resetIdentityServiceForTests();
fixture.cleanup();
}
});
it('should reject setup when password already exists', async () => {
securityConfig.passwordHash = 'existing-hash';

View file

@ -32,6 +32,7 @@ import {
getDeviceSessionService,
type ExchangeDevicePairingInput,
} from '../services/device-session-service.js';
import { getDesktopSetupContext } from '../services/desktop-setup-context-service.js';
const router: IRouter = Router();
@ -175,6 +176,20 @@ function clearAttempts(ip: string): void {
* authenticated: { type: boolean, description: 'True if current session is valid' }
* sessionExpiry: { type: string, format: date-time, nullable: true }
* authEnabled: { type: boolean }
* setupContext:
* type: object
* description: Present only during password setup in the local desktop runtime.
* properties:
* storageMode: { type: string, enum: [sqlite] }
* hasExistingData: { type: boolean }
* counts:
* type: object
* properties:
* tasks: { type: integer }
* squadMessages: { type: integer }
* telemetryEvents: { type: integer }
* workflowDefinitions: { type: integer }
* workflowRuns: { type: integer }
*/
router.get(
'/status',
@ -203,6 +218,7 @@ router.get(
authenticated,
sessionExpiry,
authEnabled: config.authEnabled !== false,
...(needsSetup ? { setupContext: getDesktopSetupContext() } : {}),
});
})
);
@ -394,9 +410,7 @@ router.post(
let identity: unknown;
if (process.env.VERITAS_STORAGE === 'sqlite') {
identity = getIdentityService().ensureOwnerSetup({
displayName: 'Local User',
});
identity = getIdentityService().ensureOwnerSetup();
}
// Return recovery key (only time it's shown in plaintext)

View file

@ -0,0 +1,19 @@
import type { DesktopSetupContext } from '@veritas-kanban/shared';
import { getStorage, getStorageTypeFromEnv } from '../storage/index.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('desktop-setup-context');
export function getDesktopSetupContext(): DesktopSetupContext | undefined {
if (process.env.VERITAS_DESKTOP_RUNTIME !== '1' || getStorageTypeFromEnv() !== 'sqlite') {
return undefined;
}
try {
const storage = getStorage();
return storage.setupContext?.getSetupContext();
} catch (error) {
log.warn({ err: error }, 'Could not inspect the desktop database during setup');
return undefined;
}
}

View file

@ -23,6 +23,7 @@ export type {
ManagedListRepository,
ManagedListProvider,
TelemetryRepository,
SetupContextRepository,
StorageProvider,
} from './interfaces.js';
export {
@ -62,6 +63,7 @@ export { SqlitePromptRegistryRepository } from './sqlite/prompt-registry-reposit
export { SqliteActivityRepository } from './sqlite/activity-repository.js';
export { SqliteStatusHistoryRepository } from './sqlite/status-history-repository.js';
export { SqliteTelemetryRepository } from './sqlite/telemetry-repository.js';
export { SqliteSetupContextRepository } from './sqlite/setup-context-repository.js';
export {
SqliteDecisionRepository,
SqliteDriftRepository,

View file

@ -27,6 +27,7 @@ import type {
UpdatePromptTemplateInput,
RenderPreviewRequest,
RenderPreviewResponse,
DesktopSetupContext,
} from '@veritas-kanban/shared';
import type { Activity, ActivityType } from '../services/activity-service.js';
import type {
@ -279,6 +280,15 @@ export interface TelemetryRepository {
isEnabled(): boolean;
}
// ---------------------------------------------------------------------------
// Setup Context Repository
// ---------------------------------------------------------------------------
export interface SetupContextRepository {
/** Return a read-only summary of data already present in the active store. */
getSetupContext(): DesktopSetupContext;
}
// ---------------------------------------------------------------------------
// Storage Provider (top-level aggregate)
// ---------------------------------------------------------------------------
@ -292,6 +302,7 @@ export interface StorageProvider {
readonly statusHistory: StatusHistoryRepository;
readonly managedLists: ManagedListProvider;
readonly telemetry: TelemetryRepository;
readonly setupContext?: SetupContextRepository;
/** One-time startup hook (create dirs, open connections, etc.). */
initialize(): Promise<void>;

View file

@ -0,0 +1,40 @@
import type { DesktopSetupContext, DesktopSetupDataCounts } from '@veritas-kanban/shared';
import type { SetupContextRepository } from '../interfaces.js';
import type { SqliteDatabase } from './database.js';
const SETUP_COUNT_QUERIES = {
tasks: 'SELECT COUNT(*) AS count FROM tasks WHERE deleted_at IS NULL',
squadMessages: 'SELECT COUNT(*) AS count FROM squad_messages',
telemetryEvents: 'SELECT COUNT(*) AS count FROM telemetry_events',
workflowDefinitions: 'SELECT COUNT(*) AS count FROM workflow_definitions',
workflowRuns: 'SELECT COUNT(*) AS count FROM workflow_runs',
} as const satisfies Record<keyof DesktopSetupDataCounts, string>;
interface CountRow {
count: number;
}
export class SqliteSetupContextRepository implements SetupContextRepository {
constructor(private readonly database: SqliteDatabase) {}
getSetupContext(): DesktopSetupContext {
const connection = this.database.getConnection();
const countRows = (query: string): number => {
const row = connection.prepare(query).get() as CountRow | undefined;
return Number(row?.count ?? 0);
};
const counts: DesktopSetupDataCounts = {
tasks: countRows(SETUP_COUNT_QUERIES.tasks),
squadMessages: countRows(SETUP_COUNT_QUERIES.squadMessages),
telemetryEvents: countRows(SETUP_COUNT_QUERIES.telemetryEvents),
workflowDefinitions: countRows(SETUP_COUNT_QUERIES.workflowDefinitions),
workflowRuns: countRows(SETUP_COUNT_QUERIES.workflowRuns),
};
return {
storageMode: 'sqlite',
hasExistingData: Object.values(counts).some((count) => count > 0),
counts,
};
}
}

View file

@ -10,6 +10,7 @@ import { SqliteActivityRepository } from './activity-repository.js';
import { SqliteStatusHistoryRepository } from './status-history-repository.js';
import { SqliteTelemetryRepository } from './telemetry-repository.js';
import { SqliteOperationalProvenanceRepository } from './provenance-repository.js';
import { SqliteSetupContextRepository } from './setup-context-repository.js';
import { createDefaultConfig, normalizeAppConfig } from '../../services/config-service.js';
export interface SqliteStorageOptions {
@ -27,6 +28,7 @@ export class SqliteStorageProvider implements StorageProvider {
readonly managedLists: SqliteManagedListProvider;
readonly telemetry: SqliteTelemetryRepository;
readonly provenance: SqliteOperationalProvenanceRepository;
readonly setupContext: SqliteSetupContextRepository;
private readonly sqlite: SqliteDatabase;
@ -45,6 +47,7 @@ export class SqliteStorageProvider implements StorageProvider {
this.managedLists = new SqliteManagedListProvider(this.sqlite);
this.telemetry = new SqliteTelemetryRepository(this.sqlite);
this.provenance = new SqliteOperationalProvenanceRepository(this.sqlite);
this.setupContext = new SqliteSetupContextRepository(this.sqlite);
}
getDatabase(): SqliteDatabase {

View file

@ -0,0 +1,21 @@
export interface DesktopSetupDataCounts {
tasks: number;
squadMessages: number;
telemetryEvents: number;
workflowDefinitions: number;
workflowRuns: number;
}
export interface DesktopSetupContext {
storageMode: 'sqlite';
hasExistingData: boolean;
counts: DesktopSetupDataCounts;
}
export interface AuthStatus {
needsSetup: boolean;
authenticated: boolean;
sessionExpiry: string | null;
authEnabled: boolean;
setupContext?: DesktopSetupContext;
}

View file

@ -48,3 +48,4 @@ export * from './watcher-policy.types.js';
export * from './evidence.types.js';
export * from './time-breakdown.types.js';
export * from './task-envelope.types.js';
export * from './auth.types.js';

View file

@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen } from '@testing-library/react';
import { AuthGuard } from '@/components/auth/AuthGuard';
import { renderWithProviders } from './test-utils';
const mocks = vi.hoisted(() => ({
auth: {
status: null,
isLoading: true,
error: null,
refreshStatus: vi.fn(),
} as {
status: null;
isLoading: boolean;
error: string | null;
refreshStatus: ReturnType<typeof vi.fn>;
},
}));
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => mocks.auth,
}));
describe('AuthGuard desktop surfaces', () => {
afterEach(() => {
cleanup();
mocks.auth.status = null;
mocks.auth.isLoading = true;
mocks.auth.error = null;
mocks.auth.refreshStatus.mockReset();
});
it('keeps the loading surface draggable', () => {
renderWithProviders(
<AuthGuard>
<div>Board</div>
</AuthGuard>
);
expect(screen.getByText('Loading...').closest('.desktop-window-drag')).not.toBeNull();
});
it('keeps the connection-error surface draggable and its retry action clickable', () => {
mocks.auth.isLoading = false;
mocks.auth.error = 'Local server is unavailable.';
renderWithProviders(
<AuthGuard>
<div>Board</div>
</AuthGuard>
);
expect(screen.getByText('Connection Error').closest('.desktop-window-drag')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(mocks.auth.refreshStatus).toHaveBeenCalledOnce();
});
});

View file

@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { LoginScreen } from '@/components/auth/LoginScreen';
import { DESKTOP_ONBOARDING_STORAGE_KEY } from '@/components/auth/DesktopOnboarding';
@ -55,10 +57,19 @@ describe('auth screens Mantine migration', () => {
vi.restoreAllMocks();
});
it('keeps links and labels clickable inside desktop drag surfaces', () => {
const globalStyles = readFileSync(resolve(process.cwd(), 'src/globals.css'), 'utf8');
expect(globalStyles).toContain("html[data-client='desktop'] a,");
expect(globalStyles).toContain("html[data-client='desktop'] label,");
expect(globalStyles).toContain("html[data-client='desktop'] [contenteditable='true'],");
});
it('renders setup password and recovery controls through direct Mantine primitives', async () => {
const { container } = renderWithProviders(<SetupScreen />);
expect(screen.getByText('Secure Your Board')).toBeDefined();
expect(screen.getByText('Secure Your Board').closest('.desktop-window-drag')).not.toBeNull();
expect(screen.getByLabelText('Password')).toBeDefined();
expect(screen.getByLabelText('Confirm Password')).toBeDefined();
expect(container.querySelectorAll('.mantine-PasswordInput-root')).toHaveLength(2);
@ -76,6 +87,9 @@ describe('auth screens Mantine migration', () => {
await waitFor(() => expect(mocks.setup).toHaveBeenCalledWith('StrongPass1!'));
expect(await screen.findByText('Save Your Recovery Key')).toBeDefined();
expect(
screen.getByText('Save Your Recovery Key').closest('.desktop-window-drag')
).not.toBeNull();
expect(screen.getByText('AAAA-BBBB-CCCC-DDDD')).toBeDefined();
expect(container.querySelectorAll('.mantine-Checkbox-root')).toHaveLength(1);
expect(container.querySelectorAll('.mantine-Button-root')).toHaveLength(3);
@ -87,6 +101,7 @@ describe('auth screens Mantine migration', () => {
const { container } = renderWithProviders(<LoginScreen />);
expect(screen.getByText('Welcome Back')).toBeDefined();
expect(screen.getByText('Welcome Back').closest('.desktop-window-drag')).not.toBeNull();
expect(screen.getByLabelText('Password')).toBeDefined();
expect(screen.getByLabelText('Remember me for 30 days')).toBeDefined();
expect(container.querySelectorAll('.mantine-PasswordInput-root')).toHaveLength(1);
@ -117,6 +132,9 @@ describe('auth screens Mantine migration', () => {
fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' }));
expect(screen.getByRole('heading', { name: 'Reset Password' })).toBeDefined();
expect(
screen.getByRole('heading', { name: 'Reset Password' }).closest('.desktop-window-drag')
).not.toBeNull();
expect(screen.getByLabelText('Recovery Key')).toBeDefined();
expect(screen.getByLabelText('New Password')).toBeDefined();
expect(screen.getByLabelText('Confirm New Password')).toBeDefined();

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen } from '@testing-library/react';
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from './test-utils';
import {
@ -37,7 +37,17 @@ describe('desktop onboarding', () => {
it('shows the board-only first-run path before password setup', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ needsSetup: true })))
vi.fn(
async () =>
new Response(
JSON.stringify({
needsSetup: true,
authenticated: false,
sessionExpiry: null,
authEnabled: true,
})
)
)
);
renderWithProviders(
@ -47,14 +57,96 @@ describe('desktop onboarding', () => {
);
expect(await screen.findByText('Choose setup path')).toBeDefined();
expect(screen.getByTestId('setup-mode-board').getAttribute('aria-pressed')).toBe('true');
fireEvent.click(screen.getByTestId('setup-mode-board'));
fireEvent.click(screen.getByRole('button', { name: 'Continue to Password' }));
expect(screen.getByText('Secure Your Board')).toBeDefined();
expect(screen.getByText('Secure Your Board').closest('.desktop-window-drag')).not.toBeNull();
expect(window.localStorage.getItem(DESKTOP_ONBOARDING_STORAGE_KEY)).toBe('true');
expect(window.localStorage.getItem(PRODUCT_MODE_PENDING_STORAGE_KEY)).toBe('board-only');
});
it('keeps desktop setup draggable while its controls remain interactive', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
needsSetup: true,
authenticated: false,
sessionExpiry: null,
authEnabled: true,
})
)
)
);
renderWithProviders(
<AuthProvider>
<SetupScreen />
</AuthProvider>
);
const heading = await screen.findByText('Choose setup path');
const setupSurface = heading.closest('.desktop-window-drag');
const continueButton = screen.getByRole('button', { name: 'Continue to Password' });
expect(setupSurface).not.toBeNull();
expect(continueButton.classList.contains('desktop-no-drag')).toBe(true);
});
it('defaults a populated desktop database to using existing data without changing its mode', async () => {
window.localStorage.setItem(DESKTOP_ONBOARDING_STORAGE_KEY, 'true');
window.localStorage.setItem(PRODUCT_MODE_PENDING_STORAGE_KEY, 'agent-ready');
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
needsSetup: true,
authenticated: false,
sessionExpiry: null,
authEnabled: true,
setupContext: {
storageMode: 'sqlite',
hasExistingData: true,
counts: {
tasks: 2236,
squadMessages: 74196,
telemetryEvents: 98,
workflowDefinitions: 2,
workflowRuns: 3,
},
},
})
)
)
);
renderWithProviders(
<AuthProvider>
<SetupScreen />
</AuthProvider>
);
expect(await screen.findByText('Use Existing Data')).toBeDefined();
await waitFor(() => {
expect(screen.getByTestId('setup-mode-existing').getAttribute('aria-pressed')).toBe('true');
});
expect(screen.queryByTestId('setup-mode-board')).toBeNull();
expect(screen.getByText('2,236 tasks')).toBeDefined();
expect(screen.getByText('74,196 squad messages')).toBeDefined();
expect(screen.getByText(/does not import, overwrite, or migrate it again/i)).toBeDefined();
fireEvent.click(screen.getByRole('button', { name: 'Secure Existing Data' }));
expect(screen.getByText('Secure Your Board')).toBeDefined();
expect(window.localStorage.getItem(DESKTOP_ONBOARDING_STORAGE_KEY)).toBe('true');
expect(window.localStorage.getItem(PRODUCT_MODE_PENDING_STORAGE_KEY)).toBeNull();
});
it('validates a remote URL through the desktop bridge', async () => {
const validateConnectionConfig = vi.fn(async () => ({
mode: 'remote' as const,

View file

@ -21,7 +21,7 @@ interface AuthGuardProps {
function AuthSurfaceFallback() {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="desktop-window-drag min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin mx-auto text-primary" />
<p className="text-muted-foreground">Loading...</p>
@ -68,7 +68,7 @@ export function AuthGuard({ children }: AuthGuardProps) {
// Error state
if (error && !status) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="desktop-window-drag flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-7 text-center shadow-lg">
<div className="mx-auto grid h-14 w-14 place-items-center rounded-2xl border border-destructive/30 bg-destructive/10 text-destructive">
<Scale className="h-7 w-7" aria-hidden="true" />

View file

@ -5,6 +5,7 @@ import {
Bot,
CheckCircle2,
Clipboard,
Database,
DatabaseBackup,
KanbanSquare,
Loader2,
@ -13,15 +14,22 @@ import {
ShieldCheck,
Upload,
} from 'lucide-react';
import { blockedRemoteConnectionDestinationReason } from '@veritas-kanban/shared';
import {
blockedRemoteConnectionDestinationReason,
type DesktopSetupContext,
} from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
import { persistPendingProductMode, productModeForSetupMode } from '@/lib/product-modes';
import {
clearPendingProductMode,
persistPendingProductMode,
productModeForSetupMode,
} from '@/lib/product-modes';
export const DESKTOP_ONBOARDING_STORAGE_KEY = 'veritas-desktop-onboarding-complete';
type HealthState = 'ok' | 'warning' | 'failed' | 'unknown' | 'unsupported';
type SetupMode = 'board' | 'agent' | 'remote' | 'restore';
type SetupMode = 'existing' | 'board' | 'agent' | 'remote' | 'restore';
interface DesktopDiagnosticCheck {
name: string;
@ -74,15 +82,26 @@ interface DesktopBridgeApi {
interface DesktopOnboardingPanelProps {
onContinue?: () => void;
compact?: boolean;
setupContext?: DesktopSetupContext | null;
}
const setupModes: Array<{
type SetupModeDefinition = {
id: SetupMode;
title: string;
badge: string;
description: string;
icon: typeof KanbanSquare;
}> = [
};
const existingDataMode: SetupModeDefinition = {
id: 'existing',
title: 'Use Existing Data',
badge: 'Detected',
description: 'Secure the populated desktop database already in use. No migration runs again.',
icon: Database,
};
const freshSetupModes: SetupModeDefinition[] = [
{
id: 'board',
title: 'Board Only',
@ -106,9 +125,9 @@ const setupModes: Array<{
},
{
id: 'restore',
title: 'Restore or Migrate',
title: 'Restore Backup',
badge: 'Recovery',
description: 'Check desktop paths and select a backup bundle before continuing setup.',
description: 'Roll back or recover from a backup bundle. This is not the normal upgrade path.',
icon: DatabaseBackup,
},
];
@ -243,11 +262,37 @@ function remoteValidationTitle(result: DesktopConnectionValidationResult): strin
return 'Remote target is reachable.';
}
function existingDataCountLabels(context: DesktopSetupContext): string[] {
const formatter = new Intl.NumberFormat();
const definitions: Array<[keyof DesktopSetupContext['counts'], string, string]> = [
['tasks', 'task', 'tasks'],
['squadMessages', 'squad message', 'squad messages'],
['telemetryEvents', 'telemetry event', 'telemetry events'],
['workflowDefinitions', 'workflow definition', 'workflow definitions'],
['workflowRuns', 'workflow run', 'workflow runs'],
];
return definitions.flatMap(([key, singular, plural]) => {
const count = context.counts[key];
return count > 0 ? [`${formatter.format(count)} ${count === 1 ? singular : plural}`] : [];
});
}
export function DesktopOnboardingPanel({
onContinue,
compact = false,
setupContext = null,
}: DesktopOnboardingPanelProps) {
const [selectedMode, setSelectedMode] = useState<SetupMode>('board');
const hasExistingData = setupContext?.hasExistingData === true;
const recommendedMode: SetupMode = hasExistingData ? 'existing' : 'board';
const setupModes = useMemo(
() =>
hasExistingData
? [existingDataMode, ...freshSetupModes.filter((mode) => mode.id !== 'board')]
: freshSetupModes,
[hasExistingData]
);
const [selectedMode, setSelectedMode] = useState<SetupMode>(recommendedMode);
const [diagnostics, setDiagnostics] = useState<DesktopSetupDiagnostics | null>(null);
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
const [copiedDiagnostics, setCopiedDiagnostics] = useState(false);
@ -274,6 +319,12 @@ export function DesktopOnboardingPanel({
void loadDiagnostics();
}, [loadDiagnostics]);
useEffect(() => {
if (hasExistingData) {
setSelectedMode('existing');
}
}, [hasExistingData]);
const validateRemote = async () => {
setRemoteLoading(true);
setRemoteResult(null);
@ -336,14 +387,19 @@ export function DesktopOnboardingPanel({
Choose setup path
</h1>
<p className="max-w-xl break-words text-sm leading-6 text-muted-foreground">
Start with the board, then layer in agents, remote access, and recovery paths when
they are needed.
{hasExistingData
? 'Your desktop database is already populated. Secure it now, then continue with your existing board and history.'
: 'Start with the board, then layer in agents, remote access, and recovery paths when they are needed.'}
</p>
</div>
</div>
<div className="grid gap-2">
{['Select path', 'Check readiness', 'Secure board'].map((step, index) => (
{[
'Select path',
'Check readiness',
hasExistingData ? 'Secure existing data' : 'Secure board',
].map((step, index) => (
<div key={step} className="flex items-center gap-3 rounded-lg border bg-card/60 p-3">
<span className="flex size-6 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{index + 1}
@ -418,6 +474,7 @@ export function DesktopOnboardingPanel({
key={mode.id}
type="button"
data-testid={`setup-mode-${mode.id}`}
aria-pressed={selected}
onClick={() => setSelectedMode(mode.id)}
className={cn(
'min-h-36 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none',
@ -429,8 +486,8 @@ export function DesktopOnboardingPanel({
<Icon className="h-4 w-4" />
</span>
<Badge
variant={mode.id === 'board' ? 'filled' : 'outline'}
color={mode.id === 'board' ? 'violet' : 'gray'}
variant={mode.id === recommendedMode ? 'filled' : 'outline'}
color={mode.id === recommendedMode ? 'violet' : 'gray'}
tt="none"
>
{mode.badge}
@ -446,6 +503,29 @@ export function DesktopOnboardingPanel({
</div>
<div className="rounded-lg border bg-card p-4">
{selectedMode === 'existing' && setupContext && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="h-4 w-4 text-emerald-400" />
Existing desktop data detected
</div>
<p className="text-sm text-muted-foreground">
The app is already using this SQLite database. Continuing only secures the existing
data; it does not import, overwrite, or migrate it again.
</p>
<div className="flex flex-wrap gap-2" aria-label="Existing desktop data summary">
{existingDataCountLabels(setupContext).map((label) => (
<span
key={label}
className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-200"
>
{label}
</span>
))}
</div>
</div>
)}
{selectedMode === 'board' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold">
@ -453,8 +533,9 @@ export function DesktopOnboardingPanel({
Local board setup
</div>
<p className="text-sm text-muted-foreground">
Veritas will create a local SQLite-backed workspace, store desktop secrets through
the native secret store when available, and keep optional integrations disabled.
Veritas will create a new local SQLite-backed workspace, store desktop secrets
through the native secret store when available, and keep optional integrations
disabled.
</p>
</div>
)}
@ -557,8 +638,9 @@ export function DesktopOnboardingPanel({
Restore preflight
</div>
<p className="text-sm text-muted-foreground">
Desktop startup already copies legacy profile data forward without deleting it.
Select a backup bundle now, then complete restore from Data settings after setup.
Choose this only to roll back or recover from a backup. A normal web-to-desktop
cutover should use the populated desktop database directly instead of importing it
again.
</p>
<Button type="button" variant="outline" onClick={pickRestoreFile}>
<Upload className="mr-1.5 h-4 w-4" />
@ -581,15 +663,20 @@ export function DesktopOnboardingPanel({
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
className="sm:min-w-44"
className="desktop-no-drag sm:min-w-44"
disabled={!canContinue}
onClick={() => {
persistPendingProductMode(productModeForSetupMode(selectedMode));
const productMode = productModeForSetupMode(selectedMode);
if (productMode) {
persistPendingProductMode(productMode);
} else {
clearPendingProductMode();
}
markDesktopOnboardingComplete();
onContinue();
}}
>
Continue to Password
{selectedMode === 'existing' ? 'Secure Existing Data' : 'Continue to Password'}
</Button>
</div>
)}
@ -598,11 +685,17 @@ export function DesktopOnboardingPanel({
);
}
export function DesktopOnboardingScreen({ onContinue }: { onContinue: () => void }) {
export function DesktopOnboardingScreen({
onContinue,
setupContext,
}: {
onContinue: () => void;
setupContext?: DesktopSetupContext | null;
}) {
return (
<div className="min-h-screen bg-background px-4 py-8">
<div className="desktop-window-drag min-h-screen bg-background px-4 py-8">
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center">
<DesktopOnboardingPanel onContinue={onContinue} />
<DesktopOnboardingPanel onContinue={onContinue} setupContext={setupContext} />
</div>
</div>
);

View file

@ -84,7 +84,7 @@ export function LoginScreen() {
// Show new recovery key after successful password reset
if (newRecoveryKey) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="desktop-window-drag min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-500 mb-4">
@ -150,7 +150,7 @@ export function LoginScreen() {
const isValid = recoveryKey && newPassword.length >= 8 && passwordsMatch;
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="desktop-window-drag min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-sm space-y-8">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500/10 text-amber-500 mb-4">
@ -238,7 +238,7 @@ export function LoginScreen() {
// Login form
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="desktop-window-drag min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-sm space-y-8">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">

View file

@ -21,8 +21,9 @@ function getPasswordStrength(password: string): { score: number; label: string;
}
export function SetupScreen() {
const { setup } = useAuth();
const { setup, status } = useAuth();
const [showOnboarding, setShowOnboarding] = useState(() => shouldShowDesktopOnboarding());
const [existingDataAcknowledged, setExistingDataAcknowledged] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
@ -38,8 +39,19 @@ export function SetupScreen() {
const passwordsMatch = password === confirmPassword;
const isValid = password.length >= 8 && passwordsMatch;
if (showOnboarding) {
return <DesktopOnboardingScreen onContinue={() => setShowOnboarding(false)} />;
const requiresExistingDataReview =
status?.setupContext?.hasExistingData === true && !existingDataAcknowledged;
if (showOnboarding || requiresExistingDataReview) {
return (
<DesktopOnboardingScreen
onContinue={() => {
setShowOnboarding(false);
setExistingDataAcknowledged(true);
}}
setupContext={status?.setupContext}
/>
);
}
const handleSubmit = async (e: React.FormEvent) => {
@ -88,7 +100,7 @@ export function SetupScreen() {
// Show recovery key screen after successful setup
if (recoveryKey) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="desktop-window-drag min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-emerald-500/10 text-emerald-500 mb-4">
@ -147,7 +159,7 @@ export function SetupScreen() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="desktop-window-drag min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">

View file

@ -208,6 +208,8 @@ html[data-client='desktop'] .desktop-window-drag {
}
html[data-client='desktop'] .desktop-no-drag,
html[data-client='desktop'] a,
html[data-client='desktop'] label,
html[data-client='desktop'] button,
html[data-client='desktop'] input,
html[data-client='desktop'] textarea,
@ -216,6 +218,7 @@ html[data-client='desktop'] [role='button'],
html[data-client='desktop'] [role='menu'],
html[data-client='desktop'] [role='menuitem'],
html[data-client='desktop'] [role='combobox'],
html[data-client='desktop'] [contenteditable='true'],
html[data-client='desktop'] .mantine-Input-input {
-webkit-app-region: no-drag;
}

View file

@ -1,13 +1,9 @@
import { useState, useEffect, useCallback, createContext, useContext, type ReactNode } from 'react';
import type { AuthStatus } from '@veritas-kanban/shared';
import { apiFetch } from '@/lib/api/helpers';
// Types
export interface AuthStatus {
needsSetup: boolean;
authenticated: boolean;
sessionExpiry: string | null;
authEnabled: boolean;
}
export type { AuthStatus } from '@veritas-kanban/shared';
export interface AuthContextValue {
/** Current auth status */

View file

@ -110,8 +110,9 @@ export function productModeDefinition(id: ProductModeId): ProductModeDefinition
}
export function productModeForSetupMode(
mode: 'board' | 'agent' | 'remote' | 'restore'
): ProductModeId {
mode: 'existing' | 'board' | 'agent' | 'remote' | 'restore'
): ProductModeId | null {
if (mode === 'existing') return null;
if (mode === 'board') return 'board-only';
if (mode === 'agent') return 'agent-ready';
return 'operations';