mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Merge remote-tracking branch 'origin/main' into docs/web-to-mac-desktop-migration
This commit is contained in:
commit
772b9c9b90
26 changed files with 660 additions and 65 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
29
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
142
server/src/__tests__/desktop-setup-context-service.test.ts
Normal file
142
server/src/__tests__/desktop-setup-context-service.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
19
server/src/services/desktop-setup-context-service.ts
Normal file
19
server/src/services/desktop-setup-context-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
40
server/src/storage/sqlite/setup-context-repository.ts
Normal file
40
server/src/storage/sqlite/setup-context-repository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
21
shared/src/types/auth.types.ts
Normal file
21
shared/src/types/auth.types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
58
web/src/__tests__/auth-guard.test.tsx
Normal file
58
web/src/__tests__/auth-guard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue