mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
28 Commits
improvemen
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a51c8f184 | ||
|
|
fac5f63bd1 | ||
|
|
369289497e | ||
|
|
b004cba88a | ||
|
|
60a6745336 | ||
|
|
8b411d4c27 | ||
|
|
c6ab7962a4 | ||
|
|
8b14ba1dfe | ||
|
|
c3328e0c08 | ||
|
|
446de8f459 | ||
|
|
9e1d103ee3 | ||
|
|
ab50c2d246 | ||
|
|
f7b5055dfa | ||
|
|
8f0efca279 | ||
|
|
76e2235427 | ||
|
|
748f0dd8b8 | ||
|
|
6ee02a85ad | ||
|
|
48c417f4b1 | ||
|
|
54683d7971 | ||
|
|
6241ca909b | ||
|
|
299998c25a | ||
|
|
b32a3884c7 | ||
|
|
336c3ef852 | ||
|
|
ce345b9112 | ||
|
|
370148a4d8 | ||
|
|
e8717bb5c0 | ||
|
|
7ec025973e | ||
|
|
e0da2852bd |
206
apps/docs/content/docs/en/credentials/google-service-account.mdx
Normal file
206
apps/docs/content/docs/en/credentials/google-service-account.mdx
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
title: Google Service Accounts
|
||||
description: Set up Google service accounts with domain-wide delegation for Gmail, Sheets, Drive, Calendar, and other Google services
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Google service accounts with domain-wide delegation let your workflows access Google APIs on behalf of users in your Google Workspace domain — without requiring each user to complete an OAuth consent flow. This is ideal for automated workflows that need to send emails, read spreadsheets, or manage files across your organization.
|
||||
|
||||
For example, you could build a workflow that iterates through a list of employees, impersonates each one to read their Google Docs, and uploads the contents to a shared knowledge base — all without requiring any of those users to sign in.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before adding a service account to Sim, you need to configure it in the Google Cloud Console and Google Workspace Admin Console.
|
||||
|
||||
### 1. Create a Service Account in Google Cloud
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one)
|
||||
</Step>
|
||||
<Step>
|
||||
Navigate to **IAM & Admin** → **Service Accounts**
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Create Service Account**, give it a name and description, then click **Create and Continue**
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-create-service-account.png"
|
||||
alt="Google Cloud Console — Create service account form"
|
||||
width={700}
|
||||
height={500}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Skip the optional role and user access steps and click **Done**
|
||||
</Step>
|
||||
<Step>
|
||||
Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key**
|
||||
</Step>
|
||||
<Step>
|
||||
Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-create-private-key.png"
|
||||
alt="Google Cloud Console — Create private key dialog with JSON selected"
|
||||
width={700}
|
||||
height={400}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="warn">
|
||||
The JSON key file contains your service account's private key. Treat it like a password — do not commit it to source control or share it publicly.
|
||||
</Callout>
|
||||
|
||||
### 2. Enable the Required APIs
|
||||
|
||||
In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the APIs for the services your workflows will use. See the [scopes reference](#scopes-reference) below for the full list of APIs by service.
|
||||
|
||||
### 3. Set Up Domain-Wide Delegation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
In the Google Cloud Console, go to **IAM & Admin** → **Service Accounts**, click on your service account, and copy the **Client ID** (the numeric ID, not the email)
|
||||
</Step>
|
||||
<Step>
|
||||
Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls**
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Manage Domain Wide Delegation**, then click **Add new**
|
||||
</Step>
|
||||
<Step>
|
||||
Paste the **Client ID** from your service account, then add the OAuth scopes for the services your workflows need. Copy the full scope URLs from the [scopes reference](#scopes-reference) below — only authorize scopes for services you plan to use.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/gcp-add-client-id.png"
|
||||
alt="Google Workspace Admin Console — Add a new client ID with OAuth scopes"
|
||||
width={350}
|
||||
height={300}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Authorize**
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Domain-wide delegation must be configured by a Google Workspace admin. If you are not an admin, send the Client ID and required scopes to your admin.
|
||||
</Callout>
|
||||
|
||||
### Scopes Reference
|
||||
|
||||
The table below lists every Google service that supports service account authentication in Sim, the API to enable in Google Cloud Console, and the delegation scopes to authorize. Copy the scope string for each service you need and paste it into the Google Workspace Admin Console.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="whitespace-nowrap">Service</th>
|
||||
<th className="whitespace-nowrap">API to Enable</th>
|
||||
<th>Delegation Scopes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Gmail</td><td>Gmail API</td><td><code>{'https://www.googleapis.com/auth/gmail.send'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.modify'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.labels'}</code></td></tr>
|
||||
<tr><td>Google Sheets</td><td>Google Sheets API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Drive</td><td>Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Docs</td><td>Google Docs API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Slides</td><td>Google Slides API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
|
||||
<tr><td>Google Forms</td><td>Google Forms API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/forms.body'}</code><br/><code>{'https://www.googleapis.com/auth/forms.responses.readonly'}</code></td></tr>
|
||||
<tr><td>Google Calendar</td><td>Google Calendar API</td><td><code>{'https://www.googleapis.com/auth/calendar'}</code></td></tr>
|
||||
<tr><td>Google Contacts</td><td>People API</td><td><code>{'https://www.googleapis.com/auth/contacts'}</code></td></tr>
|
||||
<tr><td>BigQuery</td><td>BigQuery API</td><td><code>{'https://www.googleapis.com/auth/bigquery'}</code></td></tr>
|
||||
<tr><td>Google Tasks</td><td>Tasks API</td><td><code>{'https://www.googleapis.com/auth/tasks'}</code></td></tr>
|
||||
<tr><td>Google Vault</td><td>Vault API, Cloud Storage API</td><td><code>{'https://www.googleapis.com/auth/ediscovery'}</code><br/><code>{'https://www.googleapis.com/auth/devstorage.read_only'}</code></td></tr>
|
||||
<tr><td>Google Groups</td><td>Admin SDK API</td><td><code>{'https://www.googleapis.com/auth/admin.directory.group'}</code><br/><code>{'https://www.googleapis.com/auth/admin.directory.group.member'}</code></td></tr>
|
||||
<tr><td>Google Meet</td><td>Google Meet API</td><td><code>{'https://www.googleapis.com/auth/meetings.space.created'}</code><br/><code>{'https://www.googleapis.com/auth/meetings.space.readonly'}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Callout type="info">
|
||||
You only need to enable APIs and authorize scopes for the services you plan to use. When authorizing multiple services, combine their scope strings with commas into a single entry in the Admin Console.
|
||||
</Callout>
|
||||
|
||||
## Adding the Service Account to Sim
|
||||
|
||||
Once Google Cloud and Workspace are configured, add the service account as a credential in Sim.
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Open your workspace **Settings** and go to the **Integrations** tab
|
||||
</Step>
|
||||
<Step>
|
||||
Search for "Google Service Account" and click **Connect**
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/integrations-service-account.png"
|
||||
alt="Integrations page showing Google Service Account"
|
||||
width={800}
|
||||
height={150}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Paste the full contents of your JSON key file into the text area
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/add-service-account.png"
|
||||
alt="Add Google Service Account dialog"
|
||||
width={350}
|
||||
height={420}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Give the credential a display name (the service account email is used by default)
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Save**
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The JSON key file is validated for the required fields (`type`, `client_email`, `private_key`, `project_id`) and encrypted before being stored.
|
||||
|
||||
## Using Delegated Access in Workflows
|
||||
|
||||
When you use a Google block (Gmail, Sheets, Drive, etc.) in a workflow and select a service account credential, an **Impersonate User Email** field appears below the credential selector.
|
||||
|
||||
Enter the email address of the Google Workspace user you want the service account to act as. For example, if you enter `alice@yourcompany.com`, the workflow will send emails from Alice's account, read her spreadsheets, or access her calendar — depending on the scopes you authorized.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/credentials/workflow-impersonated-account.png"
|
||||
alt="Gmail block in a workflow showing the Impersonated Account field with a service account credential"
|
||||
width={800}
|
||||
height={350}
|
||||
className="my-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout type="warn">
|
||||
The impersonated email must belong to a user in the Google Workspace domain where you configured domain-wide delegation. Impersonating external email addresses will fail.
|
||||
</Callout>
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Can I use a service account without domain-wide delegation?", answer: "Yes, but it will only be able to access resources owned by the service account itself (e.g., spreadsheets shared directly with the service account email). Without delegation, you cannot impersonate users or access their personal data like Gmail." },
|
||||
{ question: "What happens if the impersonation email field is left blank?", answer: "The service account will authenticate as itself. This works for accessing shared resources (like a Google Sheet shared with the service account email) but will fail for user-specific APIs like Gmail." },
|
||||
{ question: "Can I use the same service account for multiple Google services?", answer: "Yes. A single service account can be used across Gmail, Sheets, Drive, Calendar, and other Google services — as long as the required API is enabled in Google Cloud and the corresponding scopes are authorized in the Workspace admin console." },
|
||||
{ question: "How do I rotate the service account key?", answer: "Create a new JSON key in the Google Cloud Console under your service account's Keys tab, then update the credential in Sim with the new key. Delete the old key from Google Cloud once the new one is working." },
|
||||
{ question: "Does the impersonated user need a Google Workspace license?", answer: "Yes. Domain-wide delegation only works with users who have a Google Workspace account in the domain. Consumer Gmail accounts (e.g., @gmail.com) cannot be impersonated." },
|
||||
]} />
|
||||
5
apps/docs/content/docs/en/credentials/meta.json
Normal file
5
apps/docs/content/docs/en/credentials/meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Credentials",
|
||||
"pages": ["index", "google-service-account"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
BIN
apps/docs/public/static/credentials/add-service-account.png
Normal file
BIN
apps/docs/public/static/credentials/add-service-account.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/docs/public/static/credentials/gcp-add-client-id.png
Normal file
BIN
apps/docs/public/static/credentials/gcp-add-client-id.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/docs/public/static/credentials/gcp-create-private-key.png
Normal file
BIN
apps/docs/public/static/credentials/gcp-create-private-key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -7,7 +7,10 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getServiceAccountProviderForProviderId,
|
||||
} from '@/lib/oauth/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -36,7 +39,8 @@ function toCredentialResponse(
|
||||
displayName: string,
|
||||
providerId: string,
|
||||
updatedAt: Date,
|
||||
scope: string | null
|
||||
scope: string | null,
|
||||
credentialType: 'oauth' | 'service_account' = 'oauth'
|
||||
) {
|
||||
const storedScope = scope?.trim()
|
||||
// Some providers (e.g. Box) don't return scopes in their token response,
|
||||
@@ -52,6 +56,7 @@ function toCredentialResponse(
|
||||
id,
|
||||
name: displayName,
|
||||
provider: providerId,
|
||||
type: credentialType,
|
||||
lastUsed: updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
scopes,
|
||||
@@ -149,6 +154,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 +165,49 @@ export async function GET(request: NextRequest) {
|
||||
.limit(1)
|
||||
|
||||
if (platformCredential) {
|
||||
if (platformCredential.type === 'service_account') {
|
||||
if (
|
||||
workflowId &&
|
||||
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
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,
|
||||
'service_account'
|
||||
),
|
||||
],
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
}
|
||||
@@ -238,14 +287,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 saProviderId = getServiceAccountProviderForProviderId(providerParam)
|
||||
|
||||
if (saProviderId) {
|
||||
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, saProviderId)
|
||||
)
|
||||
)
|
||||
|
||||
for (const sa of serviceAccountCreds) {
|
||||
results.push(
|
||||
toCredentialResponse(
|
||||
sa.id,
|
||||
sa.displayName,
|
||||
sa.providerId || saProviderId,
|
||||
sa.updatedAt,
|
||||
null,
|
||||
'service_account'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: results }, { status: 200 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
mockGetCredential,
|
||||
mockRefreshTokenIfNeeded,
|
||||
mockGetOAuthToken,
|
||||
mockResolveOAuthAccountId,
|
||||
mockGetServiceAccountToken,
|
||||
mockAuthorizeCredentialUse,
|
||||
mockCheckSessionOrInternalAuth,
|
||||
mockLogger,
|
||||
@@ -29,6 +31,8 @@ const {
|
||||
mockGetCredential: vi.fn(),
|
||||
mockRefreshTokenIfNeeded: vi.fn(),
|
||||
mockGetOAuthToken: vi.fn(),
|
||||
mockResolveOAuthAccountId: vi.fn(),
|
||||
mockGetServiceAccountToken: vi.fn(),
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockCheckSessionOrInternalAuth: vi.fn(),
|
||||
mockLogger: logger,
|
||||
@@ -40,6 +44,8 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
getCredential: mockGetCredential,
|
||||
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
|
||||
getOAuthToken: mockGetOAuthToken,
|
||||
resolveOAuthAccountId: mockResolveOAuthAccountId,
|
||||
getServiceAccountToken: mockGetServiceAccountToken,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
@@ -50,6 +56,10 @@ vi.mock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUse: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: vi.fn(),
|
||||
@@ -62,6 +72,7 @@ import { GET, POST } from '@/app/api/auth/oauth/token/route'
|
||||
describe('OAuth Token API Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveOAuthAccountId.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,31 @@ 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 accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
scopes ?? [],
|
||||
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,
|
||||
|
||||
@@ -160,7 +160,12 @@ describe('OAuth Utils', () => {
|
||||
|
||||
describe('refreshAccessTokenIfNeeded', () => {
|
||||
it('should return valid access token without refresh if not expired', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'valid-token',
|
||||
@@ -169,7 +174,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
@@ -179,7 +184,12 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should refresh token when expired', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
@@ -188,7 +198,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
mockUpdateChain()
|
||||
|
||||
@@ -215,7 +225,12 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should return null if refresh fails', async () => {
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockResolvedCredential = {
|
||||
id: 'credential-id',
|
||||
type: 'oauth',
|
||||
accountId: 'account-id',
|
||||
workspaceId: 'workspace-id',
|
||||
}
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
@@ -224,7 +239,7 @@ describe('OAuth Utils', () => {
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockResolvedCredential])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
||||
|
||||
@@ -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,
|
||||
@@ -11,6 +13,16 @@ import {
|
||||
|
||||
const logger = createLogger('OAuthUtilsAPI')
|
||||
|
||||
export class ServiceAccountTokenError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly errorDescription: string
|
||||
) {
|
||||
super(errorDescription)
|
||||
this.name = 'ServiceAccountTokenError'
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountInsertData {
|
||||
id: string
|
||||
userId: string
|
||||
@@ -25,16 +37,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<ResolvedCredential | null> {
|
||||
const [credentialRow] = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
accountId: credential.accountId,
|
||||
workspaceId: credential.workspaceId,
|
||||
@@ -44,6 +66,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 +89,124 @@ export async function resolveOAuthAccountId(
|
||||
return { accountId: credentialId, usedCredentialTable: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Userinfo scopes are excluded because service accounts don't represent a user
|
||||
* and cannot request user identity information. Google rejects token requests
|
||||
* that include these scopes for service account credentials.
|
||||
*/
|
||||
const SA_EXCLUDED_SCOPES = new Set([
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
])
|
||||
|
||||
/**
|
||||
* Generates a short-lived access token for a Google service account credential
|
||||
* using the two-legged OAuth JWT flow (RFC 7523).
|
||||
*
|
||||
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
|
||||
* where the service account must impersonate a domain user via domain-wide delegation.
|
||||
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
|
||||
* authenticates directly with its own IAM permissions.
|
||||
*/
|
||||
export async function getServiceAccountToken(
|
||||
credentialId: string,
|
||||
scopes: string[],
|
||||
impersonateEmail?: string
|
||||
): Promise<string> {
|
||||
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 filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
|
||||
const tokenUri =
|
||||
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
|
||||
? keyData.token_uri
|
||||
: 'https://oauth2.googleapis.com/token'
|
||||
|
||||
const header = { alg: 'RS256', typ: 'JWT' }
|
||||
const payload: Record<string, unknown> = {
|
||||
iss: keyData.client_email,
|
||||
scope: filteredScopes.join(' '),
|
||||
aud: tokenUri,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}
|
||||
|
||||
if (impersonateEmail) {
|
||||
payload.sub = impersonateEmail
|
||||
}
|
||||
|
||||
logger.info('Service account JWT payload', {
|
||||
iss: keyData.client_email,
|
||||
sub: impersonateEmail || '(none)',
|
||||
scopes: filteredScopes.join(' '),
|
||||
aud: tokenUri,
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
let description = `Token exchange failed: ${response.status}`
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody) as { error_description?: string }
|
||||
if (parsed.error_description) {
|
||||
const raw = parsed.error_description
|
||||
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
|
||||
description = 'Invalid account credentials.'
|
||||
} else {
|
||||
description = raw
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// use default description
|
||||
}
|
||||
throw new ServiceAccountTokenError(response.status, description)
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -81,19 +231,13 @@ export async function safeAccountInsert(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID and verify it belongs to the user
|
||||
* Get a credential by resolved account ID and verify it belongs to the user.
|
||||
*/
|
||||
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
|
||||
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
@@ -103,10 +247,22 @@ export async function getCredential(requestId: string, credentialId: string, use
|
||||
|
||||
return {
|
||||
...credentials[0],
|
||||
resolvedCredentialId: resolved.accountId,
|
||||
resolvedCredentialId: accountId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID and verify it belongs to the user.
|
||||
*/
|
||||
export async function getCredential(requestId: string, credentialId: string, userId: string) {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
|
||||
return undefined
|
||||
}
|
||||
return getCredentialByAccountId(requestId, resolved.accountId, userId)
|
||||
}
|
||||
|
||||
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
|
||||
const connections = await db
|
||||
.select({
|
||||
@@ -196,19 +352,36 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an OAuth token if needed based on credential information
|
||||
* Refreshes an OAuth token if needed based on credential information.
|
||||
* Also handles service account credentials by generating a JWT-based token.
|
||||
* @param credentialId The ID of the credential to check and potentially refresh
|
||||
* @param userId The user ID who owns the credential (for security verification)
|
||||
* @param requestId Request ID for log correlation
|
||||
* @param scopes Optional scopes for service account token generation
|
||||
* @returns The valid access token or null if refresh fails
|
||||
*/
|
||||
export async function refreshAccessTokenIfNeeded(
|
||||
credentialId: string,
|
||||
userId: string,
|
||||
requestId: string
|
||||
requestId: string,
|
||||
scopes?: string[],
|
||||
impersonateEmail?: string
|
||||
): Promise<string | null> {
|
||||
// Get the credential directly using the getCredential helper
|
||||
const credential = await getCredential(requestId, credentialId, userId)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
if (!scopes?.length) {
|
||||
throw new Error('Scopes are required for service account credentials')
|
||||
}
|
||||
logger.info(`[${requestId}] Using service account token for credential`)
|
||||
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
|
||||
}
|
||||
|
||||
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
|
||||
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
|
||||
|
||||
if (!credential) {
|
||||
return null
|
||||
|
||||
@@ -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,37 @@ 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'
|
||||
) {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(parseResult.data.serviceAccountJson)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 })
|
||||
}
|
||||
if (
|
||||
parsed.type !== 'service_account' ||
|
||||
typeof parsed.client_email !== 'string' ||
|
||||
typeof parsed.private_key !== 'string' ||
|
||||
typeof parsed.project_id !== 'string'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 })
|
||||
}
|
||||
const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson)
|
||||
updates.encryptedServiceAccountKey = encrypted
|
||||
}
|
||||
|
||||
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.',
|
||||
@@ -134,6 +167,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const row = await getCredentialResponse(id, session.user.id)
|
||||
return NextResponse.json({ credential: row }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('unique')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A service account credential with this name already exists in the workspace' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
logger.error('Failed to update credential', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -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 { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/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) {
|
||||
|
||||
@@ -6,7 +6,11 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
refreshTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
|
||||
@@ -365,6 +369,14 @@ async function resolveVertexCredential(requestId: string, credentialId: string):
|
||||
throw new Error(`Vertex AI credential not found: ${credentialId}`)
|
||||
}
|
||||
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
const accessToken = await getServiceAccountToken(resolved.credentialId, [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
])
|
||||
logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`)
|
||||
return accessToken
|
||||
}
|
||||
|
||||
const credential = await db.query.account.findFirst({
|
||||
where: eq(account.id, resolved.accountId),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GoogleDriveFileAPI')
|
||||
@@ -26,6 +27,7 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const fileId = searchParams.get('fileId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId || !fileId) {
|
||||
logger.warn(`[${requestId}] Missing required parameters`)
|
||||
@@ -46,7 +48,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-drive'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -157,6 +161,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ file }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching file from Google Drive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GoogleDriveFilesAPI')
|
||||
@@ -85,6 +86,7 @@ export async function GET(request: NextRequest) {
|
||||
const query = searchParams.get('query') || ''
|
||||
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
@@ -100,7 +102,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId!,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-drive'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -175,6 +179,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ files }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching files from Google Drive`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
ServiceAccountTokenError,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -26,6 +32,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const labelId = searchParams.get('labelId')
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId || !labelId) {
|
||||
logger.warn(`[${requestId}] Missing required parameters`)
|
||||
@@ -58,29 +65,40 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
getScopesForService('gmail'),
|
||||
impersonateEmail
|
||||
)
|
||||
} else {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId,
|
||||
getScopesForService('gmail')
|
||||
)
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
@@ -127,6 +145,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ label: formattedLabel }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Gmail label:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
ServiceAccountTokenError,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GmailLabelsAPI')
|
||||
@@ -33,6 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const query = searchParams.get('query')
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
@@ -62,29 +69,40 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
accessToken = await getServiceAccountToken(
|
||||
resolved.credentialId,
|
||||
getScopesForService('gmail'),
|
||||
impersonateEmail
|
||||
)
|
||||
} else {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId,
|
||||
getScopesForService('gmail')
|
||||
)
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
@@ -139,6 +157,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ labels: filteredLabels }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Gmail labels:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleBigQueryDatasetsAPI')
|
||||
|
||||
@@ -20,7 +21,7 @@ export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, projectId } = body
|
||||
const { credential, workflowId, projectId, impersonateEmail } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
@@ -43,7 +44,9 @@ export async function POST(request: Request) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-bigquery'),
|
||||
impersonateEmail
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
@@ -91,6 +94,9 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ datasets })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error processing BigQuery datasets request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message },
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleBigQueryTablesAPI')
|
||||
|
||||
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, projectId, datasetId } = body
|
||||
const { credential, workflowId, projectId, datasetId, impersonateEmail } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
@@ -40,7 +41,9 @@ export async function POST(request: Request) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-bigquery'),
|
||||
impersonateEmail
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
@@ -85,6 +88,9 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ tables })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error processing BigQuery tables request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve BigQuery tables', details: (error as Error).message },
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GoogleCalendarAPI')
|
||||
@@ -28,6 +29,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
@@ -41,7 +43,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-calendar'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -98,6 +102,10 @@ export async function GET(request: NextRequest) {
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Google calendars`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -40,6 +41,7 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const spreadsheetId = searchParams.get('spreadsheetId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
@@ -59,7 +61,9 @@ export async function GET(request: NextRequest) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-sheets'),
|
||||
impersonateEmail
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -114,6 +118,10 @@ export async function GET(request: NextRequest) {
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error fetching Google Sheets sheets`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleTasksTaskListsAPI')
|
||||
|
||||
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
const { credential, workflowId, impersonateEmail } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
@@ -30,7 +31,9 @@ export async function POST(request: Request) {
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
requestId,
|
||||
getScopesForService('google-tasks'),
|
||||
impersonateEmail
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
@@ -70,6 +73,9 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ taskLists })
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceAccountTokenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error('Error processing Google Tasks task lists request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message },
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Input as UiInput } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
|
||||
@@ -91,6 +92,13 @@ 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<string | null>(null)
|
||||
const [saIsSubmitting, setSaIsSubmitting] = useState(false)
|
||||
const [saDragActive, setSaDragActive] = useState(false)
|
||||
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id || ''
|
||||
|
||||
@@ -110,7 +118,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 +356,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 +370,10 @@ export function IntegrationsManager() {
|
||||
setCreateError(null)
|
||||
setCreateStep(1)
|
||||
setServiceSearch('')
|
||||
setSaJsonInput('')
|
||||
setSaDisplayName('')
|
||||
setSaDescription('')
|
||||
setSaError(null)
|
||||
pendingReturnOriginRef.current = undefined
|
||||
}
|
||||
|
||||
@@ -456,25 +464,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 +637,117 @@ export function IntegrationsManager() {
|
||||
setShowCreateModal(true)
|
||||
}, [])
|
||||
|
||||
const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => {
|
||||
let parsed: Record<string, unknown>
|
||||
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 readSaJsonFile = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.name.endsWith('.json')) {
|
||||
setSaError('Only .json files are supported')
|
||||
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)
|
||||
},
|
||||
[saDisplayName]
|
||||
)
|
||||
|
||||
const handleSaFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
readSaJsonFile(file)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleSaDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setSaDragActive(true)
|
||||
}, [])
|
||||
|
||||
const handleSaDragLeave = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setSaDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleSaDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setSaDragActive(false)
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file) readSaJsonFile(file)
|
||||
},
|
||||
[readSaJsonFile]
|
||||
)
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!serviceSearch.trim()) return oauthServiceOptions
|
||||
const q = serviceSearch.toLowerCase()
|
||||
@@ -700,7 +824,7 @@ export function IntegrationsManager() {
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
) : selectedOAuthService?.authType !== 'service_account' ? (
|
||||
<>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
@@ -827,6 +951,160 @@ export function IntegrationsManager() {
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setCreateStep(1)
|
||||
setSaError(null)
|
||||
}}
|
||||
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
|
||||
aria-label='Back'
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span>
|
||||
Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
|
||||
</span>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{saError && (
|
||||
<div className='mb-3'>
|
||||
<Badge variant='red' size='lg' dot className='max-w-full'>
|
||||
{saError}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
|
||||
{selectedOAuthService &&
|
||||
createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Add {selectedOAuthService?.name || 'service account'}
|
||||
</p>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{selectedOAuthService?.description || 'Paste or upload the JSON key file'}
|
||||
</p>
|
||||
<a
|
||||
href='https://docs.sim.ai/credentials/google-service-account'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[12px] text-[var(--accent)] hover:underline'
|
||||
>
|
||||
View setup guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
JSON Key<span className='ml-1'>*</span>
|
||||
</Label>
|
||||
<div
|
||||
onDragOver={handleSaDragOver}
|
||||
onDragLeave={handleSaDragLeave}
|
||||
onDrop={handleSaDrop}
|
||||
className={cn(
|
||||
'relative mt-1.5 rounded-md border-2 border-dashed transition-colors',
|
||||
saDragActive
|
||||
? 'border-[var(--accent)] bg-[var(--accent)]/5'
|
||||
: 'border-transparent'
|
||||
)}
|
||||
>
|
||||
{saDragActive && (
|
||||
<div className='pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md bg-[var(--accent)]/5'>
|
||||
<p className='font-medium text-[13px] text-[var(--accent)]'>
|
||||
Drop JSON key file here
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
value={saJsonInput}
|
||||
onChange={(event) => {
|
||||
setSaJsonInput(event.target.value)
|
||||
setSaError(null)
|
||||
if (!saDisplayName.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(event.target.value)
|
||||
if (parsed.client_email) setSaDisplayName(parsed.client_email)
|
||||
} catch {
|
||||
// not valid yet
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder='Paste your service account JSON key here or drag & drop a .json file...'
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className={cn(
|
||||
'min-h-[120px] resize-none border-0 font-mono text-[12px]',
|
||||
saDragActive && 'opacity-30'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1.5'>
|
||||
<label className='inline-flex cursor-pointer items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'>
|
||||
<input
|
||||
type='file'
|
||||
accept='.json'
|
||||
onChange={handleSaFileUpload}
|
||||
className='hidden'
|
||||
/>
|
||||
Or upload a .json file
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Display name</Label>
|
||||
<Input
|
||||
value={saDisplayName}
|
||||
onChange={(event) => setSaDisplayName(event.target.value)}
|
||||
placeholder='Auto-populated from client_email'
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-1.5'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={saDescription}
|
||||
onChange={(event) => setSaDescription(event.target.value)}
|
||||
placeholder='Optional description'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-1.5 min-h-[80px] resize-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
setCreateStep(1)
|
||||
setSaError(null)
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleCreateServiceAccount}
|
||||
disabled={!saJsonInput.trim() || saIsSubmitting}
|
||||
>
|
||||
{saIsSubmitting ? 'Adding...' : 'Add Service Account'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
@@ -869,9 +1147,11 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={disconnectOAuthService.isPending}
|
||||
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
|
||||
>
|
||||
{disconnectOAuthService.isPending ? 'Disconnecting...' : 'Disconnect'}
|
||||
{disconnectOAuthService.isPending || deleteCredential.isPending
|
||||
? 'Disconnecting...'
|
||||
: 'Disconnect'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
@@ -920,10 +1200,14 @@ export function IntegrationsManager() {
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
{resolveProviderLabel(selectedCredential.providerId) || 'Unknown service'}
|
||||
{selectedOAuthServiceConfig?.name ||
|
||||
resolveProviderLabel(selectedCredential.providerId) ||
|
||||
'Unknown service'}
|
||||
</p>
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
oauth
|
||||
{selectedOAuthServiceConfig?.authType === 'service_account'
|
||||
? 'service account'
|
||||
: 'oauth'}
|
||||
</Badge>
|
||||
{selectedCredential.role && (
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
@@ -931,7 +1215,9 @@ export function IntegrationsManager() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-[var(--text-muted)] text-small'>Connected service</p>
|
||||
<p className='text-[var(--text-muted)] text-small'>
|
||||
{selectedOAuthServiceConfig?.description || 'Connected service'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1116,15 +1402,17 @@ export function IntegrationsManager() {
|
||||
<div className='flex items-center gap-2'>
|
||||
{isSelectedAdmin && (
|
||||
<>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleReconnectOAuth}
|
||||
disabled={connectOAuthService.isPending}
|
||||
>
|
||||
{`Reconnect to ${
|
||||
resolveProviderLabel(selectedCredential.providerId) || 'service'
|
||||
}`}
|
||||
</Button>
|
||||
{selectedOAuthServiceConfig?.authType !== 'service_account' && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleReconnectOAuth}
|
||||
disabled={connectOAuthService.isPending}
|
||||
>
|
||||
{`Reconnect to ${
|
||||
resolveProviderLabel(selectedCredential.providerId) || 'service'
|
||||
}`}
|
||||
</Button>
|
||||
)}
|
||||
{(workspaceUserOptions.length > 0 || isShareingWithWorkspace) && (
|
||||
<Button
|
||||
variant='default'
|
||||
@@ -1138,7 +1426,7 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDeleteClick(selectedCredential)}
|
||||
disabled={disconnectOAuthService.isPending}
|
||||
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
@@ -1234,7 +1522,11 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDeleteClick(credential)}
|
||||
disabled={disconnectOAuthService.isPending}
|
||||
disabled={
|
||||
credential.type === 'service_account'
|
||||
? deleteCredential.isPending
|
||||
: disconnectOAuthService.isPending
|
||||
}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
|
||||
@@ -98,8 +98,10 @@ export function CredentialSelector({
|
||||
)
|
||||
const provider = effectiveProviderId
|
||||
|
||||
const isTriggerMode = subBlock.mode === 'trigger'
|
||||
|
||||
const {
|
||||
data: credentials = [],
|
||||
data: rawCredentials = [],
|
||||
isFetching: credentialsLoading,
|
||||
refetch: refetchCredentials,
|
||||
} = useOAuthCredentials(effectiveProviderId, {
|
||||
@@ -108,11 +110,24 @@ export function CredentialSelector({
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
|
||||
const credentials = useMemo(
|
||||
() =>
|
||||
isTriggerMode
|
||||
? rawCredentials.filter((cred) => cred.type !== 'service_account')
|
||||
: rawCredentials,
|
||||
[rawCredentials, isTriggerMode]
|
||||
)
|
||||
|
||||
const selectedCredential = useMemo(
|
||||
() => credentials.find((cred) => cred.id === selectedId),
|
||||
[credentials, selectedId]
|
||||
)
|
||||
|
||||
const isServiceAccount = useMemo(
|
||||
() => selectedCredential?.type === 'service_account',
|
||||
[selectedCredential]
|
||||
)
|
||||
|
||||
const selectedCredentialSet = useMemo(
|
||||
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
|
||||
[credentialSets, selectedCredentialSetId]
|
||||
@@ -151,6 +166,7 @@ export function CredentialSelector({
|
||||
|
||||
const needsUpdate =
|
||||
hasSelection &&
|
||||
!isServiceAccount &&
|
||||
missingRequiredScopes.length > 0 &&
|
||||
!effectiveDisabled &&
|
||||
!isPreview &&
|
||||
@@ -230,6 +246,7 @@ export function CredentialSelector({
|
||||
const credentialItems = credentials.map((cred) => ({
|
||||
label: cred.name,
|
||||
value: cred.id,
|
||||
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
|
||||
}))
|
||||
credentialItems.push({
|
||||
label:
|
||||
@@ -237,6 +254,7 @@ export function CredentialSelector({
|
||||
? `Connect another ${getProviderName(provider)} account`
|
||||
: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
iconElement: <ExternalLink className='h-3 w-3' />,
|
||||
})
|
||||
|
||||
groups.push({
|
||||
@@ -250,6 +268,7 @@ export function CredentialSelector({
|
||||
const options = credentials.map((cred) => ({
|
||||
label: cred.name,
|
||||
value: cred.id,
|
||||
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
|
||||
}))
|
||||
|
||||
options.push({
|
||||
@@ -258,6 +277,7 @@ export function CredentialSelector({
|
||||
? `Connect another ${getProviderName(provider)} account`
|
||||
: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
iconElement: <ExternalLink className='h-3 w-3' />,
|
||||
})
|
||||
|
||||
return { comboboxOptions: options, comboboxGroups: undefined }
|
||||
@@ -265,6 +285,7 @@ export function CredentialSelector({
|
||||
credentials,
|
||||
provider,
|
||||
effectiveProviderId,
|
||||
getProviderIcon,
|
||||
getProviderName,
|
||||
canUseCredentialSets,
|
||||
credentialSets,
|
||||
@@ -300,6 +321,7 @@ export function CredentialSelector({
|
||||
selectedCredentialProvider,
|
||||
isCredentialSetSelected,
|
||||
selectedCredentialSet,
|
||||
isServiceAccount,
|
||||
])
|
||||
|
||||
const handleComboboxChange = useCallback(
|
||||
|
||||
@@ -51,6 +51,7 @@ import { getAllBlocks } from '@/blocks'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
|
||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
import {
|
||||
type CustomTool as CustomToolDefinition,
|
||||
useCustomTools,
|
||||
@@ -88,6 +89,7 @@ import {
|
||||
evaluateSubBlockCondition,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
resolveDependencyValue,
|
||||
type SubBlockCondition,
|
||||
} from '@/tools/params-resolver'
|
||||
|
||||
@@ -482,6 +484,42 @@ export const ToolInput = memo(function ToolInput({
|
||||
? (value as StoredTool[])
|
||||
: []
|
||||
|
||||
// Look up credential type for reactive condition filtering (e.g. service account detection).
|
||||
// Uses canonical resolution so the active field (basic vs advanced) is respected.
|
||||
const toolCredentialId = useMemo(() => {
|
||||
const allBlocks = getAllBlocks()
|
||||
for (const tool of selectedTools) {
|
||||
const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type)
|
||||
if (!blockConfig?.subBlocks) continue
|
||||
const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks)
|
||||
const scopedOverrides: CanonicalModeOverrides = {}
|
||||
if (canonicalModeOverrides) {
|
||||
for (const [key, val] of Object.entries(canonicalModeOverrides)) {
|
||||
const prefix = `${tool.type}:`
|
||||
if (key.startsWith(prefix) && val) {
|
||||
scopedOverrides[key.slice(prefix.length)] = val as 'basic' | 'advanced'
|
||||
}
|
||||
}
|
||||
}
|
||||
const reactiveSubBlock = blockConfig.subBlocks.find(
|
||||
(sb: { reactiveCondition?: unknown }) => sb.reactiveCondition
|
||||
)
|
||||
const reactiveCond = reactiveSubBlock?.reactiveCondition as
|
||||
| { watchFields: string[]; requiredType: string }
|
||||
| undefined
|
||||
if (!reactiveCond) continue
|
||||
for (const field of reactiveCond.watchFields) {
|
||||
const val = resolveDependencyValue(field, tool.params || {}, toolCanonical, scopedOverrides)
|
||||
if (val && typeof val === 'string') return val
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [selectedTools, canonicalModeOverrides])
|
||||
const { data: toolCredential } = useWorkspaceCredential(
|
||||
toolCredentialId,
|
||||
Boolean(toolCredentialId)
|
||||
)
|
||||
|
||||
const hasReferenceOnlyCustomTools = selectedTools.some(
|
||||
(tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code
|
||||
)
|
||||
@@ -1637,7 +1675,11 @@ export const ToolInput = memo(function ToolInput({
|
||||
? mcpToolParams
|
||||
: toolParams?.userInputParameters || []
|
||||
const displaySubBlocks: BlockSubBlockConfig[] = useSubBlocks
|
||||
? subBlocksResult!.subBlocks
|
||||
? subBlocksResult!.subBlocks.filter(
|
||||
(sb) =>
|
||||
!sb.reactiveCondition ||
|
||||
toolCredential?.type === sb.reactiveCondition.requiredType
|
||||
)
|
||||
: []
|
||||
|
||||
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useDependsOnGate } from './use-depends-on-gate'
|
||||
import { useSubBlockValue } from './use-sub-block-value'
|
||||
|
||||
/**
|
||||
* Resolves all selector configuration from a sub-block's declarative properties.
|
||||
@@ -39,6 +40,8 @@ export function useSelectorSetup(
|
||||
opts
|
||||
)
|
||||
|
||||
const [impersonateUserEmail] = useSubBlockValue<string | null>(blockId, 'impersonateUserEmail')
|
||||
|
||||
const resolvedDependencyValues = useMemo(() => {
|
||||
const resolved: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(dependencyValues)) {
|
||||
@@ -75,8 +78,18 @@ export function useSelectorSetup(
|
||||
}
|
||||
}
|
||||
|
||||
if (context.oauthCredential && impersonateUserEmail) {
|
||||
context.impersonateUserEmail = impersonateUserEmail
|
||||
}
|
||||
|
||||
return context
|
||||
}, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
|
||||
}, [
|
||||
resolvedDependencyValues,
|
||||
canonicalIndex,
|
||||
workflowId,
|
||||
subBlock.mimeType,
|
||||
impersonateUserEmail,
|
||||
])
|
||||
|
||||
return {
|
||||
selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null,
|
||||
|
||||
@@ -1,17 +1,77 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
evaluateSubBlockCondition,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockHiddenByHostedKey,
|
||||
isSubBlockVisibleForMode,
|
||||
resolveDependencyValue,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
|
||||
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Evaluates reactive conditions for subblocks. Always calls the same hooks
|
||||
* regardless of whether a reactive condition exists (Rules of Hooks).
|
||||
*
|
||||
* Returns a Set of subblock IDs that should be hidden.
|
||||
*/
|
||||
function useReactiveConditions(
|
||||
subBlocks: SubBlockConfig[],
|
||||
blockId: string,
|
||||
activeWorkflowId: string | null,
|
||||
canonicalModeOverrides?: CanonicalModeOverrides
|
||||
): Set<string> {
|
||||
const reactiveSubBlock = useMemo(() => subBlocks.find((sb) => sb.reactiveCondition), [subBlocks])
|
||||
const reactiveCond = reactiveSubBlock?.reactiveCondition
|
||||
|
||||
const canonicalIndex = useMemo(() => buildCanonicalIndex(subBlocks), [subBlocks])
|
||||
|
||||
// Resolve watchFields through canonical index to get the active credential value
|
||||
const watchedCredentialId = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!reactiveCond || !activeWorkflowId) return ''
|
||||
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {}
|
||||
for (const field of reactiveCond.watchFields) {
|
||||
const val = resolveDependencyValue(
|
||||
field,
|
||||
blockValues,
|
||||
canonicalIndex,
|
||||
canonicalModeOverrides
|
||||
)
|
||||
if (val && typeof val === 'string') return val
|
||||
}
|
||||
return ''
|
||||
},
|
||||
[reactiveCond, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
)
|
||||
|
||||
// Always call useWorkspaceCredential (stable hook count), disable when not needed
|
||||
const { data: credential } = useWorkspaceCredential(
|
||||
watchedCredentialId || undefined,
|
||||
Boolean(reactiveCond && watchedCredentialId)
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
const hidden = new Set<string>()
|
||||
if (!reactiveSubBlock || !reactiveCond) return hidden
|
||||
|
||||
const conditionMet = credential?.type === reactiveCond.requiredType
|
||||
if (!conditionMet) {
|
||||
hidden.add(reactiveSubBlock.id)
|
||||
}
|
||||
return hidden
|
||||
}, [reactiveSubBlock, reactiveCond, credential?.type])
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for computing subblock layout in the editor panel.
|
||||
* Determines which subblocks should be visible based on mode, conditions, and feature flags.
|
||||
@@ -39,6 +99,14 @@ export function useEditorSubblockLayout(
|
||||
)
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
// Evaluate reactive conditions (hooks-based, must be called before useMemo)
|
||||
const hiddenByReactiveCondition = useReactiveConditions(
|
||||
config?.subBlocks || [],
|
||||
blockId,
|
||||
activeWorkflowId,
|
||||
blockDataFromStore?.canonicalModes
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
// Guard against missing config or block selection
|
||||
if (!config || !Array.isArray((config as any).subBlocks) || !blockId) {
|
||||
@@ -100,9 +168,18 @@ export function useEditorSubblockLayout(
|
||||
const effectiveAdvanced = displayAdvancedMode
|
||||
const canonicalModeOverrides = blockData?.canonicalModes
|
||||
|
||||
// Expose canonical mode overrides to condition functions so they can
|
||||
// react to basic/advanced credential toggles (e.g. SERVICE_ACCOUNT_SUBBLOCKS).
|
||||
if (canonicalModeOverrides) {
|
||||
rawValues.__canonicalModes = canonicalModeOverrides
|
||||
}
|
||||
|
||||
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
|
||||
if (block.hidden) return false
|
||||
|
||||
// Filter by reactive condition (evaluated via hooks before useMemo)
|
||||
if (hiddenByReactiveCondition.has(block.id)) return false
|
||||
|
||||
// Hide skill-input subblock when skills are disabled via permissions
|
||||
if (block.type === 'skill-input' && permissionConfig.disableSkills) return false
|
||||
|
||||
@@ -158,6 +235,7 @@ export function useEditorSubblockLayout(
|
||||
activeWorkflowId,
|
||||
isSnapshotView,
|
||||
blockDataFromStore,
|
||||
hiddenByReactiveCondition,
|
||||
permissionConfig.disableSkills,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { GmailIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils'
|
||||
import {
|
||||
createVersionedToolSelector,
|
||||
normalizeFileInput,
|
||||
SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
} from '@/blocks/utils'
|
||||
import type { GmailToolResponse } from '@/tools/gmail/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
@@ -95,6 +99,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Send Email Fields
|
||||
{
|
||||
id: 'to',
|
||||
|
||||
@@ -52,7 +52,6 @@ export const GoogleAdsBlock: BlockConfig = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'developerToken',
|
||||
title: 'Developer Token',
|
||||
|
||||
@@ -51,7 +51,6 @@ export const GoogleBigQueryBlock: BlockConfig = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'projectId',
|
||||
title: 'Project ID',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GoogleCalendarIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleCalendarResponse } from '@/tools/google_calendar/types'
|
||||
|
||||
export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
@@ -58,6 +58,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Calendar selector (basic mode) - not needed for list_calendars
|
||||
{
|
||||
id: 'calendarId',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoogleContactsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleContactsResponse } from '@/tools/google_contacts/types'
|
||||
|
||||
export const GoogleContactsBlock: BlockConfig<GoogleContactsResponse> = {
|
||||
@@ -52,6 +53,7 @@ export const GoogleContactsBlock: BlockConfig<GoogleContactsResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
|
||||
// Create Contact Fields
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoogleDocsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleDocsResponse } from '@/tools/google_docs/types'
|
||||
|
||||
export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
@@ -51,6 +52,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Document selector (basic mode)
|
||||
{
|
||||
id: 'documentId',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GoogleDriveIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { normalizeFileInput } from '@/blocks/utils'
|
||||
import { normalizeFileInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleDriveResponse } from '@/tools/google_drive/types'
|
||||
|
||||
export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
@@ -63,6 +63,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Create/Upload File Fields
|
||||
{
|
||||
id: 'fileName',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoogleFormsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { IntegrationType } from '@/blocks/types'
|
||||
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
export const GoogleFormsBlock: BlockConfig = {
|
||||
@@ -54,6 +55,7 @@ export const GoogleFormsBlock: BlockConfig = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Form selector (basic mode)
|
||||
{
|
||||
id: 'formSelector',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoogleGroupsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
|
||||
export const GoogleGroupsBlock: BlockConfig = {
|
||||
type: 'google_groups',
|
||||
@@ -61,6 +62,7 @@ export const GoogleGroupsBlock: BlockConfig = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
|
||||
{
|
||||
id: 'customer',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoogleMeetIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleMeetResponse } from '@/tools/google_meet/types'
|
||||
|
||||
export const GoogleMeetBlock: BlockConfig<GoogleMeetResponse> = {
|
||||
@@ -52,6 +53,7 @@ export const GoogleMeetBlock: BlockConfig<GoogleMeetResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
|
||||
// Create Space Fields
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GoogleSheetsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { createVersionedToolSelector } from '@/blocks/utils'
|
||||
import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleSheetsResponse, GoogleSheetsV2Response } from '@/tools/google_sheets/types'
|
||||
|
||||
// Legacy block - hidden from toolbar
|
||||
@@ -55,6 +55,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Spreadsheet Selector
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
@@ -350,6 +351,7 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Spreadsheet Selector (basic mode) - not for create operation
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { normalizeFileInput } from '@/blocks/utils'
|
||||
import { normalizeFileInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleSlidesResponse } from '@/tools/google_slides/types'
|
||||
|
||||
export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
|
||||
@@ -65,6 +65,7 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Presentation selector (basic mode) - for operations that need an existing presentation
|
||||
{
|
||||
id: 'presentationId',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoogleTasksIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
import type { GoogleTasksResponse } from '@/tools/google_tasks/types'
|
||||
|
||||
export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
|
||||
@@ -53,6 +54,7 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
|
||||
// Task List - shown for all task operations (not list_task_lists)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GoogleVaultIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils'
|
||||
|
||||
export const GoogleVaultBlock: BlockConfig = {
|
||||
type: 'google_vault',
|
||||
@@ -53,6 +54,7 @@ export const GoogleVaultBlock: BlockConfig = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
...SERVICE_ACCOUNT_SUBBLOCKS,
|
||||
// Create Hold inputs
|
||||
{
|
||||
id: 'matterId',
|
||||
|
||||
@@ -356,6 +356,18 @@ export interface SubBlockConfig {
|
||||
not?: boolean
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Credential-type visibility gate. The first non-empty string value from
|
||||
* `watchFields` is treated as a credential ID and fetched via the credentials
|
||||
* API. The subblock is hidden unless `credential.type` matches `requiredType`.
|
||||
*
|
||||
* Only one subblock per block may use this. The serializer ignores it —
|
||||
* the field is always serialized when it has a value.
|
||||
*/
|
||||
reactiveCondition?: {
|
||||
watchFields: string[]
|
||||
requiredType: 'oauth' | 'service_account'
|
||||
}
|
||||
// Props specific to 'code' sub-block type
|
||||
language?: 'javascript' | 'json' | 'python'
|
||||
generationType?: GenerationType
|
||||
|
||||
@@ -8,6 +8,26 @@ import {
|
||||
} from '@/providers/utils'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
/**
|
||||
* Standard subblocks for Google service account impersonation.
|
||||
* Uses a reactive condition that fetches the credential by ID to check if it's
|
||||
* a service account — works in both block editor and agent tool-input contexts.
|
||||
*/
|
||||
export const SERVICE_ACCOUNT_SUBBLOCKS: SubBlockConfig[] = [
|
||||
{
|
||||
id: 'impersonateUserEmail',
|
||||
title: 'Impersonated Account',
|
||||
type: 'short-input',
|
||||
placeholder: 'Email to impersonate (for service accounts)',
|
||||
paramVisibility: 'user-only',
|
||||
reactiveCondition: {
|
||||
watchFields: ['oauthCredential'],
|
||||
requiredType: 'service_account',
|
||||
},
|
||||
mode: 'both',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns model options for combobox subblocks, combining all provider sources.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,11 @@ import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
getServiceAccountToken,
|
||||
refreshTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('VertexCredential')
|
||||
|
||||
@@ -23,6 +27,14 @@ export async function resolveVertexCredential(
|
||||
throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`)
|
||||
}
|
||||
|
||||
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
|
||||
const accessToken = await getServiceAccountToken(resolved.credentialId, [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
])
|
||||
logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`)
|
||||
return accessToken
|
||||
}
|
||||
|
||||
const credential = await db.query.account.findFirst({
|
||||
where: eq(account.id, resolved.accountId),
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { environmentKeys } from '@/hooks/queries/environment'
|
||||
import { fetchJson } from '@/hooks/selectors/helpers'
|
||||
|
||||
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal'
|
||||
export type WorkspaceCredentialType = 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
|
||||
export type WorkspaceCredentialRole = 'admin' | 'member'
|
||||
export type WorkspaceCredentialMemberStatus = 'active' | 'pending' | 'revoked'
|
||||
|
||||
@@ -173,6 +173,7 @@ export function useCreateWorkspaceCredential() {
|
||||
accountId?: string
|
||||
envKey?: string
|
||||
envOwnerUserId?: string
|
||||
serviceAccountJson?: string
|
||||
}) => {
|
||||
const response = await fetch('/api/credentials', {
|
||||
method: 'POST',
|
||||
@@ -204,6 +205,7 @@ export function useUpdateWorkspaceCredential() {
|
||||
displayName?: string
|
||||
description?: string | null
|
||||
accountId?: string
|
||||
serviceAccountJson?: string
|
||||
}) => {
|
||||
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
|
||||
method: 'PUT',
|
||||
@@ -212,6 +214,7 @@ export function useUpdateWorkspaceCredential() {
|
||||
displayName: payload.displayName,
|
||||
description: payload.description,
|
||||
accountId: payload.accountId,
|
||||
serviceAccountJson: payload.serviceAccountJson,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -252,6 +252,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'bigquery.datasets',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.projectId ?? 'none',
|
||||
context.impersonateUserEmail ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential && context.projectId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
@@ -261,6 +262,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
projectId: context.projectId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
})
|
||||
const data = await fetchJson<{ datasets: BigQueryDataset[] }>(
|
||||
'/api/tools/google_bigquery/datasets',
|
||||
@@ -278,6 +280,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
projectId: context.projectId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
})
|
||||
const data = await fetchJson<{ datasets: BigQueryDataset[] }>(
|
||||
'/api/tools/google_bigquery/datasets',
|
||||
@@ -301,6 +304,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
context.oauthCredential ?? 'none',
|
||||
context.projectId ?? 'none',
|
||||
context.datasetId ?? 'none',
|
||||
context.impersonateUserEmail ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(context.oauthCredential && context.projectId && context.datasetId),
|
||||
@@ -313,6 +317,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
workflowId: context.workflowId,
|
||||
projectId: context.projectId,
|
||||
datasetId: context.datasetId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
})
|
||||
const data = await fetchJson<{ tables: BigQueryTable[] }>(
|
||||
'/api/tools/google_bigquery/tables',
|
||||
@@ -331,6 +336,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
workflowId: context.workflowId,
|
||||
projectId: context.projectId,
|
||||
datasetId: context.datasetId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
})
|
||||
const data = await fetchJson<{ tables: BigQueryTable[] }>(
|
||||
'/api/tools/google_bigquery/tables',
|
||||
@@ -557,11 +563,16 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'selectors',
|
||||
'google.tasks.lists',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.impersonateUserEmail ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'google.tasks.lists')
|
||||
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
})
|
||||
const data = await fetchJson<{ taskLists: GoogleTaskList[] }>(
|
||||
'/api/tools/google_tasks/task-lists',
|
||||
{ method: 'POST', body }
|
||||
@@ -571,7 +582,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
const credentialId = ensureCredential(context, 'google.tasks.lists')
|
||||
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
})
|
||||
const data = await fetchJson<{ taskLists: GoogleTaskList[] }>(
|
||||
'/api/tools/google_tasks/task-lists',
|
||||
{ method: 'POST', body }
|
||||
@@ -877,11 +892,15 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'selectors',
|
||||
'gmail.labels',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.impersonateUserEmail ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', {
|
||||
searchParams: { credentialId: context.oauthCredential },
|
||||
searchParams: {
|
||||
credentialId: context.oauthCredential,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
},
|
||||
})
|
||||
return (data.labels || []).map((label) => ({
|
||||
id: label.id,
|
||||
@@ -915,12 +934,18 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'selectors',
|
||||
'google.calendar',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.impersonateUserEmail ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>(
|
||||
'/api/tools/google_calendar/calendars',
|
||||
{ searchParams: { credentialId: context.oauthCredential } }
|
||||
{
|
||||
searchParams: {
|
||||
credentialId: context.oauthCredential,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
},
|
||||
}
|
||||
)
|
||||
return (data.calendars || []).map((calendar) => ({
|
||||
id: calendar.id,
|
||||
@@ -1393,6 +1418,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
context.mimeType ?? 'any',
|
||||
context.fileId ?? 'root',
|
||||
search ?? '',
|
||||
context.impersonateUserEmail ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
@@ -1406,6 +1432,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
parentId: context.fileId,
|
||||
query: search,
|
||||
workflowId: context.workflowId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1424,6 +1451,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
credentialId,
|
||||
fileId: detailId,
|
||||
workflowId: context.workflowId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1440,6 +1468,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'google.sheets',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.spreadsheetId ?? 'none',
|
||||
context.impersonateUserEmail ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
@@ -1454,6 +1483,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
credentialId,
|
||||
spreadsheetId: context.spreadsheetId,
|
||||
workflowId: context.workflowId,
|
||||
impersonateEmail: context.impersonateUserEmail,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface SelectorContext {
|
||||
baseId?: string
|
||||
datasetId?: string
|
||||
serviceDeskId?: string
|
||||
impersonateUserEmail?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -65,6 +65,54 @@ export async function authorizeCredentialUse(
|
||||
.limit(1)
|
||||
|
||||
if (platformCredential) {
|
||||
if (platformCredential.type === 'service_account') {
|
||||
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
|
||||
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
|
||||
}
|
||||
|
||||
if (actingUserId) {
|
||||
const requesterPerm = await getUserEntityPermissions(
|
||||
actingUserId,
|
||||
'workspace',
|
||||
platformCredential.workspaceId
|
||||
)
|
||||
|
||||
const [membership] = await db
|
||||
.select({ id: credentialMember.id })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, platformCredential.id),
|
||||
eq(credentialMember.userId, actingUserId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'You do not have access to this credential. Ask the credential admin to add you as a member.',
|
||||
}
|
||||
}
|
||||
if (requesterPerm === null) {
|
||||
return { ok: false, error: 'You do not have access to this workspace.' }
|
||||
}
|
||||
} else if (!workflowContext) {
|
||||
return { ok: false, error: 'workflowId is required' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authType: auth.authType as CredentialAccessResult['authType'],
|
||||
requesterUserId: auth.userId,
|
||||
credentialOwnerUserId: actingUserId || auth.userId,
|
||||
workspaceId: platformCredential.workspaceId,
|
||||
resolvedCredentialId: platformCredential.id,
|
||||
}
|
||||
}
|
||||
|
||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
||||
return { ok: false, error: 'Unsupported credential type for OAuth access' }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils'
|
||||
import { getTableById, queryRows } from '@/lib/table/service'
|
||||
import {
|
||||
downloadWorkspaceFile,
|
||||
@@ -97,7 +98,10 @@ export async function executeIntegrationToolDirect(
|
||||
}
|
||||
|
||||
const accessibleCreds = await getAccessibleOAuthCredentials(workspaceId, userId)
|
||||
const match = accessibleCreds.find((c) => c.providerId === provider)
|
||||
const saProviderId = getServiceAccountProviderForProviderId(provider)
|
||||
const match =
|
||||
accessibleCreds.find((c) => c.providerId === provider) ||
|
||||
(saProviderId ? accessibleCreds.find((c) => c.providerId === saProviderId) : undefined)
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
@@ -110,34 +114,39 @@ export async function executeIntegrationToolDirect(
|
||||
}
|
||||
|
||||
const matchCtx = await getCredentialActorContext(resolvedCredentialId, userId)
|
||||
const accountId = matchCtx.credential?.accountId
|
||||
if (!accountId) {
|
||||
return {
|
||||
success: false,
|
||||
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
|
||||
|
||||
if (matchCtx.credential?.type === 'service_account') {
|
||||
executionParams.oauthCredential = resolvedCredentialId
|
||||
} else {
|
||||
const accountId = matchCtx.credential?.accountId
|
||||
if (!accountId) {
|
||||
return {
|
||||
success: false,
|
||||
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [acc] = await db.select().from(account).where(eq(account.id, accountId)).limit(1)
|
||||
const [acc] = await db.select().from(account).where(eq(account.id, accountId)).limit(1)
|
||||
|
||||
if (!acc) {
|
||||
return {
|
||||
success: false,
|
||||
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
|
||||
if (!acc) {
|
||||
return {
|
||||
success: false,
|
||||
error: `OAuth account for ${provider} not found. Please reconnect your account.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = generateRequestId()
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, acc, acc.id)
|
||||
const requestId = generateRequestId()
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, acc, acc.id)
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: `OAuth token not available for ${provider}. Please reconnect your account.`,
|
||||
if (!accessToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: `OAuth token not available for ${provider}. Please reconnect your account.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executionParams.accessToken = accessToken
|
||||
executionParams.accessToken = accessToken
|
||||
}
|
||||
}
|
||||
|
||||
const hasHostedKeySupport = isHosted && !!toolConfig.hosting
|
||||
|
||||
@@ -25,6 +25,7 @@ const logger = createLogger('WorkspaceContext')
|
||||
|
||||
const PROVIDER_SERVICES: Record<string, string[]> = {
|
||||
google: ['Gmail', 'Sheets', 'Calendar', 'Drive'],
|
||||
'google-service-account': ['Gmail', 'Sheets', 'Calendar', 'Drive'],
|
||||
slack: ['Slack'],
|
||||
github: ['GitHub'],
|
||||
microsoft: ['Outlook', 'OneDrive'],
|
||||
|
||||
@@ -389,7 +389,12 @@ export async function getAccessibleOAuthCredentials(
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account'])
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
.filter((row): row is AccessibleOAuthCredential => Boolean(row.providerId))
|
||||
|
||||
@@ -72,6 +72,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-drive': {
|
||||
name: 'Google Drive',
|
||||
@@ -85,6 +86,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-docs': {
|
||||
name: 'Google Docs',
|
||||
@@ -98,6 +100,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-sheets': {
|
||||
name: 'Google Sheets',
|
||||
@@ -111,6 +114,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-forms': {
|
||||
name: 'Google Forms',
|
||||
@@ -125,6 +129,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/forms.body',
|
||||
'https://www.googleapis.com/auth/forms.responses.readonly',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-calendar': {
|
||||
name: 'Google Calendar',
|
||||
@@ -137,6 +142,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/calendar',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-contacts': {
|
||||
name: 'Google Contacts',
|
||||
@@ -149,6 +155,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/contacts',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-ads': {
|
||||
name: 'Google Ads',
|
||||
@@ -173,6 +180,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/bigquery',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-tasks': {
|
||||
name: 'Google Tasks',
|
||||
@@ -185,6 +193,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/tasks',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-vault': {
|
||||
name: 'Google Vault',
|
||||
@@ -198,6 +207,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/ediscovery',
|
||||
'https://www.googleapis.com/auth/devstorage.read_only',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-groups': {
|
||||
name: 'Google Groups',
|
||||
@@ -211,6 +221,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/admin.directory.group',
|
||||
'https://www.googleapis.com/auth/admin.directory.group.member',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-meet': {
|
||||
name: 'Google Meet',
|
||||
@@ -224,6 +235,16 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/meetings.space.created',
|
||||
'https://www.googleapis.com/auth/meetings.space.readonly',
|
||||
],
|
||||
serviceAccountProviderId: 'google-service-account',
|
||||
},
|
||||
'google-service-account': {
|
||||
name: 'Google Service Account',
|
||||
description: 'Authenticate with a JSON key file from Google Cloud Console.',
|
||||
providerId: 'google-service-account',
|
||||
icon: GoogleIcon,
|
||||
baseProviderIcon: GoogleIcon,
|
||||
scopes: [],
|
||||
authType: 'service_account',
|
||||
},
|
||||
'vertex-ai': {
|
||||
name: 'Vertex AI',
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface OAuthProviderConfig {
|
||||
defaultService: string
|
||||
}
|
||||
|
||||
export type OAuthAuthType = 'oauth' | 'service_account'
|
||||
|
||||
export interface OAuthServiceConfig {
|
||||
name: string
|
||||
description: string
|
||||
@@ -116,6 +118,8 @@ export interface OAuthServiceConfig {
|
||||
icon: (props: { className?: string }) => ReactNode
|
||||
baseProviderIcon: (props: { className?: string }) => ReactNode
|
||||
scopes: string[]
|
||||
authType?: OAuthAuthType
|
||||
serviceAccountProviderId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +136,7 @@ export interface Credential {
|
||||
id: string
|
||||
name: string
|
||||
provider: OAuthProvider
|
||||
type?: 'oauth' | 'service_account'
|
||||
serviceId?: string
|
||||
lastUsed?: string
|
||||
isDefault?: boolean
|
||||
|
||||
@@ -488,6 +488,11 @@ export function getServiceConfigByProviderId(providerId: string): OAuthServiceCo
|
||||
return null
|
||||
}
|
||||
|
||||
export function getServiceAccountProviderForProviderId(providerId: string): string | undefined {
|
||||
const serviceConfig = getServiceConfigByProviderId(providerId)
|
||||
return serviceConfig?.serviceAccountProviderId
|
||||
}
|
||||
|
||||
export function getCanonicalScopesForProvider(providerId: string): string[] {
|
||||
const service = getServiceConfigByProviderId(providerId)
|
||||
return service?.scopes ? [...service.scopes] : []
|
||||
|
||||
@@ -21,6 +21,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
|
||||
'baseId',
|
||||
'datasetId',
|
||||
'serviceDeskId',
|
||||
'impersonateUserEmail',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -688,6 +688,16 @@ export async function executeTool(
|
||||
if (workflowId) {
|
||||
tokenPayload.workflowId = workflowId
|
||||
}
|
||||
if (contextParams.impersonateUserEmail) {
|
||||
tokenPayload.impersonateEmail = contextParams.impersonateUserEmail as string
|
||||
}
|
||||
if (tool?.oauth?.provider) {
|
||||
const { getCanonicalScopesForProvider } = await import('@/lib/oauth/utils')
|
||||
const providerScopes = getCanonicalScopesForProvider(tool.oauth.provider)
|
||||
if (providerScopes.length > 0) {
|
||||
tokenPayload.scopes = providerScopes
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Fetching access token from ${baseUrl}/api/auth/oauth/token`)
|
||||
|
||||
@@ -754,6 +764,7 @@ export async function executeTool(
|
||||
}
|
||||
// Clean up params we don't need to pass to the actual tool
|
||||
contextParams.credential = undefined
|
||||
contextParams.impersonateUserEmail = undefined
|
||||
if (contextParams.workflowId) contextParams.workflowId = undefined
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching access token for ${toolId}:`, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getCanonicalValues,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
resolveDependencyValue,
|
||||
type SubBlockCondition,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
@@ -17,6 +18,7 @@ export {
|
||||
evaluateSubBlockCondition,
|
||||
isCanonicalPair,
|
||||
resolveCanonicalMode,
|
||||
resolveDependencyValue,
|
||||
type SubBlockCondition,
|
||||
}
|
||||
|
||||
|
||||
@@ -989,8 +989,7 @@ export function getSubBlocksForToolInput(
|
||||
// Filter by visibility: exclude hidden and llm-only
|
||||
if (visibility === 'hidden' || visibility === 'llm-only') continue
|
||||
|
||||
// Evaluate condition against current values
|
||||
if (sb.condition) {
|
||||
if (sb.condition && !sb.reactiveCondition) {
|
||||
const conditionMet = evaluateSubBlockCondition(
|
||||
sb.condition as SubBlockCondition,
|
||||
valuesWithOperation
|
||||
|
||||
@@ -190,6 +190,8 @@ export interface OAuthTokenPayload {
|
||||
credentialAccountUserId?: string
|
||||
providerId?: string
|
||||
workflowId?: string
|
||||
impersonateEmail?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
3
packages/db/migrations/0184_bumpy_exiles.sql
Normal file
3
packages/db/migrations/0184_bumpy_exiles.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TYPE "public"."credential_type" ADD VALUE 'service_account';--> statement-breakpoint
|
||||
ALTER TABLE "credential" ADD COLUMN "encrypted_service_account_key" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "credential_workspace_service_account_unique" ON "credential" USING btree ("workspace_id","type","provider_id","display_name") WHERE type = 'service_account';
|
||||
15372
packages/db/migrations/meta/0184_snapshot.json
Normal file
15372
packages/db/migrations/meta/0184_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1282,6 +1282,13 @@
|
||||
"when": 1774724904239,
|
||||
"tag": "0183_workable_apocalypse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 184,
|
||||
"version": "7",
|
||||
"when": 1775117879646,
|
||||
"tag": "0184_bumpy_exiles",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2311,6 +2311,7 @@ export const credentialTypeEnum = pgEnum('credential_type', [
|
||||
'oauth',
|
||||
'env_workspace',
|
||||
'env_personal',
|
||||
'service_account',
|
||||
])
|
||||
|
||||
export const credential = pgTable(
|
||||
@@ -2327,6 +2328,7 @@ export const credential = pgTable(
|
||||
accountId: text('account_id').references(() => account.id, { onDelete: 'cascade' }),
|
||||
envKey: text('env_key'),
|
||||
envOwnerUserId: text('env_owner_user_id').references(() => user.id, { onDelete: 'cascade' }),
|
||||
encryptedServiceAccountKey: text('encrypted_service_account_key'),
|
||||
createdBy: text('created_by')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
@@ -2348,6 +2350,9 @@ export const credential = pgTable(
|
||||
workspacePersonalEnvUnique: uniqueIndex('credential_workspace_personal_env_unique')
|
||||
.on(table.workspaceId, table.type, table.envKey, table.envOwnerUserId)
|
||||
.where(sql`type = 'env_personal'`),
|
||||
workspaceServiceAccountUnique: uniqueIndex('credential_workspace_service_account_unique')
|
||||
.on(table.workspaceId, table.type, table.providerId, table.displayName)
|
||||
.where(sql`type = 'service_account'`),
|
||||
oauthSourceConstraint: check(
|
||||
'credential_oauth_source_check',
|
||||
sql`(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)`
|
||||
|
||||
Reference in New Issue
Block a user