From e0da2852bd6fec5f9d6e56989d09f2bc84d09ea7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 27 Mar 2026 16:29:40 -0700 Subject: [PATCH 1/6] feat(auth): allow google service account --- .../app/api/auth/oauth/credentials/route.ts | 95 +- apps/sim/app/api/auth/oauth/token/route.ts | 48 +- apps/sim/app/api/auth/oauth/utils.ts | 101 +- apps/sim/app/api/credentials/[id]/route.ts | 50 +- apps/sim/app/api/credentials/route.ts | 121 +- .../integrations/integrations-manager.tsx | 306 +- apps/sim/hooks/queries/credentials.ts | 5 +- apps/sim/lib/auth/credential-access.ts | 46 + apps/sim/lib/oauth/oauth.ts | 9 + apps/sim/lib/oauth/types.ts | 3 + .../db/migrations/0182_cute_emma_frost.sql | 4 + .../db/migrations/meta/0182_snapshot.json | 15192 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 9 +- packages/db/schema.ts | 9 + 14 files changed, 15936 insertions(+), 62 deletions(-) create mode 100644 packages/db/migrations/0182_cute_emma_frost.sql create mode 100644 packages/db/migrations/meta/0182_snapshot.json diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index eab12f41f86..0c7d3070a99 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -149,6 +149,7 @@ export async function GET(request: NextRequest) { displayName: credential.displayName, providerId: credential.providerId, accountId: credential.accountId, + updatedAt: credential.updatedAt, accountProviderId: account.providerId, accountScope: account.scope, accountUpdatedAt: account.updatedAt, @@ -159,6 +160,48 @@ export async function GET(request: NextRequest) { .limit(1) if (platformCredential) { + if (platformCredential.type === 'service_account') { + if (workflowId) { + if ( + !effectiveWorkspaceId || + platformCredential.workspaceId !== effectiveWorkspaceId + ) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + } else { + const [membership] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, platformCredential.id), + eq(credentialMember.userId, requesterUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + + if (!membership) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + } + + return NextResponse.json( + { + credentials: [ + toCredentialResponse( + platformCredential.id, + platformCredential.displayName, + platformCredential.providerId || 'google-service-account', + platformCredential.updatedAt, + null + ), + ], + }, + { status: 200 } + ) + } + if (platformCredential.type !== 'oauth' || !platformCredential.accountId) { return NextResponse.json({ credentials: [] }, { status: 200 }) } @@ -238,14 +281,52 @@ export async function GET(request: NextRequest) { ) ) - return NextResponse.json( - { - credentials: credentialsData.map((row) => - toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope) - ), - }, - { status: 200 } + const results = credentialsData.map((row) => + toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope) ) + + const isGoogleProvider = + providerParam.startsWith('google') || providerParam === 'gmail' + + if (isGoogleProvider) { + const serviceAccountCreds = await db + .select({ + id: credential.id, + displayName: credential.displayName, + providerId: credential.providerId, + updatedAt: credential.updatedAt, + }) + .from(credential) + .innerJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, requesterUserId), + eq(credentialMember.status, 'active') + ) + ) + .where( + and( + eq(credential.workspaceId, effectiveWorkspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, 'google-service-account') + ) + ) + + for (const sa of serviceAccountCreds) { + results.push( + toCredentialResponse( + sa.id, + sa.displayName, + sa.providerId || 'google-service-account', + sa.updatedAt, + null + ) + ) + } + } + + return NextResponse.json({ credentials: results }, { status: 200 }) } return NextResponse.json({ credentials: [] }, { status: 200 }) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index fcc6f128702..88249579f20 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -4,7 +4,13 @@ import { z } from 'zod' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { + getCredential, + getOAuthToken, + getServiceAccountToken, + refreshTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -18,6 +24,8 @@ const tokenRequestSchema = z credentialAccountUserId: z.string().min(1).optional(), providerId: z.string().min(1).optional(), workflowId: z.string().min(1).nullish(), + scopes: z.array(z.string()).optional(), + impersonateEmail: z.string().email().optional(), }) .refine( (data) => data.credentialId || (data.credentialAccountUserId && data.providerId), @@ -63,7 +71,14 @@ export async function POST(request: NextRequest) { ) } - const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data + const { + credentialId, + credentialAccountUserId, + providerId, + workflowId, + scopes, + impersonateEmail, + } = parseResult.data if (credentialAccountUserId && providerId) { logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, { @@ -112,6 +127,35 @@ export async function POST(request: NextRequest) { const callerUserId = new URL(request.url).searchParams.get('userId') || undefined + const resolved = await resolveOAuthAccountId(credentialId) + if (resolved?.credentialType === 'service_account' && resolved.credentialId) { + const authz = await authorizeCredentialUse(request, { + credentialId, + workflowId: workflowId ?? undefined, + requireWorkflowIdForInternal: false, + callerUserId, + }) + if (!authz.ok) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + try { + const defaultScopes = ['https://www.googleapis.com/auth/cloud-platform'] + const accessToken = await getServiceAccountToken( + resolved.credentialId, + scopes && scopes.length > 0 ? scopes : defaultScopes, + impersonateEmail + ) + return NextResponse.json({ accessToken }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Service account token error:`, error) + return NextResponse.json( + { error: 'Failed to get service account token' }, + { status: 401 } + ) + } + } + const authz = await authorizeCredentialUse(request, { credentialId, workflowId: workflowId ?? undefined, diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 4228c3f3f2b..5a915ffa4f4 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -1,7 +1,9 @@ +import { createSign } from 'crypto' import { db } from '@sim/db' import { account, credential, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq, inArray } from 'drizzle-orm' +import { decryptSecret } from '@/lib/core/security/encryption' import { refreshOAuthToken } from '@/lib/oauth' import { getMicrosoftRefreshTokenExpiry, @@ -25,16 +27,26 @@ interface AccountInsertData { accessTokenExpiresAt?: Date } +export interface ResolvedCredential { + accountId: string + workspaceId?: string + usedCredentialTable: boolean + credentialType?: string + credentialId?: string +} + /** * Resolves a credential ID to its underlying account ID. * If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`. + * For service_account credentials, returns credentialId and type instead of accountId. * Otherwise assumes `credentialId` is already a raw `account.id` (legacy). */ export async function resolveOAuthAccountId( credentialId: string -): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> { +): Promise { const [credentialRow] = await db .select({ + id: credential.id, type: credential.type, accountId: credential.accountId, workspaceId: credential.workspaceId, @@ -44,6 +56,16 @@ export async function resolveOAuthAccountId( .limit(1) if (credentialRow) { + if (credentialRow.type === 'service_account') { + return { + accountId: '', + credentialId: credentialRow.id, + credentialType: 'service_account', + workspaceId: credentialRow.workspaceId, + usedCredentialTable: true, + } + } + if (credentialRow.type !== 'oauth' || !credentialRow.accountId) { return null } @@ -57,6 +79,83 @@ export async function resolveOAuthAccountId( return { accountId: credentialId, usedCredentialTable: false } } +/** + * Generates a short-lived access token for a Google service account credential + * using the two-legged OAuth JWT flow (RFC 7523). + */ +export async function getServiceAccountToken( + credentialId: string, + scopes: string[], + impersonateEmail?: string +): Promise { + const [credentialRow] = await db + .select({ + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + if (!credentialRow?.encryptedServiceAccountKey) { + throw new Error('Service account key not found') + } + + const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) + const keyData = JSON.parse(decrypted) as { + client_email: string + private_key: string + token_uri?: string + } + + const now = Math.floor(Date.now() / 1000) + const tokenUri = keyData.token_uri || 'https://oauth2.googleapis.com/token' + + const header = { alg: 'RS256', typ: 'JWT' } + const payload: Record = { + iss: keyData.client_email, + scope: scopes.join(' '), + aud: tokenUri, + iat: now, + exp: now + 3600, + } + + if (impersonateEmail) { + payload.sub = impersonateEmail + } + + const toBase64Url = (obj: unknown) => + Buffer.from(JSON.stringify(obj)).toString('base64url') + + const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}` + + const signer = createSign('RSA-SHA256') + signer.update(signingInput) + const signature = signer.sign(keyData.private_key, 'base64url') + + const jwt = `${signingInput}.${signature}` + + const response = await fetch(tokenUri, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwt, + }), + }) + + if (!response.ok) { + const errorBody = await response.text() + logger.error('Service account token exchange failed', { + status: response.status, + body: errorBody, + }) + throw new Error(`Token exchange failed: ${response.status}`) + } + + const tokenData = (await response.json()) as { access_token: string } + return tokenData.access_token +} + /** * Safely inserts an account record, handling duplicate constraint violations gracefully. * If a duplicate is detected (unique constraint violation), logs a warning and returns success. diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 7da93846c75..9b9f38006dc 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { syncPersonalEnvCredentialsForUser, @@ -17,12 +18,19 @@ const updateCredentialSchema = z .object({ displayName: z.string().trim().min(1).max(255).optional(), description: z.string().trim().max(500).nullish(), + serviceAccountJson: z.string().min(1).optional(), }) .strict() - .refine((data) => data.displayName !== undefined || data.description !== undefined, { - message: 'At least one field must be provided', - path: ['displayName'], - }) + .refine( + (data) => + data.displayName !== undefined || + data.description !== undefined || + data.serviceAccountJson !== undefined, + { + message: 'At least one field must be provided', + path: ['displayName'], + } + ) async function getCredentialResponse(credentialId: string, userId: string) { const [row] = await db @@ -106,12 +114,42 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ updates.description = parseResult.data.description ?? null } - if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') { + if ( + parseResult.data.displayName !== undefined && + (access.credential.type === 'oauth' || access.credential.type === 'service_account') + ) { updates.displayName = parseResult.data.displayName } + if ( + parseResult.data.serviceAccountJson !== undefined && + access.credential.type === 'service_account' + ) { + try { + const parsed = JSON.parse(parseResult.data.serviceAccountJson) + if ( + parsed.type !== 'service_account' || + !parsed.client_email || + !parsed.private_key || + !parsed.project_id + ) { + return NextResponse.json( + { error: 'Invalid service account JSON key' }, + { status: 400 } + ) + } + const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson) + updates.encryptedServiceAccountKey = encrypted + } catch { + return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }) + } + } + if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth') { + if ( + access.credential.type === 'oauth' || + access.credential.type === 'service_account' + ) { return NextResponse.json( { error: 'No updatable fields provided.', diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index e0fea07f3e3..f88f1180aad 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { encryptSecret } from '@/lib/core/security/encryption' import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth' @@ -14,7 +15,7 @@ import { isValidEnvVarName } from '@/executor/constants' const logger = createLogger('CredentialsAPI') -const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal']) +const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal', 'service_account']) function normalizeEnvKeyInput(raw: string): string { const trimmed = raw.trim() @@ -29,6 +30,56 @@ const listCredentialsSchema = z.object({ credentialId: z.string().optional(), }) +const serviceAccountJsonSchema = z + .string() + .min(1, 'Service account JSON key is required') + .transform((val, ctx) => { + try { + const parsed = JSON.parse(val) + if (parsed.type !== 'service_account') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must have type "service_account"', + }) + return z.NEVER + } + if (!parsed.client_email || typeof parsed.client_email !== 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must contain a valid client_email', + }) + return z.NEVER + } + if (!parsed.private_key || typeof parsed.private_key !== 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must contain a valid private_key', + }) + return z.NEVER + } + if (!parsed.project_id || typeof parsed.project_id !== 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JSON key must contain a valid project_id', + }) + return z.NEVER + } + return parsed as { + type: 'service_account' + client_email: string + private_key: string + project_id: string + [key: string]: unknown + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid JSON format', + }) + return z.NEVER + } + }) + const createCredentialSchema = z .object({ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), @@ -39,6 +90,7 @@ const createCredentialSchema = z accountId: z.string().trim().min(1).optional(), envKey: z.string().trim().min(1).optional(), envOwnerUserId: z.string().trim().min(1).optional(), + serviceAccountJson: z.string().optional(), }) .superRefine((data, ctx) => { if (data.type === 'oauth') { @@ -66,6 +118,17 @@ const createCredentialSchema = z return } + if (data.type === 'service_account') { + if (!data.serviceAccountJson) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'serviceAccountJson is required for service account credentials', + path: ['serviceAccountJson'], + }) + } + return + } + const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : '' if (!normalizedEnvKey) { ctx.addIssue({ @@ -87,14 +150,16 @@ const createCredentialSchema = z interface ExistingCredentialSourceParams { workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' accountId?: string | null envKey?: string | null envOwnerUserId?: string | null + displayName?: string | null + providerId?: string | null } async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - const { workspaceId, type, accountId, envKey, envOwnerUserId } = params + const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params if (type === 'oauth' && accountId) { const [row] = await db @@ -142,6 +207,22 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa return row ?? null } + if (type === 'service_account' && displayName && providerId) { + const [row] = await db + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, providerId), + eq(credential.displayName, displayName) + ) + ) + .limit(1) + return row ?? null + } + return null } @@ -288,6 +369,7 @@ export async function POST(request: NextRequest) { accountId, envKey, envOwnerUserId, + serviceAccountJson, } = parseResult.data const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) @@ -301,6 +383,7 @@ export async function POST(request: NextRequest) { let resolvedAccountId: string | null = accountId ?? null const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null let resolvedEnvOwnerUserId: string | null = null + let resolvedEncryptedServiceAccountKey: string | null = null if (type === 'oauth') { const [accountRow] = await db @@ -335,6 +418,33 @@ export async function POST(request: NextRequest) { resolvedDisplayName = getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId } + } else if (type === 'service_account') { + if (!serviceAccountJson) { + return NextResponse.json( + { error: 'serviceAccountJson is required for service account credentials' }, + { status: 400 } + ) + } + + const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson) + if (!jsonParseResult.success) { + return NextResponse.json( + { error: jsonParseResult.error.errors[0]?.message || 'Invalid service account JSON' }, + { status: 400 } + ) + } + + const parsed = jsonParseResult.data + resolvedProviderId = 'google-service-account' + resolvedAccountId = null + resolvedEnvOwnerUserId = null + + if (!resolvedDisplayName) { + resolvedDisplayName = parsed.client_email + } + + const { encrypted } = await encryptSecret(serviceAccountJson) + resolvedEncryptedServiceAccountKey = encrypted } else if (type === 'env_personal') { resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id if (resolvedEnvOwnerUserId !== session.user.id) { @@ -363,6 +473,8 @@ export async function POST(request: NextRequest) { accountId: resolvedAccountId, envKey: resolvedEnvKey, envOwnerUserId: resolvedEnvOwnerUserId, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, }) if (existingCredential) { @@ -441,12 +553,13 @@ export async function POST(request: NextRequest) { accountId: resolvedAccountId, envKey: resolvedEnvKey, envOwnerUserId: resolvedEnvOwnerUserId, + encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, createdBy: session.user.id, createdAt: now, updatedAt: now, }) - if (type === 'env_workspace' && workspaceRow?.ownerId) { + if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) { const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId) if (workspaceUserIds.length > 0) { for (const memberUserId of workspaceUserIds) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx index c59f5cc0fc5..4130198bb2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx @@ -91,6 +91,12 @@ export function IntegrationsManager() { | { type: 'kb-connectors'; knowledgeBaseId: string } | undefined >(undefined) + const [saJsonInput, setSaJsonInput] = useState('') + const [saDisplayName, setSaDisplayName] = useState('') + const [saDescription, setSaDescription] = useState('') + const [saError, setSaError] = useState(null) + const [saIsSubmitting, setSaIsSubmitting] = useState(false) + const { data: session } = useSession() const currentUserId = session?.user?.id || '' @@ -110,7 +116,7 @@ export function IntegrationsManager() { const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null) const oauthCredentials = useMemo( - () => credentials.filter((c) => c.type === 'oauth'), + () => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'), [credentials] ) @@ -348,11 +354,7 @@ export function IntegrationsManager() { const isSelectedAdmin = selectedCredential?.role === 'admin' const selectedOAuthServiceConfig = useMemo(() => { - if ( - !selectedCredential || - selectedCredential.type !== 'oauth' || - !selectedCredential.providerId - ) { + if (!selectedCredential?.providerId) { return null } @@ -366,6 +368,10 @@ export function IntegrationsManager() { setCreateError(null) setCreateStep(1) setServiceSearch('') + setSaJsonInput('') + setSaDisplayName('') + setSaDescription('') + setSaError(null) pendingReturnOriginRef.current = undefined } @@ -456,25 +462,30 @@ export function IntegrationsManager() { setDeleteError(null) try { - if (!credentialToDelete.accountId || !credentialToDelete.providerId) { - const errorMessage = - 'Cannot disconnect: missing account information. Please try reconnecting this credential first.' - setDeleteError(errorMessage) - logger.error('Cannot disconnect OAuth credential: missing accountId or providerId') - return - } - await disconnectOAuthService.mutateAsync({ - provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId, - providerId: credentialToDelete.providerId, - serviceId: credentialToDelete.providerId, - accountId: credentialToDelete.accountId, - }) - await refetchCredentials() - window.dispatchEvent( - new CustomEvent('oauth-credentials-updated', { - detail: { providerId: credentialToDelete.providerId, workspaceId }, + if (credentialToDelete.type === 'service_account') { + await deleteCredential.mutateAsync(credentialToDelete.id) + await refetchCredentials() + } else { + if (!credentialToDelete.accountId || !credentialToDelete.providerId) { + const errorMessage = + 'Cannot disconnect: missing account information. Please try reconnecting this credential first.' + setDeleteError(errorMessage) + logger.error('Cannot disconnect OAuth credential: missing accountId or providerId') + return + } + await disconnectOAuthService.mutateAsync({ + provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId, + providerId: credentialToDelete.providerId, + serviceId: credentialToDelete.providerId, + accountId: credentialToDelete.accountId, }) - ) + await refetchCredentials() + window.dispatchEvent( + new CustomEvent('oauth-credentials-updated', { + detail: { providerId: credentialToDelete.providerId, workspaceId }, + }) + ) + } if (selectedCredentialId === credentialToDelete.id) { setSelectedCredentialId(null) @@ -624,6 +635,84 @@ export function IntegrationsManager() { setShowCreateModal(true) }, []) + const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => { + let parsed: Record + try { + parsed = JSON.parse(raw) + } catch { + return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' } + } + if (parsed.type !== 'service_account') { + return { valid: false, error: 'JSON key must have "type": "service_account".' } + } + if (!parsed.client_email || typeof parsed.client_email !== 'string') { + return { valid: false, error: 'Missing "client_email" field.' } + } + if (!parsed.private_key || typeof parsed.private_key !== 'string') { + return { valid: false, error: 'Missing "private_key" field.' } + } + if (!parsed.project_id || typeof parsed.project_id !== 'string') { + return { valid: false, error: 'Missing "project_id" field.' } + } + return { valid: true } + } + + const handleCreateServiceAccount = async () => { + setSaError(null) + const trimmed = saJsonInput.trim() + if (!trimmed) { + setSaError('Paste the service account JSON key.') + return + } + const validation = validateServiceAccountJson(trimmed) + if (!validation.valid) { + setSaError(validation.error ?? 'Invalid JSON') + return + } + setSaIsSubmitting(true) + try { + await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + displayName: saDisplayName.trim() || undefined, + description: saDescription.trim() || undefined, + serviceAccountJson: trimmed, + }) + setShowCreateModal(false) + resetCreateForm() + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Failed to add service account' + setSaError(message) + logger.error('Failed to create service account credential', error) + } finally { + setSaIsSubmitting(false) + } + } + + const handleSaFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (e) => { + const text = e.target?.result + if (typeof text === 'string') { + setSaJsonInput(text) + setSaError(null) + try { + const parsed = JSON.parse(text) + if (parsed.client_email && !saDisplayName.trim()) { + setSaDisplayName(parsed.client_email) + } + } catch { + // validation will catch this on submit + } + } + } + reader.readAsText(file) + event.target.value = '' + } + const filteredServices = useMemo(() => { if (!serviceSearch.trim()) return oauthServiceOptions const q = serviceSearch.toLowerCase() @@ -700,7 +789,7 @@ export function IntegrationsManager() { - ) : ( + ) : selectedOAuthService?.authType !== 'service_account' ? ( <>
@@ -827,6 +916,131 @@ export function IntegrationsManager() { + ) : ( + <> + +
+ + + Add{' '} + {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)} + +
+
+ + {saError && ( +
+ + {saError} + +
+ )} +
+
+
+ {selectedOAuthService && + createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })} +
+
+

+ Add {selectedOAuthService?.name || 'service account'} +

+

+ {selectedOAuthService?.description || 'Paste or upload the JSON key file'} +

+
+
+ +
+ +