From dab65bee137584061c12fbe07ee9e193cae51d88 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 6 Mar 2026 22:27:51 +0000 Subject: [PATCH] refactor: cleanup god-function duplication and dead code --- src/api/routers/integrationsDiscovery.ts | 152 +++------------ src/backends/adapter.ts | 4 +- src/backends/agent-profiles.ts | 7 - src/backends/llmist/index.ts | 2 +- src/backends/secretBuilder.ts | 2 +- src/backends/toolManifests.ts | 6 - src/config/triggerConfig.ts | 19 -- src/db/repositories/credentialsRepository.ts | 104 ++++++++++ tests/unit/backends/adapter.test.ts | 6 +- tests/unit/backends/agent-profiles.test.ts | 2 +- tests/unit/backends/llmist.test.ts | 4 +- tests/unit/backends/secretBuilder.test.ts | 2 +- tests/unit/backends/toolManifests.test.ts | 2 +- tests/unit/config/triggerConfig.test.ts | 34 ---- web/src/components/projects/email-wizard.tsx | 154 +-------------- web/src/components/projects/pm-wizard.tsx | 154 +-------------- web/src/components/projects/twilio-wizard.tsx | 158 +--------------- web/src/components/projects/wizard-shared.tsx | 178 ++++++++++++++++++ 18 files changed, 335 insertions(+), 655 deletions(-) delete mode 100644 src/backends/agent-profiles.ts delete mode 100644 src/backends/toolManifests.ts create mode 100644 web/src/components/projects/wizard-shared.tsx diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index e2234888..8c4ec793 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -1,11 +1,15 @@ import { TRPCError } from '@trpc/server'; -import { and, eq } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { ImapFlow } from 'imapflow'; import twilio from 'twilio'; import { z } from 'zod'; import { getDb } from '../../db/client.js'; -import { decryptCredential, encryptCredential } from '../../db/crypto.js'; -import { credentials, integrationCredentials, projectIntegrations } from '../../db/schema/index.js'; +import { decryptCredential } from '../../db/crypto.js'; +import { + upsertCredentialByEnvVarKey, + upsertGmailIntegrationWithCredentials, +} from '../../db/repositories/credentialsRepository.js'; +import { credentials } from '../../db/schema/index.js'; import { exchangeGmailCode, getGmailAuthUrl, getGmailUserInfo } from '../../email/gmail/oauth.js'; import { jiraClient, withJiraCredentials } from '../../jira/client.js'; import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; @@ -289,127 +293,31 @@ export const integrationsDiscoveryRouter = router({ // Get user email const userInfo = await getGmailUserInfo(tokens.access_token); - const db = getDb(); - - // Ensure Gmail integration exists for the project - const [existingIntegration] = await db - .select({ id: projectIntegrations.id }) - .from(projectIntegrations) - .where( - and( - eq(projectIntegrations.projectId, projectId), - eq(projectIntegrations.category, 'email'), - ), - ); - - let integrationId: number; - if (existingIntegration) { - // Update to gmail provider - await db - .update(projectIntegrations) - .set({ provider: 'gmail', config: {}, updatedAt: new Date() }) - .where(eq(projectIntegrations.id, existingIntegration.id)); - integrationId = existingIntegration.id; - } else { - // Create new gmail integration - const [newIntegration] = await db - .insert(projectIntegrations) - .values({ - projectId, - category: 'email', - provider: 'gmail', - config: {}, - }) - .returning({ id: projectIntegrations.id }); - integrationId = newIntegration.id; - } - - // Create or update gmail_email credential - const emailCredName = `Gmail: ${userInfo.email}`; - const [existingEmailCred] = await db - .select({ id: credentials.id }) - .from(credentials) - .where( - and( - eq(credentials.orgId, ctx.effectiveOrgId), - eq(credentials.envVarKey, 'EMAIL_GMAIL_ADDRESS'), - eq(credentials.name, emailCredName), - ), - ); - - let emailCredId: number; - if (existingEmailCred) { - await db - .update(credentials) - .set({ - value: encryptCredential(userInfo.email, ctx.effectiveOrgId), - updatedAt: new Date(), - }) - .where(eq(credentials.id, existingEmailCred.id)); - emailCredId = existingEmailCred.id; - } else { - const [newCred] = await db - .insert(credentials) - .values({ - orgId: ctx.effectiveOrgId, - name: emailCredName, - envVarKey: 'EMAIL_GMAIL_ADDRESS', - value: encryptCredential(userInfo.email, ctx.effectiveOrgId), - isDefault: false, - }) - .returning({ id: credentials.id }); - emailCredId = newCred.id; - } - - // Create or update gmail_refresh_token credential - const refreshCredName = `Gmail Refresh Token: ${userInfo.email}`; - const [existingRefreshCred] = await db - .select({ id: credentials.id }) - .from(credentials) - .where( - and( - eq(credentials.orgId, ctx.effectiveOrgId), - eq(credentials.envVarKey, 'EMAIL_GMAIL_REFRESH_TOKEN'), - eq(credentials.name, refreshCredName), - ), - ); - - let refreshCredId: number; - if (existingRefreshCred) { - await db - .update(credentials) - .set({ - value: encryptCredential(tokens.refresh_token, ctx.effectiveOrgId), - updatedAt: new Date(), - }) - .where(eq(credentials.id, existingRefreshCred.id)); - refreshCredId = existingRefreshCred.id; - } else { - const [newCred] = await db - .insert(credentials) - .values({ - orgId: ctx.effectiveOrgId, - name: refreshCredName, - envVarKey: 'EMAIL_GMAIL_REFRESH_TOKEN', - value: encryptCredential(tokens.refresh_token, ctx.effectiveOrgId), - isDefault: false, - }) - .returning({ id: credentials.id }); - refreshCredId = newCred.id; - } - - // Link credentials to integration - // Delete any existing credential links for this integration - await db - .delete(integrationCredentials) - .where(eq(integrationCredentials.integrationId, integrationId)); - - // Insert new credential links - await db.insert(integrationCredentials).values([ - { integrationId, role: 'gmail_email', credentialId: emailCredId }, - { integrationId, role: 'gmail_refresh_token', credentialId: refreshCredId }, + // Upsert gmail_email and gmail_refresh_token credentials + const [emailCredId, refreshCredId] = await Promise.all([ + upsertCredentialByEnvVarKey({ + orgId: ctx.effectiveOrgId, + envVarKey: 'EMAIL_GMAIL_ADDRESS', + name: `Gmail: ${userInfo.email}`, + value: userInfo.email, + }), + upsertCredentialByEnvVarKey({ + orgId: ctx.effectiveOrgId, + envVarKey: 'EMAIL_GMAIL_REFRESH_TOKEN', + name: `Gmail Refresh Token: ${userInfo.email}`, + value: tokens.refresh_token, + }), ]); + // Upsert the Gmail integration and link credentials + await upsertGmailIntegrationWithCredentials({ + projectId, + credentialLinks: [ + { role: 'gmail_email', credentialId: emailCredId }, + { role: 'gmail_refresh_token', credentialId: refreshCredId }, + ], + }); + logger.info('Gmail OAuth credentials stored successfully', { projectId, email: userInfo.email, diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index b651ed38..1d1d8831 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -1,6 +1,8 @@ import type { ModelSpec } from 'llmist'; import { hasFinishValidation } from '../agents/definitions/index.js'; +import { getAgentProfile } from '../agents/definitions/profiles.js'; +import { getToolManifests } from '../agents/definitions/toolManifests.js'; import type { PromptContext } from '../agents/prompts/index.js'; import { type LogWriter, @@ -17,11 +19,9 @@ import { loadPartials } from '../db/repositories/partialsRepository.js'; import { recordInitialComment } from '../gadgets/sessionState.js'; import { withGitHubToken } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; -import { getAgentProfile } from './agent-profiles.js'; import { postProcessResult } from './postProcess.js'; import { createProgressMonitor } from './progress.js'; import { augmentProjectSecrets, resolveGitHubToken } from './secretBuilder.js'; -import { getToolManifests } from './toolManifests.js'; import type { AgentBackend, AgentBackendInput } from './types.js'; /** diff --git a/src/backends/agent-profiles.ts b/src/backends/agent-profiles.ts deleted file mode 100644 index 73fdee1b..00000000 --- a/src/backends/agent-profiles.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @deprecated Use src/agents/definitions/profiles.ts directly. - * This file is kept as a backward-compatible re-export shim so existing - * import paths continue to resolve without churn in every consumer. - */ -export type { AgentCapabilities, AgentProfile } from '../agents/definitions/profiles.js'; -export { getAgentProfile } from '../agents/definitions/profiles.js'; diff --git a/src/backends/llmist/index.ts b/src/backends/llmist/index.ts index a39ddf58..6c814845 100644 --- a/src/backends/llmist/index.ts +++ b/src/backends/llmist/index.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import { LLMist, type ModelSpec, createLogger } from 'llmist'; import { createIntegrationChecker } from '../../agents/capabilities/index.js'; +import { getAgentProfile } from '../../agents/definitions/profiles.js'; import { type BuilderType, createConfiguredBuilder } from '../../agents/shared/builderFactory.js'; import { injectSyntheticCall } from '../../agents/shared/syntheticCalls.js'; import { runAgentLoop } from '../../agents/utils/agentLoop.js'; @@ -13,7 +14,6 @@ import { createTrackingContext } from '../../agents/utils/tracking.js'; import { CUSTOM_MODELS } from '../../config/customModels.js'; import { createLLMCallLogger } from '../../utils/llmLogging.js'; import { extractPRUrl } from '../../utils/prUrl.js'; -import { getAgentProfile } from '../agent-profiles.js'; import type { AgentBackend, AgentBackendInput, AgentBackendResult } from '../types.js'; /** diff --git a/src/backends/secretBuilder.ts b/src/backends/secretBuilder.ts index 1754d549..2bed347a 100644 --- a/src/backends/secretBuilder.ts +++ b/src/backends/secretBuilder.ts @@ -1,9 +1,9 @@ +import type { AgentProfile } from '../agents/definitions/profiles.js'; import { getAllProjectCredentials } from '../config/provider.js'; import { getPersonaToken } from '../github/personas.js'; import { getJiraConfig } from '../pm/config.js'; import type { AgentInput, ProjectConfig } from '../types/index.js'; import { parseRepoFullName } from '../utils/repo.js'; -import type { AgentProfile } from './agent-profiles.js'; /** * Resolve the GitHub token for profiles that need GitHub client access. diff --git a/src/backends/toolManifests.ts b/src/backends/toolManifests.ts deleted file mode 100644 index 764f166b..00000000 --- a/src/backends/toolManifests.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * @deprecated Use src/agents/definitions/toolManifests.ts directly. - * This file is kept as a backward-compatible re-export shim so existing - * import paths continue to resolve without churn in every consumer. - */ -export { getToolManifests } from '../agents/definitions/toolManifests.js'; diff --git a/src/config/triggerConfig.ts b/src/config/triggerConfig.ts index 3a6a1e50..19a9a14a 100644 --- a/src/config/triggerConfig.ts +++ b/src/config/triggerConfig.ts @@ -60,12 +60,6 @@ export const TrelloTriggerConfigSchema = z.object({ commentMention: z.boolean().default(true), }); -/** - * @deprecated Use `StatusChangedSchema` instead. - */ -export const IssueTransitionedSchema = StatusChangedSchema; -export type IssueTransitionedConfig = StatusChangedConfig; - /** * Trigger configuration for JIRA integrations. * All triggers default to `true` for backward compatibility. @@ -434,16 +428,3 @@ export function resolveTrelloStatusChangedEnabled( // No config present — default enabled for backward compatibility return true; } - -/** - * @deprecated Use `resolveStatusChangedEnabled` instead. - * Resolve whether the issue-transitioned trigger is enabled for a specific agent type. - * Supports both the new nested object format and the legacy boolean format. - * Returns `true` when no config is present (backward compatible). - */ -export function resolveIssueTransitionedEnabled( - config: Partial | undefined, - agentType: string, -): boolean { - return resolveStatusChangedEnabled(config, agentType); -} diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 56e1c5ef..a8810e47 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -3,6 +3,110 @@ import { getDb } from '../client.js'; import { decryptCredential, encryptCredential } from '../crypto.js'; import { credentials, integrationCredentials, projectIntegrations } from '../schema/index.js'; +// ============================================================================ +// Gmail-specific repository helpers +// ============================================================================ + +/** + * Find or create a credential by (orgId, envVarKey, name), then update its value. + * Returns the credential ID. + * + * Used in the Gmail OAuth callback to upsert gmail_email and gmail_refresh_token + * credentials without duplicating the find-or-create + update pattern inline. + */ +export async function upsertCredentialByEnvVarKey(params: { + orgId: string; + envVarKey: string; + name: string; + value: string; +}): Promise { + const db = getDb(); + const { orgId, envVarKey, name, value } = params; + const encryptedValue = encryptCredential(value, orgId); + + const [existing] = await db + .select({ id: credentials.id }) + .from(credentials) + .where( + and( + eq(credentials.orgId, orgId), + eq(credentials.envVarKey, envVarKey), + eq(credentials.name, name), + ), + ); + + if (existing) { + await db + .update(credentials) + .set({ value: encryptedValue, updatedAt: new Date() }) + .where(eq(credentials.id, existing.id)); + return existing.id; + } + + const [created] = await db + .insert(credentials) + .values({ + orgId, + name, + envVarKey, + value: encryptedValue, + isDefault: false, + }) + .returning({ id: credentials.id }); + return created.id; +} + +/** + * Upsert a Gmail integration for a project (find/create the integration row), + * then replace all credential links with the provided ones. + * + * @param projectId - The project to upsert the integration for. + * @param credentialLinks - Array of { role, credentialId } pairs to link. + * @returns The integration ID. + */ +export async function upsertGmailIntegrationWithCredentials(params: { + projectId: string; + credentialLinks: Array<{ role: string; credentialId: number }>; +}): Promise { + const db = getDb(); + const { projectId, credentialLinks } = params; + + // Find or create the email integration row + const [existing] = await db + .select({ id: projectIntegrations.id }) + .from(projectIntegrations) + .where( + and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, 'email')), + ); + + let integrationId: number; + if (existing) { + await db + .update(projectIntegrations) + .set({ provider: 'gmail', config: {}, updatedAt: new Date() }) + .where(eq(projectIntegrations.id, existing.id)); + integrationId = existing.id; + } else { + const [created] = await db + .insert(projectIntegrations) + .values({ projectId, category: 'email', provider: 'gmail', config: {} }) + .returning({ id: projectIntegrations.id }); + integrationId = created.id; + } + + // Replace credential links + await db + .delete(integrationCredentials) + .where(eq(integrationCredentials.integrationId, integrationId)); + await db + .insert(integrationCredentials) + .values( + credentialLinks.map(({ role, credentialId }) => ({ integrationId, role, credentialId })), + ); + + return integrationId; +} + // ============================================================================ // Integration credential resolution // ============================================================================ diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 04db6d68..fa078c9e 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -66,8 +66,10 @@ vi.mock('../../../src/github/client.js', () => ({ withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), })); -vi.mock('../../../src/backends/agent-profiles.js', () => ({ +vi.mock('../../../src/agents/definitions/profiles.js', () => ({ getAgentProfile: vi.fn(), + hasFinishValidation: vi.fn(() => false), + getAgentCapabilities: vi.fn(), })); const mockCaptureException = vi.fn(); @@ -113,11 +115,11 @@ vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ storeRunLogs: (...args: unknown[]) => mockStoreRunLogs(...args), })); +import { type AgentProfile, getAgentProfile } from '../../../src/agents/definitions/profiles.js'; import { resolveModelConfig } from '../../../src/agents/shared/modelResolution.js'; import { setupRepository } from '../../../src/agents/shared/repository.js'; import { createAgentLogger } from '../../../src/agents/utils/logging.js'; import { executeWithBackend } from '../../../src/backends/adapter.js'; -import { type AgentProfile, getAgentProfile } from '../../../src/backends/agent-profiles.js'; import { createProgressMonitor } from '../../../src/backends/progress.js'; import type { AgentBackend } from '../../../src/backends/types.js'; import { getAllProjectCredentials } from '../../../src/config/provider.js'; diff --git a/tests/unit/backends/agent-profiles.test.ts b/tests/unit/backends/agent-profiles.test.ts index d51d81c5..1d033657 100644 --- a/tests/unit/backends/agent-profiles.test.ts +++ b/tests/unit/backends/agent-profiles.test.ts @@ -151,6 +151,7 @@ vi.mock('node:child_process', () => ({ import { execFileSync } from 'node:child_process'; import { hasFinishValidation } from '../../../src/agents/definitions/profiles.js'; +import { type AgentProfile, getAgentProfile } from '../../../src/agents/definitions/profiles.js'; import { formatPRComments, formatPRDetails, @@ -159,7 +160,6 @@ import { formatPRReviews, readPRFileContents, } from '../../../src/agents/shared/prFormatting.js'; -import { type AgentProfile, getAgentProfile } from '../../../src/backends/agent-profiles.js'; import { readWorkItem } from '../../../src/gadgets/pm/core/readWorkItem.js'; import { githubClient } from '../../../src/github/client.js'; import { resolveSquintDbPath } from '../../../src/utils/squintDb.js'; diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index 5520e413..2d4a6113 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -19,7 +19,7 @@ vi.mock('../../../src/agents/definitions/index.js', () => ({ resolveAgentDefinition: vi.fn(async () => ({ backend: {} })), })); -vi.mock('../../../src/backends/agent-profiles.js', () => ({ +vi.mock('../../../src/agents/definitions/profiles.js', () => ({ getAgentProfile: vi.fn(() => ({ getLlmistGadgets: vi.fn(() => []), })), @@ -276,7 +276,7 @@ describe('LlmistBackend.execute', () => { loopTerminated: false, }); - const { getAgentProfile } = await import('../../../src/backends/agent-profiles.js'); + const { getAgentProfile } = await import('../../../src/agents/definitions/profiles.js'); const mockGetAgentProfile = vi.mocked(getAgentProfile); const mockGetLlmistGadgets = vi.fn().mockReturnValue([]); mockGetAgentProfile.mockReturnValue({ diff --git a/tests/unit/backends/secretBuilder.test.ts b/tests/unit/backends/secretBuilder.test.ts index 81a26c22..6fc2a936 100644 --- a/tests/unit/backends/secretBuilder.test.ts +++ b/tests/unit/backends/secretBuilder.test.ts @@ -8,7 +8,7 @@ vi.mock('../../../src/github/personas.js', () => ({ getPersonaToken: vi.fn(), })); -import type { AgentProfile } from '../../../src/backends/agent-profiles.js'; +import type { AgentProfile } from '../../../src/agents/definitions/profiles.js'; import { augmentProjectSecrets, resolveGitHubToken } from '../../../src/backends/secretBuilder.js'; import { getAllProjectCredentials } from '../../../src/config/provider.js'; import { getPersonaToken } from '../../../src/github/personas.js'; diff --git a/tests/unit/backends/toolManifests.test.ts b/tests/unit/backends/toolManifests.test.ts index fc489dba..40832611 100644 --- a/tests/unit/backends/toolManifests.test.ts +++ b/tests/unit/backends/toolManifests.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getToolManifests } from '../../../src/backends/toolManifests.js'; +import { getToolManifests } from '../../../src/agents/definitions/toolManifests.js'; describe('getToolManifests', () => { it('returns an array of tool manifests', () => { diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts index 9eaa74e4..ad8b9917 100644 --- a/tests/unit/config/triggerConfig.test.ts +++ b/tests/unit/config/triggerConfig.test.ts @@ -7,7 +7,6 @@ import { parseEmailJokeTriggers, resolveEmailJokeTriggerConfig, resolveGitHubTriggerEnabled, - resolveIssueTransitionedEnabled, resolveJiraTriggerEnabled, resolvePerAgentToggle, resolveReadyToProcessEnabled, @@ -440,39 +439,6 @@ describe('resolveStatusChangedEnabled', () => { }); }); -describe('resolveIssueTransitionedEnabled (deprecated alias)', () => { - it('returns true when config is undefined (backward compatible)', () => { - expect(resolveIssueTransitionedEnabled(undefined, 'splitting')).toBe(true); - expect(resolveIssueTransitionedEnabled(undefined, 'planning')).toBe(true); - expect(resolveIssueTransitionedEnabled(undefined, 'implementation')).toBe(true); - }); - - it('returns true when issueTransitioned is not set', () => { - expect(resolveIssueTransitionedEnabled({}, 'splitting')).toBe(true); - }); - - it('applies legacy boolean true to all agents', () => { - const config = { issueTransitioned: true as const }; - expect(resolveIssueTransitionedEnabled(config, 'splitting')).toBe(true); - expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(true); - expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(true); - }); - - it('applies legacy boolean false to all agents', () => { - const config = { issueTransitioned: false as const }; - expect(resolveIssueTransitionedEnabled(config, 'splitting')).toBe(false); - expect(resolveIssueTransitionedEnabled(config, 'planning')).toBe(false); - expect(resolveIssueTransitionedEnabled(config, 'implementation')).toBe(false); - }); - - it('defaults all agents to true when nested object is empty (Zod fills defaults)', () => { - const parsed = JiraTriggerConfigSchema.parse({ issueTransitioned: {} }); - expect(resolveIssueTransitionedEnabled(parsed, 'splitting')).toBe(true); - expect(resolveIssueTransitionedEnabled(parsed, 'planning')).toBe(true); - expect(resolveIssueTransitionedEnabled(parsed, 'implementation')).toBe(true); - }); -}); - describe('resolveTrelloStatusChangedEnabled', () => { it('returns true when config is undefined (backward compatible)', () => { expect(resolveTrelloStatusChangedEnabled(undefined, 'splitting')).toBe(true); diff --git a/web/src/components/projects/email-wizard.tsx b/web/src/components/projects/email-wizard.tsx index c26108cc..49b8abc7 100644 --- a/web/src/components/projects/email-wizard.tsx +++ b/web/src/components/projects/email-wizard.tsx @@ -2,30 +2,15 @@ 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 { - AlertCircle, - Check, - CheckCircle, - ChevronDown, - ChevronRight, - Loader2, - Mail, - Plus, - XCircle, -} from 'lucide-react'; +import { AlertCircle, CheckCircle, Loader2, Mail, XCircle } from 'lucide-react'; import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; +import { InlineCredentialCreator, WizardStep } from './wizard-shared.js'; +import type { CredentialOption } from './wizard-shared.js'; // ============================================================================ // Types // ============================================================================ -interface CredentialOption { - id: number; - name: string; - envVarKey: string; - value: string; -} - type Provider = 'gmail' | 'imap'; interface WizardState { @@ -108,139 +93,6 @@ function wizardReducer(state: WizardState, action: WizardAction): WizardState { const STEP_TITLES = ['Provider', 'Connect', 'Verify', 'Save'] as const; -function WizardStep({ - stepNumber, - title, - status, - isOpen, - onToggle, - children, -}: { - stepNumber: number; - title: string; - status: 'pending' | 'complete' | 'active'; - isOpen: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - const statusClasses = - status === 'complete' - ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' - : status === 'active' - ? 'bg-primary text-primary-foreground' - : 'bg-muted text-muted-foreground'; - - return ( -
- - {isOpen &&
{children}
} -
- ); -} - -// ============================================================================ -// Inline Credential Creator -// ============================================================================ - -function InlineCredentialCreator({ - onCreated, - suggestedKey, -}: { - onCreated: (id: number) => void; - suggestedKey?: string; -}) { - const [isOpen, setIsOpen] = useState(false); - const [name, setName] = useState(''); - const [envVarKey, setEnvVarKey] = useState(suggestedKey ?? ''); - const [value, setValue] = useState(''); - const queryClient = useQueryClient(); - - const createMutation = useMutation({ - mutationFn: () => - trpcClient.credentials.create.mutate({ name, envVarKey, value, isDefault: false }), - onSuccess: async (result) => { - await queryClient.invalidateQueries({ - queryKey: trpc.credentials.list.queryOptions().queryKey, - }); - onCreated((result as { id: number }).id); - setIsOpen(false); - setName(''); - setEnvVarKey(suggestedKey ?? ''); - setValue(''); - }, - }); - - if (!isOpen) { - return ( - - ); - } - - return ( -
-
- setName(e.target.value)} - placeholder="Name" - className="flex-1" - /> - setEnvVarKey(e.target.value.toUpperCase())} - placeholder="ENV_VAR_KEY" - className="flex-1" - /> -
- setValue(e.target.value)} - placeholder="Secret value" - type="password" - /> -
- - -
-
- ); -} - // ============================================================================ // Credential Select Component // ============================================================================ diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index cdf9fc50..610e8725 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -6,7 +6,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertCircle, AlertTriangle, - Check, CheckCircle, ChevronDown, ChevronRight, @@ -14,24 +13,18 @@ import { Globe, KeyRound, Loader2, - Plus, RefreshCw, Trash2, XCircle, } from 'lucide-react'; import { type Reducer, useEffect, useReducer, useState } from 'react'; +import { InlineCredentialCreator, WizardStep } from './wizard-shared.js'; +import type { CredentialOption } from './wizard-shared.js'; // ============================================================================ // Types // ============================================================================ -interface CredentialOption { - id: number; - name: string; - envVarKey: string; - value: string; -} - interface TrelloBoardOption { id: string; name: string; @@ -267,149 +260,6 @@ const STEP_TITLES = [ 'Save', ] as const; -function WizardStep({ - stepNumber, - title, - status, - isOpen, - onToggle, - children, -}: { - stepNumber: number; - title: string; - status: 'pending' | 'complete' | 'error' | 'active'; - isOpen: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - return ( -
- - {isOpen &&
{children}
} -
- ); -} - -// ============================================================================ -// Inline Credential Creator -// ============================================================================ - -function InlineCredentialCreator({ - onCreated, -}: { - onCreated: (id: number) => void; -}) { - const [isOpen, setIsOpen] = useState(false); - const [name, setName] = useState(''); - const [envVarKey, setEnvVarKey] = useState(''); - const [value, setValue] = useState(''); - const queryClient = useQueryClient(); - - const createMutation = useMutation({ - mutationFn: async () => { - return trpcClient.credentials.create.mutate({ - name, - envVarKey, - value, - isDefault: false, - }); - }, - onSuccess: async (result) => { - await queryClient.invalidateQueries({ - queryKey: trpc.credentials.list.queryOptions().queryKey, - }); - onCreated((result as { id: number }).id); - setIsOpen(false); - setName(''); - setEnvVarKey(''); - setValue(''); - }, - }); - - if (!isOpen) { - return ( - - ); - } - - return ( -
-
- setName(e.target.value)} - placeholder="Name (e.g. My Trello Key)" - className="flex-1" - /> - setEnvVarKey(e.target.value.toUpperCase())} - placeholder="ENV_VAR_KEY" - className="flex-1" - /> -
- setValue(e.target.value)} - placeholder="Secret value" - type="password" - /> -
- - - {createMutation.isError && ( - - {createMutation.error.message} - - )} -
-
- ); -} - // ============================================================================ // Searchable Select // ============================================================================ diff --git a/web/src/components/projects/twilio-wizard.tsx b/web/src/components/projects/twilio-wizard.tsx index dd318d10..711113d4 100644 --- a/web/src/components/projects/twilio-wizard.tsx +++ b/web/src/components/projects/twilio-wizard.tsx @@ -1,31 +1,15 @@ -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 { - Check, - CheckCircle, - ChevronDown, - ChevronRight, - Copy, - Loader2, - MessageSquare, - Plus, - XCircle, -} from 'lucide-react'; +import { Check, CheckCircle, Copy, Loader2, MessageSquare, XCircle } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { InlineCredentialCreator, WizardStep } from './wizard-shared.js'; +import type { CredentialOption } from './wizard-shared.js'; // ============================================================================ // Types // ============================================================================ -interface CredentialOption { - id: number; - name: string; - envVarKey: string; - value: string; -} - interface WizardState { accountSidCredentialId: number | null; authTokenCredentialId: number | null; @@ -35,141 +19,9 @@ interface WizardState { } // ============================================================================ -// Wizard Step Shell +// Wizard Step Shell / Inline Credential Creator // ============================================================================ - -function WizardStep({ - stepNumber, - title, - status, - isOpen, - onToggle, - children, -}: { - stepNumber: number; - title: string; - status: 'pending' | 'complete' | 'active'; - isOpen: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - const statusClasses = - status === 'complete' - ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' - : status === 'active' - ? 'bg-primary text-primary-foreground' - : 'bg-muted text-muted-foreground'; - - return ( -
- - {isOpen &&
{children}
} -
- ); -} - -// ============================================================================ -// Inline Credential Creator -// ============================================================================ - -function InlineCredentialCreator({ - onCreated, - suggestedKey, -}: { - onCreated: (id: number) => void; - suggestedKey?: string; -}) { - const [isOpen, setIsOpen] = useState(false); - const [name, setName] = useState(''); - const [envVarKey, setEnvVarKey] = useState(suggestedKey ?? ''); - const [value, setValue] = useState(''); - const queryClient = useQueryClient(); - - const createMutation = useMutation({ - mutationFn: () => - trpcClient.credentials.create.mutate({ name, envVarKey, value, isDefault: false }), - onSuccess: async (result) => { - await queryClient.invalidateQueries({ - queryKey: trpc.credentials.list.queryOptions().queryKey, - }); - onCreated((result as { id: number }).id); - setIsOpen(false); - setName(''); - setEnvVarKey(suggestedKey ?? ''); - setValue(''); - }, - }); - - if (!isOpen) { - return ( - - ); - } - - return ( -
-
- setName(e.target.value)} - placeholder="Name" - className="flex-1" - /> - setEnvVarKey(e.target.value.toUpperCase())} - placeholder="ENV_VAR_KEY" - className="flex-1" - /> -
- setValue(e.target.value)} - placeholder="Secret value" - type="password" - /> -
- - -
-
- ); -} +// (Imported from wizard-shared.tsx) // ============================================================================ // Credential Select diff --git a/web/src/components/projects/wizard-shared.tsx b/web/src/components/projects/wizard-shared.tsx new file mode 100644 index 00000000..2c6f61ba --- /dev/null +++ b/web/src/components/projects/wizard-shared.tsx @@ -0,0 +1,178 @@ +/** + * Shared wizard UI components used across pm-wizard, email-wizard, and twilio-wizard. + * Extracted to eliminate ~250 lines of verbatim duplication. + */ +import { Input } from '@/components/ui/input.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Check, ChevronDown, ChevronRight, Loader2, Plus } from 'lucide-react'; +import { useState } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CredentialOption { + id: number; + name: string; + envVarKey: string; + value: string; +} + +// ============================================================================ +// WizardStep Shell +// ============================================================================ + +/** + * Collapsible wizard step with a numbered circle indicator. + * + * The `status` prop is a superset of all three wizard usages: + * - pm-wizard uses 'pending' | 'complete' | 'error' | 'active' + * - email-wizard and twilio-wizard use 'pending' | 'complete' | 'active' + */ +export function WizardStep({ + stepNumber, + title, + status, + isOpen, + onToggle, + children, +}: { + stepNumber: number; + title: string; + status: 'pending' | 'complete' | 'error' | 'active'; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + const statusClasses = + status === 'complete' + ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + : status === 'error' + ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + : status === 'active' + ? 'bg-primary text-primary-foreground' + : 'bg-muted text-muted-foreground'; + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +// ============================================================================ +// Inline Credential Creator +// ============================================================================ + +/** + * Inline form for creating a new credential without leaving the wizard. + * Renders as a "Create new" link that expands into a small form. + * + * The optional `suggestedKey` prop pre-fills the ENV_VAR_KEY input and is + * reset on success (used by email-wizard and twilio-wizard; absent in pm-wizard). + */ +export function InlineCredentialCreator({ + onCreated, + suggestedKey, +}: { + onCreated: (id: number) => void; + suggestedKey?: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [envVarKey, setEnvVarKey] = useState(suggestedKey ?? ''); + const [value, setValue] = useState(''); + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: () => + trpcClient.credentials.create.mutate({ name, envVarKey, value, isDefault: false }), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ + queryKey: trpc.credentials.list.queryOptions().queryKey, + }); + onCreated((result as { id: number }).id); + setIsOpen(false); + setName(''); + setEnvVarKey(suggestedKey ?? ''); + setValue(''); + }, + }); + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+ setName(e.target.value)} + placeholder="Name" + className="flex-1" + /> + setEnvVarKey(e.target.value.toUpperCase())} + placeholder="ENV_VAR_KEY" + className="flex-1" + /> +
+ setValue(e.target.value)} + placeholder="Secret value" + type="password" + /> +
+ + + {createMutation.isError && ( + + {createMutation.error.message} + + )} +
+
+ ); +}