diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 892ad9c6..c77c8782 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -133,6 +133,9 @@ {"id":"agent-relay-468","title":"Implement messages API endpoints","description":"Create new router: src/cloud/api/messages.ts\n\nEndpoints:\n1. GET /api/messages/conversations\n - List all conversations for user in workspace\n - Include unread count per conversation\n - Sort by last_message_at descending\n\n2. GET /api/messages/conversations/:conversationId\n - Paginated message list (50 per page, cursor-based)\n - Include sender name/avatar lookup\n\n3. POST /api/messages/send\n - Body: { recipientId, recipientType, workspaceId, content, contentType? }\n - Create conversation if doesn't exist\n - Store message\n - Return message + conversationId\n\n4. POST /api/messages/conversations/:conversationId/read\n - Update message_read_state\n - Return success\n\n5. GET /api/messages/unread-count\n - Total unread across all conversations\n - For badge display\n\nAll endpoints require workspace access (use requireWorkspaceAccess middleware)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T00:14:32.043028+01:00","updated_at":"2026-01-06T00:14:32.043028+01:00","dependencies":[{"issue_id":"agent-relay-468","depends_on_id":"agent-relay-467","type":"blocks","created_at":"2026-01-06T14:07:38.45694+01:00","created_by":"import"},{"issue_id":"agent-relay-468","depends_on_id":"agent-relay-464","type":"blocks","created_at":"2026-01-06T14:07:38.457577+01:00","created_by":"import"}]} {"id":"agent-relay-469","title":"Extend presence WebSocket for real-time DM delivery","description":"Extend /ws/presence WebSocket handler to support DM delivery:\n\nNew message types:\n1. dm_send (client-\u003eserver)\n - recipientId, recipientType, content, tempId\n - Store message via messages API\n - Forward to recipient's WebSocket connection\n\n2. dm_received (server-\u003eclient)\n - Full message object\n - Sent to recipient when they're online\n\n3. dm_sent (server-\u003eclient)\n - Confirmation with tempId for optimistic UI\n - Sent back to sender\n\n4. dm_typing (both directions)\n - conversationId, userId, userName\n - Debounced, auto-expires after 3s\n\nAgent bridging:\n- If recipientType='agent', forward via daemon WebSocket\n- Store message for history\n- Handle agent responses coming back\n\nLocation: src/cloud/server.ts (presence WebSocket section)\n\nConsider: separate /ws/messages endpoint vs extending presence","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T00:14:44.150375+01:00","updated_at":"2026-01-06T00:14:44.150375+01:00","dependencies":[{"issue_id":"agent-relay-469","depends_on_id":"agent-relay-468","type":"blocks","created_at":"2026-01-06T14:07:38.458202+01:00","created_by":"import"}]} {"id":"agent-relay-470","title":"Build messaging UI components for dashboard","description":"Create React components for messaging:\n\n1. ConversationList\n - Sidebar showing all DM threads\n - Unread count badges\n - Click to select conversation\n - \"New DM\" button\n\n2. MessageThread\n - Main chat view\n - Infinite scroll for history\n - New message indicator\n - Typing indicator display\n\n3. MessageInput\n - Text input with send button\n - Typing indicator emission\n - Markdown/code toggle (optional v1)\n\n4. UserSelector\n - Modal/dropdown to start new DM\n - Show online workspace members + agents\n - Search/filter\n\n5. UnreadBadge\n - Notification count component\n - Used in navigation\n\nIntegration:\n- Add to workspace dashboard layout\n- Connect to WebSocket for real-time\n- Use API for initial data + history\n\nLocation: src/dashboard/react-components/messaging/\nStyling: Follow existing Tailwind patterns","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T00:14:56.997803+01:00","updated_at":"2026-01-06T00:14:56.997803+01:00","dependencies":[{"issue_id":"agent-relay-470","depends_on_id":"agent-relay-469","type":"blocks","created_at":"2026-01-06T14:07:38.458868+01:00","created_by":"import"}]} +{"id":"agent-relay-471","title":"Use 1GB instance for free tier workspaces","description":"Reduce free tier Fly.io costs by using smaller instance. Change provisioner to use 1 CPU / 1GB RAM (shared-cpu-1x) for free tier users instead of 2 CPU / 2GB. Saves ~$8/user/month.","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-06T15:47:42.391116+01:00","updated_at":"2026-01-06T15:47:42.391116+01:00"} +{"id":"agent-relay-472","title":"Add cron to enforce free tier 5-hour compute limit","description":"Add enforcement for free tier compute hours. Create a cron job that: 1) Checks all free tier users' compute usage, 2) Stops workspaces when 5 hours reached, 3) Sends notification email. Run every 15 minutes.","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-06T15:47:44.48545+01:00","updated_at":"2026-01-06T15:47:44.48545+01:00"} +{"id":"agent-relay-473","title":"Add remaining compute hours banner for free tier","description":"Show free tier users their remaining compute hours in the dashboard. Display banner with: 'X of 5 hours used this month' and upgrade CTA when approaching limit (\u003e4 hours). Include in workspace header.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T15:47:45.396171+01:00","updated_at":"2026-01-06T15:47:45.396171+01:00"} {"id":"agent-relay-47z","title":"Express 5 may have breaking changes from Express 4 patterns","description":"package.json uses express@5.2.1 which is a major version with breaking changes from Express 4. Verify: (1) Error handling middleware patterns, (2) Router behavior, (3) Body parsing (express.json vs body-parser).","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-20T00:18:49.269841+01:00","updated_at":"2025-12-20T00:18:49.269841+01:00"} {"id":"agent-relay-4e0","title":"Fix message truncation - messages cut off at source","description":"Root cause found: parser.ts:40 inline regex only captures single line. Multi-line messages are split by parsePassThrough() at line 206. Fix options: (1) Allow continuation lines in inline format, (2) Use block format for multi-line, (3) Add heuristic to join lines until next @relay pattern.","status":"closed","priority":2,"issue_type":"bug","assignee":"MistyShelter","created_at":"2025-12-19T23:40:35.082717+01:00","updated_at":"2025-12-20T00:03:54.806087+01:00","closed_at":"2025-12-20T00:03:54.806087+01:00"} {"id":"agent-relay-4ft","title":"Merge project info into status command","status":"closed","priority":2,"issue_type":"task","assignee":"Pruner","created_at":"2025-12-19T21:59:52.685495+01:00","updated_at":"2025-12-19T22:06:44.276187+01:00","closed_at":"2025-12-19T22:06:44.276187+01:00"} diff --git a/src/cloud/api/billing.ts b/src/cloud/api/billing.ts index 7a3748ed..f00423d6 100644 --- a/src/cloud/api/billing.ts +++ b/src/cloud/api/billing.ts @@ -19,7 +19,21 @@ export const billingRouter = Router(); * Get all available billing plans */ billingRouter.get('/plans', (req, res) => { - const plans = getAllPlans(); + const rawPlans = getAllPlans(); + + // Transform plans to frontend format + const plans = rawPlans.map((plan) => ({ + tier: plan.id, + name: plan.name, + description: plan.description, + price: { + monthly: plan.priceMonthly / 100, // Convert cents to dollars + yearly: plan.priceYearly / 100, + }, + features: plan.features, + limits: plan.limits, + recommended: plan.id === 'pro', + })); // Add publishable key for frontend const config = getConfig(); diff --git a/src/cloud/api/daemons.ts b/src/cloud/api/daemons.ts index b5a5f576..dd2388ed 100644 --- a/src/cloud/api/daemons.ts +++ b/src/cloud/api/daemons.ts @@ -13,7 +13,6 @@ import { Router, Request, Response } from 'express'; import { randomBytes, createHash } from 'crypto'; import { requireAuth } from './auth.js'; import { db } from '../db/index.js'; -import { vault } from '../vault/index.js'; export const daemonsRouter = Router(); @@ -235,24 +234,29 @@ daemonsRouter.post('/heartbeat', requireDaemonAuth as any, async (req: Request, /** * GET /api/daemons/credentials - * Get credentials for the daemon's user (syncs cloud credentials to local) + * Get credentials for the daemon's user + * + * Note: Tokens are no longer stored centrally. CLI tools authenticate directly + * on workspace/local instances. This endpoint returns connected provider info only. */ daemonsRouter.get('/credentials', requireDaemonAuth as any, async (req: Request, res: Response) => { const daemon = (req as any).daemon; try { - // Get all decrypted credentials for the user via vault - const credentialsMap = await vault.getUserCredentials(daemon.userId); - - // Convert Map to array format for API response - const credentials = Array.from(credentialsMap.entries()).map(([provider, cred]) => ({ - provider, - accessToken: cred.accessToken, - tokenType: 'bearer', - expiresAt: cred.tokenExpiresAt, + // Get connected providers for this user (no tokens stored centrally) + const credentials = await db.credentials.findByUserId(daemon.userId); + + // Return provider info without tokens + const providers = credentials.map((cred) => ({ + provider: cred.provider, + providerAccountEmail: cred.providerAccountEmail, + connectedAt: cred.createdAt, })); - res.json({ credentials }); + res.json({ + providers, + note: 'Tokens are authenticated locally on workspace instances via CLI.', + }); } catch (error) { console.error('Error fetching credentials:', error); res.status(500).json({ error: 'Failed to fetch credentials' }); diff --git a/src/cloud/api/onboarding.ts b/src/cloud/api/onboarding.ts index 92287103..063cb59c 100644 --- a/src/cloud/api/onboarding.ts +++ b/src/cloud/api/onboarding.ts @@ -15,7 +15,6 @@ import type { IPty } from 'node-pty'; import * as crypto from 'crypto'; import { requireAuth } from './auth.js'; import { db } from '../db/index.js'; -import { vault } from '../vault/index.js'; // Import for local use import { @@ -414,13 +413,11 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request, }); } - // Store in vault with refresh token and expiry - await vault.storeCredential({ + // Mark provider as connected (tokens are not stored centrally - CLI tools + // authenticate directly on workspace instances) + await db.credentials.upsert({ userId, provider, - accessToken, - refreshToken, - tokenExpiresAt, scopes: getProviderScopes(provider), }); @@ -568,11 +565,11 @@ onboardingRouter.post('/token/:provider', async (req: Request, res: Response) => return res.status(400).json({ error: 'Invalid token' }); } - // Store in vault - await vault.storeCredential({ + // Mark provider as connected (tokens are not stored centrally - CLI tools + // authenticate directly on workspace instances) + await db.credentials.upsert({ userId, provider, - accessToken: token, scopes: getProviderScopes(provider), providerAccountEmail: email, }); @@ -580,10 +577,11 @@ onboardingRouter.post('/token/:provider', async (req: Request, res: Response) => res.json({ success: true, message: `${provider} connected successfully`, + note: 'Token validated. Configure this on your workspace for usage.', }); } catch (error) { - console.error(`Error storing token for ${provider}:`, error); - res.status(500).json({ error: 'Failed to store token' }); + console.error(`Error storing provider connection for ${provider}:`, error); + res.status(500).json({ error: 'Failed to store provider connection' }); } }); diff --git a/src/cloud/api/providers.ts b/src/cloud/api/providers.ts index 7ad7448e..fac7c810 100644 --- a/src/cloud/api/providers.ts +++ b/src/cloud/api/providers.ts @@ -10,7 +10,6 @@ import { createClient } from 'redis'; import { requireAuth } from './auth.js'; import { getConfig } from '../config.js'; import { db } from '../db/index.js'; -import { vault } from '../vault/index.js'; export const providersRouter = Router(); @@ -334,12 +333,11 @@ providersRouter.post('/:provider/verify', async (req: Request, res: Response) => // In production, we'd verify by making a test API call with the credentials try { - // For now, mark as connected (in production, verify credentials exist) - // This would be called after the user's workspace detects valid credentials + // Mark as connected (tokens are not stored centrally - CLI tools + // authenticate directly on workspace instances) await db.credentials.upsert({ userId, provider, - accessToken: 'cli-authenticated', // Placeholder - real token from CLI scopes: [], // CLI auth doesn't use scopes providerAccountEmail: req.body.email, // User can optionally provide }); @@ -347,7 +345,7 @@ providersRouter.post('/:provider/verify', async (req: Request, res: Response) => res.json({ success: true, message: `${providerConfig.displayName} connected via CLI`, - note: 'Credentials will be synced when workspace starts', + note: 'CLI credentials remain on your local machine', }); } catch (error) { console.error(`Error verifying ${provider} auth:`, error); @@ -403,18 +401,19 @@ providersRouter.post('/:provider/api-key', async (req: Request, res: Response) = return res.status(400).json({ error: 'Invalid API key' }); } - // Store the API key - use scopes from device flow providers, empty for CLI providers + // Mark provider as connected (tokens are not stored centrally - CLI tools + // authenticate directly on workspace instances) const scopes = isDeviceFlowProvider(providerConfig) ? providerConfig.scopes : []; - await vault.storeCredential({ + await db.credentials.upsert({ userId, provider, - accessToken: apiKey, scopes, }); res.json({ success: true, message: `${providerConfig.displayName} connected`, + note: 'API key validated. Configure this key on your workspace for usage.', }); } catch (error) { console.error(`Error connecting ${provider} with API key:`, error); @@ -592,7 +591,9 @@ async function pollForToken(flowId: string, provider: ProviderType, clientId: st } /** - * Store tokens after successful device flow + * Mark provider as connected after successful device flow + * Note: Tokens are not stored centrally - CLI tools authenticate directly + * on workspace instances. We only record the connection status and user info. */ async function storeProviderTokens( userId: string, @@ -623,15 +624,10 @@ async function storeProviderTokens( } } - // Encrypt and store - await vault.storeCredential({ + // Mark provider as connected (without storing tokens) + await db.credentials.upsert({ userId, provider, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - tokenExpiresAt: tokens.expiresIn - ? new Date(Date.now() + tokens.expiresIn * 1000) - : undefined, scopes: tokens.scope?.split(' '), providerAccountId: userInfo.id, providerAccountEmail: userInfo.email, diff --git a/src/cloud/api/workspaces.ts b/src/cloud/api/workspaces.ts index 6f4d78e2..5f47ef3b 100644 --- a/src/cloud/api/workspaces.ts +++ b/src/cloud/api/workspaces.ts @@ -473,6 +473,34 @@ workspacesRouter.post('/', checkWorkspaceLimit, async (req: Request, res: Respon return res.status(400).json({ error: 'Repositories array is required' }); } + // Check if any of the repos already have a workspace the user can access + // This prevents creating duplicate workspaces for the same repo + for (const repoFullName of repositories as string[]) { + const existingRepos = await db.repositories.findByGithubFullName(repoFullName); + for (const existingRepo of existingRepos) { + if (existingRepo.workspaceId) { + const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId); + if (accessResult.hasAccess) { + const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId); + if (existingWorkspace) { + console.log(`[workspaces/create] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repoFullName}`); + return res.status(409).json({ + error: 'A workspace already exists for one of these repositories', + existingWorkspace: { + id: existingWorkspace.id, + name: existingWorkspace.name, + publicUrl: existingWorkspace.publicUrl, + accessType: accessResult.accessType, + }, + conflictingRepo: repoFullName, + message: `You already have ${accessResult.accessType} access to workspace "${existingWorkspace.name}" which includes ${repoFullName}.`, + }); + } + } + } + } + } + // Verify user has credentials for all providers const credentials = await db.credentials.findByUserId(userId); const connectedProviders = new Set(credentials.map((c) => c.provider)); @@ -988,6 +1016,141 @@ workspacesRouter.post('/:id/repos', async (req: Request, res: Response) => { } }); +/** + * GET /api/workspaces/:id/repos + * List repositories linked to a workspace + */ +workspacesRouter.get('/:id/repos', async (req: Request, res: Response) => { + const userId = req.session.userId!; + const { id } = req.params; + + try { + const workspace = await db.workspaces.findById(id); + + if (!workspace) { + return res.status(404).json({ error: 'Workspace not found' }); + } + + // Check access (owner, member, or contributor) + const accessResult = await checkWorkspaceAccess(userId, id); + if (!accessResult.hasAccess) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + // Get repos linked to this workspace + const repos = await db.repositories.findByWorkspaceId(id); + + res.json({ + repositories: repos.map(r => ({ + id: r.id, + githubFullName: r.githubFullName, + defaultBranch: r.defaultBranch, + isPrivate: r.isPrivate, + syncStatus: r.syncStatus, + lastSyncedAt: r.lastSyncedAt, + })), + }); + } catch (error) { + console.error('Error listing workspace repos:', error); + res.status(500).json({ error: 'Failed to list repositories' }); + } +}); + +/** + * DELETE /api/workspaces/:id/repos/:repoId + * Remove a repository from a workspace + */ +workspacesRouter.delete('/:id/repos/:repoId', async (req: Request, res: Response) => { + const userId = req.session.userId!; + const { id, repoId } = req.params; + + try { + const workspace = await db.workspaces.findById(id); + + if (!workspace) { + return res.status(404).json({ error: 'Workspace not found' }); + } + + // Only owner can remove repos + if (workspace.userId !== userId) { + return res.status(403).json({ error: 'Only workspace owner can remove repositories' }); + } + + // Unlink repo from workspace (set workspaceId to null) + await db.repositories.assignToWorkspace(repoId, null); + + // Also update workspace config to remove from repositories array + const currentRepos = workspace.config.repositories || []; + const repo = await db.repositories.findById(repoId); + if (repo) { + const updatedRepos = currentRepos.filter( + r => r.toLowerCase() !== repo.githubFullName.toLowerCase() + ); + await db.workspaces.update(id, { + config: { ...workspace.config, repositories: updatedRepos }, + }); + } + + res.json({ success: true, message: 'Repository removed from workspace' }); + } catch (error) { + console.error('Error removing repo from workspace:', error); + res.status(500).json({ error: 'Failed to remove repository' }); + } +}); + +/** + * PATCH /api/workspaces/:id + * Update workspace settings (name, etc.) + */ +workspacesRouter.patch('/:id', async (req: Request, res: Response) => { + const userId = req.session.userId!; + const { id } = req.params; + const { name } = req.body; + + try { + const workspace = await db.workspaces.findById(id); + + if (!workspace) { + return res.status(404).json({ error: 'Workspace not found' }); + } + + // Only owner can rename + if (workspace.userId !== userId) { + return res.status(403).json({ error: 'Only workspace owner can update settings' }); + } + + // Validate name if provided + if (name !== undefined) { + if (typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ error: 'Name must be a non-empty string' }); + } + if (name.length > 100) { + return res.status(400).json({ error: 'Name must be 100 characters or less' }); + } + } + + // Update workspace + await db.workspaces.update(id, { + ...(name && { name: name.trim() }), + }); + + const updated = await db.workspaces.findById(id); + + res.json({ + success: true, + workspace: { + id: updated!.id, + name: updated!.name, + status: updated!.status, + publicUrl: updated!.publicUrl, + }, + }); + } catch (error) { + console.error('Error updating workspace:', error); + res.status(500).json({ error: 'Failed to update workspace' }); + } +}); + /** * POST /api/workspaces/:id/autoscale * Trigger auto-scaling based on current agent count @@ -1435,6 +1598,30 @@ workspacesRouter.post('/quick', checkWorkspaceLimit, async (req: Request, res: R } try { + // Check if a workspace already exists for this repo + // If so, check if user has access and return it instead of creating a duplicate + const existingRepos = await db.repositories.findByGithubFullName(repositoryFullName); + for (const existingRepo of existingRepos) { + if (existingRepo.workspaceId) { + // Check if user has access to this workspace + const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId); + if (accessResult.hasAccess) { + const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId); + if (existingWorkspace) { + console.log(`[workspaces/quick] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repositoryFullName}`); + return res.status(200).json({ + workspaceId: existingWorkspace.id, + status: existingWorkspace.status, + publicUrl: existingWorkspace.publicUrl, + existingWorkspace: true, + accessType: accessResult.accessType, + message: `You already have ${accessResult.accessType} access to a workspace for this repository.`, + }); + } + } + } + } + // Get user's connected providers (optional now) const credentials = await db.credentials.findByUserId(userId); const providers = credentials diff --git a/src/cloud/db/drizzle.ts b/src/cloud/db/drizzle.ts index b60415df..4955e782 100644 --- a/src/cloud/db/drizzle.ts +++ b/src/cloud/db/drizzle.ts @@ -289,14 +289,13 @@ export const githubInstallationQueries: GitHubInstallationQueries = { }; // ============================================================================ -// Credential Queries +// Credential Queries (connected provider registry - no token storage) // ============================================================================ export interface CredentialQueries { findByUserId(userId: string): Promise; findByUserAndProvider(userId: string, provider: string): Promise; upsert(data: schema.NewCredential): Promise; - updateTokens(userId: string, provider: string, accessToken: string, refreshToken?: string, expiresAt?: Date): Promise; delete(userId: string, provider: string): Promise; } @@ -323,9 +322,6 @@ export const credentialQueries: CredentialQueries = { .onConflictDoUpdate({ target: [schema.credentials.userId, schema.credentials.provider], set: { - accessToken: data.accessToken, - refreshToken: data.refreshToken ?? sql`credentials.refresh_token`, - tokenExpiresAt: data.tokenExpiresAt, scopes: data.scopes, providerAccountId: data.providerAccountId, providerAccountEmail: data.providerAccountEmail, @@ -336,30 +332,6 @@ export const credentialQueries: CredentialQueries = { return result[0]; }, - async updateTokens( - userId: string, - provider: string, - accessToken: string, - refreshToken?: string, - expiresAt?: Date - ): Promise { - const db = getDb(); - const updates: Record = { - accessToken, - updatedAt: new Date(), - }; - if (refreshToken !== undefined) { - updates.refreshToken = refreshToken; - } - if (expiresAt !== undefined) { - updates.tokenExpiresAt = expiresAt; - } - await db - .update(schema.credentials) - .set(updates) - .where(and(eq(schema.credentials.userId, userId), eq(schema.credentials.provider, provider))); - }, - async delete(userId: string, provider: string): Promise { const db = getDb(); await db @@ -378,6 +350,7 @@ export interface WorkspaceQueries { findByCustomDomain(domain: string): Promise; findAll(): Promise; create(data: schema.NewWorkspace): Promise; + update(id: string, data: Partial>): Promise; updateStatus( id: string, status: string, @@ -433,6 +406,14 @@ export const workspaceQueries: WorkspaceQueries = { return result[0]; }, + async update(id: string, data: Partial>): Promise { + const db = getDb(); + await db + .update(schema.workspaces) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.workspaces.id, id)); + }, + async updateStatus( id: string, status: string, @@ -967,7 +948,7 @@ export interface RepositoryQueries { findByWorkspaceId(workspaceId: string): Promise; findByProjectGroupId(projectGroupId: string): Promise; upsert(data: schema.NewRepository): Promise; - assignToWorkspace(repoId: string, workspaceId: string): Promise; + assignToWorkspace(repoId: string, workspaceId: string | null): Promise; assignToGroup(repoId: string, projectGroupId: string | null): Promise; updateProjectAgent(id: string, config: schema.ProjectAgentConfig): Promise; updateSyncStatus(id: string, status: string, lastSyncedAt?: Date): Promise; @@ -1043,7 +1024,7 @@ export const repositoryQueries: RepositoryQueries = { return result[0]; }, - async assignToWorkspace(repoId: string, workspaceId: string): Promise { + async assignToWorkspace(repoId: string, workspaceId: string | null): Promise { const db = getDb(); await db .update(schema.repositories) diff --git a/src/cloud/db/migrations/0010_remove_credential_tokens.sql b/src/cloud/db/migrations/0010_remove_credential_tokens.sql new file mode 100644 index 00000000..2aa5871d --- /dev/null +++ b/src/cloud/db/migrations/0010_remove_credential_tokens.sql @@ -0,0 +1,8 @@ +-- Remove token storage from credentials table +-- Tokens are no longer stored centrally. CLI tools authenticate directly on workspace instances. + +ALTER TABLE credentials DROP COLUMN IF EXISTS access_token; +--> statement-breakpoint +ALTER TABLE credentials DROP COLUMN IF EXISTS refresh_token; +--> statement-breakpoint +ALTER TABLE credentials DROP COLUMN IF EXISTS token_expires_at; diff --git a/src/cloud/db/migrations/meta/_journal.json b/src/cloud/db/migrations/meta/_journal.json index 71235457..ef64ab56 100644 --- a/src/cloud/db/migrations/meta/_journal.json +++ b/src/cloud/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1736208002000, "tag": "0009_ci_mentions_monitoring", "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1736208003000, + "tag": "0010_remove_credential_tokens", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/cloud/db/schema.ts b/src/cloud/db/schema.ts index 2e78166d..e49b758d 100644 --- a/src/cloud/db/schema.ts +++ b/src/cloud/db/schema.ts @@ -89,16 +89,15 @@ export const githubInstallationsRelations = relations(githubInstallations, ({ on })); // ============================================================================ -// Credentials (provider tokens) +// Credentials (connected provider registry - no token storage) +// Note: Tokens are not stored centrally. CLI tools authenticate directly +// on workspace instances. This table tracks which providers a user has connected. // ============================================================================ export const credentials = pgTable('credentials', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), provider: varchar('provider', { length: 50 }).notNull(), - accessToken: text('access_token').notNull(), - refreshToken: text('refresh_token'), - tokenExpiresAt: timestamp('token_expires_at'), scopes: text('scopes').array(), providerAccountId: varchar('provider_account_id', { length: 255 }), providerAccountEmail: varchar('provider_account_email', { length: 255 }), diff --git a/src/cloud/index.ts b/src/cloud/index.ts index 6cdc9932..67088255 100644 --- a/src/cloud/index.ts +++ b/src/cloud/index.ts @@ -10,7 +10,6 @@ export { createServer } from './server.js'; export { getConfig, loadConfig, CloudConfig } from './config.js'; // Services -export { CredentialVault } from './vault/index.js'; export { WorkspaceProvisioner, ProvisionConfig, Workspace, WorkspaceStatus } from './provisioner/index.js'; // Scaling infrastructure diff --git a/src/cloud/provisioner/index.ts b/src/cloud/provisioner/index.ts index 53b12a8d..b940602d 100644 --- a/src/cloud/provisioner/index.ts +++ b/src/cloud/provisioner/index.ts @@ -7,7 +7,6 @@ import * as crypto from 'crypto'; import { getConfig } from '../config.js'; import { db, Workspace, PlanType } from '../db/index.js'; -import { vault } from '../vault/index.js'; import { nangoService } from '../services/nango.js'; import { canAutoScale, @@ -104,20 +103,6 @@ async function getGithubAppTokenForUser(userId: string): Promise } } -async function loadCredentialToken(userId: string, provider: string): Promise { - try { - const cred = await vault.getCredential(userId, provider); - if (cred?.accessToken) { - return cred.accessToken; - } - } catch (error) { - console.warn(`Failed to decrypt ${provider} credential from vault; trying raw storage fallback`, error); - const raw = await db.credentials.findByUserAndProvider(userId, provider); - return raw?.accessToken ?? null; - } - return null; -} - async function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -1636,6 +1621,38 @@ export class WorkspaceProvisioner { // Auto-accept the creator's membership await db.workspaceMembers.acceptInvite(workspace.id, config.userId); + // Link repositories to this workspace + // This enables auto-access for users with GitHub access to these repos + for (const repoFullName of config.repositories) { + try { + // Find the user's repo record (may not exist if user didn't import it first) + const userRepos = await db.repositories.findByUserId(config.userId); + const repoRecord = userRepos.find( + r => r.githubFullName.toLowerCase() === repoFullName.toLowerCase() + ); + if (repoRecord) { + await db.repositories.assignToWorkspace(repoRecord.id, workspace.id); + console.log(`[provisioner] Linked repo ${repoFullName} to workspace ${workspace.id.substring(0, 8)}`); + } else { + // Create a placeholder repo record if it doesn't exist + // This ensures the repo is tracked for workspace access checks + console.log(`[provisioner] Creating repo record for ${repoFullName}`); + const newRepo = await db.repositories.upsert({ + userId: config.userId, + githubFullName: repoFullName, + githubId: 0, // Will be updated when actually synced + defaultBranch: 'main', + isPrivate: true, // Assume private, will be updated + workspaceId: workspace.id, + }); + console.log(`[provisioner] Created and linked repo ${repoFullName} (id: ${newRepo.id.substring(0, 8)})`); + } + } catch (err) { + console.warn(`[provisioner] Failed to link repo ${repoFullName}:`, err); + // Continue with other repos + } + } + // Initialize stage tracking immediately updateProvisioningStage(workspace.id, 'creating'); @@ -1655,14 +1672,11 @@ export class WorkspaceProvisioner { * Run the actual provisioning work asynchronously */ private async runProvisioningAsync(workspace: Workspace, config: ProvisionConfig): Promise { - // Get credentials + // Build credentials map for workspace provisioning + // Note: Provider tokens (Claude, Codex, etc.) are no longer stored centrally. + // CLI tools authenticate directly on workspace instances. + // Only GitHub App tokens are obtained from Nango for repository cloning. const credentials = new Map(); - for (const provider of config.providers) { - const token = await loadCredentialToken(config.userId, provider); - if (token) { - credentials.set(provider, token); - } - } // GitHub token is required for cloning repositories // Use direct token if provided (for testing), otherwise get from Nango diff --git a/src/cloud/vault/index.test.ts b/src/cloud/vault/index.test.ts deleted file mode 100644 index 0816d658..00000000 --- a/src/cloud/vault/index.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; -import { CredentialVault } from './index.js'; -import type { StoredCredential } from './index.js'; - -const masterKey = vi.hoisted(() => Buffer.alloc(32, 1).toString('base64')); -const key = (userId: string, provider: string) => `${userId}:${provider}`; - -const mockConfig = vi.hoisted(() => ({ - vault: { masterKey }, - providers: { - anthropic: { clientId: 'anthropic-client' }, - openai: { clientId: 'openai-client' }, - google: { clientId: 'google-client', clientSecret: 'google-secret' }, - }, - github: { - clientId: 'github-client', - clientSecret: 'github-secret', - }, -} as any)); - -const store = vi.hoisted(() => new Map()); - -const dbMock = vi.hoisted(() => ({ - credentials: { - upsert: vi.fn(async (credential: any) => { - store.set(key(credential.userId, credential.provider), { ...credential }); - }), - findByUserAndProvider: vi.fn(async (userId: string, provider: string) => { - return store.get(key(userId, provider)) || null; - }), - findByUserId: vi.fn(async (userId: string) => { - return Array.from(store.values()).filter((cred) => cred.userId === userId); - }), - updateTokens: vi.fn(async ( - userId: string, - provider: string, - accessToken: string, - refreshToken?: string, - expiresAt?: Date - ) => { - const existing = store.get(key(userId, provider)); - if (existing) { - existing.accessToken = accessToken; - existing.refreshToken = refreshToken; - existing.tokenExpiresAt = expiresAt; - } - }), - delete: vi.fn(async (userId: string, provider: string) => { - store.delete(key(userId, provider)); - }), - }, -})); - -vi.mock('../config.js', () => ({ - getConfig: vi.fn(() => mockConfig), -})); - -vi.mock('../db/index.js', () => ({ - db: dbMock, -})); - -const originalFetch = global.fetch; - -describe('CredentialVault', () => { - beforeEach(() => { - store.clear(); - vi.clearAllMocks(); - mockConfig.vault.masterKey = masterKey; - global.fetch = originalFetch; - }); - - afterAll(() => { - global.fetch = originalFetch; - }); - - it('throws when master key is not 32 bytes', () => { - mockConfig.vault.masterKey = Buffer.alloc(16, 1).toString('base64'); - - expect(() => new CredentialVault()).toThrow( - 'Vault master key must be 32 bytes (base64 encoded)' - ); - }); - - it('encrypts stored tokens and decrypts on retrieval', async () => { - const vault = new CredentialVault(); - const tokenExpiresAt = new Date('2025-01-01T00:00:00Z'); - const credential: StoredCredential = { - userId: 'user-1', - provider: 'openai', - accessToken: 'access-123', - refreshToken: 'refresh-456', - tokenExpiresAt, - scopes: ['scope1', 'scope2'], - providerAccountId: 'acct-1', - providerAccountEmail: 'user@example.com', - }; - - await vault.storeCredential(credential); - - const stored = store.get(key('user-1', 'openai')); - expect(stored.accessToken).not.toBe(credential.accessToken); - expect(stored.refreshToken).not.toBe(credential.refreshToken); - - const result = await vault.getCredential('user-1', 'openai'); - expect(result).toEqual({ - accessToken: 'access-123', - refreshToken: 'refresh-456', - tokenExpiresAt, - scopes: ['scope1', 'scope2'], - providerAccountId: 'acct-1', - providerAccountEmail: 'user@example.com', - }); - }); - - it('returns null when credential does not exist', async () => { - const vault = new CredentialVault(); - - const result = await vault.getCredential('missing', 'openai'); - - expect(result).toBeNull(); - }); - - it('returns decrypted credential map for a user', async () => { - const vault = new CredentialVault(); - await vault.storeCredential({ - userId: 'user-1', - provider: 'openai', - accessToken: 'access-1', - refreshToken: 'refresh-1', - }); - await vault.storeCredential({ - userId: 'user-1', - provider: 'google', - accessToken: 'g-access', - refreshToken: 'g-refresh', - scopes: ['email'], - }); - - const credentials = await vault.getUserCredentials('user-1'); - - expect(credentials.size).toBe(2); - expect(credentials.get('openai')?.accessToken).toBe('access-1'); - expect(credentials.get('google')?.refreshToken).toBe('g-refresh'); - expect(credentials.get('google')?.scopes).toEqual(['email']); - }); - - it('updates tokens with encryption', async () => { - const vault = new CredentialVault(); - await vault.storeCredential({ - userId: 'user-1', - provider: 'openai', - accessToken: 'old-access', - refreshToken: 'old-refresh', - }); - - const newExpiry = new Date(Date.now() + 60 * 60 * 1000); - await vault.updateTokens('user-1', 'openai', 'new-access', 'new-refresh', newExpiry); - - const stored = store.get(key('user-1', 'openai')); - expect(stored.accessToken).not.toBe('new-access'); - expect(stored.refreshToken).not.toBe('new-refresh'); - - const decrypted = await vault.getCredential('user-1', 'openai'); - expect(decrypted?.accessToken).toBe('new-access'); - expect(decrypted?.refreshToken).toBe('new-refresh'); - expect(decrypted?.tokenExpiresAt?.getTime()).toBeCloseTo(newExpiry.getTime()); - }); - - it('deletes credentials for a provider', async () => { - const vault = new CredentialVault(); - await vault.storeCredential({ - userId: 'user-1', - provider: 'openai', - accessToken: 'to-delete', - }); - - await vault.deleteCredential('user-1', 'openai'); - - expect(await vault.getCredential('user-1', 'openai')).toBeNull(); - expect(dbMock.credentials.delete).toHaveBeenCalledWith('user-1', 'openai'); - }); - - it('checks refresh necessity based on expiry time', async () => { - const vault = new CredentialVault(); - const soon = new Date(Date.now() + 4 * 60 * 1000); - const later = new Date(Date.now() + 10 * 60 * 1000); - - await vault.storeCredential({ - userId: 'user-1', - provider: 'openai', - accessToken: 'token', - tokenExpiresAt: soon, - }); - await vault.storeCredential({ - userId: 'user-2', - provider: 'openai', - accessToken: 'token', - tokenExpiresAt: later, - }); - - expect(await vault.needsRefresh('user-1', 'openai')).toBe(true); - expect(await vault.needsRefresh('user-2', 'openai')).toBe(false); - expect(await vault.needsRefresh('missing', 'openai')).toBe(false); - }); - - it('returns false when refresh token is missing', async () => { - const vault = new CredentialVault(); - await vault.storeCredential({ - userId: 'user-1', - provider: 'openai', - accessToken: 'token', - }); - - const refreshed = await vault.refreshToken('user-1', 'openai'); - - expect(refreshed).toBe(false); - }); - - it('returns false for unknown providers', async () => { - const vault = new CredentialVault(); - await vault.storeCredential({ - userId: 'user-1', - provider: 'unknown', - accessToken: 'token', - refreshToken: 'refresh-token', - }); - - const refreshed = await vault.refreshToken('user-1', 'unknown'); - - expect(refreshed).toBe(false); - }); - - it('refreshes tokens via provider endpoint and updates stored values', async () => { - const vault = new CredentialVault(); - await vault.storeCredential({ - userId: 'user-1', - provider: 'openai', - accessToken: 'old-access', - refreshToken: 'refresh-token', - }); - - const mockResponse = { - ok: true, - json: async () => ({ - access_token: 'new-access', - refresh_token: 'new-refresh', - expires_in: 120, - }), - }; - const mockFetch = vi.fn().mockResolvedValue(mockResponse as any); - global.fetch = mockFetch as any; - - const refreshed = await vault.refreshToken('user-1', 'openai'); - - expect(refreshed).toBe(true); - const body = mockFetch.mock.calls[0][1]?.body as URLSearchParams; - expect(body.get('refresh_token')).toBe('refresh-token'); - expect(body.get('client_id')).toBe('openai-client'); - - const updated = await vault.getCredential('user-1', 'openai'); - expect(updated?.accessToken).toBe('new-access'); - expect(updated?.refreshToken).toBe('new-refresh'); - expect(updated?.tokenExpiresAt).toBeInstanceOf(Date); - }); - - it('handles refresh failures without throwing', async () => { - const vault = new CredentialVault(); - await vault.storeCredential({ - userId: 'user-1', - provider: 'openai', - accessToken: 'old-access', - refreshToken: 'refresh-token', - }); - - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - text: async () => 'bad request', - } as any); - global.fetch = mockFetch as any; - - const refreshed = await vault.refreshToken('user-1', 'openai'); - - expect(refreshed).toBe(false); - const stillStored = await vault.getCredential('user-1', 'openai'); - expect(stillStored?.accessToken).toBe('old-access'); - }); -}); diff --git a/src/cloud/vault/index.ts b/src/cloud/vault/index.ts deleted file mode 100644 index c6448b9b..00000000 --- a/src/cloud/vault/index.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Agent Relay Cloud - Credential Vault - * - * Secure storage for OAuth tokens with AES-256-GCM encryption. - */ - -import * as crypto from 'crypto'; -import { getConfig } from '../config.js'; -import { db } from '../db/index.js'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 12; -const AUTH_TAG_LENGTH = 16; - -export interface StoredCredential { - userId: string; - provider: string; - accessToken: string; - refreshToken?: string; - tokenExpiresAt?: Date; - scopes?: string[]; - providerAccountId?: string; - providerAccountEmail?: string; -} - -export interface DecryptedCredential { - accessToken: string; - refreshToken?: string; - tokenExpiresAt?: Date; - scopes?: string[]; - providerAccountId?: string; - providerAccountEmail?: string; -} - -export class CredentialVault { - private masterKey: Buffer; - - constructor() { - const config = getConfig(); - this.masterKey = Buffer.from(config.vault.masterKey, 'base64'); - - if (this.masterKey.length !== 32) { - throw new Error('Vault master key must be 32 bytes (base64 encoded)'); - } - } - - /** - * Encrypt a string value - */ - private encrypt(plaintext: string): string { - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, this.masterKey, iv); - - const encrypted = Buffer.concat([ - cipher.update(plaintext, 'utf8'), - cipher.final(), - ]); - - const authTag = cipher.getAuthTag(); - - // Format: base64(iv + authTag + ciphertext) - const combined = Buffer.concat([iv, authTag, encrypted]); - return combined.toString('base64'); - } - - /** - * Decrypt a string value - */ - private decrypt(ciphertext: string): string { - const combined = Buffer.from(ciphertext, 'base64'); - - const iv = combined.subarray(0, IV_LENGTH); - const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); - const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); - - const decipher = crypto.createDecipheriv(ALGORITHM, this.masterKey, iv); - decipher.setAuthTag(authTag); - - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final(), - ]); - - return decrypted.toString('utf8'); - } - - /** - * Store encrypted credential - */ - async storeCredential(credential: StoredCredential): Promise { - const encryptedAccessToken = this.encrypt(credential.accessToken); - const encryptedRefreshToken = credential.refreshToken - ? this.encrypt(credential.refreshToken) - : undefined; - - await db.credentials.upsert({ - userId: credential.userId, - provider: credential.provider, - accessToken: encryptedAccessToken, - refreshToken: encryptedRefreshToken, - tokenExpiresAt: credential.tokenExpiresAt, - scopes: credential.scopes, - providerAccountId: credential.providerAccountId, - providerAccountEmail: credential.providerAccountEmail, - }); - } - - /** - * Retrieve and decrypt credential - */ - async getCredential(userId: string, provider: string): Promise { - const credential = await db.credentials.findByUserAndProvider(userId, provider); - if (!credential) { - return null; - } - - return { - accessToken: this.decrypt(credential.accessToken), - refreshToken: credential.refreshToken - ? this.decrypt(credential.refreshToken) - : undefined, - tokenExpiresAt: credential.tokenExpiresAt ?? undefined, - scopes: credential.scopes ?? undefined, - providerAccountId: credential.providerAccountId ?? undefined, - providerAccountEmail: credential.providerAccountEmail ?? undefined, - }; - } - - /** - * Get all credentials for a user (decrypted) - */ - async getUserCredentials(userId: string): Promise> { - const credentials = await db.credentials.findByUserId(userId); - const result = new Map(); - - for (const cred of credentials) { - result.set(cred.provider, { - accessToken: this.decrypt(cred.accessToken), - refreshToken: cred.refreshToken - ? this.decrypt(cred.refreshToken) - : undefined, - tokenExpiresAt: cred.tokenExpiresAt ?? undefined, - scopes: cred.scopes ?? undefined, - providerAccountId: cred.providerAccountId ?? undefined, - providerAccountEmail: cred.providerAccountEmail ?? undefined, - }); - } - - return result; - } - - /** - * Update tokens (e.g., after refresh) - */ - async updateTokens( - userId: string, - provider: string, - accessToken: string, - refreshToken?: string, - expiresAt?: Date - ): Promise { - const encryptedAccessToken = this.encrypt(accessToken); - const encryptedRefreshToken = refreshToken - ? this.encrypt(refreshToken) - : undefined; - - await db.credentials.updateTokens( - userId, - provider, - encryptedAccessToken, - encryptedRefreshToken, - expiresAt - ); - } - - /** - * Delete credential - */ - async deleteCredential(userId: string, provider: string): Promise { - await db.credentials.delete(userId, provider); - } - - /** - * Check if credential needs refresh (within 5 minutes of expiry) - */ - async needsRefresh(userId: string, provider: string): Promise { - const credential = await db.credentials.findByUserAndProvider(userId, provider); - if (!credential || !credential.tokenExpiresAt) { - return false; - } - - const fiveMinutes = 5 * 60 * 1000; - return Date.now() > credential.tokenExpiresAt.getTime() - fiveMinutes; - } - - /** - * Refresh OAuth token for a provider - */ - async refreshToken(userId: string, provider: string): Promise { - const credential = await this.getCredential(userId, provider); - if (!credential?.refreshToken) { - return false; - } - - // Provider-specific refresh endpoints - const refreshEndpoints: Record = { - anthropic: 'https://api.anthropic.com/oauth/token', - openai: 'https://auth.openai.com/oauth/token', - google: 'https://oauth2.googleapis.com/token', - github: 'https://github.com/login/oauth/access_token', - }; - - const endpoint = refreshEndpoints[provider]; - if (!endpoint) { - console.error(`Unknown provider for refresh: ${provider}`); - return false; - } - - try { - const config = getConfig(); - const providerConfig = config.providers[provider as keyof typeof config.providers]; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: credential.refreshToken, - client_id: (providerConfig as any)?.clientId || config.github.clientId, - ...(provider === 'google' && { - client_secret: (providerConfig as any)?.clientSecret, - }), - ...(provider === 'github' && { - client_secret: config.github.clientSecret, - }), - }), - }); - - if (!response.ok) { - const error = await response.text(); - console.error(`Token refresh failed for ${provider}:`, error); - return false; - } - - const data = await response.json() as { - access_token: string; - refresh_token?: string; - expires_in?: number; - }; - - await this.updateTokens( - userId, - provider, - data.access_token, - data.refresh_token, - data.expires_in - ? new Date(Date.now() + data.expires_in * 1000) - : undefined - ); - - return true; - } catch (error) { - console.error(`Error refreshing token for ${provider}:`, error); - return false; - } - } -} - -// Singleton instance -let _vault: CredentialVault | null = null; - -export function getVault(): CredentialVault { - if (!_vault) { - _vault = new CredentialVault(); - } - return _vault; -} - -export const vault = { - get instance() { - return getVault(); - }, - storeCredential: (cred: StoredCredential) => getVault().storeCredential(cred), - getCredential: (userId: string, provider: string) => - getVault().getCredential(userId, provider), - getUserCredentials: (userId: string) => getVault().getUserCredentials(userId), - updateTokens: ( - userId: string, - provider: string, - accessToken: string, - refreshToken?: string, - expiresAt?: Date - ) => getVault().updateTokens(userId, provider, accessToken, refreshToken, expiresAt), - deleteCredential: (userId: string, provider: string) => - getVault().deleteCredential(userId, provider), - needsRefresh: (userId: string, provider: string) => - getVault().needsRefresh(userId, provider), - refreshToken: (userId: string, provider: string) => - getVault().refreshToken(userId, provider), -}; - -// Generate a new master key (for setup) -export function generateMasterKey(): string { - return crypto.randomBytes(32).toString('base64'); -} diff --git a/src/dashboard/react-components/App.tsx b/src/dashboard/react-components/App.tsx index 7a8ca782..268f3e5c 100644 --- a/src/dashboard/react-components/App.tsx +++ b/src/dashboard/react-components/App.tsx @@ -20,6 +20,7 @@ import { MentionAutocomplete, getMentionQuery, completeMentionInValue, type Huma import { FileAutocomplete, getFileQuery, completeFileInValue } from './FileAutocomplete'; import { WorkspaceSelector, type Workspace } from './WorkspaceSelector'; import { AddWorkspaceModal } from './AddWorkspaceModal'; +import { WorkspaceSettingsPanel } from './WorkspaceSettingsPanel'; import { LogViewerPanel } from './LogViewerPanel'; import { TrajectoryViewer } from './TrajectoryViewer'; import { DecisionQueue, type Decision } from './DecisionQueue'; @@ -36,6 +37,7 @@ import { useMessages } from './hooks/useMessages'; import { useOrchestrator } from './hooks/useOrchestrator'; import { useTrajectory } from './hooks/useTrajectory'; import { useRecentRepos } from './hooks/useRecentRepos'; +import { useWorkspaceRepos } from './hooks/useWorkspaceRepos'; import { usePresence, type UserPresence } from './hooks/usePresence'; import { useCloudSessionOptional } from './CloudSessionProvider'; import { WorkspaceProvider } from './WorkspaceContext'; @@ -199,6 +201,9 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { const [isAddingWorkspace, setIsAddingWorkspace] = useState(false); const [addWorkspaceError, setAddWorkspaceError] = useState(null); + // Workspace settings panel state + const [isWorkspaceSettingsPanelOpen, setIsWorkspaceSettingsPanelOpen] = useState(false); + // Command palette state const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); @@ -234,6 +239,13 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { // Recent repos tracking const { recentRepos, addRecentRepo, getRecentProjects } = useRecentRepos(); + // Workspace repos for multi-repo workspaces + const { repos: workspaceRepos, refetch: refetchWorkspaceRepos } = useWorkspaceRepos({ + workspaceId: effectiveActiveWorkspaceId ?? undefined, + apiBaseUrl: '/api', + enabled: isCloudMode && !!effectiveActiveWorkspaceId, + }); + // Coordinator panel state const [isCoordinatorOpen, setIsCoordinatorOpen] = useState(false); @@ -364,28 +376,51 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { // Check if fleet view is available const isFleetAvailable = Boolean(data?.fleet?.servers?.length) || workspaces.length > 0; - // Convert workspaces to projects for unified navigation + // Convert workspaces/repos to projects for unified navigation useEffect(() => { if (workspaces.length > 0) { - // Convert workspaces to projects - const projectList: Project[] = workspaces.map((workspace) => ({ - id: workspace.id, - path: workspace.path, - name: workspace.name, - agents: orchestratorAgents - .filter((a) => a.workspaceId === workspace.id) - .map((a) => ({ - name: a.name, - status: a.status === 'running' ? 'online' : 'offline', - isSpawned: true, - cli: a.provider, - })) as Agent[], - lead: undefined, - })); - setProjects(projectList); - setCurrentProject(activeWorkspaceId); + // If we have repos for the active workspace, show each repo as a project folder + if (workspaceRepos.length > 1 && effectiveActiveWorkspaceId) { + const projectList: Project[] = workspaceRepos.map((repo) => ({ + id: repo.id, + path: repo.githubFullName, + name: repo.githubFullName.split('/').pop() || repo.githubFullName, + agents: orchestratorAgents + .filter((a) => a.workspaceId === effectiveActiveWorkspaceId) + .map((a) => ({ + name: a.name, + status: a.status === 'running' ? 'online' : 'offline', + isSpawned: true, + cli: a.provider, + })) as Agent[], + lead: undefined, + })); + setProjects(projectList); + // Set first repo as current if none selected + if (!currentProject || !projectList.find(p => p.id === currentProject)) { + setCurrentProject(projectList[0]?.id); + } + } else { + // Single repo or no repos fetched yet - show workspace as single project + const projectList: Project[] = workspaces.map((workspace) => ({ + id: workspace.id, + path: workspace.path, + name: workspace.name, + agents: orchestratorAgents + .filter((a) => a.workspaceId === workspace.id) + .map((a) => ({ + name: a.name, + status: a.status === 'running' ? 'online' : 'offline', + isSpawned: true, + cli: a.provider, + })) as Agent[], + lead: undefined, + })); + setProjects(projectList); + setCurrentProject(activeWorkspaceId); + } } - }, [workspaces, orchestratorAgents, activeWorkspaceId]); + }, [workspaces, orchestratorAgents, activeWorkspaceId, workspaceRepos, effectiveActiveWorkspaceId, currentProject]); // Fetch bridge/project data for multi-project mode useEffect(() => { @@ -561,10 +596,9 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { setIsFullSettingsOpen(true); }, []); - // Handle workspace settings click - opens settings to workspace tab + // Handle workspace settings click - opens workspace settings panel const handleWorkspaceSettingsClick = useCallback(() => { - setSettingsInitialTab('workspace'); - setIsFullSettingsOpen(true); + setIsWorkspaceSettingsPanelOpen(true); }, []); // Handle history click @@ -1125,6 +1159,30 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { error={addWorkspaceError} /> + {/* Workspace Settings Panel */} + {effectiveActiveWorkspaceId && ( + setIsWorkspaceSettingsPanelOpen(false)} + workspaceId={effectiveActiveWorkspaceId} + workspaceName={effectiveWorkspaces.find(w => w.id === effectiveActiveWorkspaceId)?.name || 'Workspace'} + isOwner={true} + apiBaseUrl="/api" + onWorkspaceUpdated={() => { + // Refetch cloud workspaces if in cloud mode + if (isCloudMode) { + cloudApi.getWorkspaceSummary().then(result => { + if (result.success && result.data.workspaces) { + setCloudWorkspaces(result.data.workspaces); + } + }); + } + // Refetch workspace repos + refetchWorkspaceRepos(); + }} + /> + )} + {/* Conversation History */} void; + workspaceId: string; + workspaceName: string; + isOwner: boolean; + apiBaseUrl: string; + onWorkspaceUpdated?: () => void; +} + +export function WorkspaceSettingsPanel({ + isOpen, + onClose, + workspaceId, + workspaceName, + isOwner, + apiBaseUrl, + onWorkspaceUpdated, +}: WorkspaceSettingsProps) { + const [name, setName] = useState(workspaceName); + const [repos, setRepos] = useState([]); + const [availableRepos, setAvailableRepos] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [showAddRepo, setShowAddRepo] = useState(false); + + // Fetch workspace repos and available repos + const fetchData = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + // Fetch workspace repos + const reposRes = await fetch(`${apiBaseUrl}/workspaces/${workspaceId}/repos`, { + credentials: 'include', + }); + if (reposRes.ok) { + const data = await reposRes.json(); + setRepos(data.repositories || []); + } + + // Fetch user's available repos (not yet linked to this workspace) + const availableRes = await fetch(`${apiBaseUrl}/repos`, { + credentials: 'include', + }); + if (availableRes.ok) { + const data = await availableRes.json(); + setAvailableRepos(data.repositories || []); + } + } catch (err) { + setError('Failed to load workspace data'); + console.error('Error fetching workspace data:', err); + } finally { + setIsLoading(false); + } + }, [apiBaseUrl, workspaceId]); + + useEffect(() => { + if (isOpen) { + setName(workspaceName); + fetchData(); + } + }, [isOpen, workspaceName, fetchData]); + + const handleRename = async () => { + if (!name.trim() || name === workspaceName) return; + + setIsSaving(true); + setError(null); + try { + const res = await fetch(`${apiBaseUrl}/workspaces/${workspaceId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: name.trim() }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to rename workspace'); + } + + setSuccessMessage('Workspace renamed successfully'); + setTimeout(() => setSuccessMessage(null), 3000); + onWorkspaceUpdated?.(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rename workspace'); + } finally { + setIsSaving(false); + } + }; + + const handleRemoveRepo = async (repoId: string) => { + if (!confirm('Remove this repository from the workspace?')) return; + + setError(null); + try { + const res = await fetch(`${apiBaseUrl}/workspaces/${workspaceId}/repos/${repoId}`, { + method: 'DELETE', + credentials: 'include', + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to remove repository'); + } + + setRepos(repos.filter(r => r.id !== repoId)); + setSuccessMessage('Repository removed'); + setTimeout(() => setSuccessMessage(null), 3000); + onWorkspaceUpdated?.(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove repository'); + } + }; + + const handleAddRepo = async (repoId: string) => { + setError(null); + try { + const res = await fetch(`${apiBaseUrl}/workspaces/${workspaceId}/repos`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ repositoryIds: [repoId] }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to add repository'); + } + + // Refresh data + await fetchData(); + setShowAddRepo(false); + setSuccessMessage('Repository added'); + setTimeout(() => setSuccessMessage(null), 3000); + onWorkspaceUpdated?.(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add repository'); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + if (!isOpen) return null; + + // Filter out repos already in the workspace + const linkedRepoIds = new Set(repos.map(r => r.id)); + const unlinkedRepos = availableRepos.filter(r => !linkedRepoIds.has(r.id)); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Workspace Settings

+ +
+ + {/* Error/Success Messages */} + {error && ( +
+ {error} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} + + {isLoading ? ( +
Loading...
+ ) : ( + <> + {/* Rename Section */} +
+ +
+ setName(e.target.value)} + disabled={!isOwner || isSaving} + className="flex-1 px-3 py-2.5 bg-[#2a2a3e] border border-[#3a3a4e] rounded-md text-[#e8e8e8] text-sm outline-none transition-colors focus:border-[#00c896] disabled:opacity-60 disabled:cursor-not-allowed" + /> + {isOwner && name !== workspaceName && ( + + )} +
+ {!isOwner && ( +

Only the workspace owner can rename it.

+ )} +
+ + {/* Repositories Section */} +
+
+ + {isOwner && ( + + )} +
+ + {/* Add Repo Dropdown */} + {showAddRepo && unlinkedRepos.length > 0 && ( +
+

Select a repository to add:

+
+ {unlinkedRepos.map(repo => ( + + ))} +
+
+ )} + + {showAddRepo && unlinkedRepos.length === 0 && ( +
+ No additional repositories available to add. +
+ )} + + {/* Linked Repos List */} + {repos.length === 0 ? ( +
+ No repositories linked to this workspace. +
+ ) : ( +
+ {repos.map(repo => ( +
+
+ +
+
{repo.githubFullName}
+
+ {repo.defaultBranch} + {repo.isPrivate && Private} +
+
+
+ {isOwner && repos.length > 1 && ( + + )} +
+ ))} +
+ )} + {repos.length === 1 && isOwner && ( +

+ A workspace must have at least one repository. +

+ )} +
+ + {/* Footer */} +
+ +
+ + )} +
+
+ ); +} + +function CloseIcon() { + return ( + + + + + ); +} + +function PlusIcon() { + return ( + + + + + ); +} + +function RepoIcon() { + return ( + + + + ); +} + +function TrashIcon() { + return ( + + + + + ); +} diff --git a/src/dashboard/react-components/hooks/index.ts b/src/dashboard/react-components/hooks/index.ts index 549b64a8..498f198f 100644 --- a/src/dashboard/react-components/hooks/index.ts +++ b/src/dashboard/react-components/hooks/index.ts @@ -33,3 +33,9 @@ export { type UseWorkspaceStatusReturn, type WorkspaceStatus, } from './useWorkspaceStatus'; +export { + useWorkspaceRepos, + type UseWorkspaceReposOptions, + type UseWorkspaceReposReturn, + type WorkspaceRepo, +} from './useWorkspaceRepos'; diff --git a/src/dashboard/react-components/hooks/useWorkspaceRepos.ts b/src/dashboard/react-components/hooks/useWorkspaceRepos.ts new file mode 100644 index 00000000..528624b4 --- /dev/null +++ b/src/dashboard/react-components/hooks/useWorkspaceRepos.ts @@ -0,0 +1,73 @@ +/** + * Hook for fetching and managing workspace repositories + */ + +import { useState, useEffect, useCallback } from 'react'; + +export interface WorkspaceRepo { + id: string; + githubFullName: string; + defaultBranch: string; + isPrivate: boolean; + syncStatus: string; + lastSyncedAt: string | null; +} + +export interface UseWorkspaceReposOptions { + workspaceId?: string; + apiBaseUrl?: string; + enabled?: boolean; +} + +export interface UseWorkspaceReposReturn { + repos: WorkspaceRepo[]; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +export function useWorkspaceRepos({ + workspaceId, + apiBaseUrl = '/api', + enabled = true, +}: UseWorkspaceReposOptions): UseWorkspaceReposReturn { + const [repos, setRepos] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchRepos = useCallback(async () => { + if (!workspaceId || !enabled) return; + + setIsLoading(true); + setError(null); + + try { + const res = await fetch(`${apiBaseUrl}/workspaces/${workspaceId}/repos`, { + credentials: 'include', + }); + + if (!res.ok) { + throw new Error('Failed to fetch workspace repos'); + } + + const data = await res.json(); + setRepos(data.repositories || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch repos'); + console.error('Error fetching workspace repos:', err); + } finally { + setIsLoading(false); + } + }, [workspaceId, apiBaseUrl, enabled]); + + useEffect(() => { + fetchRepos(); + }, [fetchRepos]); + + return { + repos, + isLoading, + error, + refetch: fetchRepos, + }; +} diff --git a/src/dashboard/react-components/index.ts b/src/dashboard/react-components/index.ts index 697fecd9..76c72284 100644 --- a/src/dashboard/react-components/index.ts +++ b/src/dashboard/react-components/index.ts @@ -28,6 +28,7 @@ export { App, appStyles, type AppProps } from './App'; export { MentionAutocomplete, useMentionAutocomplete, getMentionQuery, completeMentionInValue, type MentionAutocompleteProps } from './MentionAutocomplete'; export { ProjectList, type ProjectListProps } from './ProjectList'; export { WorkspaceSelector, type WorkspaceSelectorProps, type Workspace } from './WorkspaceSelector'; +export { WorkspaceSettingsPanel, type WorkspaceSettingsProps, type WorkspaceRepo, type AvailableRepo } from './WorkspaceSettingsPanel'; export { AddWorkspaceModal, type AddWorkspaceModalProps } from './AddWorkspaceModal'; export { PricingPlans, type PricingPlansProps, type Plan } from './PricingPlans'; export { BillingPanel, type BillingPanelProps, type Subscription, type Invoice, type PaymentMethod } from './BillingPanel';