From 47d3b45d84abbdab3bc0946651f7e917fa9eb584 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Feb 2026 12:54:29 +0000 Subject: [PATCH 1/2] feat: add configuration management CRUD UI for projects, credentials, and agent configs Adds a complete self-service configuration UI to the CASCADE dashboard, replacing the need for direct SQL or CLI scripts to manage projects, credentials, agent configs, and organization defaults. Backend: - New settingsRepository with CRUD for orgs, defaults, projects, integrations, and agent configs - Four new tRPC routers: organization, defaults, credentials, agentConfigs - Expanded projects router with integrations and credential overrides sub-routers - Credential values masked in API responses (last 4 chars only) - Ownership verification on all mutations Frontend: - Sidebar navigation with Projects and Settings sections - Project list page with create dialog and tabbed detail view (General, Integrations, Credentials, Agent Configs) - Settings pages for org name, cascade defaults, credentials CRUD, and global agent config management - shadcn/ui components (14 primitives) with react-hook-form - Agent backend fields use Select dropdowns (llmist, claude-code) Quality: - 96 new tests across 7 test files (routers + repository) - CI now builds and typechecks the web frontend - Fixed pre-existing lint warnings (cognitive complexity) in claude-code backend and manage-secrets CLI - Zero lint errors/warnings Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 8 +- .gitignore | 1 + package.json | 2 +- src/api/router.ts | 8 + src/api/routers/agentConfigs.ts | 134 ++ src/api/routers/credentials.ts | 89 + src/api/routers/defaults.ts | 30 + src/api/routers/organization.ts | 15 + src/api/routers/projects.ts | 198 ++ src/backends/claude-code/index.ts | 183 +- src/db/repositories/settingsRepository.ts | 237 +++ tests/unit/api/router.test.ts | 76 +- tests/unit/api/routers/agentConfigs.test.ts | 270 +++ tests/unit/api/routers/credentials.test.ts | 212 ++ tests/unit/api/routers/defaults.test.ts | 126 ++ tests/unit/api/routers/organization.test.ts | 81 + tests/unit/api/routers/projects.test.ts | 352 +++ .../repositories/settingsRepository.test.ts | 356 ++++ tools/manage-secrets.ts | 300 +-- web/components.json | 21 + web/package-lock.json | 1881 ++++++++++++++++- web/package.json | 5 + web/src/components/layout/sidebar.tsx | 67 +- .../projects/credential-overrides.tsx | 217 ++ .../components/projects/integration-form.tsx | 174 ++ .../projects/project-agent-configs.tsx | 267 +++ .../projects/project-form-dialog.tsx | 141 ++ .../projects/project-general-form.tsx | 159 ++ .../components/projects/projects-table.tsx | 129 ++ .../settings/agent-config-form-dialog.tsx | 169 ++ .../settings/agent-configs-table.tsx | 106 + .../settings/credential-form-dialog.tsx | 158 ++ .../components/settings/credentials-table.tsx | 137 ++ web/src/components/settings/defaults-form.tsx | 185 ++ web/src/components/settings/org-form.tsx | 60 + web/src/components/ui/alert-dialog.tsx | 179 ++ web/src/components/ui/badge.tsx | 46 + web/src/components/ui/button.tsx | 62 + web/src/components/ui/card.tsx | 75 + web/src/components/ui/dialog.tsx | 142 ++ web/src/components/ui/form.tsx | 150 ++ web/src/components/ui/input.tsx | 21 + web/src/components/ui/label.tsx | 19 + web/src/components/ui/select.tsx | 173 ++ web/src/components/ui/separator.tsx | 28 + web/src/components/ui/sonner.tsx | 38 + web/src/components/ui/table.tsx | 92 + web/src/components/ui/tabs.tsx | 81 + web/src/components/ui/textarea.tsx | 18 + web/src/routes/projects/$projectId.tsx | 84 + web/src/routes/projects/index.tsx | 47 + web/src/routes/route-tree.ts | 16 +- web/src/routes/settings/agents.tsx | 52 + web/src/routes/settings/credentials.tsx | 52 + web/src/routes/settings/general.tsx | 34 + 55 files changed, 7676 insertions(+), 287 deletions(-) create mode 100644 src/api/routers/agentConfigs.ts create mode 100644 src/api/routers/credentials.ts create mode 100644 src/api/routers/defaults.ts create mode 100644 src/api/routers/organization.ts create mode 100644 src/db/repositories/settingsRepository.ts create mode 100644 tests/unit/api/routers/agentConfigs.test.ts create mode 100644 tests/unit/api/routers/credentials.test.ts create mode 100644 tests/unit/api/routers/defaults.test.ts create mode 100644 tests/unit/api/routers/organization.test.ts create mode 100644 tests/unit/db/repositories/settingsRepository.test.ts create mode 100644 web/components.json create mode 100644 web/src/components/projects/credential-overrides.tsx create mode 100644 web/src/components/projects/integration-form.tsx create mode 100644 web/src/components/projects/project-agent-configs.tsx create mode 100644 web/src/components/projects/project-form-dialog.tsx create mode 100644 web/src/components/projects/project-general-form.tsx create mode 100644 web/src/components/projects/projects-table.tsx create mode 100644 web/src/components/settings/agent-config-form-dialog.tsx create mode 100644 web/src/components/settings/agent-configs-table.tsx create mode 100644 web/src/components/settings/credential-form-dialog.tsx create mode 100644 web/src/components/settings/credentials-table.tsx create mode 100644 web/src/components/settings/defaults-form.tsx create mode 100644 web/src/components/settings/org-form.tsx create mode 100644 web/src/components/ui/alert-dialog.tsx create mode 100644 web/src/components/ui/badge.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/components/ui/form.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/components/ui/select.tsx create mode 100644 web/src/components/ui/separator.tsx create mode 100644 web/src/components/ui/sonner.tsx create mode 100644 web/src/components/ui/table.tsx create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/components/ui/textarea.tsx create mode 100644 web/src/routes/projects/$projectId.tsx create mode 100644 web/src/routes/projects/index.tsx create mode 100644 web/src/routes/settings/agents.tsx create mode 100644 web/src/routes/settings/credentials.tsx create mode 100644 web/src/routes/settings/general.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aba148f..10ad67e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,15 @@ jobs: - name: Install dependencies run: npm ci - - name: Build + - name: Install web dependencies + run: cd web && npm ci + + - name: Build backend run: npm run build + - name: Build frontend + run: npm run build:web + - name: Run typecheck run: npm run typecheck diff --git a/.gitignore b/.gitignore index 31bbe596..0a064d76 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ logs/ # Temp /tmp/ *.tmp +tmp-*.ts # Local agent workspace (repos, logs) workspace/ diff --git a/package.json b/package.json index b4388aa3..49ac6138 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "node --env-file=.env --import tsx/esm --watch src/index.ts", "dev:web": "cd web && npx vite", "build": "tsc", "build:web": "cd web && npm run build", diff --git a/src/api/router.ts b/src/api/router.ts index 980e5d1c..5f84d64c 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -1,4 +1,8 @@ +import { agentConfigsRouter } from './routers/agentConfigs.js'; import { authRouter } from './routers/auth.js'; +import { credentialsRouter } from './routers/credentials.js'; +import { defaultsRouter } from './routers/defaults.js'; +import { organizationRouter } from './routers/organization.js'; import { projectsRouter } from './routers/projects.js'; import { runsRouter } from './routers/runs.js'; import { router } from './trpc.js'; @@ -7,6 +11,10 @@ export const appRouter = router({ auth: authRouter, runs: runsRouter, projects: projectsRouter, + organization: organizationRouter, + defaults: defaultsRouter, + credentials: credentialsRouter, + agentConfigs: agentConfigsRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts new file mode 100644 index 00000000..4b3c8112 --- /dev/null +++ b/src/api/routers/agentConfigs.ts @@ -0,0 +1,134 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDb } from '../../db/client.js'; +import { + createAgentConfig, + deleteAgentConfig, + listAgentConfigs, + updateAgentConfig, +} from '../../db/repositories/settingsRepository.js'; +import { agentConfigs, projects } from '../../db/schema/index.js'; +import { protectedProcedure, router } from '../trpc.js'; + +export const agentConfigsRouter = router({ + list: protectedProcedure + .input(z.object({ projectId: z.string().optional() }).optional()) + .query(async ({ ctx, input }) => { + if (input?.projectId) { + // Verify project belongs to org + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, input.projectId)); + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + return listAgentConfigs({ projectId: input.projectId }); + } + return listAgentConfigs({ orgId: ctx.user.orgId }); + }), + + create: protectedProcedure + .input( + z.object({ + orgId: z.string().nullish(), + projectId: z.string().nullish(), + agentType: z.string().min(1), + model: z.string().nullish(), + maxIterations: z.number().int().positive().nullish(), + agentBackend: z.string().nullish(), + prompt: z.string().nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + // If projectId given, verify ownership + if (input.projectId) { + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, input.projectId)); + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + } + return createAgentConfig({ + orgId: input.orgId ?? ctx.user.orgId, + projectId: input.projectId, + agentType: input.agentType, + model: input.model, + maxIterations: input.maxIterations, + agentBackend: input.agentBackend, + prompt: input.prompt, + }); + }), + + update: protectedProcedure + .input( + z.object({ + id: z.number(), + agentType: z.string().min(1).optional(), + model: z.string().nullish(), + maxIterations: z.number().int().positive().nullish(), + agentBackend: z.string().nullish(), + prompt: z.string().nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Verify ownership + const db = getDb(); + const [config] = await db + .select({ orgId: agentConfigs.orgId, projectId: agentConfigs.projectId }) + .from(agentConfigs) + .where(eq(agentConfigs.id, input.id)); + if (!config) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + // Check org-scoped configs belong to user's org + if (config.orgId && config.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + // Check project-scoped configs belong to user's org + if (config.projectId) { + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, config.projectId)); + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + } + + const { id, ...updates } = input; + await updateAgentConfig(id, updates); + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + const db = getDb(); + const [config] = await db + .select({ orgId: agentConfigs.orgId, projectId: agentConfigs.projectId }) + .from(agentConfigs) + .where(eq(agentConfigs.id, input.id)); + if (!config) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + if (config.orgId && config.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + if (config.projectId) { + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, config.projectId)); + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + } + + await deleteAgentConfig(input.id); + }), +}); diff --git a/src/api/routers/credentials.ts b/src/api/routers/credentials.ts new file mode 100644 index 00000000..cefd9f7b --- /dev/null +++ b/src/api/routers/credentials.ts @@ -0,0 +1,89 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDb } from '../../db/client.js'; +import { + createCredential, + deleteCredential, + listOrgCredentials, + updateCredential, +} from '../../db/repositories/credentialsRepository.js'; +import { credentials } from '../../db/schema/index.js'; +import { protectedProcedure, router } from '../trpc.js'; + +function maskValue(value: string): string { + if (value.length <= 4) return '****'; + return `****${value.slice(-4)}`; +} + +export const credentialsRouter = router({ + list: protectedProcedure.query(async ({ ctx }) => { + const rows = await listOrgCredentials(ctx.user.orgId); + return rows.map((row) => ({ + ...row, + value: maskValue(row.value), + })); + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + envVarKey: z.string().regex(/^[A-Z_][A-Z0-9_]*$/), + value: z.string().min(1), + description: z.string().optional(), + isDefault: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return createCredential({ + orgId: ctx.user.orgId, + name: input.name, + envVarKey: input.envVarKey, + value: input.value, + description: input.description, + isDefault: input.isDefault, + }); + }), + + update: protectedProcedure + .input( + z.object({ + id: z.number(), + name: z.string().min(1).optional(), + value: z.string().min(1).optional(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Verify ownership + const db = getDb(); + const [cred] = await db + .select({ orgId: credentials.orgId }) + .from(credentials) + .where(eq(credentials.id, input.id)); + if (!cred || cred.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + + const { id, ...updates } = input; + await updateCredential(id, updates); + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + // Verify ownership + const db = getDb(); + const [cred] = await db + .select({ orgId: credentials.orgId }) + .from(credentials) + .where(eq(credentials.id, input.id)); + if (!cred || cred.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + + await deleteCredential(input.id); + }), +}); diff --git a/src/api/routers/defaults.ts b/src/api/routers/defaults.ts new file mode 100644 index 00000000..a9f9f21d --- /dev/null +++ b/src/api/routers/defaults.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { + getCascadeDefaults, + upsertCascadeDefaults, +} from '../../db/repositories/settingsRepository.js'; +import { protectedProcedure, router } from '../trpc.js'; + +export const defaultsRouter = router({ + get: protectedProcedure.query(async ({ ctx }) => { + return getCascadeDefaults(ctx.user.orgId); + }), + + upsert: protectedProcedure + .input( + z.object({ + model: z.string().nullish(), + maxIterations: z.number().int().positive().nullish(), + freshMachineTimeoutMs: z.number().int().positive().nullish(), + watchdogTimeoutMs: z.number().int().positive().nullish(), + postJobGracePeriodMs: z.number().int().positive().nullish(), + cardBudgetUsd: z.string().nullish(), + agentBackend: z.string().nullish(), + progressModel: z.string().nullish(), + progressIntervalMinutes: z.string().nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + await upsertCascadeDefaults(ctx.user.orgId, input); + }), +}); diff --git a/src/api/routers/organization.ts b/src/api/routers/organization.ts new file mode 100644 index 00000000..69105400 --- /dev/null +++ b/src/api/routers/organization.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { getOrganization, updateOrganization } from '../../db/repositories/settingsRepository.js'; +import { protectedProcedure, router } from '../trpc.js'; + +export const organizationRouter = router({ + get: protectedProcedure.query(async ({ ctx }) => { + return getOrganization(ctx.user.orgId); + }), + + update: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + await updateOrganization(ctx.user.orgId, { name: input.name }); + }), +}); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index b6137c1f..6a593a96 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -1,8 +1,206 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDb } from '../../db/client.js'; +import { + listProjectOverrides, + removeAgentCredentialOverride, + removeProjectCredentialOverride, + setAgentCredentialOverride, + setProjectCredentialOverride, +} from '../../db/repositories/credentialsRepository.js'; import { listProjectsForOrg } from '../../db/repositories/runsRepository.js'; +import { + createProject, + deleteProject, + deleteProjectIntegration, + getProjectFull, + listProjectIntegrations, + listProjectsFull, + updateProject, + upsertProjectIntegration, +} from '../../db/repositories/settingsRepository.js'; +import { credentials, projects } from '../../db/schema/index.js'; import { protectedProcedure, router } from '../trpc.js'; +async function verifyProjectOwnership(projectId: string, orgId: string) { + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, projectId)); + if (!project || project.orgId !== orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } +} + +async function verifyCredentialOwnership(credentialId: number, orgId: string) { + const db = getDb(); + const [cred] = await db + .select({ orgId: credentials.orgId }) + .from(credentials) + .where(eq(credentials.id, credentialId)); + if (!cred || cred.orgId !== orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } +} + export const projectsRouter = router({ + // Existing - returns id+name for dropdowns list: protectedProcedure.query(async ({ ctx }) => { return listProjectsForOrg(ctx.user.orgId); }), + + // New - returns all columns + listFull: protectedProcedure.query(async ({ ctx }) => { + return listProjectsFull(ctx.user.orgId); + }), + + getById: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { + const project = await getProjectFull(input.id, ctx.user.orgId); + if (!project) throw new TRPCError({ code: 'NOT_FOUND' }); + return project; + }), + + create: protectedProcedure + .input( + z.object({ + id: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/), + name: z.string().min(1), + repo: z.string().min(1), + baseBranch: z.string().optional(), + branchPrefix: z.string().optional(), + model: z.string().nullish(), + cardBudgetUsd: z.string().nullish(), + agentBackend: z.string().nullish(), + subscriptionCostZero: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return createProject(ctx.user.orgId, input); + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string(), + name: z.string().min(1).optional(), + repo: z.string().min(1).optional(), + baseBranch: z.string().optional(), + branchPrefix: z.string().optional(), + model: z.string().nullish(), + cardBudgetUsd: z.string().nullish(), + agentBackend: z.string().nullish(), + subscriptionCostZero: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.id, ctx.user.orgId); + const { id, ...updates } = input; + await updateProject(id, ctx.user.orgId, updates); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.id, ctx.user.orgId); + await deleteProject(input.id, ctx.user.orgId); + }), + + // Integrations + integrations: router({ + list: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + return listProjectIntegrations(input.projectId); + }), + + upsert: protectedProcedure + .input( + z.object({ + projectId: z.string(), + type: z.string().min(1), + config: z.record(z.unknown()), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + await upsertProjectIntegration(input.projectId, input.type, input.config); + }), + + delete: protectedProcedure + .input(z.object({ projectId: z.string(), type: z.string() })) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + await deleteProjectIntegration(input.projectId, input.type); + }), + }), + + // Credential Overrides + credentialOverrides: router({ + list: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + return listProjectOverrides(input.projectId); + }), + + set: protectedProcedure + .input( + z.object({ + projectId: z.string(), + envVarKey: z.string(), + credentialId: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + await verifyCredentialOwnership(input.credentialId, ctx.user.orgId); + await setProjectCredentialOverride(input.projectId, input.envVarKey, input.credentialId); + }), + + remove: protectedProcedure + .input(z.object({ projectId: z.string(), envVarKey: z.string() })) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + await removeProjectCredentialOverride(input.projectId, input.envVarKey); + }), + + setAgent: protectedProcedure + .input( + z.object({ + projectId: z.string(), + envVarKey: z.string(), + agentType: z.string(), + credentialId: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + await verifyCredentialOwnership(input.credentialId, ctx.user.orgId); + await setAgentCredentialOverride( + input.projectId, + input.envVarKey, + input.agentType, + input.credentialId, + ); + }), + + removeAgent: protectedProcedure + .input( + z.object({ + projectId: z.string(), + envVarKey: z.string(), + agentType: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.projectId, ctx.user.orgId); + await removeAgentCredentialOverride(input.projectId, input.envVarKey, input.agentType); + }), + }), }); diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index 464980e2..a4d8521f 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -157,6 +157,19 @@ function extractPRUrlFromMessages(assistantMessages: SDKAssistantMessage[]): str return undefined; } +/** + * Try to extract a finish comment from a single content block. + */ +function extractCommentFromBlock(block: { type: string; name?: string; input?: unknown }): + | string + | undefined { + if (block.type !== 'tool_use' || block.name !== 'Bash') return undefined; + const { command } = block.input as { command?: string }; + if (!command?.includes('cascade-tools') || !command?.includes('session finish')) return undefined; + const match = command.match(/--comment\s+(?:"([^"]+)"|'([^']+)'|(\S+))/); + return match ? (match[1] ?? match[2] ?? match[3]) : undefined; +} + /** * Extract finish comment from assistant messages that invoked cascade-tools session finish. */ @@ -164,14 +177,8 @@ function extractFinishComment(assistantMessages: SDKAssistantMessage[]): string for (const msg of assistantMessages) { if (!msg.message?.content) continue; for (const block of msg.message.content) { - if (block.type === 'tool_use' && block.name === 'Bash') { - const input = block.input as { command?: string }; - if (input.command?.includes('cascade-tools') && input.command?.includes('session finish')) { - // Extract --comment value from the command - const match = input.command.match(/--comment\s+(?:"([^"]+)"|'([^']+)'|(\S+))/); - if (match) return match[1] ?? match[2] ?? match[3]; - } - } + const comment = extractCommentFromBlock(block); + if (comment) return comment; } } return undefined; @@ -235,6 +242,53 @@ function processSystemMessage( } } +/** + * Build the result from collected stream data. + */ +function buildResult( + assistantMessages: SDKAssistantMessage[], + resultMessage: SDKResultMessage | undefined, + stderrChunks: string[], + input: AgentBackendInput, + startTime: number, +): AgentBackendResult { + const finishComment = extractFinishComment(assistantMessages); + const success = resultMessage?.subtype === 'success'; + const cost = resultMessage?.total_cost_usd; + + let output = finishComment ?? ''; + if (!output && resultMessage?.subtype === 'success') { + output = (resultMessage as SDKResultSuccess).result ?? ''; + } + + let error: string | undefined; + if (resultMessage && resultMessage.subtype !== 'success') { + const errorResult = resultMessage as Exclude; + error = errorResult.errors?.join('; ') ?? errorResult.subtype; + } + + const stderrOutput = stderrChunks.join('').trim(); + if (stderrOutput) { + input.logWriter('WARN', 'Claude Code stderr output', { stderr: stderrOutput }); + if (error) { + error += ` | stderr: ${stderrOutput}`; + } + } + + const prUrl = extractPRUrl(output) ?? extractPRUrlFromMessages(assistantMessages); + + input.logWriter('INFO', 'Claude Code SDK execution completed', { + success, + subtype: resultMessage?.subtype, + turns: resultMessage?.num_turns, + cost, + prUrl: prUrl ?? null, + durationMs: Date.now() - startTime, + }); + + return { success, output, cost, error, prUrl }; +} + /** * Claude Code SDK backend for CASCADE. * @@ -271,88 +325,45 @@ export class ClaudeCodeBackend implements AgentBackend { let turnCount = 0; const stderrChunks: string[] = []; - try { - const stream = query({ - prompt: taskPrompt, - options: { - model, - systemPrompt, - cwd: input.repoDir, - // No maxTurns — rely on watchdog time limit instead - maxBudgetUsd: input.budgetUsd, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], - allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], - persistSession: false, - hooks, - env, - debug: true, - stderr: (data: string) => { - stderrChunks.push(data); - input.logWriter('INFO', 'Claude Code stderr', { data: data.trim() }); - }, + const stream = query({ + prompt: taskPrompt, + options: { + model, + systemPrompt, + cwd: input.repoDir, + maxBudgetUsd: input.budgetUsd, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], + allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], + persistSession: false, + hooks, + env, + debug: true, + stderr: (data: string) => { + stderrChunks.push(data); + input.logWriter('INFO', 'Claude Code stderr', { data: data.trim() }); }, - }); - - for await (const message of stream) { - if (message.type === 'assistant') { - const assistantMsg = message as SDKAssistantMessage; - assistantMessages.push(assistantMsg); - turnCount++; - await input.progressReporter.onIteration(turnCount, input.maxIterations); - processAssistantMessage(assistantMsg, turnCount, input); - } - - if (message.type === 'system') { - processSystemMessage( - message as { subtype: string; [key: string]: unknown }, - input.logWriter, - ); - } - - if (message.type === 'result') { - resultMessage = message as SDKResultMessage; - } - } - } finally { - // no-op: auth via env vars, no temp dirs to clean up - } - - const finishComment = extractFinishComment(assistantMessages); - const success = resultMessage?.subtype === 'success'; - const cost = resultMessage?.total_cost_usd; - - let output = finishComment ?? ''; - if (!output && resultMessage?.subtype === 'success') { - output = (resultMessage as SDKResultSuccess).result ?? ''; - } - - let error: string | undefined; - if (resultMessage && resultMessage.subtype !== 'success') { - const errorResult = resultMessage as Exclude; - error = errorResult.errors?.join('; ') ?? errorResult.subtype; - } + }, + }); - const stderrOutput = stderrChunks.join('').trim(); - if (stderrOutput) { - input.logWriter('WARN', 'Claude Code stderr output', { stderr: stderrOutput }); - if (error) { - error += ` | stderr: ${stderrOutput}`; + for await (const message of stream) { + if (message.type === 'assistant') { + const assistantMsg = message as SDKAssistantMessage; + assistantMessages.push(assistantMsg); + turnCount++; + await input.progressReporter.onIteration(turnCount, input.maxIterations); + processAssistantMessage(assistantMsg, turnCount, input); + } else if (message.type === 'system') { + processSystemMessage( + message as { subtype: string; [key: string]: unknown }, + input.logWriter, + ); + } else if (message.type === 'result') { + resultMessage = message as SDKResultMessage; } } - const prUrl = extractPRUrl(output) ?? extractPRUrlFromMessages(assistantMessages); - - input.logWriter('INFO', 'Claude Code SDK execution completed', { - success, - subtype: resultMessage?.subtype, - turns: resultMessage?.num_turns, - cost, - prUrl: prUrl ?? null, - durationMs: Date.now() - startTime, - }); - - return { success, output, cost, error, prUrl }; + return buildResult(assistantMessages, resultMessage, stderrChunks, input, startTime); } } diff --git a/src/db/repositories/settingsRepository.ts b/src/db/repositories/settingsRepository.ts new file mode 100644 index 00000000..9b870d25 --- /dev/null +++ b/src/db/repositories/settingsRepository.ts @@ -0,0 +1,237 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { + agentConfigs, + cascadeDefaults, + organizations, + projectIntegrations, + projects, +} from '../schema/index.js'; + +// ============================================================================ +// Organizations +// ============================================================================ + +export async function getOrganization(orgId: string) { + const db = getDb(); + const [row] = await db.select().from(organizations).where(eq(organizations.id, orgId)); + return row ?? null; +} + +export async function updateOrganization(orgId: string, data: { name: string }) { + const db = getDb(); + await db.update(organizations).set({ name: data.name }).where(eq(organizations.id, orgId)); +} + +// ============================================================================ +// Cascade Defaults +// ============================================================================ + +export async function getCascadeDefaults(orgId: string) { + const db = getDb(); + const [row] = await db.select().from(cascadeDefaults).where(eq(cascadeDefaults.orgId, orgId)); + return row ?? null; +} + +export async function upsertCascadeDefaults( + orgId: string, + data: { + model?: string | null; + maxIterations?: number | null; + freshMachineTimeoutMs?: number | null; + watchdogTimeoutMs?: number | null; + postJobGracePeriodMs?: number | null; + cardBudgetUsd?: string | null; + agentBackend?: string | null; + progressModel?: string | null; + progressIntervalMinutes?: string | null; + }, +) { + const db = getDb(); + const existing = await getCascadeDefaults(orgId); + if (existing) { + await db + .update(cascadeDefaults) + .set({ ...data, updatedAt: new Date() }) + .where(eq(cascadeDefaults.orgId, orgId)); + } else { + await db.insert(cascadeDefaults).values({ orgId, ...data }); + } +} + +// ============================================================================ +// Projects (full CRUD) +// ============================================================================ + +export async function listProjectsFull(orgId: string) { + const db = getDb(); + return db.select().from(projects).where(eq(projects.orgId, orgId)); +} + +export async function getProjectFull(projectId: string, orgId: string) { + const db = getDb(); + const [row] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); + return row ?? null; +} + +export async function createProject( + orgId: string, + data: { + id: string; + name: string; + repo: string; + baseBranch?: string; + branchPrefix?: string; + model?: string | null; + cardBudgetUsd?: string | null; + agentBackend?: string | null; + subscriptionCostZero?: boolean; + }, +) { + const db = getDb(); + const [row] = await db + .insert(projects) + .values({ + id: data.id, + orgId, + name: data.name, + repo: data.repo, + baseBranch: data.baseBranch ?? 'main', + branchPrefix: data.branchPrefix ?? 'feature/', + model: data.model, + cardBudgetUsd: data.cardBudgetUsd, + agentBackend: data.agentBackend, + subscriptionCostZero: data.subscriptionCostZero ?? false, + }) + .returning(); + return row; +} + +export async function updateProject( + projectId: string, + orgId: string, + updates: { + name?: string; + repo?: string; + baseBranch?: string; + branchPrefix?: string; + model?: string | null; + cardBudgetUsd?: string | null; + agentBackend?: string | null; + subscriptionCostZero?: boolean; + }, +) { + const db = getDb(); + await db + .update(projects) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); +} + +export async function deleteProject(projectId: string, orgId: string) { + const db = getDb(); + await db.delete(projects).where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); +} + +// ============================================================================ +// Project Integrations +// ============================================================================ + +export async function listProjectIntegrations(projectId: string) { + const db = getDb(); + return db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, projectId)); +} + +export async function upsertProjectIntegration( + projectId: string, + type: string, + config: Record, +) { + const db = getDb(); + // Delete then insert to handle the unique constraint + await db + .delete(projectIntegrations) + .where(and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.type, type))); + await db.insert(projectIntegrations).values({ projectId, type, config }); +} + +export async function deleteProjectIntegration(projectId: string, type: string) { + const db = getDb(); + await db + .delete(projectIntegrations) + .where(and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.type, type))); +} + +// ============================================================================ +// Agent Configs +// ============================================================================ + +export async function listAgentConfigs(filter?: { orgId?: string; projectId?: string }) { + const db = getDb(); + const conditions = []; + + if (filter?.projectId) { + conditions.push(eq(agentConfigs.projectId, filter.projectId)); + } else if (filter?.orgId) { + // Return global (no orgId, no projectId) + org-scoped (orgId set, no projectId) + conditions.push(isNull(agentConfigs.projectId)); + } + + if (conditions.length > 0) { + return db + .select() + .from(agentConfigs) + .where(and(...conditions)); + } + return db.select().from(agentConfigs); +} + +export async function createAgentConfig(data: { + orgId?: string | null; + projectId?: string | null; + agentType: string; + model?: string | null; + maxIterations?: number | null; + agentBackend?: string | null; + prompt?: string | null; +}) { + const db = getDb(); + const [row] = await db + .insert(agentConfigs) + .values({ + orgId: data.orgId ?? null, + projectId: data.projectId ?? null, + agentType: data.agentType, + model: data.model, + maxIterations: data.maxIterations, + agentBackend: data.agentBackend, + prompt: data.prompt, + }) + .returning({ id: agentConfigs.id }); + return row; +} + +export async function updateAgentConfig( + id: number, + updates: { + agentType?: string; + model?: string | null; + maxIterations?: number | null; + agentBackend?: string | null; + prompt?: string | null; + }, +) { + const db = getDb(); + await db + .update(agentConfigs) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(agentConfigs.id, id)); +} + +export async function deleteAgentConfig(id: number) { + const db = getDb(); + await db.delete(agentConfigs).where(eq(agentConfigs.id, id)); +} diff --git a/tests/unit/api/router.test.ts b/tests/unit/api/router.test.ts index acf0df64..69f07007 100644 --- a/tests/unit/api/router.test.ts +++ b/tests/unit/api/router.test.ts @@ -7,6 +7,8 @@ vi.mock('../../../src/db/client.js', () => ({ vi.mock('../../../src/db/schema/index.js', () => ({ projects: {}, + credentials: {}, + agentConfigs: {}, })); vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ @@ -19,6 +21,37 @@ vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ listProjectsForOrg: vi.fn(), })); +vi.mock('../../../src/db/repositories/settingsRepository.js', () => ({ + getOrganization: vi.fn(), + updateOrganization: vi.fn(), + getCascadeDefaults: vi.fn(), + upsertCascadeDefaults: vi.fn(), + listProjectsFull: vi.fn(), + getProjectFull: vi.fn(), + createProject: vi.fn(), + updateProject: vi.fn(), + deleteProject: vi.fn(), + listProjectIntegrations: vi.fn(), + upsertProjectIntegration: vi.fn(), + deleteProjectIntegration: vi.fn(), + listAgentConfigs: vi.fn(), + createAgentConfig: vi.fn(), + updateAgentConfig: vi.fn(), + deleteAgentConfig: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ + listOrgCredentials: vi.fn(), + createCredential: vi.fn(), + updateCredential: vi.fn(), + deleteCredential: vi.fn(), + listProjectOverrides: vi.fn(), + setProjectCredentialOverride: vi.fn(), + removeProjectCredentialOverride: vi.fn(), + setAgentCredentialOverride: vi.fn(), + removeAgentCredentialOverride: vi.fn(), +})); + import { appRouter } from '../../../src/api/router.js'; describe('appRouter', () => { @@ -37,8 +70,49 @@ describe('appRouter', () => { expect(procedures).toContain('runs.getDebugAnalysis'); }); - it('has projects sub-router with list procedure', () => { + it('has projects sub-router with all procedures', () => { const procedures = Object.keys(appRouter._def.procedures); expect(procedures).toContain('projects.list'); + expect(procedures).toContain('projects.listFull'); + expect(procedures).toContain('projects.getById'); + expect(procedures).toContain('projects.create'); + expect(procedures).toContain('projects.update'); + expect(procedures).toContain('projects.delete'); + expect(procedures).toContain('projects.integrations.list'); + expect(procedures).toContain('projects.integrations.upsert'); + expect(procedures).toContain('projects.integrations.delete'); + expect(procedures).toContain('projects.credentialOverrides.list'); + expect(procedures).toContain('projects.credentialOverrides.set'); + expect(procedures).toContain('projects.credentialOverrides.remove'); + expect(procedures).toContain('projects.credentialOverrides.setAgent'); + expect(procedures).toContain('projects.credentialOverrides.removeAgent'); + }); + + it('has organization sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('organization.get'); + expect(procedures).toContain('organization.update'); + }); + + it('has defaults sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('defaults.get'); + expect(procedures).toContain('defaults.upsert'); + }); + + it('has credentials sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('credentials.list'); + expect(procedures).toContain('credentials.create'); + expect(procedures).toContain('credentials.update'); + expect(procedures).toContain('credentials.delete'); + }); + + it('has agentConfigs sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('agentConfigs.list'); + expect(procedures).toContain('agentConfigs.create'); + expect(procedures).toContain('agentConfigs.update'); + expect(procedures).toContain('agentConfigs.delete'); }); }); diff --git a/tests/unit/api/routers/agentConfigs.test.ts b/tests/unit/api/routers/agentConfigs.test.ts new file mode 100644 index 00000000..55e94665 --- /dev/null +++ b/tests/unit/api/routers/agentConfigs.test.ts @@ -0,0 +1,270 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +const mockListAgentConfigs = vi.fn(); +const mockCreateAgentConfig = vi.fn(); +const mockUpdateAgentConfig = vi.fn(); +const mockDeleteAgentConfig = vi.fn(); + +vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ + listAgentConfigs: (...args: unknown[]) => mockListAgentConfigs(...args), + createAgentConfig: (...args: unknown[]) => mockCreateAgentConfig(...args), + updateAgentConfig: (...args: unknown[]) => mockUpdateAgentConfig(...args), + deleteAgentConfig: (...args: unknown[]) => mockDeleteAgentConfig(...args), +})); + +// Mock getDb for ownership checks +const mockDbSelect = vi.fn(); +const mockDbFrom = vi.fn(); +const mockDbWhere = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: mockDbSelect, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentConfigs: { id: 'id', orgId: 'org_id', projectId: 'project_id' }, + projects: { id: 'id', orgId: 'org_id' }, +})); + +import { agentConfigsRouter } from '../../../../src/api/routers/agentConfigs.js'; + +function createCaller(ctx: TRPCContext) { + return agentConfigsRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('agentConfigsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + }); + + describe('list', () => { + it('lists org-scoped configs when no projectId', async () => { + const configs = [{ id: 1, agentType: 'implementation', model: 'claude-sonnet-4-5-20250929' }]; + mockListAgentConfigs.mockResolvedValue(configs); + const caller = createCaller({ user: mockUser }); + + const result = await caller.list(); + + expect(mockListAgentConfigs).toHaveBeenCalledWith({ orgId: 'org-1' }); + expect(result).toEqual(configs); + }); + + it('lists project-scoped configs when projectId provided', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + const configs = [{ id: 2, agentType: 'review', projectId: 'proj-1' }]; + mockListAgentConfigs.mockResolvedValue(configs); + const caller = createCaller({ user: mockUser }); + + const result = await caller.list({ projectId: 'proj-1' }); + + expect(mockListAgentConfigs).toHaveBeenCalledWith({ projectId: 'proj-1' }); + expect(result).toEqual(configs); + }); + + it('throws NOT_FOUND when project does not belong to org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.list({ projectId: 'proj-x' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when project does not exist', async () => { + mockDbWhere.mockResolvedValue([]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.list({ projectId: 'missing' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.list()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('create', () => { + it('creates org-scoped config', async () => { + mockCreateAgentConfig.mockResolvedValue({ id: 10 }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.create({ + agentType: 'implementation', + model: 'claude-sonnet-4-5-20250929', + maxIterations: 25, + }); + + expect(mockCreateAgentConfig).toHaveBeenCalledWith({ + orgId: 'org-1', + projectId: undefined, + agentType: 'implementation', + model: 'claude-sonnet-4-5-20250929', + maxIterations: 25, + agentBackend: undefined, + prompt: undefined, + }); + expect(result).toEqual({ id: 10 }); + }); + + it('creates project-scoped config after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockCreateAgentConfig.mockResolvedValue({ id: 11 }); + const caller = createCaller({ user: mockUser }); + + await caller.create({ + projectId: 'proj-1', + agentType: 'review', + agentBackend: 'claude-code', + }); + + expect(mockCreateAgentConfig).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'proj-1', + agentType: 'review', + agentBackend: 'claude-code', + }), + ); + }); + + it('throws NOT_FOUND when project does not belong to org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + const caller = createCaller({ user: mockUser }); + + await expect( + caller.create({ projectId: 'proj-x', agentType: 'review' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('rejects empty agentType', async () => { + const caller = createCaller({ user: mockUser }); + await expect(caller.create({ agentType: '' })).rejects.toThrow(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.create({ agentType: 'test' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + describe('update', () => { + it('updates org-scoped config', async () => { + // First call: find config + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1', projectId: null }]); + mockUpdateAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.update({ id: 10, model: 'new-model', maxIterations: 30 }); + + expect(mockUpdateAgentConfig).toHaveBeenCalledWith(10, { + model: 'new-model', + maxIterations: 30, + }); + }); + + it('updates project-scoped config after verifying project ownership', async () => { + // First call: find config + mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: 'proj-1' }]); + // Second call: verify project + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockUpdateAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.update({ id: 11, agentBackend: 'claude-code' }); + + expect(mockUpdateAgentConfig).toHaveBeenCalledWith(11, { + agentBackend: 'claude-code', + }); + }); + + it('throws NOT_FOUND when config does not exist', async () => { + mockDbWhere.mockResolvedValue([]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.update({ id: 999, model: 'x' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when org-scoped config belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org', projectId: null }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.update({ id: 10, model: 'x' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.update({ id: 10, model: 'x' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + describe('delete', () => { + it('deletes org-scoped config', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1', projectId: null }]); + mockDeleteAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.delete({ id: 10 }); + + expect(mockDeleteAgentConfig).toHaveBeenCalledWith(10); + }); + + it('deletes project-scoped config after verifying project ownership', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: null, projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockDeleteAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.delete({ id: 11 }); + + expect(mockDeleteAgentConfig).toHaveBeenCalledWith(11); + }); + + it('throws NOT_FOUND when config does not exist', async () => { + mockDbWhere.mockResolvedValue([]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.delete({ id: 999 })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when org-scoped config belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org', projectId: null }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.delete({ id: 10 })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.delete({ id: 10 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/api/routers/credentials.test.ts b/tests/unit/api/routers/credentials.test.ts new file mode 100644 index 00000000..d0ef7483 --- /dev/null +++ b/tests/unit/api/routers/credentials.test.ts @@ -0,0 +1,212 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +const mockListOrgCredentials = vi.fn(); +const mockCreateCredential = vi.fn(); +const mockUpdateCredential = vi.fn(); +const mockDeleteCredential = vi.fn(); + +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + listOrgCredentials: (...args: unknown[]) => mockListOrgCredentials(...args), + createCredential: (...args: unknown[]) => mockCreateCredential(...args), + updateCredential: (...args: unknown[]) => mockUpdateCredential(...args), + deleteCredential: (...args: unknown[]) => mockDeleteCredential(...args), +})); + +// Mock getDb for ownership checks +const mockDbSelect = vi.fn(); +const mockDbFrom = vi.fn(); +const mockDbWhere = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: mockDbSelect, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + credentials: { id: 'id', orgId: 'org_id' }, +})); + +import { credentialsRouter } from '../../../../src/api/routers/credentials.js'; + +function createCaller(ctx: TRPCContext) { + return credentialsRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('credentialsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + }); + + describe('list', () => { + it('returns credentials with masked values', async () => { + mockListOrgCredentials.mockResolvedValue([ + { + id: 1, + name: 'Token', + envVarKey: 'GITHUB_TOKEN', + value: 'ghp_abc123def456', + isDefault: true, + }, + { id: 2, name: 'Key', envVarKey: 'API_KEY', value: 'sk', isDefault: false }, + ]); + const caller = createCaller({ user: mockUser }); + + const result = await caller.list(); + + expect(mockListOrgCredentials).toHaveBeenCalledWith('org-1'); + expect(result).toHaveLength(2); + expect(result[0].value).toBe('****f456'); + expect(result[1].value).toBe('****'); + }); + + it('returns empty array when no credentials', async () => { + mockListOrgCredentials.mockResolvedValue([]); + const caller = createCaller({ user: mockUser }); + + const result = await caller.list(); + expect(result).toEqual([]); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.list()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('create', () => { + it('creates credential with all fields', async () => { + mockCreateCredential.mockResolvedValue({ id: 42 }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.create({ + name: 'GitHub Bot', + envVarKey: 'GITHUB_TOKEN', + value: 'ghp_test123', + description: 'Bot token', + isDefault: true, + }); + + expect(mockCreateCredential).toHaveBeenCalledWith({ + orgId: 'org-1', + name: 'GitHub Bot', + envVarKey: 'GITHUB_TOKEN', + value: 'ghp_test123', + description: 'Bot token', + isDefault: true, + }); + expect(result).toEqual({ id: 42 }); + }); + + it('rejects invalid env var key format', async () => { + const caller = createCaller({ user: mockUser }); + await expect( + caller.create({ name: 'X', envVarKey: 'invalid-key', value: 'v' }), + ).rejects.toThrow(); + }); + + it('rejects env var key starting with number', async () => { + const caller = createCaller({ user: mockUser }); + await expect(caller.create({ name: 'X', envVarKey: '123KEY', value: 'v' })).rejects.toThrow(); + }); + + it('accepts underscore-prefixed env var key', async () => { + mockCreateCredential.mockResolvedValue({ id: 1 }); + const caller = createCaller({ user: mockUser }); + + await caller.create({ name: 'X', envVarKey: '_MY_KEY', value: 'v' }); + expect(mockCreateCredential).toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect( + caller.create({ name: 'X', envVarKey: 'KEY', value: 'v' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('update', () => { + it('updates credential after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpdateCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.update({ id: 42, name: 'Updated Name', value: 'new-secret' }); + + expect(mockUpdateCredential).toHaveBeenCalledWith(42, { + name: 'Updated Name', + value: 'new-secret', + }); + }); + + it('throws NOT_FOUND when credential belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.update({ id: 42, name: 'X' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + expect(mockUpdateCredential).not.toHaveBeenCalled(); + }); + + it('throws NOT_FOUND when credential does not exist', async () => { + mockDbWhere.mockResolvedValue([]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.update({ id: 999, name: 'X' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + }); + + describe('delete', () => { + it('deletes credential after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockDeleteCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.delete({ id: 42 }); + + expect(mockDeleteCredential).toHaveBeenCalledWith(42); + }); + + it('throws NOT_FOUND when credential belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.delete({ id: 42 })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + expect(mockDeleteCredential).not.toHaveBeenCalled(); + }); + + it('throws NOT_FOUND when credential does not exist', async () => { + mockDbWhere.mockResolvedValue([]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.delete({ id: 999 })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.delete({ id: 42 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/api/routers/defaults.test.ts b/tests/unit/api/routers/defaults.test.ts new file mode 100644 index 00000000..a29c94ba --- /dev/null +++ b/tests/unit/api/routers/defaults.test.ts @@ -0,0 +1,126 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +const mockGetCascadeDefaults = vi.fn(); +const mockUpsertCascadeDefaults = vi.fn(); + +vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ + getCascadeDefaults: (...args: unknown[]) => mockGetCascadeDefaults(...args), + upsertCascadeDefaults: (...args: unknown[]) => mockUpsertCascadeDefaults(...args), +})); + +import { defaultsRouter } from '../../../../src/api/routers/defaults.js'; + +function createCaller(ctx: TRPCContext) { + return defaultsRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('defaultsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('get', () => { + it('returns cascade defaults for user orgId', async () => { + const mockDefaults = { + orgId: 'org-1', + model: 'claude-sonnet-4-5-20250929', + maxIterations: 20, + }; + mockGetCascadeDefaults.mockResolvedValue(mockDefaults); + const caller = createCaller({ user: mockUser }); + + const result = await caller.get(); + + expect(mockGetCascadeDefaults).toHaveBeenCalledWith('org-1'); + expect(result).toEqual(mockDefaults); + }); + + it('returns null when no defaults configured', async () => { + mockGetCascadeDefaults.mockResolvedValue(null); + const caller = createCaller({ user: mockUser }); + + const result = await caller.get(); + expect(result).toBeNull(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.get()).rejects.toThrow(TRPCError); + await expect(caller.get()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('upsert', () => { + it('upserts all fields', async () => { + mockUpsertCascadeDefaults.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.upsert({ + model: 'claude-sonnet-4-5-20250929', + maxIterations: 30, + freshMachineTimeoutMs: 600000, + watchdogTimeoutMs: 300000, + postJobGracePeriodMs: 30000, + cardBudgetUsd: '5.00', + agentBackend: 'claude-code', + progressModel: 'claude-haiku-3-20240307', + progressIntervalMinutes: '10', + }); + + expect(mockUpsertCascadeDefaults).toHaveBeenCalledWith('org-1', { + model: 'claude-sonnet-4-5-20250929', + maxIterations: 30, + freshMachineTimeoutMs: 600000, + watchdogTimeoutMs: 300000, + postJobGracePeriodMs: 30000, + cardBudgetUsd: '5.00', + agentBackend: 'claude-code', + progressModel: 'claude-haiku-3-20240307', + progressIntervalMinutes: '10', + }); + }); + + it('accepts partial updates with null values', async () => { + mockUpsertCascadeDefaults.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.upsert({ model: null, maxIterations: 15 }); + + expect(mockUpsertCascadeDefaults).toHaveBeenCalledWith( + 'org-1', + expect.objectContaining({ model: null, maxIterations: 15 }), + ); + }); + + it('accepts empty input', async () => { + mockUpsertCascadeDefaults.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.upsert({}); + + expect(mockUpsertCascadeDefaults).toHaveBeenCalledWith('org-1', {}); + }); + + it('rejects negative maxIterations', async () => { + const caller = createCaller({ user: mockUser }); + await expect(caller.upsert({ maxIterations: -1 })).rejects.toThrow(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.upsert({ model: 'test' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/api/routers/organization.test.ts b/tests/unit/api/routers/organization.test.ts new file mode 100644 index 00000000..0643e9aa --- /dev/null +++ b/tests/unit/api/routers/organization.test.ts @@ -0,0 +1,81 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +const mockGetOrganization = vi.fn(); +const mockUpdateOrganization = vi.fn(); + +vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ + getOrganization: (...args: unknown[]) => mockGetOrganization(...args), + updateOrganization: (...args: unknown[]) => mockUpdateOrganization(...args), +})); + +import { organizationRouter } from '../../../../src/api/routers/organization.js'; + +function createCaller(ctx: TRPCContext) { + return organizationRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('organizationRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('get', () => { + it('returns organization for user orgId', async () => { + const mockOrg = { id: 'org-1', name: 'My Org' }; + mockGetOrganization.mockResolvedValue(mockOrg); + const caller = createCaller({ user: mockUser }); + + const result = await caller.get(); + + expect(mockGetOrganization).toHaveBeenCalledWith('org-1'); + expect(result).toEqual(mockOrg); + }); + + it('returns null when organization not found', async () => { + mockGetOrganization.mockResolvedValue(null); + const caller = createCaller({ user: mockUser }); + + const result = await caller.get(); + expect(result).toBeNull(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.get()).rejects.toThrow(TRPCError); + await expect(caller.get()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('update', () => { + it('updates organization name', async () => { + mockUpdateOrganization.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.update({ name: 'New Name' }); + + expect(mockUpdateOrganization).toHaveBeenCalledWith('org-1', { name: 'New Name' }); + }); + + it('rejects empty name', async () => { + const caller = createCaller({ user: mockUser }); + await expect(caller.update({ name: '' })).rejects.toThrow(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.update({ name: 'New' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 91e33d4c..eccd7456 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -8,6 +8,57 @@ vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ listProjectsForOrg: (...args: unknown[]) => mockListProjectsForOrg(...args), })); +const mockListProjectsFull = vi.fn(); +const mockGetProjectFull = vi.fn(); +const mockCreateProject = vi.fn(); +const mockUpdateProject = vi.fn(); +const mockDeleteProject = vi.fn(); +const mockListProjectIntegrations = vi.fn(); +const mockUpsertProjectIntegration = vi.fn(); +const mockDeleteProjectIntegration = vi.fn(); + +vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ + listProjectsFull: (...args: unknown[]) => mockListProjectsFull(...args), + getProjectFull: (...args: unknown[]) => mockGetProjectFull(...args), + createProject: (...args: unknown[]) => mockCreateProject(...args), + updateProject: (...args: unknown[]) => mockUpdateProject(...args), + deleteProject: (...args: unknown[]) => mockDeleteProject(...args), + listProjectIntegrations: (...args: unknown[]) => mockListProjectIntegrations(...args), + upsertProjectIntegration: (...args: unknown[]) => mockUpsertProjectIntegration(...args), + deleteProjectIntegration: (...args: unknown[]) => mockDeleteProjectIntegration(...args), +})); + +const mockListProjectOverrides = vi.fn(); +const mockSetProjectCredentialOverride = vi.fn(); +const mockRemoveProjectCredentialOverride = vi.fn(); +const mockSetAgentCredentialOverride = vi.fn(); +const mockRemoveAgentCredentialOverride = vi.fn(); + +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + listProjectOverrides: (...args: unknown[]) => mockListProjectOverrides(...args), + setProjectCredentialOverride: (...args: unknown[]) => mockSetProjectCredentialOverride(...args), + removeProjectCredentialOverride: (...args: unknown[]) => + mockRemoveProjectCredentialOverride(...args), + setAgentCredentialOverride: (...args: unknown[]) => mockSetAgentCredentialOverride(...args), + removeAgentCredentialOverride: (...args: unknown[]) => mockRemoveAgentCredentialOverride(...args), +})); + +// Mock getDb for ownership checks +const mockDbSelect = vi.fn(); +const mockDbFrom = vi.fn(); +const mockDbWhere = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: mockDbSelect, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + credentials: { id: 'id', orgId: 'org_id' }, + projects: { id: 'id', orgId: 'org_id' }, +})); + import { projectsRouter } from '../../../../src/api/routers/projects.js'; function createCaller(ctx: TRPCContext) { @@ -25,8 +76,14 @@ const mockUser = { describe('projectsRouter', () => { beforeEach(() => { vi.clearAllMocks(); + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); }); + // ============================================================================ + // Existing list procedure + // ============================================================================ + describe('list', () => { it('calls listProjectsForOrg with orgId from user context', async () => { mockListProjectsForOrg.mockResolvedValue([ @@ -61,4 +118,299 @@ describe('projectsRouter', () => { }); }); }); + + // ============================================================================ + // New CRUD procedures + // ============================================================================ + + describe('listFull', () => { + it('returns all project columns', async () => { + const projects = [{ id: 'p1', name: 'Project 1', repo: 'owner/repo1', baseBranch: 'main' }]; + mockListProjectsFull.mockResolvedValue(projects); + const caller = createCaller({ user: mockUser }); + + const result = await caller.listFull(); + + expect(mockListProjectsFull).toHaveBeenCalledWith('org-1'); + expect(result).toEqual(projects); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.listFull()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('getById', () => { + it('returns project when found', async () => { + const project = { id: 'p1', orgId: 'org-1', name: 'Project 1' }; + mockGetProjectFull.mockResolvedValue(project); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getById({ id: 'p1' }); + + expect(mockGetProjectFull).toHaveBeenCalledWith('p1', 'org-1'); + expect(result).toEqual(project); + }); + + it('throws NOT_FOUND when project does not exist', async () => { + mockGetProjectFull.mockResolvedValue(null); + const caller = createCaller({ user: mockUser }); + + await expect(caller.getById({ id: 'missing' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + }); + + describe('create', () => { + it('creates project with required fields', async () => { + const created = { id: 'my-project', orgId: 'org-1', name: 'My Project', repo: 'owner/repo' }; + mockCreateProject.mockResolvedValue(created); + const caller = createCaller({ user: mockUser }); + + const result = await caller.create({ + id: 'my-project', + name: 'My Project', + repo: 'owner/repo', + }); + + expect(mockCreateProject).toHaveBeenCalledWith('org-1', { + id: 'my-project', + name: 'My Project', + repo: 'owner/repo', + }); + expect(result).toEqual(created); + }); + + it('rejects invalid id format', async () => { + const caller = createCaller({ user: mockUser }); + await expect( + caller.create({ id: 'INVALID ID!', name: 'X', repo: 'owner/repo' }), + ).rejects.toThrow(); + }); + + it('rejects empty name', async () => { + const caller = createCaller({ user: mockUser }); + await expect( + caller.create({ id: 'valid-id', name: '', repo: 'owner/repo' }), + ).rejects.toThrow(); + }); + }); + + describe('update', () => { + it('updates project after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpdateProject.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.update({ id: 'p1', name: 'Updated Name', model: 'new-model' }); + + expect(mockUpdateProject).toHaveBeenCalledWith('p1', 'org-1', { + name: 'Updated Name', + model: 'new-model', + }); + }); + + it('throws NOT_FOUND when project belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.update({ id: 'p1', name: 'X' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + expect(mockUpdateProject).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('deletes project after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockDeleteProject.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.delete({ id: 'p1' }); + + expect(mockDeleteProject).toHaveBeenCalledWith('p1', 'org-1'); + }); + + it('throws NOT_FOUND when project belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.delete({ id: 'p1' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + expect(mockDeleteProject).not.toHaveBeenCalled(); + }); + }); + + // ============================================================================ + // Integrations sub-router + // ============================================================================ + + describe('integrations', () => { + describe('list', () => { + it('lists integrations after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + const integrations = [{ id: 1, type: 'trello', config: { boardId: 'abc' } }]; + mockListProjectIntegrations.mockResolvedValue(integrations); + const caller = createCaller({ user: mockUser }); + + const result = await caller.integrations.list({ projectId: 'p1' }); + + expect(result).toEqual(integrations); + }); + + it('throws NOT_FOUND when project not owned', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'other-org' }]); + const caller = createCaller({ user: mockUser }); + + await expect(caller.integrations.list({ projectId: 'p1' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + }); + + describe('upsert', () => { + it('upserts integration after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockUpsertProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.integrations.upsert({ + projectId: 'p1', + type: 'trello', + config: { boardId: 'abc123' }, + }); + + expect(mockUpsertProjectIntegration).toHaveBeenCalledWith('p1', 'trello', { + boardId: 'abc123', + }); + }); + }); + + describe('delete', () => { + it('deletes integration after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockDeleteProjectIntegration.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.integrations.delete({ projectId: 'p1', type: 'trello' }); + + expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'trello'); + }); + }); + }); + + // ============================================================================ + // Credential Overrides sub-router + // ============================================================================ + + describe('credentialOverrides', () => { + describe('list', () => { + it('lists overrides after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + const overrides = [ + { envVarKey: 'GITHUB_TOKEN', credentialId: 42, credentialName: 'Bot', agentType: null }, + ]; + mockListProjectOverrides.mockResolvedValue(overrides); + const caller = createCaller({ user: mockUser }); + + const result = await caller.credentialOverrides.list({ projectId: 'p1' }); + + expect(result).toEqual(overrides); + }); + }); + + describe('set', () => { + it('sets override after verifying project and credential ownership', async () => { + // First call: verify project, second call: verify credential + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockSetProjectCredentialOverride.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.credentialOverrides.set({ + projectId: 'p1', + envVarKey: 'GITHUB_TOKEN', + credentialId: 42, + }); + + expect(mockSetProjectCredentialOverride).toHaveBeenCalledWith('p1', 'GITHUB_TOKEN', 42); + }); + + it('throws NOT_FOUND when credential belongs to different org', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project OK + mockDbWhere.mockResolvedValueOnce([{ orgId: 'different-org' }]); // credential not owned + const caller = createCaller({ user: mockUser }); + + await expect( + caller.credentialOverrides.set({ + projectId: 'p1', + envVarKey: 'KEY', + credentialId: 99, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + }); + + describe('remove', () => { + it('removes override after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockRemoveProjectCredentialOverride.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.credentialOverrides.remove({ + projectId: 'p1', + envVarKey: 'GITHUB_TOKEN', + }); + + expect(mockRemoveProjectCredentialOverride).toHaveBeenCalledWith('p1', 'GITHUB_TOKEN'); + }); + }); + + describe('setAgent', () => { + it('sets agent-scoped override after verifying both ownerships', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // credential + mockSetAgentCredentialOverride.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.credentialOverrides.setAgent({ + projectId: 'p1', + envVarKey: 'GITHUB_TOKEN', + agentType: 'review', + credentialId: 42, + }); + + expect(mockSetAgentCredentialOverride).toHaveBeenCalledWith( + 'p1', + 'GITHUB_TOKEN', + 'review', + 42, + ); + }); + }); + + describe('removeAgent', () => { + it('removes agent-scoped override after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockRemoveAgentCredentialOverride.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser }); + + await caller.credentialOverrides.removeAgent({ + projectId: 'p1', + envVarKey: 'GITHUB_TOKEN', + agentType: 'review', + }); + + expect(mockRemoveAgentCredentialOverride).toHaveBeenCalledWith( + 'p1', + 'GITHUB_TOKEN', + 'review', + ); + }); + }); + }); }); diff --git a/tests/unit/db/repositories/settingsRepository.test.ts b/tests/unit/db/repositories/settingsRepository.test.ts new file mode 100644 index 00000000..2f1ce12f --- /dev/null +++ b/tests/unit/db/repositories/settingsRepository.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + createAgentConfig, + createProject, + deleteAgentConfig, + deleteProject, + deleteProjectIntegration, + getCascadeDefaults, + getOrganization, + getProjectFull, + listAgentConfigs, + listProjectIntegrations, + listProjectsFull, + updateAgentConfig, + updateOrganization, + updateProject, + upsertCascadeDefaults, + upsertProjectIntegration, +} from '../../../../src/db/repositories/settingsRepository.js'; + +function createMockDb() { + const chain: Record> = {}; + + chain.where = vi.fn().mockResolvedValue([]); + chain.returning = vi.fn().mockResolvedValue([]); + chain.limit = vi.fn().mockReturnValue(chain); + + chain.innerJoin = vi.fn().mockReturnValue({ where: chain.where }); + chain.from = vi.fn().mockReturnValue({ + where: chain.where, + innerJoin: chain.innerJoin, + limit: chain.limit, + }); + chain.set = vi.fn().mockReturnValue({ where: chain.where }); + chain.values = vi.fn().mockReturnValue({ + returning: chain.returning, + }); + + // Make chain itself thenable for queries without .where() terminal + // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve([]).then(resolve); + + const db = { + select: vi.fn().mockReturnValue({ from: chain.from }), + insert: vi.fn().mockReturnValue({ values: chain.values }), + update: vi.fn().mockReturnValue({ set: chain.set }), + delete: vi.fn().mockReturnValue({ where: chain.where }), + }; + + return { db, chain }; +} + +describe('settingsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDb(); + vi.mocked(getDb).mockReturnValue(mockDb.db as never); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ============================================================================ + // Organizations + // ============================================================================ + + describe('getOrganization', () => { + it('returns organization when found', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ id: 'org-1', name: 'My Org' }]); + + const result = await getOrganization('org-1'); + expect(result).toEqual({ id: 'org-1', name: 'My Org' }); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getOrganization('missing'); + expect(result).toBeNull(); + }); + }); + + describe('updateOrganization', () => { + it('updates organization name', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateOrganization('org-1', { name: 'New Name' }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + expect(mockDb.chain.set).toHaveBeenCalledWith({ name: 'New Name' }); + }); + }); + + // ============================================================================ + // Cascade Defaults + // ============================================================================ + + describe('getCascadeDefaults', () => { + it('returns defaults when found', async () => { + const defaults = { orgId: 'org-1', model: 'claude-sonnet-4-5-20250929', maxIterations: 20 }; + mockDb.chain.where.mockResolvedValueOnce([defaults]); + + const result = await getCascadeDefaults('org-1'); + expect(result).toEqual(defaults); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getCascadeDefaults('missing'); + expect(result).toBeNull(); + }); + }); + + describe('upsertCascadeDefaults', () => { + it('inserts when no existing defaults', async () => { + // getCascadeDefaults returns null + mockDb.chain.where.mockResolvedValueOnce([]); + + await upsertCascadeDefaults('org-1', { model: 'test-model' }); + + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ orgId: 'org-1', model: 'test-model' }), + ); + }); + + it('updates when existing defaults found', async () => { + // getCascadeDefaults returns existing row + mockDb.chain.where.mockResolvedValueOnce([{ orgId: 'org-1', model: 'old-model' }]); + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await upsertCascadeDefaults('org-1', { model: 'new-model' }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + expect(mockDb.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ model: 'new-model' }), + ); + }); + }); + + // ============================================================================ + // Projects + // ============================================================================ + + describe('listProjectsFull', () => { + it('queries projects by orgId', async () => { + const projects = [{ id: 'p1', name: 'Project 1' }]; + mockDb.chain.where.mockResolvedValueOnce(projects); + + const result = await listProjectsFull('org-1'); + expect(result).toEqual(projects); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + }); + + describe('getProjectFull', () => { + it('returns project when found with matching org', async () => { + const project = { id: 'p1', orgId: 'org-1', name: 'Project 1' }; + mockDb.chain.where.mockResolvedValueOnce([project]); + + const result = await getProjectFull('p1', 'org-1'); + expect(result).toEqual(project); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getProjectFull('missing', 'org-1'); + expect(result).toBeNull(); + }); + }); + + describe('createProject', () => { + it('inserts project and returns row', async () => { + const newProject = { id: 'p1', orgId: 'org-1', name: 'New Project', repo: 'owner/repo' }; + mockDb.chain.returning.mockResolvedValueOnce([newProject]); + + const result = await createProject('org-1', { + id: 'p1', + name: 'New Project', + repo: 'owner/repo', + }); + + expect(result).toEqual(newProject); + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'p1', + orgId: 'org-1', + name: 'New Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + subscriptionCostZero: false, + }), + ); + }); + }); + + describe('updateProject', () => { + it('updates project with new values', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateProject('p1', 'org-1', { name: 'Updated', model: 'new-model' }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.name).toBe('Updated'); + expect(setArg.model).toBe('new-model'); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); + }); + + describe('deleteProject', () => { + it('deletes project by id and orgId', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteProject('p1', 'org-1'); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); + + // ============================================================================ + // Project Integrations + // ============================================================================ + + describe('listProjectIntegrations', () => { + it('returns integrations for project', async () => { + const integrations = [{ id: 1, projectId: 'p1', type: 'trello', config: {} }]; + mockDb.chain.where.mockResolvedValueOnce(integrations); + + const result = await listProjectIntegrations('p1'); + expect(result).toEqual(integrations); + }); + }); + + describe('upsertProjectIntegration', () => { + it('deletes then inserts integration', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); // delete + + await upsertProjectIntegration('p1', 'trello', { boardId: 'abc' }); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + projectId: 'p1', + type: 'trello', + config: { boardId: 'abc' }, + }); + }); + }); + + describe('deleteProjectIntegration', () => { + it('deletes integration by projectId and type', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteProjectIntegration('p1', 'trello'); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); + + // ============================================================================ + // Agent Configs + // ============================================================================ + + describe('listAgentConfigs', () => { + it('returns all configs when no filter', async () => { + const configs = [{ id: 1, agentType: 'impl' }]; + // No where clause → thenable chain resolves + const fromMock = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(configs), + // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains + then: (resolve: (v: unknown) => unknown) => Promise.resolve(configs).then(resolve), + }); + mockDb.db.select.mockReturnValue({ from: fromMock }); + + const result = await listAgentConfigs(); + expect(result).toEqual(configs); + }); + + it('filters by projectId when provided', async () => { + const configs = [{ id: 2, agentType: 'review', projectId: 'p1' }]; + mockDb.chain.where.mockResolvedValueOnce(configs); + + const result = await listAgentConfigs({ projectId: 'p1' }); + expect(result).toEqual(configs); + }); + + it('filters to non-project configs when orgId provided', async () => { + const configs = [{ id: 3, agentType: 'impl', orgId: 'org-1' }]; + mockDb.chain.where.mockResolvedValueOnce(configs); + + const result = await listAgentConfigs({ orgId: 'org-1' }); + expect(result).toEqual(configs); + }); + }); + + describe('createAgentConfig', () => { + it('inserts config and returns id', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 42 }]); + + const result = await createAgentConfig({ + orgId: 'org-1', + agentType: 'implementation', + model: 'test-model', + maxIterations: 20, + }); + + expect(result).toEqual({ id: 42 }); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + orgId: 'org-1', + projectId: null, + agentType: 'implementation', + model: 'test-model', + maxIterations: 20, + }), + ); + }); + }); + + describe('updateAgentConfig', () => { + it('updates config fields', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateAgentConfig(42, { model: 'new-model', maxIterations: 30 }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.model).toBe('new-model'); + expect(setArg.maxIterations).toBe(30); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); + }); + + describe('deleteAgentConfig', () => { + it('deletes config by id', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteAgentConfig(42); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tools/manage-secrets.ts b/tools/manage-secrets.ts index 2bbdd61b..bcd8e137 100644 --- a/tools/manage-secrets.ts +++ b/tools/manage-secrets.ts @@ -58,169 +58,171 @@ function maskValue(value: string): string { return `${value.slice(0, 4)}...${value.slice(-4)}`; } -async function main() { - const args = process.argv.slice(2); - const command = args[0]; +async function handleCreate(args: string[]): Promise { + const [, orgId, envVarKey, value] = args; + if (!orgId || !envVarKey || !value) { + console.error('Error: create requires '); + printUsage(); + process.exit(1); + } + const name = parseFlag(args, '--name') ?? envVarKey; + const description = parseFlag(args, '--description'); + const isDefault = hasFlag(args, '--default'); - if (!command) { + const { id } = await createCredential({ orgId, name, envVarKey, value, description, isDefault }); + console.log( + `Created credential #${id}: ${name} (${envVarKey}) for org ${orgId}${isDefault ? ' [DEFAULT]' : ''}`, + ); +} + +async function handleList(args: string[]): Promise { + const orgId = args[1]; + if (!orgId) { + console.error('Error: list requires '); printUsage(); process.exit(1); } + const creds = await listOrgCredentials(orgId); + if (creds.length === 0) { + console.log(`No credentials found for org ${orgId}`); + return; + } + console.log(`Credentials for org ${orgId}:`); + for (const c of creds) { + const defaultTag = c.isDefault ? ' [DEFAULT]' : ''; + const desc = c.description ? ` - ${c.description}` : ''; + console.log( + ` #${c.id}: ${c.name} (${c.envVarKey}) = ${maskValue(c.value)}${defaultTag}${desc}`, + ); + } +} - switch (command) { - case 'create': { - const [, orgId, envVarKey, value] = args; - if (!orgId || !envVarKey || !value) { - console.error('Error: create requires '); - printUsage(); - process.exit(1); - } - const name = parseFlag(args, '--name') ?? envVarKey; - const description = parseFlag(args, '--description'); - const isDefault = hasFlag(args, '--default'); - - const { id } = await createCredential({ - orgId, - name, - envVarKey, - value, - description, - isDefault, - }); - console.log( - `Created credential #${id}: ${name} (${envVarKey}) for org ${orgId}${isDefault ? ' [DEFAULT]' : ''}`, - ); - break; - } +async function handleDelete(args: string[]): Promise { + const credIdStr = args[1]; + if (!credIdStr) { + console.error('Error: delete requires '); + printUsage(); + process.exit(1); + } + const credId = Number.parseInt(credIdStr, 10); + if (Number.isNaN(credId)) { + console.error('Error: credential-id must be a number'); + process.exit(1); + } + await deleteCredential(credId); + console.log(`Deleted credential #${credId}`); +} - case 'list': { - const orgId = args[1]; - if (!orgId) { - console.error('Error: list requires '); - printUsage(); - process.exit(1); - } - const creds = await listOrgCredentials(orgId); - if (creds.length === 0) { - console.log(`No credentials found for org ${orgId}`); - } else { - console.log(`Credentials for org ${orgId}:`); - for (const c of creds) { - const defaultTag = c.isDefault ? ' [DEFAULT]' : ''; - const desc = c.description ? ` - ${c.description}` : ''; - console.log( - ` #${c.id}: ${c.name} (${c.envVarKey}) = ${maskValue(c.value)}${defaultTag}${desc}`, - ); - } - } - break; - } +function parseCredentialId(str: string): number { + const credId = Number.parseInt(str, 10); + if (Number.isNaN(credId)) { + console.error('Error: credential-id must be a number'); + process.exit(1); + } + return credId; +} - case 'delete': { - const credIdStr = args[1]; - if (!credIdStr) { - console.error('Error: delete requires '); - printUsage(); - process.exit(1); - } - const credId = Number.parseInt(credIdStr, 10); - if (Number.isNaN(credId)) { - console.error('Error: credential-id must be a number'); - process.exit(1); - } - await deleteCredential(credId); - console.log(`Deleted credential #${credId}`); - break; - } +async function handleSetOverride(args: string[]): Promise { + const [, projectId, envVarKey, credIdStr] = args; + if (!projectId || !envVarKey || !credIdStr) { + console.error('Error: set-override requires '); + printUsage(); + process.exit(1); + } + const credId = parseCredentialId(credIdStr); + const agentType = parseFlag(args, '--agent-type'); + if (agentType) { + await setAgentCredentialOverride(projectId, envVarKey, agentType, credId); + console.log( + `Set agent override: project ${projectId} → ${envVarKey} → credential #${credId} (agent: ${agentType})`, + ); + } else { + await setProjectCredentialOverride(projectId, envVarKey, credId); + console.log(`Set override: project ${projectId} → ${envVarKey} → credential #${credId}`); + } +} - case 'set-override': { - const [, projectId, envVarKey, credIdStr] = args; - if (!projectId || !envVarKey || !credIdStr) { - console.error('Error: set-override requires '); - printUsage(); - process.exit(1); - } - const credId = Number.parseInt(credIdStr, 10); - if (Number.isNaN(credId)) { - console.error('Error: credential-id must be a number'); - process.exit(1); - } - const agentType = parseFlag(args, '--agent-type'); - if (agentType) { - await setAgentCredentialOverride(projectId, envVarKey, agentType, credId); - console.log( - `Set agent override: project ${projectId} → ${envVarKey} → credential #${credId} (agent: ${agentType})`, - ); - } else { - await setProjectCredentialOverride(projectId, envVarKey, credId); - console.log(`Set override: project ${projectId} → ${envVarKey} → credential #${credId}`); - } - break; - } +async function handleRemoveOverride(args: string[]): Promise { + const [, projectId, envVarKey] = args; + if (!projectId || !envVarKey) { + console.error('Error: remove-override requires '); + printUsage(); + process.exit(1); + } + const agentType = parseFlag(args, '--agent-type'); + if (agentType) { + await removeAgentCredentialOverride(projectId, envVarKey, agentType); + console.log( + `Removed agent override: project ${projectId} → ${envVarKey} (agent: ${agentType})`, + ); + } else { + await removeProjectCredentialOverride(projectId, envVarKey); + console.log(`Removed override: project ${projectId} → ${envVarKey}`); + } +} - case 'remove-override': { - const [, projectId, envVarKey] = args; - if (!projectId || !envVarKey) { - console.error('Error: remove-override requires '); - printUsage(); - process.exit(1); - } - const agentType = parseFlag(args, '--agent-type'); - if (agentType) { - await removeAgentCredentialOverride(projectId, envVarKey, agentType); - console.log( - `Removed agent override: project ${projectId} → ${envVarKey} (agent: ${agentType})`, - ); - } else { - await removeProjectCredentialOverride(projectId, envVarKey); - console.log(`Removed override: project ${projectId} → ${envVarKey}`); - } - break; - } +async function handleResolve(args: string[]): Promise { + const projectId = args[1]; + if (!projectId) { + console.error('Error: resolve requires '); + printUsage(); + process.exit(1); + } + const project = await findProjectByIdFromDb(projectId); + if (!project) { + console.error(`Project '${projectId}' not found`); + process.exit(1); + } + const resolved = await resolveAllCredentials(projectId, project.orgId); + const overrides = await listProjectOverrides(projectId); + const projectOverrideKeys = new Set( + overrides.filter((o) => !o.agentType).map((o) => o.envVarKey), + ); + const agentOverrides = overrides.filter((o) => o.agentType); - case 'resolve': { - const projectId = args[1]; - if (!projectId) { - console.error('Error: resolve requires '); - printUsage(); - process.exit(1); - } - const project = await findProjectByIdFromDb(projectId); - if (!project) { - console.error(`Project '${projectId}' not found`); - process.exit(1); - } - const resolved = await resolveAllCredentials(projectId, project.orgId); - const overrides = await listProjectOverrides(projectId); - const projectOverrideKeys = new Set( - overrides.filter((o) => !o.agentType).map((o) => o.envVarKey), - ); - const agentOverrides = overrides.filter((o) => o.agentType); - - if (Object.keys(resolved).length === 0 && agentOverrides.length === 0) { - console.log(`No credentials resolved for project ${projectId}`); - } else { - console.log(`Resolved credentials for project ${projectId} (org: ${project.orgId}):`); - for (const [key, value] of Object.entries(resolved)) { - const source = projectOverrideKeys.has(key) ? 'override' : 'org-default'; - console.log(` ${key}: ${maskValue(value)} [${source}]`); - } - if (agentOverrides.length > 0) { - console.log(' Agent-scoped overrides:'); - for (const o of agentOverrides) { - console.log(` ${o.envVarKey} → ${o.credentialName} (agent: ${o.agentType})`); - } - } - } - break; + if (Object.keys(resolved).length === 0 && agentOverrides.length === 0) { + console.log(`No credentials resolved for project ${projectId}`); + return; + } + console.log(`Resolved credentials for project ${projectId} (org: ${project.orgId}):`); + for (const [key, value] of Object.entries(resolved)) { + const source = projectOverrideKeys.has(key) ? 'override' : 'org-default'; + console.log(` ${key}: ${maskValue(value)} [${source}]`); + } + if (agentOverrides.length > 0) { + console.log(' Agent-scoped overrides:'); + for (const o of agentOverrides) { + console.log(` ${o.envVarKey} → ${o.credentialName} (agent: ${o.agentType})`); } + } +} + +const commandHandlers: Record Promise> = { + create: handleCreate, + list: handleList, + delete: handleDelete, + 'set-override': handleSetOverride, + 'remove-override': handleRemoveOverride, + resolve: handleResolve, +}; + +async function main() { + const args = process.argv.slice(2); + const command = args[0]; - default: - console.error(`Unknown command: ${command}`); - printUsage(); - process.exit(1); + if (!command) { + printUsage(); + process.exit(1); + } + + const handler = commandHandlers[command]; + if (!handler) { + console.error(`Unknown command: ${command}`); + printUsage(); + process.exit(1); } + await handler(args); await closeDb(); } diff --git a/web/components.json b/web/components.json new file mode 100644 index 00000000..13e1db0b --- /dev/null +++ b/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/web/package-lock.json b/web/package-lock.json index 2cbfddf8..a0432380 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "cascade-web", "dependencies": { + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.121.0", "@trpc/client": "^11.1.2", @@ -13,8 +14,12 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.71.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.0", "tw-animate-css": "^1.2.9", "zod": "^4.3.6" @@ -753,6 +758,56 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -760,49 +815,1547 @@ "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", "license": "MIT", - "engines": { - "node": ">=6.0.0" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1160,6 +2713,12 @@ "win32" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -1644,7 +3203,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1654,7 +3213,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1681,6 +3240,18 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -1784,7 +3355,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1815,6 +3386,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -1931,6 +3508,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2306,6 +3892,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -2362,6 +3958,83 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -2383,6 +4056,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2393,6 +4082,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -2475,6 +4233,16 @@ "seroval": "^1.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2545,6 +4313,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -2598,6 +4372,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/web/package.json b/web/package.json index 4d8704a7..bbff84ab 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.121.0", "@trpc/client": "^11.1.2", @@ -15,8 +16,12 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.71.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.0", "tw-animate-css": "^1.2.9", "zod": "^4.3.6" diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index f167438c..6810f8e0 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -1,12 +1,50 @@ +import { Separator } from '@/components/ui/separator.js'; import { cn } from '@/lib/utils.js'; import { Link, useRouterState } from '@tanstack/react-router'; -import { Activity, LayoutDashboard } from 'lucide-react'; +import { Activity, Bot, FolderGit2, KeyRound, LayoutDashboard, Settings } from 'lucide-react'; interface SidebarProps { user: { name: string; email: string } | undefined; } -const navItems = [{ to: '/' as const, label: 'Runs', icon: Activity }]; +const mainNav = [ + { to: '/' as const, label: 'Runs', icon: Activity }, + { to: '/projects' as const, label: 'Projects', icon: FolderGit2 }, +]; + +const settingsNav = [ + { to: '/settings/general' as const, label: 'General', icon: Settings }, + { to: '/settings/credentials' as const, label: 'Credentials', icon: KeyRound }, + { to: '/settings/agents' as const, label: 'Agent Configs', icon: Bot }, +]; + +function NavLink({ + to, + label, + icon: Icon, + currentPath, +}: { + to: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + currentPath: string; +}) { + const isActive = currentPath === to || (to !== '/' && currentPath.startsWith(to)); + return ( + + + {label} + + ); +} export function Sidebar({ user }: SidebarProps) { const routerState = useRouterState(); @@ -20,20 +58,17 @@ export function Sidebar({ user }: SidebarProps) { diff --git a/web/src/components/projects/credential-overrides.tsx b/web/src/components/projects/credential-overrides.tsx new file mode 100644 index 00000000..14a09156 --- /dev/null +++ b/web/src/components/projects/credential-overrides.tsx @@ -0,0 +1,217 @@ +import { Badge } from '@/components/ui/badge.js'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; + +export function CredentialOverrides({ projectId }: { projectId: string }) { + const queryClient = useQueryClient(); + const overridesQuery = useQuery( + trpc.projects.credentialOverrides.list.queryOptions({ projectId }), + ); + const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); + + const [addOpen, setAddOpen] = useState(false); + const [envVarKey, setEnvVarKey] = useState(''); + const [credentialId, setCredentialId] = useState(''); + const [agentType, setAgentType] = useState(''); + + const queryKey = trpc.projects.credentialOverrides.list.queryOptions({ projectId }).queryKey; + + const setMutation = useMutation({ + mutationFn: () => { + if (agentType) { + return trpcClient.projects.credentialOverrides.setAgent.mutate({ + projectId, + envVarKey, + agentType, + credentialId: Number(credentialId), + }); + } + return trpcClient.projects.credentialOverrides.set.mutate({ + projectId, + envVarKey, + credentialId: Number(credentialId), + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + setAddOpen(false); + setEnvVarKey(''); + setCredentialId(''); + setAgentType(''); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (params: { envVarKey: string; agentType: string | null }) => { + if (params.agentType) { + return trpcClient.projects.credentialOverrides.removeAgent.mutate({ + projectId, + envVarKey: params.envVarKey, + agentType: params.agentType, + }); + } + return trpcClient.projects.credentialOverrides.remove.mutate({ + projectId, + envVarKey: params.envVarKey, + }); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + }); + + if (overridesQuery.isLoading) { + return
Loading credential overrides...
; + } + + const overrides = overridesQuery.data ?? []; + const credentials = credentialsQuery.data ?? []; + + return ( +
+
+

+ Override org-level credentials for this project. +

+ +
+ +
+ + + + Env Var Key + Credential + Scope + + + + + {overrides.length === 0 && ( + + + No overrides configured — using org defaults + + + )} + {overrides.map((o) => ( + + {o.envVarKey} + {o.credentialName} + + {o.agentType ? ( + {o.agentType} + ) : ( + Project-wide + )} + + + + + + ))} + +
+
+ + + + + Add Credential Override + +
{ + e.preventDefault(); + setMutation.mutate(); + }} + className="space-y-4" + > +
+ + setEnvVarKey(e.target.value)} + placeholder="GITHUB_TOKEN" + required + /> +
+
+ + +
+
+ + setAgentType(e.target.value)} + placeholder="Leave empty for project-wide" + /> +
+
+ + +
+ {setMutation.isError && ( +

{setMutation.error.message}

+ )} +
+
+
+
+ ); +} diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx new file mode 100644 index 00000000..64e503cf --- /dev/null +++ b/web/src/components/projects/integration-form.tsx @@ -0,0 +1,174 @@ +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Plus, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface KVPair { + key: string; + value: string; +} + +function KeyValueEditor({ + label, + pairs, + onChange, +}: { + label: string; + pairs: KVPair[]; + onChange: (pairs: KVPair[]) => void; +}) { + return ( +
+
+ + +
+ {pairs.map((pair, i) => ( +
+ { + const next = [...pairs]; + next[i] = { ...next[i], key: e.target.value }; + onChange(next); + }} + placeholder="Key" + className="flex-1" + /> + { + const next = [...pairs]; + next[i] = { ...next[i], value: e.target.value }; + onChange(next); + }} + placeholder="Value" + className="flex-1" + /> + +
+ ))} + {pairs.length === 0 &&

No entries

} +
+ ); +} + +function toKVPairs(obj: Record | undefined): KVPair[] { + if (!obj) return []; + return Object.entries(obj).map(([key, value]) => ({ key, value })); +} + +function fromKVPairs(pairs: KVPair[]): Record { + const result: Record = {}; + for (const pair of pairs) { + if (pair.key.trim()) { + result[pair.key.trim()] = pair.value; + } + } + return result; +} + +export function IntegrationForm({ projectId }: { projectId: string }) { + const queryClient = useQueryClient(); + const integrationsQuery = useQuery(trpc.projects.integrations.list.queryOptions({ projectId })); + + const [boardId, setBoardId] = useState(''); + const [lists, setLists] = useState([]); + const [labels, setLabels] = useState([]); + const [costField, setCostField] = useState(''); + + useEffect(() => { + if (integrationsQuery.data) { + const trello = integrationsQuery.data.find((i) => i.type === 'trello'); + if (trello) { + const config = trello.config as Record; + setBoardId((config.boardId as string) ?? ''); + setLists(toKVPairs(config.lists as Record)); + setLabels(toKVPairs(config.labels as Record)); + const cf = config.customFields as Record | undefined; + setCostField(cf?.cost ?? ''); + } + } + }, [integrationsQuery.data]); + + const upsertMutation = useMutation({ + mutationFn: () => + trpcClient.projects.integrations.upsert.mutate({ + projectId, + type: 'trello', + config: { + boardId, + lists: fromKVPairs(lists), + labels: fromKVPairs(labels), + ...(costField ? { customFields: { cost: costField } } : {}), + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + if (integrationsQuery.isLoading) { + return
Loading integrations...
; + } + + return ( +
+

Trello Integration

+ +
+ + setBoardId(e.target.value)} + placeholder="Trello board ID" + /> +
+ + + + +
+ + setCostField(e.target.value)} + placeholder="Custom field ID for cost tracking" + /> +
+ +
+ + {upsertMutation.isSuccess && Saved} + {upsertMutation.isError && ( + {upsertMutation.error.message} + )} +
+
+ ); +} diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx new file mode 100644 index 00000000..13b8ff8c --- /dev/null +++ b/web/src/components/projects/project-agent-configs.tsx @@ -0,0 +1,267 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select.js'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table.js'; +import { Textarea } from '@/components/ui/textarea.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; + +interface AgentConfig { + id: number; + agentType: string; + model: string | null; + maxIterations: number | null; + agentBackend: string | null; + prompt: string | null; +} + +export function ProjectAgentConfigs({ projectId }: { projectId: string }) { + const queryClient = useQueryClient(); + const configsQuery = useQuery(trpc.agentConfigs.list.queryOptions({ projectId })); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [agentType, setAgentType] = useState(''); + const [model, setModel] = useState(''); + const [maxIterations, setMaxIterations] = useState(''); + const [agentBackend, setAgentBackend] = useState(''); + const [prompt, setPrompt] = useState(''); + + const queryKey = trpc.agentConfigs.list.queryOptions({ projectId }).queryKey; + + function openCreate() { + setEditing(null); + setAgentType(''); + setModel(''); + setMaxIterations(''); + setAgentBackend(''); + setPrompt(''); + setDialogOpen(true); + } + + function openEdit(config: AgentConfig) { + setEditing(config); + setAgentType(config.agentType); + setModel(config.model ?? ''); + setMaxIterations(config.maxIterations?.toString() ?? ''); + setAgentBackend(config.agentBackend ?? ''); + setPrompt(config.prompt ?? ''); + setDialogOpen(true); + } + + const createMutation = useMutation({ + mutationFn: () => + trpcClient.agentConfigs.create.mutate({ + projectId, + agentType, + model: model || null, + maxIterations: maxIterations ? Number(maxIterations) : null, + agentBackend: agentBackend || null, + prompt: prompt || null, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + setDialogOpen(false); + }, + }); + + const updateMutation = useMutation({ + mutationFn: () => + trpcClient.agentConfigs.update.mutate({ + id: editing?.id as number, + agentType, + model: model || null, + maxIterations: maxIterations ? Number(maxIterations) : null, + agentBackend: agentBackend || null, + prompt: prompt || null, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + setDialogOpen(false); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => trpcClient.agentConfigs.delete.mutate({ id }), + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + }); + + if (configsQuery.isLoading) { + return
Loading agent configs...
; + } + + const configs = (configsQuery.data ?? []) as AgentConfig[]; + const activeMutation = editing ? updateMutation : createMutation; + + return ( +
+
+

Per-agent overrides scoped to this project.

+ +
+ +
+ + + + Agent Type + Model + Max Iterations + Backend + + + + + {configs.length === 0 && ( + + + No project-scoped agent configs + + + )} + {configs.map((config) => ( + + {config.agentType} + {config.model ?? '-'} + {config.maxIterations ?? '-'} + {config.agentBackend ?? '-'} + +
+ + +
+
+
+ ))} +
+
+
+ + + + + {editing ? 'Edit Agent Config' : 'New Agent Config'} + +
{ + e.preventDefault(); + activeMutation.mutate(); + }} + className="space-y-4" + > +
+ + setAgentType(e.target.value)} + placeholder="e.g. implementation, review" + required + /> +
+
+
+ + setModel(e.target.value)} + placeholder="Optional" + /> +
+
+ + setMaxIterations(e.target.value)} + placeholder="Optional" + /> +
+
+
+ + +
+
+ +