mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
feat(credentials): add managed credential groups (#6697)
* feat(credentials): add managed credential groups * fix(audit): sync credential group mock * fix(credentials): serialize enrollment revocation * fix(credentials): isolate managed delegation * fix(credentials): serialize invitation lifecycle * fix(credentials): preserve enrollment lifecycle * refactor(credentials): migrate groups to application boundary * fix(credentials): serialize enrollment readiness * fix(credentials): preserve completed reconnect state * fix(credentials): revalidate policy before grant persistence * fix(credentials): prioritize expired invitations * fix(credentials): redirect unavailable oauth starts * fix(credentials): clarify managed oauth boundaries * fix(settings): complete feature flag test mocks * fix(credentials): clarify enrollment actions and entitlement errors * fix(credentials): preserve entitlement failure reasons * fix(credentials): refine managed oauth flow * fix(lint): use optional chain for pagination
This commit is contained in:
@@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
|
||||
# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default
|
||||
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
|
||||
# FORKING_ENABLED= # Workspace forks
|
||||
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
|
||||
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
|
||||
|
||||
# Instance organization (Optional). Most enterprise features read their settings from the
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing'
|
||||
import {
|
||||
dbChainMockFns,
|
||||
hybridAuthMockFns,
|
||||
permissionsMock,
|
||||
resetDbChainMock,
|
||||
workflowsUtilsMock,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('should handle unauthenticated user', async () => {
|
||||
@@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.credentials).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not expose a managed credential requested by exact ID', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'managed-credential-1',
|
||||
workspaceId: 'workspace-1',
|
||||
type: 'managed_oauth',
|
||||
displayName: 'Managed Gmail',
|
||||
providerId: 'google-email',
|
||||
accountId: null,
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
accountProviderId: null,
|
||||
accountScope: null,
|
||||
accountUpdatedAt: null,
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createMockRequestWithQuery('GET', '?credentialId=managed-credential-1')
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({ credentials: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,8 +12,17 @@ import {
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockAuthenticateManagedOAuthDelegation,
|
||||
mockAuthorizeCredentialUse,
|
||||
mockGetToolMetadata,
|
||||
mockResolveManagedOAuthCredentialToken,
|
||||
mockResolveServiceAccountToken,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateManagedOAuthDelegation: vi.fn(),
|
||||
mockAuthorizeCredentialUse: vi.fn(),
|
||||
mockGetToolMetadata: vi.fn(),
|
||||
mockResolveManagedOAuthCredentialToken: vi.fn(),
|
||||
mockResolveServiceAccountToken: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUseForAuth: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({
|
||||
authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation,
|
||||
InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({
|
||||
resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken },
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata }))
|
||||
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
import { GET, POST } from '@/app/api/auth/oauth/token/route'
|
||||
|
||||
@@ -108,6 +128,38 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not authenticate managed delegation for an ordinary OAuth credential', async () => {
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'internal_jwt',
|
||||
requesterUserId: 'workflow-owner-id',
|
||||
credentialOwnerUserId: 'workflow-owner-id',
|
||||
})
|
||||
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
providerId: 'google',
|
||||
})
|
||||
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
|
||||
accessToken: 'fresh-token',
|
||||
refreshed: false,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ credentialId: 'credential-id', workflowId: 'workflow-id' },
|
||||
{ 'x-sim-managed-oauth-delegation': 'Bearer stale-delegation' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({ accessToken: 'fresh-token' })
|
||||
expect(mockAuthenticateManagedOAuthDelegation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing credentialId', async () => {
|
||||
const req = createMockRequest('POST', {})
|
||||
|
||||
@@ -332,6 +384,140 @@ describe('OAuth Token API Routes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('managed OAuth path', () => {
|
||||
const managedCredential = {
|
||||
accountId: '',
|
||||
credentialId: 'managed-credential-id',
|
||||
credentialType: 'managed_oauth',
|
||||
providerId: 'google-email',
|
||||
workspaceId: 'workspace-id',
|
||||
usedCredentialTable: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential)
|
||||
mockGetToolMetadata.mockReturnValue({
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-email',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when workflow delegation is missing', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('POST', {
|
||||
credentialId: 'managed-credential-id',
|
||||
toolId: 'gmail_read',
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
|
||||
})
|
||||
expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves a manually supplied managed credential ID with scoped delegation', async () => {
|
||||
const principal = {
|
||||
kind: 'delegated' as const,
|
||||
serviceId: 'executor' as const,
|
||||
subjectUserId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
delegationId: 'delegation-id',
|
||||
audience: 'sim:managed-oauth-credentials',
|
||||
issuedAt: new Date(Date.now() - 1_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
resourceScope: { credentialId: 'managed-credential-id' },
|
||||
delegationContext: {
|
||||
kind: 'workflow_execution' as const,
|
||||
workflowId: 'workflow-id',
|
||||
},
|
||||
}
|
||||
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
|
||||
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
|
||||
accessToken: 'managed-access-token',
|
||||
refreshed: false,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ credentialId: 'managed-credential-id', toolId: 'gmail_read' },
|
||||
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' })
|
||||
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: {
|
||||
credentialId: 'managed-credential-id',
|
||||
expectedProviderId: 'google-email',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
|
||||
toolId: 'gmail_read',
|
||||
},
|
||||
request: expect.any(NextRequest),
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => {
|
||||
mockGetToolMetadata.mockReturnValueOnce({
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
})
|
||||
const principal = {
|
||||
kind: 'delegated' as const,
|
||||
serviceId: 'executor' as const,
|
||||
subjectUserId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
delegationId: 'delegation-id',
|
||||
audience: 'sim:managed-oauth-credentials',
|
||||
issuedAt: new Date(Date.now() - 1_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
resourceScope: { credentialId: 'managed-credential-id' },
|
||||
delegationContext: {
|
||||
kind: 'workflow_execution' as const,
|
||||
workflowId: 'workflow-id',
|
||||
},
|
||||
}
|
||||
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
|
||||
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
|
||||
accessToken: 'managed-slack-token',
|
||||
refreshed: false,
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ credentialId: 'managed-credential-id', toolId: 'slack_message' },
|
||||
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: {
|
||||
credentialId: 'managed-credential-id',
|
||||
expectedProviderId: 'slack',
|
||||
requiredScopes: expect.arrayContaining([
|
||||
'channels:read',
|
||||
'channels:history',
|
||||
'chat:write',
|
||||
]),
|
||||
toolId: 'slack_message',
|
||||
},
|
||||
request: expect.any(NextRequest),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('credentialAccountUserId + providerId path', () => {
|
||||
it('should reject unauthenticated requests', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
MANAGED_OAUTH_DELEGATION_HEADER,
|
||||
oauthTokenGetContract,
|
||||
oauthTokenPostContract,
|
||||
} from '@/lib/api/contracts/oauth-connections'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service'
|
||||
import {
|
||||
authenticateManagedOAuthDelegation,
|
||||
InvalidManagedOAuthDelegationError,
|
||||
} from '@/lib/credentials/application/managed-oauth-delegation'
|
||||
import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token'
|
||||
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
|
||||
import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
|
||||
import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getToolMetadata } from '@/tools/metadata'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -50,6 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
credentialId,
|
||||
credentialAccountUserId,
|
||||
providerId,
|
||||
toolId,
|
||||
workflowId,
|
||||
scopes,
|
||||
impersonateEmail,
|
||||
@@ -115,6 +127,129 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null
|
||||
if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) {
|
||||
const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER]
|
||||
if (!managedOAuthDelegation) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
|
||||
error: 'Managed credentials can only be used by an authenticated workflow execution',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal
|
||||
try {
|
||||
managedOAuthPrincipal = await authenticateManagedOAuthDelegation(
|
||||
managedOAuthDelegation,
|
||||
resolved.credentialId
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID',
|
||||
error: error.message,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
if (!toolId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED',
|
||||
error: 'A tool ID is required to use a managed credential',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const toolMetadata = getToolMetadata(toolId)
|
||||
if (!toolMetadata?.oauth?.required) {
|
||||
logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
|
||||
error: 'This tool is not configured to use managed credentials',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const requiredScopes =
|
||||
toolMetadata.oauth.requiredScopes ??
|
||||
getCanonicalScopesForProvider(toolMetadata.oauth.provider)
|
||||
if (requiredScopes.length === 0) {
|
||||
logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, {
|
||||
toolId,
|
||||
providerId: toolMetadata.oauth.provider,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
|
||||
error: 'This tool is not configured to use managed credentials',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveManagedOAuthCredentialToken.execute({
|
||||
principal: managedOAuthPrincipal,
|
||||
input: {
|
||||
credentialId: resolved.credentialId,
|
||||
expectedProviderId: toolMetadata.oauth.provider,
|
||||
requiredScopes,
|
||||
toolId,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
managedOAuthPrincipal.subjectUserId,
|
||||
'credential_used',
|
||||
{
|
||||
credential_type: 'managed_oauth',
|
||||
provider_id: toolMetadata.oauth.provider,
|
||||
workspace_id: managedOAuthPrincipal.workspaceId,
|
||||
},
|
||||
{ groups: { workspace: managedOAuthPrincipal.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken: result.accessToken,
|
||||
...(result.idToken ? { idToken: result.idToken } : {}),
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedOAuthCredentialError) {
|
||||
logger.warn(`[${requestId}] Managed OAuth credential rejected`, {
|
||||
credentialId: resolved.credentialId,
|
||||
code: error.code,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ code: error.code, error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
const orchestrationError = asOrchestrationError(error)
|
||||
if (orchestrationError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 'MANAGED_CREDENTIAL_UNAUTHORIZED',
|
||||
error: orchestrationError.message,
|
||||
},
|
||||
{ status: statusForOrchestrationError(orchestrationError.code) }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
const result = await resolveCredentialToken(auth, {
|
||||
requestId,
|
||||
@@ -124,6 +259,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
impersonateEmail,
|
||||
callerUserId,
|
||||
auditRequest: request,
|
||||
resolvedCredential: resolved,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
ipRateLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
|
||||
authenticateCredentialGroupEnrollment: mocks.authenticate,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
|
||||
completePublicCredentialGroupEnrollment: { execute: mocks.complete },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/rate-limit', () => ({
|
||||
enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/credential-groups/enroll/[token]/complete/route'
|
||||
|
||||
const principal = {
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'alex@example.com',
|
||||
invitationTokenHash: 'hash-1',
|
||||
} as const
|
||||
const context = { params: Promise.resolve({ token: 'invitation-token' }) }
|
||||
|
||||
function request() {
|
||||
return new NextRequest(
|
||||
'http://localhost:3000/api/credential-groups/enroll/invitation-token/complete',
|
||||
{ method: 'POST' }
|
||||
)
|
||||
}
|
||||
|
||||
describe('credential group enrollment completion route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.ipRateLimit.mockResolvedValue(null)
|
||||
mocks.authenticate.mockResolvedValue(principal)
|
||||
mocks.complete.mockResolvedValue({ completed: true })
|
||||
})
|
||||
|
||||
it('submits a fully connected enrollment through its invitation principal', async () => {
|
||||
const enrollmentRequest = request()
|
||||
const response = await POST(enrollmentRequest, context)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?submitted=1'
|
||||
)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(mocks.complete).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: {},
|
||||
request: enrollmentRequest,
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects an incomplete enrollment without marking it complete', async () => {
|
||||
mocks.complete.mockResolvedValue({ completed: false })
|
||||
|
||||
const response = await POST(request(), context)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?oauth=incomplete'
|
||||
)
|
||||
})
|
||||
|
||||
it('stops before token lookup when the public IP budget is exhausted', async () => {
|
||||
mocks.ipRateLimit.mockResolvedValue(
|
||||
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
)
|
||||
|
||||
const response = await POST(request(), context)
|
||||
|
||||
expect(response.status).toBe(429)
|
||||
expect(mocks.authenticate).not.toHaveBeenCalled()
|
||||
expect(mocks.complete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { completeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { asOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
|
||||
import { completePublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
|
||||
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
|
||||
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'complete')
|
||||
if (limited) return limited
|
||||
|
||||
const parsed = await parseRequest(completeCredentialGroupEnrollmentContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const principal = await authenticateCredentialGroupEnrollment(token)
|
||||
if (!principal) {
|
||||
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
|
||||
}
|
||||
const completion = await completePublicCredentialGroupEnrollment
|
||||
.execute({ principal, input: {}, request })
|
||||
.catch((error: unknown) => {
|
||||
if (asOrchestrationError(error)?.code === 'not_found') return null
|
||||
throw error
|
||||
})
|
||||
if (!completion) {
|
||||
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
|
||||
}
|
||||
const { completed } = completion
|
||||
if (completed === null) {
|
||||
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
|
||||
}
|
||||
return createCredentialGroupEnrollmentRedirect(
|
||||
token,
|
||||
completed ? { submitted: '1' } : { oauth: 'incomplete' }
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
startOAuth: vi.fn(),
|
||||
ipRateLimit: vi.fn(),
|
||||
enrollmentRateLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
|
||||
authenticateCredentialGroupEnrollment: mocks.authenticate,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
|
||||
startPublicCredentialGroupOAuth: { execute: mocks.startOAuth },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/rate-limit', () => ({
|
||||
enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit,
|
||||
enforceCredentialGroupEnrollmentOAuthRateLimit: mocks.enrollmentRateLimit,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route'
|
||||
|
||||
const principal = {
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'alex@example.com',
|
||||
invitationTokenHash: 'hash-1',
|
||||
} as const
|
||||
const context = {
|
||||
params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }),
|
||||
}
|
||||
|
||||
function request() {
|
||||
return new NextRequest(
|
||||
'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1'
|
||||
)
|
||||
}
|
||||
|
||||
describe('credential group OAuth start route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.ipRateLimit.mockResolvedValue(null)
|
||||
mocks.enrollmentRateLimit.mockResolvedValue(null)
|
||||
mocks.authenticate.mockResolvedValue(principal)
|
||||
mocks.startOAuth.mockResolvedValue({
|
||||
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=state-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects a valid enrollment to Google through its application operation', async () => {
|
||||
const oauthRequest = request()
|
||||
const response = await GET(oauthRequest, context)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toContain('https://accounts.google.com/')
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(mocks.startOAuth).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: { invitationToken: 'invitation-token', optionId: 'option-1' },
|
||||
request: oauthRequest,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an unavailable enrollment to its public page', async () => {
|
||||
mocks.authenticate.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(request(), context)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?oauth=unavailable'
|
||||
)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(mocks.startOAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a rate-limited OAuth start to its enrollment page before token lookup', async () => {
|
||||
mocks.ipRateLimit.mockResolvedValue(
|
||||
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
)
|
||||
|
||||
const response = await GET(request(), context)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?oauth=rate_limited'
|
||||
)
|
||||
expect(mocks.authenticate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns an exhausted enrollment OAuth budget to the enrollment page', async () => {
|
||||
mocks.enrollmentRateLimit.mockResolvedValue(
|
||||
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
)
|
||||
|
||||
const response = await GET(request(), context)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?oauth=rate_limited'
|
||||
)
|
||||
expect(mocks.startOAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { startCredentialGroupOAuthContract } from '@/lib/api/contracts/credential-groups'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
|
||||
import { startPublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
|
||||
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
|
||||
import {
|
||||
enforceCredentialGroupEnrollmentOAuthRateLimit,
|
||||
enforcePublicCredentialGroupIpRateLimit,
|
||||
} from '@/lib/credential-groups/rate-limit'
|
||||
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('CredentialGroupOAuthStartAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ token: string; optionId: string }> }
|
||||
) => {
|
||||
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-start')
|
||||
|
||||
const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context)
|
||||
if (!parsed.success) return limited ?? parsed.response
|
||||
const { token, optionId } = parsed.data.params
|
||||
if (limited) {
|
||||
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
|
||||
}
|
||||
const principal = await authenticateCredentialGroupEnrollment(token)
|
||||
if (!principal) {
|
||||
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
|
||||
}
|
||||
|
||||
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
|
||||
principal.enrollmentId
|
||||
)
|
||||
if (enrollmentLimited) {
|
||||
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
|
||||
}
|
||||
|
||||
try {
|
||||
const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({
|
||||
principal,
|
||||
input: { invitationToken: token, optionId },
|
||||
request,
|
||||
})
|
||||
const response = NextResponse.redirect(authorizationUrl)
|
||||
response.headers.set('Cache-Control', 'no-store')
|
||||
response.headers.set('Referrer-Policy', 'no-referrer')
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Failed to start managed OAuth authorization', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return createCredentialGroupEnrollmentRedirect(token, {
|
||||
oauth:
|
||||
error instanceof CredentialGroupOAuthError && error.statusCode === 409
|
||||
? 'configuration_changed'
|
||||
: 'unavailable',
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export function createCredentialGroupEnrollmentRedirect(
|
||||
token: string,
|
||||
params: Record<string, string>
|
||||
): NextResponse {
|
||||
const query = new URLSearchParams(params).toString()
|
||||
const location = `/credential-groups/enroll/${encodeURIComponent(token)}${query ? `?${query}` : ''}`
|
||||
return new NextResponse(null, {
|
||||
status: 307,
|
||||
headers: {
|
||||
Location: location,
|
||||
'Cache-Control': 'no-store',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
completeOAuth: vi.fn(),
|
||||
consumeAttempt: vi.fn(),
|
||||
rateLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
|
||||
authenticateCredentialGroupEnrollment: mocks.authenticate,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
|
||||
completePublicCredentialGroupOAuth: { execute: mocks.completeOAuth },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/oauth-state', () => ({
|
||||
consumeCredentialGroupOAuthAttempt: mocks.consumeAttempt,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/rate-limit', () => ({
|
||||
enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route'
|
||||
|
||||
const principal = {
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'alex@example.com',
|
||||
invitationTokenHash: 'hash-1',
|
||||
} as const
|
||||
const attempt = {
|
||||
provider: 'gmail',
|
||||
invitationToken: 'invitation-token',
|
||||
optionId: 'option-1',
|
||||
}
|
||||
const context = { params: Promise.resolve({ provider: 'gmail' }) }
|
||||
|
||||
function request(query: string) {
|
||||
return new NextRequest(
|
||||
`http://localhost:3000/api/credential-groups/oauth/gmail/callback?${query}`
|
||||
)
|
||||
}
|
||||
|
||||
describe('credential group OAuth callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.rateLimit.mockResolvedValue(null)
|
||||
mocks.consumeAttempt.mockResolvedValue(attempt)
|
||||
mocks.authenticate.mockResolvedValue(principal)
|
||||
mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' })
|
||||
})
|
||||
|
||||
it('consumes provider-bound state and enters the application operation', async () => {
|
||||
const callbackRequest = request('state=state-1&code=code-1')
|
||||
const response = await GET(callbackRequest, context)
|
||||
|
||||
expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1')
|
||||
expect(mocks.completeOAuth).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: { attempt, code: 'code-1' },
|
||||
request: callbackRequest,
|
||||
})
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?connected=option-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns without exchanging when the user denies consent', async () => {
|
||||
const response = await GET(request('state=state-1&error=access_denied'), context)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?oauth=denied'
|
||||
)
|
||||
expect(mocks.authenticate).not.toHaveBeenCalled()
|
||||
expect(mocks.completeOAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects replayed, expired, or cross-provider state', async () => {
|
||||
mocks.consumeAttempt.mockResolvedValue(null)
|
||||
|
||||
const replayedResponse = await GET(request('state=state-1&code=code-1'), context)
|
||||
expect(replayedResponse.status).toBe(400)
|
||||
|
||||
mocks.consumeAttempt.mockResolvedValue({ ...attempt, provider: 'slack' })
|
||||
const mismatchedResponse = await GET(request('state=state-2&code=code-2'), context)
|
||||
expect(mismatchedResponse.status).toBe(400)
|
||||
expect(mocks.completeOAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => {
|
||||
mocks.authenticate.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(request('state=state-1&code=code-1'), context)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?oauth=unavailable'
|
||||
)
|
||||
expect(mocks.completeOAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a valid rate-limited callback to the enrollment page without exchanging', async () => {
|
||||
mocks.rateLimit.mockResolvedValue(
|
||||
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
)
|
||||
|
||||
const response = await GET(request('state=state-1&code=code-1'), context)
|
||||
|
||||
expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1')
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/credential-groups/enroll/invitation-token?oauth=rate_limited'
|
||||
)
|
||||
expect(mocks.authenticate).not.toHaveBeenCalled()
|
||||
expect(mocks.completeOAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { credentialGroupOAuthCallbackContract } from '@/lib/api/contracts/credential-groups'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
|
||||
import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
|
||||
import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
|
||||
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
|
||||
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
|
||||
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('CredentialGroupOAuthCallbackAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => {
|
||||
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback')
|
||||
|
||||
const parsed = await parseRequest(credentialGroupOAuthCallbackContract, request, context)
|
||||
if (!parsed.success) return limited ?? parsed.response
|
||||
const { provider } = parsed.data.params
|
||||
const { state, code, error: providerError } = parsed.data.query
|
||||
|
||||
let attempt
|
||||
try {
|
||||
attempt = await consumeCredentialGroupOAuthAttempt(state)
|
||||
} catch (error) {
|
||||
logger.error('Failed to consume credential group OAuth state', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Authorization state is unavailable. Please try again.' },
|
||||
{ status: 503, headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
}
|
||||
if (!attempt || attempt.provider !== provider) {
|
||||
if (limited) return limited
|
||||
return NextResponse.json(
|
||||
{ error: 'Authorization state is invalid or expired.' },
|
||||
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
}
|
||||
if (limited) {
|
||||
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
|
||||
oauth: 'rate_limited',
|
||||
})
|
||||
}
|
||||
if (providerError) {
|
||||
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' })
|
||||
}
|
||||
if (!code) {
|
||||
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' })
|
||||
}
|
||||
|
||||
const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
|
||||
if (!principal) {
|
||||
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
|
||||
oauth: 'unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await completePublicCredentialGroupOAuth.execute({
|
||||
principal,
|
||||
input: { attempt, code },
|
||||
request,
|
||||
})
|
||||
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
|
||||
connected: attempt.optionId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Managed OAuth authorization failed', {
|
||||
provider,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
const status =
|
||||
error instanceof CredentialGroupOAuthError && error.statusCode === 403
|
||||
? error.message.startsWith('Sign in with')
|
||||
? 'account_mismatch'
|
||||
: 'permissions_required'
|
||||
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
|
||||
? 'configuration_changed'
|
||||
: 'failed'
|
||||
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { slackCredentialGroupConfigurationCallbackContract } from '@/lib/api/contracts/credential-groups'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { asOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { completeSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users'
|
||||
import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users'
|
||||
|
||||
const logger = createLogger('SlackCredentialGroupConfigurationCallbackAPI')
|
||||
const CHANNEL_NAME = 'slack-managed-users'
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function jsonLiteral(value: unknown): string {
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
|
||||
}
|
||||
|
||||
function closePopup(params: {
|
||||
ok: boolean
|
||||
message: string
|
||||
state?: string
|
||||
credentialGroupId?: string
|
||||
slackBotCredentialId?: string
|
||||
reason: string
|
||||
}): NextResponse {
|
||||
const title = params.ok ? 'Slack configured' : 'Slack setup failed'
|
||||
const payload = {
|
||||
type: CHANNEL_NAME,
|
||||
ok: params.ok,
|
||||
state: params.state,
|
||||
credentialGroupId: params.credentialGroupId,
|
||||
slackBotCredentialId: params.slackBotCredentialId,
|
||||
reason: params.reason,
|
||||
}
|
||||
const body = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><p>${escapeHtml(params.message)}</p><script>
|
||||
try { var channel = new BroadcastChannel(${jsonLiteral(CHANNEL_NAME)}); channel.postMessage(${jsonLiteral(payload)}); channel.close() } catch (error) {}
|
||||
setTimeout(function () { window.close() }, 500)
|
||||
</script></body></html>`
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const rawState = new URL(request.url).searchParams.get('state')?.slice(0, 512)
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id || !session.session?.id) {
|
||||
return closePopup({
|
||||
ok: false,
|
||||
message: 'Sign in to Sim to complete this Slack setup.',
|
||||
state: rawState,
|
||||
reason: 'unauthenticated',
|
||||
})
|
||||
}
|
||||
const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {})
|
||||
if (!parsed.success) {
|
||||
return closePopup({
|
||||
ok: false,
|
||||
message: 'Slack returned an invalid authorization response.',
|
||||
state: rawState,
|
||||
reason: 'invalid_callback',
|
||||
})
|
||||
}
|
||||
const { state, code, error: providerError } = parsed.data.query
|
||||
try {
|
||||
const result = await completeSlackCredentialGroupConfiguration.execute({
|
||||
principal: {
|
||||
kind: 'session',
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
},
|
||||
input: { state, code, providerError },
|
||||
request,
|
||||
})
|
||||
return result.ok
|
||||
? closePopup({
|
||||
ok: true,
|
||||
message: 'Slack is ready for this Credential Group. You can close this window.',
|
||||
state,
|
||||
credentialGroupId: result.result.credentialGroupId,
|
||||
slackBotCredentialId: result.result.slackBotCredentialId,
|
||||
reason: result.reason,
|
||||
})
|
||||
: closePopup({
|
||||
ok: false,
|
||||
message: 'Slack authorization was cancelled.',
|
||||
state,
|
||||
reason: result.reason,
|
||||
})
|
||||
} catch (error) {
|
||||
const orchestrationError = asOrchestrationError(error)
|
||||
const message =
|
||||
error instanceof SlackManagedUsersError || orchestrationError
|
||||
? getErrorMessage(error)
|
||||
: 'Slack setup failed. Please try again.'
|
||||
logger.error('Slack Credential Group configuration callback failed', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return closePopup({
|
||||
ok: false,
|
||||
message,
|
||||
state,
|
||||
reason:
|
||||
error instanceof SlackManagedUsersError
|
||||
? error.code
|
||||
: (orchestrationError?.code ?? 'unknown'),
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -27,12 +27,19 @@ interface RouteContext {
|
||||
|
||||
async function requireCredentialAdmin(credentialId: string, userId: string) {
|
||||
const [cred] = await db
|
||||
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
|
||||
.select({
|
||||
id: credential.id,
|
||||
workspaceId: credential.workspaceId,
|
||||
type: credential.type,
|
||||
providerId: credential.providerId,
|
||||
})
|
||||
.from(credential)
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!cred) return null
|
||||
if (!cred || cred.type === 'managed_oauth') {
|
||||
return null
|
||||
}
|
||||
|
||||
const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId)
|
||||
if (perm === null) return null
|
||||
@@ -67,12 +74,17 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route
|
||||
const { id: credentialId } = await context.params
|
||||
|
||||
const [cred] = await db
|
||||
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
|
||||
.select({
|
||||
id: credential.id,
|
||||
workspaceId: credential.workspaceId,
|
||||
type: credential.type,
|
||||
providerId: credential.providerId,
|
||||
})
|
||||
.from(credential)
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!cred) {
|
||||
if (!cred || cred.type === 'managed_oauth') {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export const GET = withRouteHandler(
|
||||
|
||||
try {
|
||||
const access = await getCredentialActorContext(id, session.user.id)
|
||||
if (!access.credential) {
|
||||
if (!access.credential || access.credential.type === 'managed_oauth') {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
if (!canUseCredential(access)) {
|
||||
@@ -82,6 +82,11 @@ export const PUT = withRouteHandler(
|
||||
const { id } = parsed.data.params
|
||||
const body = parsed.data.body
|
||||
|
||||
const currentAccess = await getCredentialActorContext(id, session.user.id)
|
||||
if (!currentAccess.credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const result = await performUpdateCredential({
|
||||
credentialId: id,
|
||||
userId: session.user.id,
|
||||
@@ -149,6 +154,10 @@ export const DELETE = withRouteHandler(
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const currentAccess = await getCredentialActorContext(id, session.user.id)
|
||||
if (!currentAccess.credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const result = await performDeleteCredential({
|
||||
credentialId: id,
|
||||
userId: session.user.id,
|
||||
@@ -162,9 +171,11 @@ export const DELETE = withRouteHandler(
|
||||
? 404
|
||||
: result.errorCode === 'forbidden'
|
||||
? 403
|
||||
: result.errorCode === 'validation'
|
||||
? 400
|
||||
: 500
|
||||
: result.errorCode === 'conflict'
|
||||
? 409
|
||||
: result.errorCode === 'validation'
|
||||
? 400
|
||||
: 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
if (credentialId) {
|
||||
const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess })
|
||||
if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) {
|
||||
if (
|
||||
!access.credential ||
|
||||
access.credential.type === 'managed_oauth' ||
|
||||
access.credential.workspaceId !== workspaceId ||
|
||||
!access.isAdmin
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required on the target credential' },
|
||||
{ status: 403 }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { credential, credentialMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
@@ -31,7 +31,9 @@ export const GET = withRouteHandler(async () => {
|
||||
})
|
||||
.from(credentialMember)
|
||||
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
|
||||
.where(eq(credentialMember.userId, session.user.id))
|
||||
.where(
|
||||
and(eq(credentialMember.userId, session.user.id), ne(credential.type, 'managed_oauth'))
|
||||
)
|
||||
|
||||
return NextResponse.json({ memberships }, { status: 200 })
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { credential } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createWorkspaceCredentialContract,
|
||||
@@ -167,7 +167,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
providerId: credential.providerId,
|
||||
})
|
||||
.from(credential)
|
||||
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
|
||||
.where(
|
||||
and(
|
||||
eq(credential.id, lookupCredentialId),
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
ne(credential.type, 'managed_oauth')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
@@ -182,7 +188,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
.where(
|
||||
and(
|
||||
eq(credential.accountId, lookupCredentialId),
|
||||
eq(credential.workspaceId, workspaceId)
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
ne(credential.type, 'managed_oauth')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
@@ -251,6 +251,11 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom
|
||||
}
|
||||
case 'delegated':
|
||||
throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads')
|
||||
case 'credential_group_enrollment':
|
||||
throw new UploadSessionError(
|
||||
'forbidden',
|
||||
'Credential Group enrollment principals cannot create uploads'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { resendCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import { resendCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit'
|
||||
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
|
||||
|
||||
export const POST = defineInternalJsonRoute({
|
||||
contract: resendCredentialGroupEnrollmentContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.resendEnrollment,
|
||||
rateLimit: internalRateLimits.none({
|
||||
reason: 'Credential Group invitation resends use a shared per-workspace delivery budget',
|
||||
}),
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy(
|
||||
'Failed to resend credential group enrollment'
|
||||
),
|
||||
async mapInput({ params }) {
|
||||
await enforceCredentialGroupInvitationRouteRateLimit(params.id)
|
||||
return {
|
||||
assertedWorkspaceId: params.id,
|
||||
credentialGroupId: params.groupId,
|
||||
enrollmentId: params.enrollmentId,
|
||||
}
|
||||
},
|
||||
useCase: resendCredentialGroupEnrollmentSettings,
|
||||
})
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { revokeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import { revokeCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
|
||||
|
||||
export const DELETE = defineInternalJsonRoute({
|
||||
contract: revokeCredentialGroupEnrollmentContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.revokeEnrollment,
|
||||
rateLimit: internalRateLimits.none({
|
||||
reason: 'Preserve existing internal Credential Group revocation behavior',
|
||||
}),
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy(
|
||||
'Failed to revoke credential group enrollment'
|
||||
),
|
||||
mapInput: ({ params }) => ({
|
||||
assertedWorkspaceId: params.id,
|
||||
credentialGroupId: params.groupId,
|
||||
enrollmentId: params.enrollmentId,
|
||||
}),
|
||||
useCase: revokeCredentialGroupEnrollmentSettings,
|
||||
})
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
rateLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/manage-enrollments', () => ({
|
||||
inviteCredentialGroupEnrollmentsSettings: {
|
||||
operation: { id: 'credential_groups.invites.send_batch' },
|
||||
execute: mocks.execute,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/rate-limit', () => {
|
||||
class CredentialGroupInvitationRateLimitError extends Error {
|
||||
readonly statusCode = 429
|
||||
|
||||
constructor(
|
||||
readonly retryAfterSeconds: number,
|
||||
readonly resetAt: Date
|
||||
) {
|
||||
super('Rate limit exceeded')
|
||||
}
|
||||
}
|
||||
return {
|
||||
CredentialGroupInvitationRateLimitError,
|
||||
enforceCredentialGroupInvitationRouteRateLimit: mocks.rateLimit,
|
||||
}
|
||||
})
|
||||
|
||||
import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit'
|
||||
import { POST } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route'
|
||||
|
||||
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const GROUP_ID = 'group-1'
|
||||
const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) }
|
||||
|
||||
function createRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/enrollments`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('credential group enrollment invitation route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getSession.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
mocks.rateLimit.mockResolvedValue(undefined)
|
||||
mocks.execute.mockResolvedValue({
|
||||
results: [{ email: 'alex@example.com', success: false, error: 'Delivery failed' }],
|
||||
sentCount: 0,
|
||||
failedCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('authenticates before parsing the batch', async () => {
|
||||
mocks.getSession.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(createRequest({}), context)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mocks.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the entire validated batch through one application command', async () => {
|
||||
const body = { emails: ['alex@example.com', 'sam@example.com'] }
|
||||
const request = createRequest(body)
|
||||
|
||||
const response = await POST(request, context)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mocks.execute).toHaveBeenCalledWith({
|
||||
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
|
||||
input: {
|
||||
assertedWorkspaceId: WORKSPACE_ID,
|
||||
credentialGroupId: GROUP_ID,
|
||||
emails: body.emails,
|
||||
},
|
||||
request,
|
||||
})
|
||||
expect(await response.json()).toMatchObject({ sentCount: 0, failedCount: 1 })
|
||||
})
|
||||
|
||||
it('rejects a batch larger than 100 before admission or delivery', async () => {
|
||||
const response = await POST(
|
||||
createRequest({
|
||||
emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`),
|
||||
}),
|
||||
context
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mocks.rateLimit).not.toHaveBeenCalled()
|
||||
expect(mocks.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the shared workspace invitation rate limit', async () => {
|
||||
mocks.rateLimit.mockRejectedValue(
|
||||
new CredentialGroupInvitationRateLimitError(30, new Date('2026-08-14T12:00:00Z'))
|
||||
)
|
||||
|
||||
const response = await POST(createRequest({ emails: ['alex@example.com'] }), context)
|
||||
|
||||
expect(response.status).toBe(429)
|
||||
expect(mocks.rateLimit).toHaveBeenCalledWith(WORKSPACE_ID)
|
||||
expect(response.headers.get('retry-after')).toBe('30')
|
||||
expect(mocks.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { inviteCredentialGroupEnrollmentsContract } from '@/lib/api/contracts/credential-groups'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit'
|
||||
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
|
||||
|
||||
export const POST = defineInternalJsonRoute({
|
||||
contract: inviteCredentialGroupEnrollmentsContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.inviteBatch,
|
||||
rateLimit: internalRateLimits.none({
|
||||
reason: 'Credential Group invitations use a shared per-workspace delivery budget',
|
||||
}),
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy(
|
||||
'Failed to invite credential group enrollments'
|
||||
),
|
||||
async mapInput({ params, body }) {
|
||||
await enforceCredentialGroupInvitationRouteRateLimit(params.id)
|
||||
return {
|
||||
assertedWorkspaceId: params.id,
|
||||
credentialGroupId: params.groupId,
|
||||
emails: body.emails,
|
||||
}
|
||||
},
|
||||
useCase: inviteCredentialGroupEnrollmentsSettings,
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
deleteCredentialGroupContract,
|
||||
getCredentialGroupContract,
|
||||
updateCredentialGroupContract,
|
||||
} from '@/lib/api/contracts/credential-groups'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import {
|
||||
deleteCredentialGroupSettings,
|
||||
getCredentialGroupSettings,
|
||||
updateCredentialGroupSettings,
|
||||
} from '@/lib/credential-groups/application/manage-groups'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
|
||||
|
||||
const rateLimit = internalRateLimits.none({
|
||||
reason: 'Preserve existing internal Credential Group detail behavior',
|
||||
})
|
||||
|
||||
export const GET = defineInternalJsonRoute({
|
||||
contract: getCredentialGroupContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.readSettings,
|
||||
rateLimit,
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to get credential group'),
|
||||
mapInput: ({ params, query }) => ({
|
||||
assertedWorkspaceId: params.id,
|
||||
credentialGroupId: params.groupId,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
}),
|
||||
useCase: getCredentialGroupSettings,
|
||||
})
|
||||
|
||||
export const PATCH = defineInternalJsonRoute({
|
||||
contract: updateCredentialGroupContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.update,
|
||||
rateLimit,
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update credential group'),
|
||||
mapInput: ({ params, body }) => ({
|
||||
assertedWorkspaceId: params.id,
|
||||
credentialGroupId: params.groupId,
|
||||
update: body,
|
||||
}),
|
||||
useCase: updateCredentialGroupSettings,
|
||||
})
|
||||
|
||||
export const DELETE = defineInternalJsonRoute({
|
||||
contract: deleteCredentialGroupContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.delete,
|
||||
rateLimit,
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'),
|
||||
mapInput: ({ params }) => ({
|
||||
assertedWorkspaceId: params.id,
|
||||
credentialGroupId: params.groupId,
|
||||
}),
|
||||
useCase: deleteCredentialGroupSettings,
|
||||
})
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { startSlackCredentialGroupConfigurationContract } from '@/lib/api/contracts/credential-groups'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
extendInternalErrorPolicy,
|
||||
internalErrorResponse,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users'
|
||||
import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users'
|
||||
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
|
||||
|
||||
const errorPolicy = extendInternalErrorPolicy(
|
||||
createCredentialGroupInternalErrorPolicy('Failed to configure Slack for Credential Group'),
|
||||
(error) =>
|
||||
error instanceof SlackManagedUsersError
|
||||
? internalErrorResponse(400, { error: error.message })
|
||||
: null
|
||||
)
|
||||
|
||||
export const POST = defineInternalJsonRoute({
|
||||
contract: startSlackCredentialGroupConfigurationContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.startSlackConfiguration,
|
||||
rateLimit: internalRateLimits.none({
|
||||
reason: 'Slack applies provider authorization limits and setup requires a workspace admin',
|
||||
}),
|
||||
errorPolicy,
|
||||
mapInput: ({ params, body }) => ({
|
||||
assertedWorkspaceId: params.id,
|
||||
credentialGroupId: params.groupId,
|
||||
slackBotCredentialId: body.slackBotCredentialId,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
}),
|
||||
useCase: startSlackCredentialGroupConfiguration,
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
createInternalResourceConcealmentPolicy,
|
||||
type InternalErrorPolicy,
|
||||
internalErrorResponse,
|
||||
internalOrchestrationErrorPolicy,
|
||||
} from '@/lib/api/server/routes'
|
||||
import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments'
|
||||
import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
|
||||
import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit'
|
||||
|
||||
export function createCredentialGroupInternalErrorPolicy(
|
||||
unhandledMessage: string,
|
||||
notFoundMessage = 'Credential group not found'
|
||||
): InternalErrorPolicy {
|
||||
if (!unhandledMessage.trim()) {
|
||||
throw new Error('Credential Group error policy requires an unhandled message')
|
||||
}
|
||||
const base: InternalErrorPolicy = {
|
||||
project(error) {
|
||||
if (error instanceof CredentialGroupProviderConfigurationError) {
|
||||
return internalErrorResponse(503, { error: error.message })
|
||||
}
|
||||
if (error instanceof CredentialGroupEnrollmentError) {
|
||||
return internalErrorResponse(error.status, { error: error.message })
|
||||
}
|
||||
if (error instanceof CredentialGroupInvitationRateLimitError) {
|
||||
return internalErrorResponse(
|
||||
429,
|
||||
{ error: error.message, retryAfter: error.resetAt.getTime() },
|
||||
{
|
||||
'Retry-After': String(error.retryAfterSeconds),
|
||||
'X-RateLimit-Reset': error.resetAt.toISOString(),
|
||||
}
|
||||
)
|
||||
}
|
||||
return internalOrchestrationErrorPolicy.project(error)
|
||||
},
|
||||
unhandled() {
|
||||
return internalErrorResponse(500, { error: unhandledMessage })
|
||||
},
|
||||
}
|
||||
return createInternalResourceConcealmentPolicy({ base, notFoundMessage })
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
list: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/manage-groups', () => ({
|
||||
createCredentialGroupSettings: {
|
||||
operation: { id: 'credential_groups.create' },
|
||||
execute: mocks.create,
|
||||
},
|
||||
listCredentialGroupSettings: {
|
||||
operation: { id: 'credential_groups.settings.list' },
|
||||
execute: mocks.list,
|
||||
},
|
||||
}))
|
||||
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
|
||||
import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route'
|
||||
|
||||
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const context = { params: Promise.resolve({ id: WORKSPACE_ID }) }
|
||||
|
||||
function createRequest(method: 'GET' | 'POST', body?: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, {
|
||||
method,
|
||||
...(body
|
||||
? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('credential groups collection route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getSession.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
session: { id: 'session-1' },
|
||||
})
|
||||
mocks.list.mockResolvedValue({ credentialGroups: [] })
|
||||
})
|
||||
|
||||
it('authenticates before parsing the request body', async () => {
|
||||
mocks.getSession.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(createRequest('POST', {}), context)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enters the application use case with the authenticated session principal', async () => {
|
||||
const request = createRequest('GET')
|
||||
const response = await GET(request, context)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ credentialGroups: [] })
|
||||
expect(mocks.list).toHaveBeenCalledWith({
|
||||
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
|
||||
input: { workspaceId: WORKSPACE_ID },
|
||||
request,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves concealed entitlement failures from the application boundary', async () => {
|
||||
mocks.list.mockRejectedValue(
|
||||
new OrchestrationError('not_found', 'Credential Groups are not available')
|
||||
)
|
||||
|
||||
const response = await GET(createRequest('GET'), context)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.json()).toEqual({ error: 'Credential Groups are not available' })
|
||||
})
|
||||
|
||||
it('fails fast when managed Gmail OAuth is not configured', async () => {
|
||||
mocks.create.mockRejectedValue(
|
||||
new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured')
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
createRequest('POST', {
|
||||
name: 'Support inboxes',
|
||||
options: [{ provider: 'gmail', label: 'Gmail', required: true }],
|
||||
}),
|
||||
context
|
||||
)
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Managed Gmail authorization is not configured',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
createCredentialGroupContract,
|
||||
listCredentialGroupsContract,
|
||||
} from '@/lib/api/contracts/credential-groups'
|
||||
import {
|
||||
defineInternalJsonRoute,
|
||||
internalRateLimits,
|
||||
internalSessionAuth,
|
||||
} from '@/lib/api/server/routes'
|
||||
import {
|
||||
createCredentialGroupSettings,
|
||||
listCredentialGroupSettings,
|
||||
} from '@/lib/credential-groups/application/manage-groups'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
|
||||
|
||||
export const GET = defineInternalJsonRoute({
|
||||
contract: listCredentialGroupsContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.listSettings,
|
||||
rateLimit: internalRateLimits.none({
|
||||
reason: 'Preserve existing internal Credential Group list behavior',
|
||||
}),
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy(
|
||||
'Failed to list credential groups',
|
||||
'Workspace not found'
|
||||
),
|
||||
mapInput: ({ params }) => ({ workspaceId: params.id }),
|
||||
useCase: listCredentialGroupSettings,
|
||||
})
|
||||
|
||||
export const POST = defineInternalJsonRoute({
|
||||
contract: createCredentialGroupContract,
|
||||
auth: internalSessionAuth,
|
||||
operation: credentialGroupOperations.create,
|
||||
rateLimit: internalRateLimits.none({
|
||||
reason: 'Preserve existing internal Credential Group create behavior',
|
||||
}),
|
||||
errorPolicy: createCredentialGroupInternalErrorPolicy(
|
||||
'Failed to create credential group',
|
||||
'Workspace not found'
|
||||
),
|
||||
mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }),
|
||||
useCase: createCredentialGroupSettings,
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
chipVariants: () => 'chip',
|
||||
}))
|
||||
|
||||
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
|
||||
|
||||
describe('OAuthConnectLink', () => {
|
||||
it('presents enrollment authorization as Connect', () => {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
|
||||
act(() => root.render(<OAuthConnectLink href='/oauth/start' />))
|
||||
|
||||
const link = container.querySelector('a')
|
||||
expect(link?.textContent).toBe('Connect')
|
||||
expect(link?.getAttribute('href')).toBe('/oauth/start')
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { chipVariants } from '@sim/emcn'
|
||||
|
||||
interface OAuthConnectLinkProps {
|
||||
href: string
|
||||
}
|
||||
|
||||
export function OAuthConnectLink({ href }: OAuthConnectLinkProps) {
|
||||
return (
|
||||
<a href={href} className={chipVariants()}>
|
||||
Connect
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockError, mockSetOAuthStatus, mockSuccess } = vi.hoisted(() => ({
|
||||
mockError: vi.fn(),
|
||||
mockSetOAuthStatus: vi.fn().mockResolvedValue(null),
|
||||
mockSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
useToast: () => ({
|
||||
toast: {
|
||||
error: mockError,
|
||||
success: mockSuccess,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryStates: () => [{}, mockSetOAuthStatus],
|
||||
}))
|
||||
|
||||
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
|
||||
|
||||
function renderToast(variant: 'success' | 'error', message: string): Root {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const root = createRoot(document.createElement('div'))
|
||||
act(() => root.render(<CredentialGroupOAuthToast message={message} variant={variant} />))
|
||||
return root
|
||||
}
|
||||
|
||||
describe('CredentialGroupOAuthToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows OAuth success once and removes callback state from the URL', () => {
|
||||
const root = renderToast('success', 'Gmail connected successfully.')
|
||||
|
||||
expect(mockSuccess).toHaveBeenCalledOnce()
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Gmail connected successfully.')
|
||||
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
|
||||
{ connected: null, oauth: null, submitted: null },
|
||||
{ history: 'replace', scroll: false }
|
||||
)
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('uses the error toast and preserves unrelated query parameters', () => {
|
||||
const root = renderToast('error', 'Authorization was canceled.')
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith('Authorization was canceled.')
|
||||
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
|
||||
{ connected: null, oauth: null, submitted: null },
|
||||
{ history: 'replace', scroll: false }
|
||||
)
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('removes the submitted state after showing the completion toast', () => {
|
||||
const root = renderToast('success', 'Accounts submitted successfully.')
|
||||
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Accounts submitted successfully.')
|
||||
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
|
||||
{ connected: null, oauth: null, submitted: null },
|
||||
{ history: 'replace', scroll: false }
|
||||
)
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useToast } from '@sim/emcn'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import {
|
||||
credentialGroupEnrollmentStatusParsers,
|
||||
credentialGroupEnrollmentStatusUrlKeys,
|
||||
} from '@/app/credential-groups/enroll/[token]/search-params'
|
||||
|
||||
interface CredentialGroupOAuthToastProps {
|
||||
message: string
|
||||
variant: 'success' | 'error'
|
||||
}
|
||||
|
||||
export function CredentialGroupOAuthToast({ message, variant }: CredentialGroupOAuthToastProps) {
|
||||
const { toast } = useToast()
|
||||
const [, setOAuthStatus] = useQueryStates(
|
||||
credentialGroupEnrollmentStatusParsers,
|
||||
credentialGroupEnrollmentStatusUrlKeys
|
||||
)
|
||||
const shownRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (shownRef.current) return
|
||||
shownRef.current = true
|
||||
|
||||
if (variant === 'success') toast.success(message)
|
||||
else toast.error(message)
|
||||
|
||||
void setOAuthStatus(
|
||||
{ connected: null, oauth: null, submitted: null },
|
||||
{ history: 'replace', scroll: false }
|
||||
)
|
||||
}, [message, setOAuthStatus, toast, variant])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { type ReactNode, Suspense } from 'react'
|
||||
import { Chip, ToastProvider } from '@sim/emcn'
|
||||
import type { Metadata } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import { asOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
|
||||
import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
|
||||
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
|
||||
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
|
||||
import { SupportFooter } from '@/app/(auth)/components'
|
||||
import { LogoShell } from '@/app/(landing)/components'
|
||||
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
|
||||
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
|
||||
import {
|
||||
RESOURCE_LIST_STACK,
|
||||
SettingsResourceRow,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Connect accounts',
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface CredentialGroupEnrollmentPageProps {
|
||||
params: Promise<{ token: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
interface PageShellProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function PageShell({ children }: PageShellProps) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<LogoShell footer={<SupportFooter position='static' />}>
|
||||
<div className='mx-auto flex w-full max-w-[640px] flex-1 flex-col px-5 pt-16 pb-20 max-sm:pt-10'>
|
||||
{children}
|
||||
</div>
|
||||
</LogoShell>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className='my-auto py-16 text-center'>
|
||||
<h1 className='text-balance text-[32px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
{rateLimited ? 'Too many requests' : 'Invitation unavailable'}
|
||||
</h1>
|
||||
<p className='mx-auto mt-3 max-w-[440px] text-pretty text-[var(--text-muted)] text-base leading-relaxed'>
|
||||
{rateLimited
|
||||
? 'This link has been opened too many times. Wait a few minutes and try again.'
|
||||
: 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'}
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const OAUTH_MESSAGES = {
|
||||
denied: 'Authorization was canceled. Nothing was connected.',
|
||||
account_mismatch: 'Choose the account matching the email address on this invitation.',
|
||||
permissions_required: 'All requested permissions are required to connect this account.',
|
||||
configuration_changed: 'This credential option changed. Reload the page and try again.',
|
||||
rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.',
|
||||
incomplete: 'Connect every account before submitting.',
|
||||
unavailable: 'Account authorization is temporarily unavailable. Please try again.',
|
||||
failed: 'Account authorization did not complete. Please try again.',
|
||||
} as const
|
||||
|
||||
function getSearchParam(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
key: string
|
||||
): string | undefined {
|
||||
const value = searchParams[key]
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
export default async function CredentialGroupEnrollmentPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: CredentialGroupEnrollmentPageProps) {
|
||||
const requestHeaders = await headers()
|
||||
const limited = await enforcePublicCredentialGroupIpRateLimit(
|
||||
{ headers: requestHeaders },
|
||||
'metadata'
|
||||
)
|
||||
if (limited) return <UnavailableInvitation rateLimited />
|
||||
|
||||
const { token } = await params
|
||||
if (!token || token.length > 128) return <UnavailableInvitation />
|
||||
|
||||
const principal = await authenticateCredentialGroupEnrollment(token)
|
||||
if (!principal) return <UnavailableInvitation />
|
||||
const enrollmentResult = await readPublicCredentialGroupEnrollment
|
||||
.execute({ principal, input: {} })
|
||||
.catch((error: unknown) => {
|
||||
if (asOrchestrationError(error)?.code === 'not_found') return null
|
||||
throw error
|
||||
})
|
||||
if (!enrollmentResult) return <UnavailableInvitation />
|
||||
const { enrollment } = enrollmentResult
|
||||
|
||||
const resolvedSearchParams = await searchParams
|
||||
const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth')
|
||||
const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected')
|
||||
const submitted = getSearchParam(resolvedSearchParams, 'submitted')
|
||||
const oauthMessage =
|
||||
oauthStatus && oauthStatus in OAUTH_MESSAGES
|
||||
? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES]
|
||||
: null
|
||||
const activeOptions = enrollment.options.filter((option) => option.status === 'active')
|
||||
const connectedOption = connectedOptionId
|
||||
? activeOptions.find((option) => option.id === connectedOptionId)
|
||||
: undefined
|
||||
const notification = submitted
|
||||
? { message: 'Accounts submitted successfully.', variant: 'success' as const }
|
||||
: connectedOptionId
|
||||
? {
|
||||
message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
|
||||
variant: 'success' as const,
|
||||
}
|
||||
: oauthMessage
|
||||
? { message: oauthMessage, variant: 'error' as const }
|
||||
: null
|
||||
const allConnected =
|
||||
activeOptions.length > 0 &&
|
||||
activeOptions.every(
|
||||
(option) => option.connections.length === 1 && option.connections[0]?.status === 'connected'
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
{notification && (
|
||||
<Suspense fallback={null}>
|
||||
<CredentialGroupOAuthToast {...notification} />
|
||||
</Suspense>
|
||||
)}
|
||||
<header>
|
||||
<h1 className='text-balance text-[36px] text-[var(--text-primary)] leading-[108%] tracking-[-0.025em] max-sm:text-[32px]'>
|
||||
Connect your accounts
|
||||
</h1>
|
||||
<p className='mt-4 max-w-[560px] text-pretty text-[var(--text-muted)] text-base leading-relaxed'>
|
||||
<span className='font-medium text-[var(--text-body)]'>{enrollment.inviterName}</span>{' '}
|
||||
invited you to connect accounts for{' '}
|
||||
<span className='font-medium text-[var(--text-body)]'>{enrollment.workspaceName}</span>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className='mt-8'>
|
||||
<SettingsSection label='Accounts'>
|
||||
<div className={RESOURCE_LIST_STACK}>
|
||||
{activeOptions.map((option) => {
|
||||
const ProviderIcon = getCredentialGroupProviderService(option.provider).icon
|
||||
const connection = option.connections[0]
|
||||
return (
|
||||
<SettingsResourceRow
|
||||
key={option.id}
|
||||
icon={<ProviderIcon />}
|
||||
title={option.label}
|
||||
description={connection?.email ?? 'Not connected'}
|
||||
trailing={
|
||||
<OAuthConnectLink
|
||||
href={`/api/credential-groups/enroll/${token}/oauth/${option.id}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
{(allConnected || enrollment.status === 'completed') && (
|
||||
<form
|
||||
action={`/api/credential-groups/enroll/${token}/complete`}
|
||||
method='post'
|
||||
className='mt-6 flex justify-end'
|
||||
>
|
||||
<Chip type='submit' variant='primary' disabled={enrollment.status === 'completed'}>
|
||||
{enrollment.status === 'completed' ? 'Submitted' : 'Submit'}
|
||||
</Chip>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { parseAsString } from 'nuqs/server'
|
||||
|
||||
/** One-shot OAuth result signals are nullable because absence means no toast. */
|
||||
export const credentialGroupEnrollmentStatusParsers = {
|
||||
connected: parseAsString,
|
||||
oauth: parseAsString,
|
||||
submitted: parseAsString,
|
||||
} as const
|
||||
|
||||
export const credentialGroupEnrollmentStatusUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
@@ -60,11 +60,13 @@ vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {},
|
||||
getEnv: vi.fn(),
|
||||
isTruthy: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
isAppConfigEnabled: false,
|
||||
isBillingEnabled: true,
|
||||
isHosted: true,
|
||||
}))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import { hasWorkspaceInboxAccess, hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
|
||||
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
|
||||
import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access'
|
||||
import { isPlatformAdmin } from '@/lib/permissions/super-user'
|
||||
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
|
||||
@@ -48,6 +49,7 @@ const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => stri
|
||||
const WORKSPACE_SECTION_MAP: Partial<Record<SettingsSection, WorkspaceSettingsSection>> = {
|
||||
teammates: 'teammates',
|
||||
secrets: 'secrets',
|
||||
'credential-groups': 'credential-groups',
|
||||
byok: 'byok',
|
||||
sandboxes: 'sandboxes',
|
||||
'custom-tools': 'custom-tools',
|
||||
@@ -118,14 +120,16 @@ export default async function WorkspaceSettingsSectionPage({
|
||||
|
||||
const workspaceSection = WORKSPACE_SECTION_MAP[parsed]
|
||||
if (workspaceSection) {
|
||||
const [permissionGroup, forksAvailable, inboxAvailable, sandboxes] = await Promise.all([
|
||||
hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
|
||||
? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
|
||||
: null,
|
||||
isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id),
|
||||
hasWorkspaceInboxAccess(workspaceId),
|
||||
hasWorkspaceSandboxAccess(workspaceId),
|
||||
])
|
||||
const [permissionGroup, forksAvailable, inboxAvailable, sandboxes, credentialGroupsAvailable] =
|
||||
await Promise.all([
|
||||
hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
|
||||
? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
|
||||
: null,
|
||||
isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id),
|
||||
hasWorkspaceInboxAccess(workspaceId),
|
||||
hasWorkspaceSandboxAccess(workspaceId),
|
||||
isCredentialGroupsAvailable(hostContext.ownerBilling),
|
||||
])
|
||||
const customBlocksAvailable = isHosted
|
||||
? hostContext.ownerBilling.isEnterprise
|
||||
: isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED'))
|
||||
@@ -134,6 +138,7 @@ export default async function WorkspaceSettingsSectionPage({
|
||||
permissionConfig: permissionGroup?.config ?? {},
|
||||
entitlements: {
|
||||
byok: isHosted,
|
||||
credentialGroups: credentialGroupsAvailable,
|
||||
inbox: inboxAvailable,
|
||||
customBlocks: customBlocksAvailable,
|
||||
forks: forksAvailable,
|
||||
|
||||
@@ -80,6 +80,30 @@ export const groupIdUrlKeys = {
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/** `credential-group-id` deep-links Credential Groups to one collection's detail view. */
|
||||
export const credentialGroupIdParam = {
|
||||
key: 'credential-group-id',
|
||||
parser: parseAsString,
|
||||
} as const
|
||||
|
||||
/** Opening a credential group is a destination; closing replaces the detail URL. */
|
||||
export const credentialGroupIdUrlKeys = {
|
||||
history: 'push',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/** Active view inside a credential-group detail page. */
|
||||
export const credentialGroupTabParam = {
|
||||
key: 'credential-group-tab',
|
||||
parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'),
|
||||
} as const
|
||||
|
||||
/** Tab view-state: clean URLs, no back-stack churn. */
|
||||
export const credentialGroupTabUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* `group-tab` is the active tab inside the deep-linked permission-group detail
|
||||
* view, so a shared `group-id` link can land on the same tab (mirrors
|
||||
|
||||
@@ -84,6 +84,9 @@ const AccessControl = dynamic(() =>
|
||||
const CustomBlocks = dynamic(() =>
|
||||
import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks)
|
||||
)
|
||||
const CredentialGroups = dynamic(() =>
|
||||
import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings)
|
||||
)
|
||||
const AuditLogs = dynamic(() =>
|
||||
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
|
||||
)
|
||||
@@ -158,6 +161,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
|
||||
{effectiveSection === 'browser' && <Browser />}
|
||||
{effectiveSection === 'terminal' && <Terminal />}
|
||||
{effectiveSection === 'secrets' && <Secrets />}
|
||||
{effectiveSection === 'credential-groups' && (
|
||||
<CredentialGroups workspaceId={hostContext.workspace.id} />
|
||||
)}
|
||||
{effectiveSection === 'access-control' && organizationId && (
|
||||
<AccessControl
|
||||
organizationId={organizationId}
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('unified settings navigation', () => {
|
||||
{ id: 'teammates', label: 'Teammates', section: 'workspace' },
|
||||
{ id: 'organization', label: 'Members', section: 'organization' },
|
||||
{ id: 'secrets', label: 'Secrets', section: 'workspace' },
|
||||
{ id: 'credential-groups', label: 'Credential groups', section: 'workspace' },
|
||||
{ id: 'custom-tools', label: 'Custom tools', section: 'workspace' },
|
||||
{ id: 'mcp', label: 'MCP tools', section: 'workspace' },
|
||||
{ id: 'apikeys', label: 'Sim API keys', section: 'workspace' },
|
||||
@@ -68,6 +69,7 @@ describe('unified settings navigation', () => {
|
||||
expect(idsForSection('workspace')).toEqual([
|
||||
'teammates',
|
||||
'secrets',
|
||||
'credential-groups',
|
||||
'mcp',
|
||||
'custom-tools',
|
||||
'byok',
|
||||
|
||||
+6
-2
@@ -253,7 +253,9 @@ export function CredentialSelector({
|
||||
|
||||
const comboboxOptions = useMemo(() => {
|
||||
if (isAllCredentials) {
|
||||
const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth')
|
||||
const oauthCredentials = allWorkspaceCredentials.filter(
|
||||
(credential) => credential.type === 'oauth'
|
||||
)
|
||||
return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id }))
|
||||
}
|
||||
if (isMergedKinds) return []
|
||||
@@ -409,7 +411,9 @@ export function CredentialSelector({
|
||||
}
|
||||
|
||||
const matchedCred = (
|
||||
isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials
|
||||
isAllCredentials
|
||||
? allWorkspaceCredentials.filter((credential) => credential.type === 'oauth')
|
||||
: credentials
|
||||
).find((c) => c.id === value)
|
||||
if (matchedCred) {
|
||||
handleSelect(value)
|
||||
|
||||
+6
@@ -141,6 +141,12 @@ export function SettingsSidebar({
|
||||
if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
item.id === 'credential-groups' &&
|
||||
(!hostContext.features?.credentialGroups || !canAdminWorkspace)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.selfHostedOverride && !isHosted) {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { GridOffset } from '@sim/emcn/icons'
|
||||
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
|
||||
import {
|
||||
type CanonicalGroup,
|
||||
resolveActiveCanonicalValue,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import {
|
||||
CREDENTIAL_GROUP_LIST_STALE_TIME,
|
||||
credentialGroupKeys,
|
||||
fetchCredentialGroupList,
|
||||
} from '@/hooks/queries/utils/credential-group-queries'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const CREDENTIAL_GROUP_CANONICAL_GROUP = {
|
||||
canonicalId: 'credentialGroupId',
|
||||
basicId: 'credentialGroup',
|
||||
advancedIds: ['manualCredentialGroup'],
|
||||
} as const satisfies CanonicalGroup
|
||||
|
||||
async function fetchCachedCredentialGroups() {
|
||||
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
|
||||
if (!workspaceId) return []
|
||||
|
||||
return getQueryClient().fetchQuery({
|
||||
queryKey: credentialGroupKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal),
|
||||
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
function resolveCredentialGroupIdForBlock(blockId: string): string | null {
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) return null
|
||||
const values = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {}
|
||||
const canonicalModes = useWorkflowStore.getState().blocks[blockId]?.data?.canonicalModes
|
||||
const value = resolveActiveCanonicalValue(
|
||||
CREDENTIAL_GROUP_CANONICAL_GROUP,
|
||||
values,
|
||||
canonicalModes
|
||||
)
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
interface CredentialGroupBlockOutput {
|
||||
success: boolean
|
||||
output: {
|
||||
credentials: Array<{
|
||||
credentialId: string
|
||||
email: string
|
||||
displayName: string
|
||||
providerId: string
|
||||
providerSubjectId: string
|
||||
providerTenantId: string | null
|
||||
}>
|
||||
credentialGroups: Array<{
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
status: 'active' | 'disabled'
|
||||
providerIds: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}>
|
||||
people: Array<{
|
||||
id: string
|
||||
email: string
|
||||
status: string
|
||||
expired: boolean
|
||||
invitedAt: string
|
||||
connections: Array<{ provider: string; status: string; count: number }>
|
||||
}>
|
||||
enrollmentId: string
|
||||
email: string
|
||||
status: string
|
||||
invitedAt: string
|
||||
expiresAt: string
|
||||
count: number
|
||||
hasMore: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_OPERATIONS = ['list_credentials', 'send_invite', 'list_people'] as const
|
||||
const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const
|
||||
|
||||
export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
|
||||
type: 'credential_group',
|
||||
name: 'Credential Groups',
|
||||
description: 'Invite people and use credentials collected by Credential Groups',
|
||||
longDescription:
|
||||
'List usable managed credentials, inspect invited people, send an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.',
|
||||
bestPractices: `
|
||||
- Use "List Credentials" with a ForEach loop to run a provider block once for every connected account.
|
||||
- Filter by email to select credentials belonging to one invited person, by provider to select one account type, or by both for an exact match.
|
||||
- Continue with nextCursor until hasMore is false when a list operation returns multiple pages.
|
||||
- "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded.
|
||||
- Use "List People" to inspect invitation and connection progress without exposing credential secrets.
|
||||
- "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list.
|
||||
`,
|
||||
docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group',
|
||||
bgColor: '#7C3AED',
|
||||
icon: GridOffset,
|
||||
canvasPresentation: {
|
||||
defaultTitle: 'Credential Groups',
|
||||
sentences: {
|
||||
byOperation: {
|
||||
list_credentials: [
|
||||
{
|
||||
text: 'List credentials from',
|
||||
field: ['credentialGroup', 'manualCredentialGroup'],
|
||||
core: true,
|
||||
},
|
||||
{ text: ', for', field: 'email' },
|
||||
{ text: ', from', field: ['providerFilter', 'manualProviderIds'] },
|
||||
{ text: ', up to', field: 'limit', after: 'credentials' },
|
||||
],
|
||||
send_invite: [
|
||||
{ text: 'Invite', field: 'email', core: true },
|
||||
{
|
||||
text: 'to',
|
||||
field: ['credentialGroup', 'manualCredentialGroup'],
|
||||
core: true,
|
||||
},
|
||||
],
|
||||
list_people: [
|
||||
{
|
||||
text: 'List people in',
|
||||
field: ['credentialGroup', 'manualCredentialGroup'],
|
||||
core: true,
|
||||
},
|
||||
{ text: ', matching', field: 'email' },
|
||||
{ text: ', with status', field: 'peopleStatuses' },
|
||||
],
|
||||
list_groups: ['List Credential Groups', { text: ', up to', field: 'limit' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
category: 'blocks',
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Credentials', id: 'list_credentials' },
|
||||
{ label: 'Send Invite', id: 'send_invite' },
|
||||
{ label: 'List People', id: 'list_people' },
|
||||
{ label: 'List Credential Groups', id: 'list_groups' },
|
||||
],
|
||||
value: () => 'list_credentials',
|
||||
},
|
||||
{
|
||||
id: 'credentialGroup',
|
||||
title: 'Credential Group',
|
||||
type: 'dropdown',
|
||||
options: [],
|
||||
required: { field: 'operation', value: [...GROUP_OPERATIONS] },
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'credentialGroupId',
|
||||
condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
|
||||
fetchOptions: async () => {
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
return groups
|
||||
.filter((group) => group.status === 'active')
|
||||
.map((group) => ({ label: group.name, id: group.id }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchOptionById: async (_blockId: string, optionId: string) => {
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === optionId)
|
||||
return group ? { label: group.name, id: group.id } : null
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'manualCredentialGroup',
|
||||
title: 'Credential Group ID',
|
||||
type: 'short-input',
|
||||
required: { field: 'operation', value: [...GROUP_OPERATIONS] },
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential group ID',
|
||||
canonicalParamId: 'credentialGroupId',
|
||||
condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email',
|
||||
type: 'short-input',
|
||||
required: { field: 'operation', value: 'send_invite' },
|
||||
placeholder: 'person@example.com',
|
||||
condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
|
||||
},
|
||||
{
|
||||
id: 'providerFilter',
|
||||
title: 'Provider',
|
||||
type: 'dropdown',
|
||||
multiSelect: true,
|
||||
emptyIsValid: true,
|
||||
options: [],
|
||||
required: false,
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'credentialProviderIds',
|
||||
dependsOn: ['credentialGroupId'],
|
||||
condition: { field: 'operation', value: 'list_credentials' },
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
|
||||
if (!credentialGroupId) return []
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === credentialGroupId)
|
||||
if (!group) return []
|
||||
return group.options
|
||||
.filter((option) => option.status === 'active')
|
||||
.map((option) => {
|
||||
const service = getCredentialGroupProviderService(option.provider)
|
||||
return { id: service.providerId, label: service.name }
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
fetchOptionById: async (blockId: string, optionId: string) => {
|
||||
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
|
||||
if (!credentialGroupId) return null
|
||||
const groups = await fetchCachedCredentialGroups()
|
||||
const group = groups.find((candidate) => candidate.id === credentialGroupId)
|
||||
const option = group?.options.find(
|
||||
(candidate) =>
|
||||
candidate.status === 'active' &&
|
||||
getCredentialGroupProviderService(candidate.provider).providerId === optionId
|
||||
)
|
||||
if (!option) return null
|
||||
return {
|
||||
id: optionId,
|
||||
label: getCredentialGroupProviderService(option.provider).name,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'manualProviderIds',
|
||||
title: 'Provider IDs',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
mode: 'advanced',
|
||||
canonicalParamId: 'credentialProviderIds',
|
||||
dependsOn: ['credentialGroupId'],
|
||||
placeholder: '["google-email", "slack"] — leave empty for all providers',
|
||||
condition: { field: 'operation', value: 'list_credentials' },
|
||||
},
|
||||
{
|
||||
id: 'peopleStatuses',
|
||||
title: 'Status',
|
||||
type: 'dropdown',
|
||||
multiSelect: true,
|
||||
emptyIsValid: true,
|
||||
options: [
|
||||
{ label: 'Invited', id: 'invited' },
|
||||
{ label: 'Delivery failed', id: 'delivery_failed' },
|
||||
{ label: 'In progress', id: 'in_progress' },
|
||||
{ label: 'Connected', id: 'completed' },
|
||||
{ label: 'Revoked', id: 'revoked' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'list_people' },
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
value: () => '100',
|
||||
mode: 'advanced',
|
||||
placeholder: '1-100',
|
||||
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
title: 'Cursor',
|
||||
type: 'short-input',
|
||||
mode: 'advanced',
|
||||
placeholder: 'nextCursor from a previous page',
|
||||
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
|
||||
},
|
||||
],
|
||||
tools: { access: [] },
|
||||
inputs: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
description: "'list_credentials', 'send_invite', 'list_people', or 'list_groups'",
|
||||
},
|
||||
credentialGroupId: { type: 'string', description: 'Credential Group ID' },
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Recipient email for invites or exact email filter for list operations',
|
||||
},
|
||||
credentialProviderIds: {
|
||||
type: 'json',
|
||||
description: 'Optional OAuth provider IDs to include when listing credentials',
|
||||
},
|
||||
peopleStatuses: {
|
||||
type: 'json',
|
||||
description: 'Optional invitation statuses to include when listing people',
|
||||
},
|
||||
limit: { type: 'number', description: 'Maximum results per page (1-100)' },
|
||||
cursor: { type: 'string', description: 'nextCursor from a previous page' },
|
||||
},
|
||||
outputs: {
|
||||
credentials: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)',
|
||||
condition: { field: 'operation', value: 'list_credentials' },
|
||||
},
|
||||
credentialGroups: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Credential Group summaries (id, name, description, status, providerIds, createdAt, updatedAt)',
|
||||
condition: { field: 'operation', value: 'list_groups' },
|
||||
},
|
||||
people: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Invited people and current connection summaries (id, email, status, expired, invitedAt, connections)',
|
||||
condition: { field: 'operation', value: 'list_people' },
|
||||
},
|
||||
enrollmentId: {
|
||||
type: 'string',
|
||||
description: 'Enrollment ID created or refreshed by the invitation',
|
||||
condition: { field: 'operation', value: 'send_invite' },
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Normalized invitation recipient email',
|
||||
condition: { field: 'operation', value: 'send_invite' },
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Invitation status',
|
||||
condition: { field: 'operation', value: 'send_invite' },
|
||||
},
|
||||
invitedAt: {
|
||||
type: 'string',
|
||||
description: 'Invitation timestamp',
|
||||
condition: { field: 'operation', value: 'send_invite' },
|
||||
},
|
||||
expiresAt: {
|
||||
type: 'string',
|
||||
description: 'Invitation expiration timestamp',
|
||||
condition: { field: 'operation', value: 'send_invite' },
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'Number of records returned',
|
||||
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
|
||||
},
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether another page is available',
|
||||
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
|
||||
},
|
||||
nextCursor: {
|
||||
type: 'string',
|
||||
description: 'Cursor for the next page, or null on the last page',
|
||||
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { ConfluenceBlock, ConfluenceBlockMeta, ConfluenceV2Block } from '@/block
|
||||
import { ContextDevBlock, ContextDevBlockMeta } from '@/blocks/blocks/context_dev'
|
||||
import { ConvexBlock, ConvexBlockMeta } from '@/blocks/blocks/convex'
|
||||
import { CredentialBlock } from '@/blocks/blocks/credential'
|
||||
import { CredentialGroupBlock } from '@/blocks/blocks/credential-group'
|
||||
import { CrowdStrikeBlock, CrowdStrikeBlockMeta } from '@/blocks/blocks/crowdstrike'
|
||||
import { CursorBlock, CursorBlockMeta, CursorV2Block } from '@/blocks/blocks/cursor'
|
||||
import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster'
|
||||
@@ -404,6 +405,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
|
||||
context_dev: ContextDevBlock,
|
||||
convex: ConvexBlock,
|
||||
credential: CredentialBlock,
|
||||
credential_group: CredentialGroupBlock,
|
||||
crowdstrike: CrowdStrikeBlock,
|
||||
cursor: CursorBlock,
|
||||
cursor_v2: CursorV2Block,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface CredentialGroupInvitationEmailProps {
|
||||
recipientEmail: string
|
||||
inviterName: string
|
||||
workspaceName: string
|
||||
credentialGroupName: string
|
||||
invitationLink: string
|
||||
}
|
||||
|
||||
export function CredentialGroupInvitationEmail({
|
||||
recipientEmail,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
credentialGroupName,
|
||||
invitationLink,
|
||||
}: CredentialGroupInvitationEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`${inviterName} invited you to connect accounts for ${workspaceName}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> invited <strong>{recipientEmail}</strong> to connect accounts
|
||||
for <strong>{credentialGroupName}</strong> in the <strong>{workspaceName}</strong> workspace
|
||||
on {brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Connect Accounts</Text>
|
||||
</Link>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
This private link expires in 7 days. {brand.name} will send you to each provider to sign in
|
||||
and will never ask for your provider password. If you did not expect this invitation, you
|
||||
can ignore it.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CredentialGroupInvitationEmail
|
||||
@@ -0,0 +1 @@
|
||||
export { CredentialGroupInvitationEmail } from './credential-group-invitation-email'
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render } from '@react-email/render'
|
||||
import { CredentialGroupInvitationEmail } from '@/components/emails/credential-groups/credential-group-invitation-email'
|
||||
|
||||
export async function renderCredentialGroupInvitationEmail(params: {
|
||||
recipientEmail: string
|
||||
inviterName: string
|
||||
workspaceName: string
|
||||
credentialGroupName: string
|
||||
invitationLink: string
|
||||
}): Promise<string> {
|
||||
return await render(CredentialGroupInvitationEmail(params))
|
||||
}
|
||||
@@ -103,3 +103,11 @@ export function getRequestConfirmationSubject(userSubject: string, requestType?:
|
||||
export function getOtpSubject(resourceLabel: string): string {
|
||||
return `Verification code for ${resourceLabel}`
|
||||
}
|
||||
|
||||
/** Names both the inviter and workspace so an external recipient can identify the request. */
|
||||
export function getCredentialGroupInvitationSubject(
|
||||
inviterName: string,
|
||||
workspaceName: string
|
||||
): string {
|
||||
return `${inviterName} invited you to connect accounts for ${workspaceName} on ${getBrandConfig().name}`
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('settings navigation boundaries', () => {
|
||||
'teammates',
|
||||
'organization',
|
||||
'secrets',
|
||||
'credential-groups',
|
||||
'custom-tools',
|
||||
'mcp',
|
||||
'apikeys',
|
||||
@@ -99,6 +100,7 @@ describe('settings navigation boundaries', () => {
|
||||
'inbox',
|
||||
'recently-deleted',
|
||||
'forks',
|
||||
'credential-groups',
|
||||
'custom-blocks',
|
||||
'self-host',
|
||||
])
|
||||
@@ -114,6 +116,7 @@ describe('settings navigation boundaries', () => {
|
||||
permissionConfig: {},
|
||||
entitlements: {
|
||||
byok: true,
|
||||
credentialGroups: true,
|
||||
inbox: true,
|
||||
customBlocks: true,
|
||||
forks: true,
|
||||
@@ -139,6 +142,7 @@ describe('settings navigation boundaries', () => {
|
||||
permissionConfig: {},
|
||||
entitlements: {
|
||||
byok: true,
|
||||
credentialGroups: true,
|
||||
inbox: true,
|
||||
customBlocks: true,
|
||||
forks: true,
|
||||
@@ -158,6 +162,7 @@ describe('settings navigation boundaries', () => {
|
||||
permissionConfig: {},
|
||||
entitlements: {
|
||||
byok: true,
|
||||
credentialGroups: true,
|
||||
inbox: true,
|
||||
customBlocks: true,
|
||||
forks: true,
|
||||
@@ -450,6 +455,7 @@ describe('settings navigation boundaries', () => {
|
||||
permissionConfig: {},
|
||||
entitlements: {
|
||||
byok: true,
|
||||
credentialGroups: true,
|
||||
customBlocks: true,
|
||||
forks: true,
|
||||
inbox: true,
|
||||
@@ -474,6 +480,7 @@ describe('settings navigation boundaries', () => {
|
||||
},
|
||||
entitlements: {
|
||||
byok: true,
|
||||
credentialGroups: true,
|
||||
customBlocks: true,
|
||||
forks: true,
|
||||
inbox: true,
|
||||
@@ -488,6 +495,7 @@ describe('settings navigation boundaries', () => {
|
||||
'workflow-mcp-servers',
|
||||
'recently-deleted',
|
||||
'forks',
|
||||
'credential-groups',
|
||||
'custom-blocks',
|
||||
'self-host',
|
||||
])
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Credit,
|
||||
Database,
|
||||
Globe,
|
||||
GridOffset,
|
||||
HexSimple,
|
||||
Key,
|
||||
KeySquare,
|
||||
@@ -64,6 +65,7 @@ export type OrganizationSettingsSection =
|
||||
export type WorkspaceSettingsSection =
|
||||
| 'teammates'
|
||||
| 'secrets'
|
||||
| 'credential-groups'
|
||||
| 'byok'
|
||||
| 'sandboxes'
|
||||
| 'custom-tools'
|
||||
@@ -99,6 +101,7 @@ export type UnifiedSettingsSection =
|
||||
| 'browser'
|
||||
| 'terminal'
|
||||
| 'secrets'
|
||||
| 'credential-groups'
|
||||
| 'access-control'
|
||||
| 'custom-blocks'
|
||||
| 'audit-logs'
|
||||
@@ -527,6 +530,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
|
||||
workspace: { id: 'secrets', group: 'workspace', order: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Credential groups',
|
||||
icon: GridOffset,
|
||||
unified: {
|
||||
id: 'credential-groups',
|
||||
description: 'Collect and manage OAuth credentials for people outside this workspace.',
|
||||
group: 'workspace',
|
||||
order: 2,
|
||||
requiresEnterprise: true,
|
||||
allowNonOrgAdmin: true,
|
||||
selfHostedOverride: true,
|
||||
},
|
||||
planes: {
|
||||
workspace: { id: 'credential-groups', group: 'enterprise', order: 10 },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Custom tools',
|
||||
icon: Wrench,
|
||||
@@ -948,6 +967,7 @@ export interface WorkspacePermissionConfig {
|
||||
|
||||
export interface WorkspaceSettingsEntitlements {
|
||||
byok: boolean
|
||||
credentialGroups: boolean
|
||||
customBlocks: boolean
|
||||
forks: boolean
|
||||
inbox: boolean
|
||||
@@ -982,6 +1002,7 @@ export interface ResolvedWorkspaceNavigationItem
|
||||
const WORKSPACE_MUTATION_PERMISSION: Record<WorkspaceSettingsSection, PermissionType> = {
|
||||
teammates: 'admin',
|
||||
secrets: 'write',
|
||||
'credential-groups': 'admin',
|
||||
byok: 'admin',
|
||||
sandboxes: 'admin',
|
||||
'custom-tools': 'write',
|
||||
@@ -1021,6 +1042,12 @@ export function resolveWorkspaceNavigation({
|
||||
if (item.id === 'mcp' && permissionConfig.disableMcpTools) return []
|
||||
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) return []
|
||||
if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return []
|
||||
if (
|
||||
item.id === 'credential-groups' &&
|
||||
(permission !== 'admin' || !entitlements.credentialGroups)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
if (item.id === 'byok' && !entitlements.byok) return []
|
||||
if (item.id === 'custom-blocks' && !entitlements.customBlocks) return []
|
||||
// Absent on Sim Cloud, where the managed service owns these settings.
|
||||
|
||||
@@ -274,7 +274,7 @@ async function fetchWorksheets(accessToken: string, basePath: string): Promise<W
|
||||
worksheets.push(...(data.value ?? []))
|
||||
|
||||
const next = data['@odata.nextLink']
|
||||
url = next && next.startsWith(GRAPH_API_BASE) ? next : undefined
|
||||
url = next?.startsWith(GRAPH_API_BASE) ? next : undefined
|
||||
}
|
||||
|
||||
return worksheets
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useCreateCredentialGroup } from '@/hooks/queries/credential-groups'
|
||||
|
||||
interface CredentialGroupCreateModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (groupId: string) => void
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function CredentialGroupCreateModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
workspaceId,
|
||||
}: CredentialGroupCreateModalProps) {
|
||||
const createGroup = useCreateCredentialGroup()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const reset = () => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
createGroup.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (createGroup.isPending) return
|
||||
onOpenChange(nextOpen)
|
||||
if (!nextOpen) reset()
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim() || createGroup.isPending) return
|
||||
try {
|
||||
const result = await createGroup.mutateAsync({
|
||||
workspaceId,
|
||||
body: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
options: [],
|
||||
},
|
||||
})
|
||||
onCreated(result.credentialGroup.id)
|
||||
handleOpenChange(false)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
srTitle='Create credential group'
|
||||
size='md'
|
||||
dismissDisabled={createGroup.isPending}
|
||||
>
|
||||
<ChipModalHeader onClose={() => handleOpenChange(false)}>
|
||||
Create credential group
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Name'
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder='Customer support accounts'
|
||||
required
|
||||
/>
|
||||
<ChipModalField
|
||||
type='textarea'
|
||||
title='Description'
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder='What these accounts will be used for'
|
||||
/>
|
||||
<ChipModalError>
|
||||
{createGroup.error ? getErrorMessage(createGroup.error) : null}
|
||||
</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
cancelDisabled={createGroup.isPending}
|
||||
primaryAction={{
|
||||
label: createGroup.isPending ? 'Creating...' : 'Create',
|
||||
onClick: handleCreate,
|
||||
disabled: !name.trim() || createGroup.isPending,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups'
|
||||
import { getEnrollmentStatus } from '@/ee/credential-groups/components/credential-group-detail'
|
||||
|
||||
const ENROLLMENT: CredentialGroupEnrollmentDetail = {
|
||||
id: 'enrollment-1',
|
||||
credentialGroupId: 'group-1',
|
||||
email: 'person@example.com',
|
||||
status: 'in_progress',
|
||||
expiresAt: '2026-08-13T00:00:00.000Z',
|
||||
invitedAt: '2026-08-12T00:00:00.000Z',
|
||||
sentAt: '2026-08-12T00:00:00.000Z',
|
||||
completedAt: null,
|
||||
revokedAt: null,
|
||||
expired: true,
|
||||
createdAt: '2026-08-12T00:00:00.000Z',
|
||||
updatedAt: '2026-08-13T00:00:00.000Z',
|
||||
connections: [{ provider: 'gmail', status: 'needs_reauth', count: 1 }],
|
||||
}
|
||||
|
||||
describe('Credential Group enrollment status', () => {
|
||||
it('keeps expired incomplete invitations ahead of credential reauthorization', () => {
|
||||
expect(getEnrollmentStatus(ENROLLMENT, ['gmail'])).toEqual({
|
||||
label: 'Expired',
|
||||
invalid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows reauthorization for completed enrollments after their invitation expires', () => {
|
||||
expect(getEnrollmentStatus({ ...ENROLLMENT, status: 'completed' }, ['gmail'])).toEqual({
|
||||
label: 'Reconnect needed',
|
||||
invalid: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn'
|
||||
import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import type {
|
||||
CredentialGroupEnrollment,
|
||||
CredentialGroupEnrollmentConnection,
|
||||
CredentialGroupEnrollmentDetail,
|
||||
} from '@/lib/api/contracts/credential-groups'
|
||||
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
|
||||
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
|
||||
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
|
||||
import {
|
||||
credentialGroupTabParam,
|
||||
credentialGroupTabUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import {
|
||||
RESOURCE_LIST_STACK,
|
||||
SettingsResourceRow,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details'
|
||||
import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal'
|
||||
import {
|
||||
useCredentialGroupDetail,
|
||||
useResendCredentialGroupEnrollment,
|
||||
useRevokeCredentialGroupEnrollment,
|
||||
} from '@/hooks/queries/credential-groups'
|
||||
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
|
||||
|
||||
interface CredentialGroupDetailProps {
|
||||
workspaceId: string
|
||||
groupId: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type CredentialGroupTab = 'details' | 'people'
|
||||
|
||||
const CREDENTIAL_GROUP_TABS = [
|
||||
{ value: 'details', label: 'Details' },
|
||||
{ value: 'people', label: 'People' },
|
||||
] as const
|
||||
|
||||
export function getEnrollmentStatus(
|
||||
enrollment: CredentialGroupEnrollmentDetail,
|
||||
activeProviders: CredentialGroupProvider[]
|
||||
) {
|
||||
if (enrollment.status === 'revoked') return { label: 'Revoked', invalid: false }
|
||||
if (enrollment.status === 'delivery_failed') return { label: 'Delivery failed', invalid: true }
|
||||
if (enrollment.status !== 'completed' && enrollment.expired) {
|
||||
return { label: 'Expired', invalid: true }
|
||||
}
|
||||
const needsReauthorization = enrollment.connections.some(
|
||||
(connection) => connection.status === 'needs_reauth'
|
||||
)
|
||||
if (needsReauthorization) return { label: 'Reconnect needed', invalid: false }
|
||||
const connectedProviders = new Set(
|
||||
enrollment.connections
|
||||
.filter((connection) => connection.status === 'active')
|
||||
.map((connection) => connection.provider)
|
||||
)
|
||||
const allProvidersConnected =
|
||||
activeProviders.length > 0 &&
|
||||
activeProviders.every((provider) => connectedProviders.has(provider))
|
||||
if (enrollment.status === 'completed' && allProvidersConnected) {
|
||||
return { label: 'Connected', invalid: false }
|
||||
}
|
||||
if (enrollment.status === 'completed') return { label: 'In progress', invalid: false }
|
||||
if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false }
|
||||
return { label: 'Invited', invalid: false }
|
||||
}
|
||||
|
||||
interface EnrollmentConnectionsProps {
|
||||
connections: CredentialGroupEnrollmentConnection[]
|
||||
}
|
||||
|
||||
interface CredentialProviderIconProps {
|
||||
provider: CredentialGroupProvider
|
||||
}
|
||||
|
||||
function CredentialProviderIcon({ provider }: CredentialProviderIconProps) {
|
||||
const ProviderIcon = getCredentialGroupProviderService(provider).icon
|
||||
return <ProviderIcon className='size-[14px]' aria-hidden />
|
||||
}
|
||||
|
||||
function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) {
|
||||
const connected = connections.filter((connection) => connection.status === 'active')
|
||||
const count = connected.reduce((total, connection) => total + connection.count, 0)
|
||||
const providers = [...new Set(connected.map((connection) => connection.provider))]
|
||||
|
||||
return (
|
||||
<span className='flex items-center gap-1.5'>
|
||||
{providers.map((provider) => {
|
||||
return <CredentialProviderIcon key={provider} provider={provider} />
|
||||
})}
|
||||
<span>
|
||||
{count} connected {count === 1 ? 'account' : 'accounts'}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function CredentialGroupDetail({
|
||||
workspaceId,
|
||||
groupId,
|
||||
onBack,
|
||||
}: CredentialGroupDetailProps) {
|
||||
const detail = useCredentialGroupDetail(workspaceId, groupId)
|
||||
const slackBots = useWorkspaceCredentials({
|
||||
workspaceId,
|
||||
type: 'service_account',
|
||||
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
|
||||
})
|
||||
const resend = useResendCredentialGroupEnrollment()
|
||||
const revoke = useRevokeCredentialGroupEnrollment()
|
||||
const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, {
|
||||
...credentialGroupTabParam.parser,
|
||||
...credentialGroupTabUrlKeys,
|
||||
})
|
||||
const [showInvite, setShowInvite] = useState(false)
|
||||
const [revokingEnrollmentId, setRevokingEnrollmentId] = useState<string | null>(null)
|
||||
const credentialGroup = detail.data?.pages[0]?.credentialGroup
|
||||
const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? []
|
||||
const revokingEnrollment = revokingEnrollmentId
|
||||
? (enrollments.find((enrollment) => enrollment.id === revokingEnrollmentId) ?? null)
|
||||
: null
|
||||
const activeProviders =
|
||||
credentialGroup?.options
|
||||
.filter((option) => option.status === 'active')
|
||||
.map((option) => option.provider) ?? []
|
||||
const configurationReady =
|
||||
Boolean(credentialGroup?.options.length) &&
|
||||
credentialGroup?.options.every(
|
||||
(option) =>
|
||||
option.provider !== 'slack' ||
|
||||
(option.configurationStatus === 'ready' &&
|
||||
slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId))
|
||||
)
|
||||
|
||||
const actions: SettingsAction[] = credentialGroup
|
||||
? [
|
||||
{
|
||||
text: 'Invite users',
|
||||
icon: Plus,
|
||||
variant: 'primary',
|
||||
onSelect: () => setShowInvite(true),
|
||||
disabled: credentialGroup.status !== 'active' || !configurationReady,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const handleResend = async (enrollment: CredentialGroupEnrollment) => {
|
||||
try {
|
||||
await resend.mutateAsync({ workspaceId, groupId, enrollmentId: enrollment.id })
|
||||
toast.success(`Invitation resent to ${enrollment.email}`)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to resend invitation'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!revokingEnrollment) return
|
||||
try {
|
||||
await revoke.mutateAsync({
|
||||
workspaceId,
|
||||
groupId,
|
||||
enrollmentId: revokingEnrollment.id,
|
||||
})
|
||||
toast.success(`Invitation revoked for ${revokingEnrollment.email}`)
|
||||
setRevokingEnrollmentId(null)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to revoke invitation'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
void setActiveTab(null, { history: 'replace' })
|
||||
onBack()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
back={{ text: 'Credential groups', icon: ArrowLeft, onSelect: handleBack }}
|
||||
title={credentialGroup?.name ?? 'Credential group'}
|
||||
description={credentialGroup?.description ?? undefined}
|
||||
actions={actions}
|
||||
>
|
||||
{detail.error ? (
|
||||
<SettingsEmptyState tone='error'>
|
||||
{getErrorMessage(detail.error, "Couldn't load credential group")}
|
||||
</SettingsEmptyState>
|
||||
) : detail.isPending || !credentialGroup ? null : (
|
||||
<div className='flex flex-col gap-7'>
|
||||
<ChipModalTabs
|
||||
tabs={CREDENTIAL_GROUP_TABS}
|
||||
value={activeTab}
|
||||
onChange={(value) => void setActiveTab(value as CredentialGroupTab)}
|
||||
aria-label='Credential group sections'
|
||||
/>
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<CredentialGroupDetails workspaceId={workspaceId} credentialGroup={credentialGroup} />
|
||||
)}
|
||||
|
||||
{activeTab === 'people' && (
|
||||
<SettingsSection
|
||||
label={`People (${enrollments.length}${detail.hasNextPage ? '+' : ''})`}
|
||||
action={
|
||||
detail.hasNextPage ? (
|
||||
<Chip
|
||||
onClick={() => void detail.fetchNextPage()}
|
||||
disabled={detail.isFetchingNextPage}
|
||||
>
|
||||
{detail.isFetchingNextPage ? 'Loading...' : 'Load more'}
|
||||
</Chip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{enrollments.length === 0 ? (
|
||||
<SettingsEmptyState variant='inline'>No people invited yet</SettingsEmptyState>
|
||||
) : (
|
||||
<div className={RESOURCE_LIST_STACK}>
|
||||
{enrollments.map((enrollment) => {
|
||||
const status = getEnrollmentStatus(enrollment, activeProviders)
|
||||
return (
|
||||
<SettingsResourceRow
|
||||
key={enrollment.id}
|
||||
icon={<KeySquare />}
|
||||
title={enrollment.email}
|
||||
description={
|
||||
<EnrollmentConnections connections={enrollment.connections} />
|
||||
}
|
||||
badge={
|
||||
<ChipTag
|
||||
variant={status.invalid ? 'invite' : 'gray'}
|
||||
invalid={status.invalid}
|
||||
>
|
||||
{status.label}
|
||||
</ChipTag>
|
||||
}
|
||||
trailing={
|
||||
enrollment.status === 'revoked' ? undefined : (
|
||||
<RowActionsMenu
|
||||
label={`${enrollment.email} actions`}
|
||||
actions={[
|
||||
{
|
||||
label: 'Resend',
|
||||
onSelect: () => void handleResend(enrollment),
|
||||
disabled: resend.isPending,
|
||||
},
|
||||
{
|
||||
label: 'Revoke',
|
||||
destructive: true,
|
||||
onSelect: () => setRevokingEnrollmentId(enrollment.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
{credentialGroup && (
|
||||
<CredentialGroupInviteModal
|
||||
open={showInvite}
|
||||
onOpenChange={setShowInvite}
|
||||
workspaceId={workspaceId}
|
||||
groupId={groupId}
|
||||
/>
|
||||
)}
|
||||
<ChipConfirmModal
|
||||
open={Boolean(revokingEnrollment)}
|
||||
onOpenChange={(open) => !open && !revoke.isPending && setRevokingEnrollmentId(null)}
|
||||
srTitle='Revoke invitation'
|
||||
title='Revoke invitation?'
|
||||
text={`Revoke the invitation for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working immediately.`}
|
||||
dismissLabel='Cancel'
|
||||
confirm={{
|
||||
label: revoke.isPending ? 'Revoking...' : 'Revoke',
|
||||
onClick: handleRevoke,
|
||||
disabled: revoke.isPending,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type {
|
||||
CredentialGroup,
|
||||
CredentialGroupOption,
|
||||
UpdateCredentialGroupBody,
|
||||
} from '@/lib/api/contracts/credential-groups'
|
||||
import {
|
||||
CREDENTIAL_GROUP_PROVIDER_IDS,
|
||||
type CredentialGroupProvider,
|
||||
type CredentialGroupStandardOAuthProvider,
|
||||
getCredentialGroupProviderService,
|
||||
getCredentialGroupProviderSupport,
|
||||
isCredentialGroupStandardOAuthProvider,
|
||||
} from '@/lib/credential-groups/providers'
|
||||
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import {
|
||||
RESOURCE_LIST_STACK,
|
||||
SettingsResourceRow,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { SettingRow } from '@/ee/components/setting-row'
|
||||
import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal'
|
||||
import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups'
|
||||
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
|
||||
|
||||
interface CredentialGroupDetailsProps {
|
||||
credentialGroup: CredentialGroup
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
function toOptionUpdateInput(
|
||||
option: CredentialGroupOption
|
||||
): NonNullable<UpdateCredentialGroupBody['options']>[number] {
|
||||
const common = {
|
||||
id: option.id,
|
||||
label: getCredentialGroupProviderService(option.provider).name,
|
||||
required: true,
|
||||
}
|
||||
if (option.provider !== 'slack') return { ...common, provider: option.provider }
|
||||
return {
|
||||
...common,
|
||||
provider: 'slack',
|
||||
slackBotCredentialId: option.slackBotCredentialId,
|
||||
}
|
||||
}
|
||||
|
||||
export function CredentialGroupDetails({
|
||||
credentialGroup,
|
||||
workspaceId,
|
||||
}: CredentialGroupDetailsProps) {
|
||||
const updateGroup = useUpdateCredentialGroup()
|
||||
const slackBots = useWorkspaceCredentials({
|
||||
workspaceId,
|
||||
type: 'service_account',
|
||||
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
|
||||
})
|
||||
const [name, setName] = useState(credentialGroup.name)
|
||||
const [description, setDescription] = useState(credentialGroup.description ?? '')
|
||||
const [slackSetupOpen, setSlackSetupOpen] = useState(false)
|
||||
const [slackSetupCredentialId, setSlackSetupCredentialId] = useState<string | undefined>()
|
||||
const [removingProvider, setRemovingProvider] = useState<CredentialGroupProvider | null>(null)
|
||||
|
||||
const normalizedDescription = description.trim() || null
|
||||
const detailsDirty =
|
||||
name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description
|
||||
const isUpdating = updateGroup.isPending
|
||||
|
||||
const updateOptions = async (
|
||||
options: NonNullable<UpdateCredentialGroupBody['options']>,
|
||||
successMessage: string
|
||||
) => {
|
||||
try {
|
||||
await updateGroup.mutateAsync({
|
||||
workspaceId,
|
||||
groupId: credentialGroup.id,
|
||||
body: { options },
|
||||
})
|
||||
toast.success(successMessage)
|
||||
return true
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not update account collection'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => {
|
||||
const service = getCredentialGroupProviderService(provider)
|
||||
const existing = credentialGroup.options.map(toOptionUpdateInput)
|
||||
const nextOption: NonNullable<UpdateCredentialGroupBody['options']>[number] = {
|
||||
provider,
|
||||
label: service.name,
|
||||
required: true,
|
||||
}
|
||||
return updateOptions([...existing, nextOption], `${service.name} added`)
|
||||
}
|
||||
|
||||
const openSlackSetup = (credentialId?: string) => {
|
||||
setSlackSetupCredentialId(credentialId)
|
||||
setSlackSetupOpen(true)
|
||||
}
|
||||
|
||||
const handleProviderAction = (provider: CredentialGroupProvider) => {
|
||||
const support = getCredentialGroupProviderSupport(provider)
|
||||
if (isCredentialGroupStandardOAuthProvider(provider)) {
|
||||
void addProvider(provider)
|
||||
return
|
||||
}
|
||||
if (support.configuration === 'slack_custom_bot') {
|
||||
openSlackSetup()
|
||||
return
|
||||
}
|
||||
throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`)
|
||||
}
|
||||
|
||||
const handleSaveDetails = async () => {
|
||||
if (!detailsDirty || !name.trim() || isUpdating) return
|
||||
try {
|
||||
await updateGroup.mutateAsync({
|
||||
workspaceId,
|
||||
groupId: credentialGroup.id,
|
||||
body: { name: name.trim(), description: normalizedDescription },
|
||||
})
|
||||
toast.success('Details saved')
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not save details'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveProvider = async () => {
|
||||
if (!removingProvider) return
|
||||
const service = getCredentialGroupProviderService(removingProvider)
|
||||
const options = credentialGroup.options
|
||||
.filter((option) => option.provider !== removingProvider)
|
||||
.map(toOptionUpdateInput)
|
||||
if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col gap-7'>
|
||||
<SettingsSection
|
||||
label='Group details'
|
||||
action={
|
||||
detailsDirty ? (
|
||||
<Chip
|
||||
variant='primary'
|
||||
onClick={() => void handleSaveDetails()}
|
||||
disabled={!name.trim() || isUpdating}
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Save changes'}
|
||||
</Chip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className='flex max-w-[560px] flex-col gap-4'>
|
||||
<SettingRow label='Name' htmlFor='credential-group-name'>
|
||||
<ChipInput
|
||||
id='credential-group-name'
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
error={!name.trim()}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label='Description' optional htmlFor='credential-group-description'>
|
||||
<ChipTextarea
|
||||
id='credential-group-description'
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder='What these accounts will be used for'
|
||||
rows={3}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label='Accounts people can connect'>
|
||||
<div className={RESOURCE_LIST_STACK}>
|
||||
{CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => {
|
||||
const service = getCredentialGroupProviderService(provider)
|
||||
const support = getCredentialGroupProviderSupport(provider)
|
||||
const option = credentialGroup.options.find(
|
||||
(candidate) => candidate.provider === provider
|
||||
)
|
||||
const ProviderIcon = service.icon
|
||||
const slackBot =
|
||||
provider === 'slack' && option?.provider === 'slack'
|
||||
? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId)
|
||||
: undefined
|
||||
const slackNeedsSetup =
|
||||
provider === 'slack' &&
|
||||
option?.provider === 'slack' &&
|
||||
(!slackBot || option.configurationStatus !== 'ready')
|
||||
const descriptionText =
|
||||
provider === 'slack' && option
|
||||
? slackBot
|
||||
? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}`
|
||||
: slackBots.isPending
|
||||
? 'Loading custom Slack app...'
|
||||
: 'Custom Slack app unavailable'
|
||||
: support.description
|
||||
|
||||
return (
|
||||
<SettingsResourceRow
|
||||
key={provider}
|
||||
icon={<ProviderIcon aria-hidden />}
|
||||
title={service.name}
|
||||
description={descriptionText}
|
||||
badge={
|
||||
option && !slackNeedsSetup ? (
|
||||
<ChipTag variant='gray'>Connected</ChipTag>
|
||||
) : undefined
|
||||
}
|
||||
trailing={
|
||||
option ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
{slackNeedsSetup && option.provider === 'slack' && slackBot ? (
|
||||
<Chip onClick={() => openSlackSetup(slackBot.id)} disabled={isUpdating}>
|
||||
Continue setup
|
||||
</Chip>
|
||||
) : null}
|
||||
<RowActionsMenu
|
||||
label={`${service.name} actions`}
|
||||
actions={[
|
||||
...(provider === 'slack'
|
||||
? [
|
||||
{
|
||||
label: 'Change Slack app',
|
||||
onSelect: () =>
|
||||
openSlackSetup(
|
||||
option?.provider === 'slack'
|
||||
? option.slackBotCredentialId
|
||||
: undefined
|
||||
),
|
||||
disabled: isUpdating,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Remove',
|
||||
destructive: true,
|
||||
onSelect: () => setRemovingProvider(provider),
|
||||
disabled: isUpdating,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Chip
|
||||
onClick={() => handleProviderAction(provider)}
|
||||
disabled={isUpdating || (provider === 'slack' && slackBots.isPending)}
|
||||
>
|
||||
{support.configuration === 'oauth' ? 'Add' : 'Set up'}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<SlackManagedUsersModal
|
||||
open={slackSetupOpen}
|
||||
workspaceId={workspaceId}
|
||||
credentialGroupId={credentialGroup.id}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setSlackSetupOpen(nextOpen)
|
||||
if (!nextOpen) setSlackSetupCredentialId(undefined)
|
||||
}}
|
||||
bots={slackBots.data ?? []}
|
||||
isLoading={slackBots.isPending}
|
||||
error={slackBots.error}
|
||||
initialCredentialId={slackSetupCredentialId}
|
||||
/>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={Boolean(removingProvider)}
|
||||
onOpenChange={(open) => !open && !isUpdating && setRemovingProvider(null)}
|
||||
srTitle='Remove account type'
|
||||
title={`Remove ${
|
||||
removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account'
|
||||
}`}
|
||||
text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.'
|
||||
dismissLabel='Cancel'
|
||||
confirm={{
|
||||
label: isUpdating ? 'Removing...' : 'Remove',
|
||||
onClick: handleRemoveProvider,
|
||||
disabled: isUpdating,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { useInviteCredentialGroupEnrollments } from '@/hooks/queries/credential-groups'
|
||||
|
||||
interface CredentialGroupInviteModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workspaceId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function CredentialGroupInviteModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
groupId,
|
||||
}: CredentialGroupInviteModalProps) {
|
||||
const invite = useInviteCredentialGroupEnrollments()
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const [deliveryError, setDeliveryError] = useState<string | null>(null)
|
||||
const canSubmit = emails.length > 0 && !invite.isPending
|
||||
|
||||
const validateEmail = useCallback((email: string): string | null => {
|
||||
const result = quickValidateEmail(email)
|
||||
return result.isValid ? null : (result.reason ?? 'Invalid email')
|
||||
}, [])
|
||||
|
||||
const handleEmailsChange = useCallback((next: string[]) => {
|
||||
setEmails(next)
|
||||
setDeliveryError(null)
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (invite.isPending) return
|
||||
onOpenChange(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setEmails([])
|
||||
setDeliveryError(null)
|
||||
invite.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return
|
||||
setDeliveryError(null)
|
||||
try {
|
||||
const result = await invite.mutateAsync({
|
||||
workspaceId,
|
||||
groupId,
|
||||
body: { emails },
|
||||
})
|
||||
const failures = result.results.filter((item) => !item.success)
|
||||
if (failures.length === 0) {
|
||||
toast.success(
|
||||
result.sentCount === 1 ? 'Invitation sent' : `${result.sentCount} invitations sent`
|
||||
)
|
||||
handleOpenChange(false)
|
||||
return
|
||||
}
|
||||
|
||||
setEmails(failures.map((item) => item.email))
|
||||
setDeliveryError(
|
||||
result.sentCount > 0
|
||||
? `${result.sentCount} sent. ${failures.length} failed: ${failures.map((item) => item.email).join(', ')}`
|
||||
: `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}`
|
||||
)
|
||||
} catch (error) {
|
||||
setDeliveryError(getErrorMessage(error, 'Failed to send invitations'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
srTitle='Invite users'
|
||||
dismissDisabled={invite.isPending}
|
||||
>
|
||||
<ChipModalHeader onClose={() => handleOpenChange(false)}>Invite users</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='emails'
|
||||
title='Emails'
|
||||
value={emails}
|
||||
onChange={handleEmailsChange}
|
||||
validate={validateEmail}
|
||||
placeholder='Enter emails'
|
||||
disabled={invite.isPending}
|
||||
/>
|
||||
<ChipModalError>
|
||||
{deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)}
|
||||
</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
cancelDisabled={invite.isPending}
|
||||
primaryAction={{
|
||||
label: invite.isPending ? 'Sending...' : 'Send invites',
|
||||
onClick: handleSubmit,
|
||||
disabled: !canSubmit,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChipConfirmModal, ChipTag } from '@sim/emcn'
|
||||
import { GridOffset, Plus } from '@sim/emcn/icons'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
credentialGroupIdParam,
|
||||
credentialGroupIdUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import {
|
||||
RESOURCE_LIST_STACK,
|
||||
SettingsResourceRow,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { CredentialGroupCreateModal } from '@/ee/credential-groups/components/credential-group-create-modal'
|
||||
import { CredentialGroupDetail } from '@/ee/credential-groups/components/credential-group-detail'
|
||||
import { useCredentialGroups, useDeleteCredentialGroup } from '@/hooks/queries/credential-groups'
|
||||
|
||||
interface CredentialGroupsSettingsProps {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) {
|
||||
const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId)
|
||||
const deleteGroup = useDeleteCredentialGroup()
|
||||
const [search, setSearch] = useSettingsSearch()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [deletingGroupId, setDeletingGroupId] = useState<string | null>(null)
|
||||
const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, {
|
||||
...credentialGroupIdParam.parser,
|
||||
...credentialGroupIdUrlKeys,
|
||||
})
|
||||
const deletingGroup = groups.find((group) => group.id === deletingGroupId)
|
||||
const selectedGroup = selectedGroupId
|
||||
? groups.find((group) => group.id === selectedGroupId)
|
||||
: undefined
|
||||
|
||||
const query = search.trim().toLowerCase()
|
||||
const filtered = query
|
||||
? groups.filter((group) =>
|
||||
[group.name, group.description ?? '', ...group.options.map((option) => option.label)].some(
|
||||
(value) => value.toLowerCase().includes(query)
|
||||
)
|
||||
)
|
||||
: groups
|
||||
|
||||
const actions: SettingsAction[] = [
|
||||
{
|
||||
text: 'Create group',
|
||||
icon: Plus,
|
||||
variant: 'primary',
|
||||
onSelect: () => setShowCreate(true),
|
||||
},
|
||||
]
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingGroupId) return
|
||||
try {
|
||||
await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId })
|
||||
setDeletingGroupId(null)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedGroup) {
|
||||
return (
|
||||
<CredentialGroupDetail
|
||||
workspaceId={workspaceId}
|
||||
groupId={selectedGroup.id}
|
||||
onBack={() => void setSelectedGroupId(null, { history: 'replace' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
actions={actions}
|
||||
search={{
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: 'Search credential groups...',
|
||||
disabled: isPending,
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<SettingsEmptyState tone='error'>
|
||||
{getErrorMessage(error, "Couldn't load credential groups")}
|
||||
</SettingsEmptyState>
|
||||
) : isPending ? null : groups.length === 0 ? (
|
||||
<SettingsEmptyState>Click "Create group" above to get started</SettingsEmptyState>
|
||||
) : filtered.length === 0 ? (
|
||||
<SettingsEmptyState variant='inline'>No groups match "{search}"</SettingsEmptyState>
|
||||
) : (
|
||||
<div className={RESOURCE_LIST_STACK}>
|
||||
{filtered.map((group) => {
|
||||
const optionCount = group.options.length
|
||||
return (
|
||||
<SettingsResourceRow
|
||||
key={group.id}
|
||||
icon={<GridOffset />}
|
||||
title={group.name}
|
||||
description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`}
|
||||
onClick={() => void setSelectedGroupId(group.id)}
|
||||
clickLabel={`Open ${group.name}`}
|
||||
navigable
|
||||
badge={
|
||||
group.status === 'disabled' ? (
|
||||
<ChipTag variant='gray'>Disabled</ChipTag>
|
||||
) : undefined
|
||||
}
|
||||
trailing={
|
||||
<RowActionsMenu
|
||||
label={`${group.name} actions`}
|
||||
actions={[
|
||||
{
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
onSelect: () => setDeletingGroupId(group.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
<CredentialGroupCreateModal
|
||||
open={showCreate}
|
||||
onOpenChange={setShowCreate}
|
||||
onCreated={(groupId) => void setSelectedGroupId(groupId)}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<ChipConfirmModal
|
||||
open={Boolean(deletingGroup)}
|
||||
onOpenChange={(open) => !open && !deleteGroup.isPending && setDeletingGroupId(null)}
|
||||
srTitle='Delete credential group'
|
||||
title='Delete credential group'
|
||||
text={[
|
||||
`Delete ${deletingGroup?.name ?? 'this credential group'}?`,
|
||||
{ text: ' This cannot be undone.', error: true },
|
||||
]}
|
||||
dismissLabel='Cancel'
|
||||
confirm={{
|
||||
label: deleteGroup.isPending ? 'Deleting...' : 'Delete',
|
||||
onClick: handleDelete,
|
||||
disabled: deleteGroup.isPending,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CredentialGroupsSettings } from '@/ee/credential-groups/components/credential-groups-settings'
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSlackManagedUsersFailureNotification } from '@/ee/credential-groups/components/slack-managed-users-modal'
|
||||
|
||||
describe('Slack managed-user authorization notifications', () => {
|
||||
it('treats provider cancellation as a non-error outcome', () => {
|
||||
expect(getSlackManagedUsersFailureNotification('provider_error')).toEqual({
|
||||
message: 'Slack authorization canceled',
|
||||
variant: 'warning',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps verification failures in the error state', () => {
|
||||
expect(getSlackManagedUsersFailureNotification('invalid_response')).toEqual({
|
||||
message: 'Slack app verification failed. Please try again.',
|
||||
variant: 'error',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import type { WorkspaceCredential } from '@/lib/api/contracts'
|
||||
import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups'
|
||||
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
|
||||
|
||||
const CHANNEL_NAME = 'slack-managed-users'
|
||||
const AUTHORIZATION_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
interface SlackManagedUsersModalProps {
|
||||
bots: WorkspaceCredential[]
|
||||
credentialGroupId: string
|
||||
error: Error | null
|
||||
initialCredentialId?: string
|
||||
isLoading: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
interface SlackManagedUsersMessage {
|
||||
type: typeof CHANNEL_NAME
|
||||
ok: boolean
|
||||
reason?: string
|
||||
state?: string
|
||||
credentialGroupId?: string
|
||||
slackBotCredentialId?: string
|
||||
}
|
||||
|
||||
export function getSlackManagedUsersFailureNotification(reason?: string): {
|
||||
message: string
|
||||
variant: 'error' | 'warning'
|
||||
} {
|
||||
return reason === 'provider_error'
|
||||
? { message: 'Slack authorization canceled', variant: 'warning' }
|
||||
: { message: 'Slack app verification failed. Please try again.', variant: 'error' }
|
||||
}
|
||||
|
||||
function isSlackManagedUsersMessage(value: unknown): value is SlackManagedUsersMessage {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const message = value as Record<string, unknown>
|
||||
return (
|
||||
message.type === CHANNEL_NAME &&
|
||||
typeof message.ok === 'boolean' &&
|
||||
(message.reason === undefined || typeof message.reason === 'string') &&
|
||||
(message.state === undefined || typeof message.state === 'string') &&
|
||||
(message.credentialGroupId === undefined || typeof message.credentialGroupId === 'string') &&
|
||||
(message.slackBotCredentialId === undefined || typeof message.slackBotCredentialId === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
export function SlackManagedUsersModal({
|
||||
bots,
|
||||
credentialGroupId,
|
||||
error,
|
||||
initialCredentialId,
|
||||
isLoading,
|
||||
onOpenChange,
|
||||
open,
|
||||
workspaceId,
|
||||
}: SlackManagedUsersModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const startAuthorization = useStartSlackCredentialGroupConfiguration()
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const expectedState = useRef<string | null>(null)
|
||||
const expectedCredentialId = useRef<string | null>(null)
|
||||
const popup = useRef<Window | null>(null)
|
||||
const popupWatcher = useRef<number | null>(null)
|
||||
|
||||
const defaultCredentialId = initialCredentialId
|
||||
? bots.some((bot) => bot.id === initialCredentialId)
|
||||
? initialCredentialId
|
||||
: ''
|
||||
: bots.length === 1
|
||||
? bots[0].id
|
||||
: ''
|
||||
const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId
|
||||
const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const channel = new BroadcastChannel(CHANNEL_NAME)
|
||||
channel.onmessage = (event: MessageEvent<unknown>) => {
|
||||
if (!isSlackManagedUsersMessage(event.data)) return
|
||||
if (!expectedState.current || event.data.state !== expectedState.current) return
|
||||
const verifiedCredentialId = expectedCredentialId.current
|
||||
expectedState.current = null
|
||||
expectedCredentialId.current = null
|
||||
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
|
||||
popupWatcher.current = null
|
||||
popup.current?.close()
|
||||
popup.current = null
|
||||
setPending(false)
|
||||
if (!event.data.ok) {
|
||||
const notification = getSlackManagedUsersFailureNotification(event.data.reason)
|
||||
if (notification.variant === 'warning') toast.warning(notification.message)
|
||||
else toast.error(notification.message)
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.data.credentialGroupId !== credentialGroupId ||
|
||||
!verifiedCredentialId ||
|
||||
event.data.slackBotCredentialId !== verifiedCredentialId
|
||||
) {
|
||||
toast.error('Slack app verification failed. Please try again.')
|
||||
return
|
||||
}
|
||||
if (!bots.some((bot) => bot.id === verifiedCredentialId)) {
|
||||
toast.error('The verified Slack app is no longer available.')
|
||||
return
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: credentialGroupKeys.list(workspaceId),
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId),
|
||||
})
|
||||
toast.success('Slack configured')
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
}
|
||||
return () => channel.close()
|
||||
}, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
|
||||
popup.current?.close()
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
popup.current?.close()
|
||||
popup.current = null
|
||||
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
|
||||
popupWatcher.current = null
|
||||
expectedState.current = null
|
||||
expectedCredentialId.current = null
|
||||
setSelectedCredentialId(null)
|
||||
setClientId('')
|
||||
setClientSecret('')
|
||||
setPending(false)
|
||||
startAuthorization.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (pending && !nextOpen) return
|
||||
onOpenChange(nextOpen)
|
||||
if (!nextOpen) reset()
|
||||
}
|
||||
|
||||
const handleSelectBot = (credentialId: string) => {
|
||||
if (pending) return
|
||||
setSelectedCredentialId(credentialId)
|
||||
setClientId('')
|
||||
setClientSecret('')
|
||||
startAuthorization.reset()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedBot || pending) return
|
||||
if (!clientId.trim() || !clientSecret.trim()) return
|
||||
|
||||
const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760')
|
||||
if (!opened) {
|
||||
toast.error('Allow popups to verify the Slack app')
|
||||
return
|
||||
}
|
||||
popup.current = opened
|
||||
setPending(true)
|
||||
try {
|
||||
const result = await startAuthorization.mutateAsync({
|
||||
workspaceId,
|
||||
credentialGroupId,
|
||||
body: {
|
||||
slackBotCredentialId: selectedBot.id,
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim(),
|
||||
},
|
||||
})
|
||||
expectedState.current = result.state
|
||||
expectedCredentialId.current = selectedBot.id
|
||||
opened.location.href = result.authorizationUrl
|
||||
const startedAt = Date.now()
|
||||
popupWatcher.current = window.setInterval(() => {
|
||||
if (!opened.closed && Date.now() - startedAt < AUTHORIZATION_TIMEOUT_MS) return
|
||||
window.clearInterval(popupWatcher.current ?? undefined)
|
||||
popupWatcher.current = null
|
||||
opened.close()
|
||||
popup.current = null
|
||||
expectedState.current = null
|
||||
expectedCredentialId.current = null
|
||||
setPending(false)
|
||||
toast.error('Slack authorization expired. Please try again.')
|
||||
}, 500)
|
||||
} catch (authorizationError) {
|
||||
opened.close()
|
||||
popup.current = null
|
||||
setPending(false)
|
||||
toast.error(getErrorMessage(authorizationError, 'Could not start Slack authorization'))
|
||||
}
|
||||
}
|
||||
|
||||
const noBots = !isLoading && bots.length === 0
|
||||
const primaryLabel = isLoading
|
||||
? 'Loading...'
|
||||
: noBots
|
||||
? 'Add Slack'
|
||||
: pending
|
||||
? 'Waiting for Slack...'
|
||||
: 'Verify and add'
|
||||
const primaryDisabled =
|
||||
isLoading || noBots || !selectedBot || pending || !clientId.trim() || !clientSecret.trim()
|
||||
|
||||
return (
|
||||
<ChipModal
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
dismissDisabled={pending}
|
||||
srTitle='Set up Slack'
|
||||
size='md'
|
||||
>
|
||||
<ChipModalHeader
|
||||
icon={SlackIcon}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
closeDisabled={pending}
|
||||
>
|
||||
Set up Slack
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
{isLoading ? (
|
||||
<div className='flex flex-col gap-3 px-2 py-1'>
|
||||
<Skeleton className='h-4 w-24 rounded' />
|
||||
<Skeleton className='h-9 w-full rounded-lg' />
|
||||
</div>
|
||||
) : noBots ? (
|
||||
<p className='px-2 py-4 text-center text-[var(--text-muted)] text-sm'>
|
||||
Add a custom Slack app from Integrations before adding Slack to this group.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='dropdown'
|
||||
title='Custom bot'
|
||||
value={effectiveCredentialId || undefined}
|
||||
onChange={handleSelectBot}
|
||||
options={bots.map((bot) => ({
|
||||
value: bot.id,
|
||||
label: bot.displayName,
|
||||
icon: SlackIcon,
|
||||
}))}
|
||||
placeholder='Select a custom bot'
|
||||
disabled={pending}
|
||||
required
|
||||
/>
|
||||
{selectedBot ? (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Client ID'
|
||||
value={clientId}
|
||||
onChange={setClientId}
|
||||
placeholder='Paste the Client ID'
|
||||
autoComplete='off'
|
||||
disabled={pending}
|
||||
required
|
||||
/>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
inputType='password'
|
||||
title='Client Secret'
|
||||
value={clientSecret}
|
||||
onChange={setClientSecret}
|
||||
placeholder='Paste the Client Secret'
|
||||
autoComplete='off'
|
||||
disabled={pending}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<ChipModalError>{error ? getErrorMessage(error) : null}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
cancelDisabled={pending}
|
||||
primaryAction={{
|
||||
label: primaryLabel,
|
||||
onClick: () => void handleSubmit(),
|
||||
disabled: primaryDisabled,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export enum BlockType {
|
||||
WORKFLOW_INPUT = 'workflow_input',
|
||||
|
||||
CREDENTIAL = 'credential',
|
||||
CREDENTIAL_GROUP = 'credential_group',
|
||||
|
||||
WAIT = 'wait',
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
buildHeaders: vi.fn(),
|
||||
enforceInviteRateLimit: vi.fn(),
|
||||
listCredentials: vi.fn(),
|
||||
listGroups: vi.fn(),
|
||||
listPeople: vi.fn(),
|
||||
sendInvite: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/delegation', () => ({
|
||||
authenticateCredentialGroupDelegation: mocks.authenticate,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/list-credentials', () => ({
|
||||
listCredentialGroupCredentials: { execute: mocks.listCredentials },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/list-groups', () => ({
|
||||
listCredentialGroupsForWorkflow: { execute: mocks.listGroups },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/list-people', () => ({
|
||||
CREDENTIAL_GROUP_PEOPLE_STATUSES: [
|
||||
'invited',
|
||||
'delivery_failed',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'revoked',
|
||||
],
|
||||
listCredentialGroupPeople: { execute: mocks.listPeople },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/send-invite', () => ({
|
||||
sendCredentialGroupInvite: { execute: mocks.sendInvite },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/rate-limit', () => ({
|
||||
enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit,
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/utils/http', () => ({
|
||||
buildExecutorDelegationHeaders: mocks.buildHeaders,
|
||||
}))
|
||||
|
||||
import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler'
|
||||
|
||||
const principal: WorkflowExecutionDelegatedPrincipal = {
|
||||
kind: 'delegated',
|
||||
serviceId: 'executor',
|
||||
subjectUserId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
delegationId: 'delegation-1',
|
||||
audience: 'sim:credential-groups',
|
||||
issuedAt: new Date(Date.now() - 1_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' },
|
||||
}
|
||||
|
||||
const context = {
|
||||
workspaceId: 'workspace-1',
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'user-1',
|
||||
} as ExecutionContext
|
||||
|
||||
const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBlock
|
||||
|
||||
describe('CredentialGroupBlockHandler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' })
|
||||
mocks.authenticate.mockResolvedValue(principal)
|
||||
})
|
||||
|
||||
it('recognizes only Credential Group blocks', () => {
|
||||
const handler = new CredentialGroupBlockHandler()
|
||||
|
||||
expect(handler.canHandle(block)).toBe(true)
|
||||
expect(handler.canHandle({ metadata: { id: BlockType.CREDENTIAL } } as SerializedBlock)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('lists credentials with normalized provider, email, and page filters', async () => {
|
||||
mocks.listCredentials.mockResolvedValue({
|
||||
credentials: [],
|
||||
count: 0,
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
})
|
||||
|
||||
const result = await new CredentialGroupBlockHandler().execute(context, block, {
|
||||
operation: 'list_credentials',
|
||||
credentialGroupId: ' group-1 ',
|
||||
credentialProviderIds: '["google-email", "google-email"]',
|
||||
email: ' person@example.com ',
|
||||
limit: '25',
|
||||
cursor: ' credential-1 ',
|
||||
})
|
||||
|
||||
expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1')
|
||||
expect(mocks.listCredentials).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: {
|
||||
credentialGroupId: 'group-1',
|
||||
credentialProviderIds: ['google-email'],
|
||||
email: 'person@example.com',
|
||||
limit: 25,
|
||||
cursor: 'credential-1',
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null })
|
||||
})
|
||||
|
||||
it('lists groups under workspace-scoped delegation', async () => {
|
||||
mocks.listGroups.mockResolvedValue({
|
||||
credentialGroups: [],
|
||||
count: 0,
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
})
|
||||
|
||||
await new CredentialGroupBlockHandler().execute(context, block, {
|
||||
operation: 'list_groups',
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined)
|
||||
expect(mocks.listGroups).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the shared workspace invitation budget before sending', async () => {
|
||||
mocks.sendInvite.mockResolvedValue({
|
||||
enrollment: {
|
||||
id: 'enrollment-1',
|
||||
email: 'person@example.com',
|
||||
status: 'invited',
|
||||
invitedAt: '2026-08-13T12:00:00.000Z',
|
||||
expiresAt: '2026-08-20T12:00:00.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
await new CredentialGroupBlockHandler().execute(context, block, {
|
||||
operation: 'send_invite',
|
||||
credentialGroupId: 'group-1',
|
||||
email: ' person@example.com ',
|
||||
})
|
||||
|
||||
expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1')
|
||||
expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.sendInvite.mock.invocationCallOrder[0]!
|
||||
)
|
||||
expect(mocks.sendInvite).toHaveBeenCalledWith({
|
||||
principal,
|
||||
input: { credentialGroupId: 'group-1', email: 'person@example.com' },
|
||||
})
|
||||
})
|
||||
|
||||
it('fails fast on unsupported people statuses', async () => {
|
||||
await expect(
|
||||
new CredentialGroupBlockHandler().execute(context, block, {
|
||||
operation: 'list_people',
|
||||
credentialGroupId: 'group-1',
|
||||
peopleStatuses: ['unknown'],
|
||||
})
|
||||
).rejects.toThrow('People statuses contain an unsupported value')
|
||||
expect(mocks.listPeople).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unsupported operations before delegation', async () => {
|
||||
await expect(
|
||||
new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' })
|
||||
).rejects.toThrow('Unsupported Credential Group operation: unknown')
|
||||
expect(mocks.buildHeaders).not.toHaveBeenCalled()
|
||||
expect(mocks.authenticate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation'
|
||||
import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials'
|
||||
import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups'
|
||||
import {
|
||||
CREDENTIAL_GROUP_PEOPLE_STATUSES,
|
||||
listCredentialGroupPeople,
|
||||
} from '@/lib/credential-groups/application/list-people'
|
||||
import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/send-invite'
|
||||
import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials'
|
||||
import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments'
|
||||
import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
|
||||
import { buildExecutorDelegationHeaders } from '@/executor/utils/http'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('CredentialGroupBlockHandler')
|
||||
|
||||
const CREDENTIAL_GROUP_OPERATION_IDS = [
|
||||
'list_credentials',
|
||||
'send_invite',
|
||||
'list_people',
|
||||
'list_groups',
|
||||
] as const
|
||||
|
||||
type CredentialGroupOperation = (typeof CREDENTIAL_GROUP_OPERATION_IDS)[number]
|
||||
|
||||
function parseOperation(value: unknown): CredentialGroupOperation {
|
||||
const operation = typeof value === 'string' ? value : 'list_credentials'
|
||||
const supported = CREDENTIAL_GROUP_OPERATION_IDS.find((candidate) => candidate === operation)
|
||||
if (!supported) throw new Error(`Unsupported Credential Group operation: ${operation}`)
|
||||
return supported
|
||||
}
|
||||
|
||||
function parseStringList(value: unknown, label: string): string[] | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
|
||||
let parsed: unknown = value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (!trimmed.startsWith('[')) return [trimmed]
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid JSON array of strings`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string' && item.trim())) {
|
||||
throw new Error(`${label} must be an array of non-empty strings`)
|
||||
}
|
||||
|
||||
const values = [...new Set(parsed.map((item) => item.trim()))]
|
||||
return values.length > 0 ? values : undefined
|
||||
}
|
||||
|
||||
function parseLimit(value: unknown): number {
|
||||
const raw = value ?? MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE
|
||||
const limit =
|
||||
typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE) {
|
||||
throw new Error(
|
||||
`Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}`
|
||||
)
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
function parseOptionalString(value: unknown, label: string): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string' || !value.trim())
|
||||
throw new Error(`${label} must be a non-empty string`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
const parsed = parseOptionalString(value, label)
|
||||
if (!parsed) throw new Error(`${label} is required`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin {
|
||||
const origin =
|
||||
ctx.executorDelegationOrigin ??
|
||||
(ctx.userId
|
||||
? {
|
||||
subjectUserId: ctx.userId,
|
||||
workflowId: ctx.workflowId,
|
||||
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
|
||||
}
|
||||
: undefined)
|
||||
if (!origin) {
|
||||
throw new Error('Credential Group operations require an authenticated workflow execution')
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
export class CredentialGroupBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.CREDENTIAL_GROUP
|
||||
}
|
||||
|
||||
async execute(
|
||||
ctx: ExecutionContext,
|
||||
_block: SerializedBlock,
|
||||
inputs: Record<string, unknown>
|
||||
): Promise<BlockOutput> {
|
||||
if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations')
|
||||
const operation = parseOperation(inputs.operation)
|
||||
const credentialGroupId =
|
||||
operation === 'list_groups'
|
||||
? undefined
|
||||
: requireString(inputs.credentialGroupId, 'Credential Group')
|
||||
const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx))
|
||||
const authorization = headers.Authorization
|
||||
if (!authorization) throw new Error('Executor delegation authorization is missing')
|
||||
const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId)
|
||||
|
||||
switch (operation) {
|
||||
case 'list_credentials': {
|
||||
const credentialProviderIds = parseStringList(
|
||||
inputs.credentialProviderIds,
|
||||
'Credential provider IDs'
|
||||
)
|
||||
const result = await listCredentialGroupCredentials.execute({
|
||||
principal,
|
||||
input: {
|
||||
credentialGroupId: credentialGroupId!,
|
||||
limit: parseLimit(inputs.limit),
|
||||
cursor: parseOptionalString(inputs.cursor, 'Cursor'),
|
||||
email: parseOptionalString(inputs.email, 'Email'),
|
||||
credentialProviderIds,
|
||||
},
|
||||
})
|
||||
logger.info('Listed Credential Group credentials', {
|
||||
credentialGroupId,
|
||||
count: result.count,
|
||||
hasMore: result.hasMore,
|
||||
})
|
||||
return result
|
||||
}
|
||||
case 'send_invite': {
|
||||
await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId)
|
||||
const result = await sendCredentialGroupInvite.execute({
|
||||
principal,
|
||||
input: {
|
||||
credentialGroupId: credentialGroupId!,
|
||||
email: requireString(inputs.email, 'Email'),
|
||||
},
|
||||
})
|
||||
logger.info('Sent Credential Group invitation', {
|
||||
credentialGroupId,
|
||||
enrollmentId: result.enrollment.id,
|
||||
})
|
||||
return {
|
||||
enrollmentId: result.enrollment.id,
|
||||
email: result.enrollment.email,
|
||||
status: result.enrollment.status,
|
||||
invitedAt: result.enrollment.invitedAt,
|
||||
expiresAt: result.enrollment.expiresAt,
|
||||
}
|
||||
}
|
||||
case 'list_people': {
|
||||
const statuses = parseStringList(inputs.peopleStatuses, 'People statuses')
|
||||
const allowedStatuses = new Set<string>(CREDENTIAL_GROUP_PEOPLE_STATUSES)
|
||||
if (statuses?.some((status) => !allowedStatuses.has(status))) {
|
||||
throw new Error('People statuses contain an unsupported value')
|
||||
}
|
||||
const result = await listCredentialGroupPeople.execute({
|
||||
principal,
|
||||
input: {
|
||||
credentialGroupId: credentialGroupId!,
|
||||
limit: parseLimit(inputs.limit),
|
||||
cursor: parseOptionalString(inputs.cursor, 'Cursor'),
|
||||
email: parseOptionalString(inputs.email, 'Email'),
|
||||
statuses: statuses as CredentialGroupEnrollmentStatus[] | undefined,
|
||||
},
|
||||
})
|
||||
logger.info('Listed Credential Group people', {
|
||||
credentialGroupId,
|
||||
count: result.count,
|
||||
hasMore: result.hasMore,
|
||||
})
|
||||
return result
|
||||
}
|
||||
case 'list_groups': {
|
||||
const result = await listCredentialGroupsForWorkflow.execute({
|
||||
principal,
|
||||
input: {
|
||||
workspaceId: ctx.workspaceId,
|
||||
limit: parseLimit(inputs.limit),
|
||||
cursor: parseOptionalString(inputs.cursor, 'Cursor'),
|
||||
},
|
||||
})
|
||||
logger.info('Listed Credential Groups', {
|
||||
workspaceId: ctx.workspaceId,
|
||||
count: result.count,
|
||||
hasMore: result.hasMore,
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,14 @@ export class CredentialBlockHandler implements BlockHandler {
|
||||
|
||||
const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select'
|
||||
|
||||
if (operation === 'list') {
|
||||
return this.listCredentials(ctx.workspaceId, inputs)
|
||||
switch (operation) {
|
||||
case 'select':
|
||||
return this.selectCredential(ctx.workspaceId, inputs)
|
||||
case 'list':
|
||||
return this.listCredentials(ctx.workspaceId, inputs)
|
||||
default:
|
||||
throw new Error(`Unsupported Credential operation: ${operation}`)
|
||||
}
|
||||
|
||||
return this.selectCredential(ctx.workspaceId, inputs)
|
||||
}
|
||||
|
||||
private async selectCredential(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler'
|
||||
import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler'
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
@@ -45,6 +46,7 @@ export function createBlockHandlers(): BlockHandler[] {
|
||||
new WorkflowBlockHandler(),
|
||||
new WaitBlockHandler(),
|
||||
new EvaluatorBlockHandler(),
|
||||
new CredentialGroupBlockHandler(),
|
||||
new CredentialBlockHandler(),
|
||||
new GenericBlockHandler(),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { ContractBodyInput } from '@/lib/api/contracts'
|
||||
import {
|
||||
createCredentialGroupContract,
|
||||
deleteCredentialGroupContract,
|
||||
getCredentialGroupContract,
|
||||
inviteCredentialGroupEnrollmentsContract,
|
||||
resendCredentialGroupEnrollmentContract,
|
||||
revokeCredentialGroupEnrollmentContract,
|
||||
startSlackCredentialGroupConfigurationContract,
|
||||
updateCredentialGroupContract,
|
||||
} from '@/lib/api/contracts/credential-groups'
|
||||
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
|
||||
import {
|
||||
CREDENTIAL_GROUP_DETAIL_STALE_TIME,
|
||||
CREDENTIAL_GROUP_LIST_STALE_TIME,
|
||||
credentialGroupKeys,
|
||||
fetchCredentialGroupList,
|
||||
} from '@/hooks/queries/utils/credential-group-queries'
|
||||
|
||||
export function useCredentialGroups(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: credentialGroupKeys.list(workspaceId),
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!workspaceId) return []
|
||||
return fetchCredentialGroupList(workspaceId, signal)
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: credentialGroupKeys.detail(workspaceId, groupId),
|
||||
queryFn: ({ signal, pageParam }) => {
|
||||
if (!workspaceId || !groupId)
|
||||
throw new Error('Credential group detail identifiers are required')
|
||||
return requestJson(getCredentialGroupContract, {
|
||||
params: { id: workspaceId, groupId },
|
||||
query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) },
|
||||
signal,
|
||||
})
|
||||
},
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage: ContractJsonResponse<typeof getCredentialGroupContract>) =>
|
||||
lastPage.nextCursor ?? undefined,
|
||||
enabled: Boolean(workspaceId && groupId),
|
||||
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateCredentialGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
body,
|
||||
}: {
|
||||
workspaceId: string
|
||||
body: ContractBodyInput<typeof createCredentialGroupContract>
|
||||
}) => requestJson(createCredentialGroupContract, { params: { id: workspaceId }, body }),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCredentialGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, groupId }: { workspaceId: string; groupId: string }) =>
|
||||
requestJson(deleteCredentialGroupContract, {
|
||||
params: { id: workspaceId, groupId },
|
||||
}),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCredentialGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
groupId,
|
||||
body,
|
||||
}: {
|
||||
workspaceId: string
|
||||
groupId: string
|
||||
body: ContractBodyInput<typeof updateCredentialGroupContract>
|
||||
}) =>
|
||||
requestJson(updateCredentialGroupContract, {
|
||||
params: { id: workspaceId, groupId },
|
||||
body,
|
||||
}),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartSlackCredentialGroupConfiguration() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
credentialGroupId,
|
||||
body,
|
||||
}: {
|
||||
workspaceId: string
|
||||
credentialGroupId: string
|
||||
body: ContractBodyInput<typeof startSlackCredentialGroupConfigurationContract>
|
||||
}) =>
|
||||
requestJson(startSlackCredentialGroupConfigurationContract, {
|
||||
params: { id: workspaceId, groupId: credentialGroupId },
|
||||
body,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useInviteCredentialGroupEnrollments() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
groupId,
|
||||
body,
|
||||
}: {
|
||||
workspaceId: string
|
||||
groupId: string
|
||||
body: ContractBodyInput<typeof inviteCredentialGroupEnrollmentsContract>
|
||||
}) =>
|
||||
requestJson(inviteCredentialGroupEnrollmentsContract, {
|
||||
params: { id: workspaceId, groupId },
|
||||
body,
|
||||
}),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useResendCredentialGroupEnrollment() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
groupId,
|
||||
enrollmentId,
|
||||
}: {
|
||||
workspaceId: string
|
||||
groupId: string
|
||||
enrollmentId: string
|
||||
}) =>
|
||||
requestJson(resendCredentialGroupEnrollmentContract, {
|
||||
params: { id: workspaceId, groupId, enrollmentId },
|
||||
}),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeCredentialGroupEnrollment() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
groupId,
|
||||
enrollmentId,
|
||||
}: {
|
||||
workspaceId: string
|
||||
groupId: string
|
||||
enrollmentId: string
|
||||
}) =>
|
||||
requestJson(revokeCredentialGroupEnrollmentContract, {
|
||||
params: { id: workspaceId, groupId, enrollmentId },
|
||||
}),
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import type { CredentialGroup } from '@/lib/api/contracts/credential-groups'
|
||||
import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups'
|
||||
|
||||
export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY
|
||||
export const CREDENTIAL_GROUP_LIST_STALE_TIME = 30 * 1000
|
||||
|
||||
export const credentialGroupKeys = {
|
||||
all: ['credential-groups'] as const,
|
||||
lists: () => [...credentialGroupKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...credentialGroupKeys.lists(), workspaceId ?? ''] as const,
|
||||
details: () => [...credentialGroupKeys.all, 'detail'] as const,
|
||||
detail: (workspaceId?: string, groupId?: string) =>
|
||||
[...credentialGroupKeys.details(), workspaceId ?? '', groupId ?? ''] as const,
|
||||
}
|
||||
|
||||
export async function fetchCredentialGroupList(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CredentialGroup[]> {
|
||||
const data = await requestJson(listCredentialGroupsContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.credentialGroups
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { overlayVisibility } from '@/blocks/visibility/context'
|
||||
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
|
||||
@@ -56,6 +57,7 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
const params = useParams()
|
||||
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
|
||||
const blockOverlayVersion = useCustomBlockOverlayVersion()
|
||||
const hostContext = useOptionalWorkspaceHostContext()
|
||||
|
||||
const { data: permissionData, isLoading: isPermissionLoading } =
|
||||
useUserPermissionConfig(workspaceId)
|
||||
@@ -94,6 +96,9 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
const isBlockAllowed = useMemo(() => {
|
||||
return (blockType: string) => {
|
||||
const normalizedBlockType = blockType.toLowerCase()
|
||||
if (normalizedBlockType === 'credential_group' && !hostContext?.features?.credentialGroups) {
|
||||
return false
|
||||
}
|
||||
const availability = integrationAvailability.get(normalizedBlockType)
|
||||
if (
|
||||
isDeploymentGatedIntegrationType(normalizedBlockType) &&
|
||||
@@ -106,7 +111,7 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
if (mergedAllowedIntegrations === null) return true
|
||||
return mergedAllowedIntegrations.includes(normalizedBlockType)
|
||||
}
|
||||
}, [integrationAvailability, mergedAllowedIntegrations])
|
||||
}, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations])
|
||||
|
||||
const isProviderAllowed = useMemo(() => {
|
||||
return (providerId: string) => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createCredentialGroupBodySchema,
|
||||
credentialGroupEnrollmentDetailSchema,
|
||||
credentialGroupEnrollmentListQuerySchema,
|
||||
credentialGroupSchema,
|
||||
inviteCredentialGroupEnrollmentsBodySchema,
|
||||
updateCredentialGroupBodySchema,
|
||||
} from '@/lib/api/contracts/credential-groups'
|
||||
|
||||
describe('credential group contracts', () => {
|
||||
it('accepts a group before account types are added', () => {
|
||||
const parsed = createCredentialGroupBodySchema.parse({
|
||||
name: 'Support team',
|
||||
options: [],
|
||||
})
|
||||
|
||||
expect(parsed.options).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts one option per provider after the group exists', () => {
|
||||
const parsed = updateCredentialGroupBodySchema.parse({
|
||||
options: [
|
||||
{ provider: 'gmail', label: 'Gmail', required: true },
|
||||
{ provider: 'google-calendar', label: 'Google Calendar', required: true },
|
||||
{
|
||||
provider: 'slack',
|
||||
label: 'Slack',
|
||||
required: true,
|
||||
slackBotCredentialId: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(parsed.options).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects the removed multiple-account option', () => {
|
||||
const result = createCredentialGroupBodySchema.safeParse({
|
||||
name: 'Support team',
|
||||
options: [
|
||||
{
|
||||
provider: 'gmail',
|
||||
label: 'Primary inbox',
|
||||
required: true,
|
||||
allowMultiple: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects duplicate option labels case-insensitively', () => {
|
||||
const result = createCredentialGroupBodySchema.safeParse({
|
||||
name: 'Support team',
|
||||
options: [
|
||||
{
|
||||
provider: 'gmail',
|
||||
label: 'Inbox',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
provider: 'gmail',
|
||||
label: 'inbox',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects duplicate providers', () => {
|
||||
const result = createCredentialGroupBodySchema.safeParse({
|
||||
name: 'Support team',
|
||||
options: [
|
||||
{ provider: 'gmail', label: 'Primary inbox', required: true },
|
||||
{ provider: 'gmail', label: 'Escalations', required: true },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a custom bot for Slack option updates', () => {
|
||||
const missingApp = updateCredentialGroupBodySchema.safeParse({
|
||||
options: [
|
||||
{
|
||||
provider: 'slack',
|
||||
label: 'Slack',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
const withApp = updateCredentialGroupBodySchema.safeParse({
|
||||
options: [
|
||||
{
|
||||
provider: 'slack',
|
||||
label: 'Slack',
|
||||
required: true,
|
||||
slackBotCredentialId: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(missingApp.success).toBe(false)
|
||||
expect(withApp.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects duplicate option IDs on update', () => {
|
||||
const option = {
|
||||
id: 'option-1',
|
||||
provider: 'gmail' as const,
|
||||
required: true,
|
||||
}
|
||||
const result = updateCredentialGroupBodySchema.safeParse({
|
||||
options: [
|
||||
{ ...option, label: 'Inbox' },
|
||||
{ ...option, label: 'Escalations' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects the authorization-app identity from settings responses', () => {
|
||||
const result = credentialGroupSchema.safeParse({
|
||||
id: 'group-1',
|
||||
workspaceId: 'workspace-1',
|
||||
name: 'Support team',
|
||||
description: null,
|
||||
options: [
|
||||
{
|
||||
id: 'option-1',
|
||||
provider: 'gmail',
|
||||
label: 'Inbox',
|
||||
required: true,
|
||||
status: 'active',
|
||||
authorizationAppId: 'server-only',
|
||||
},
|
||||
],
|
||||
status: 'active',
|
||||
createdAt: '2026-08-10T12:00:00.000Z',
|
||||
updatedAt: '2026-08-10T12:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a batch of invitation emails', () => {
|
||||
const result = inviteCredentialGroupEnrollmentsBodySchema.parse({
|
||||
emails: ['alex@example.com', 'sam@example.com'],
|
||||
})
|
||||
|
||||
expect(result.emails).toEqual(['alex@example.com', 'sam@example.com'])
|
||||
})
|
||||
|
||||
it('rejects invitation batches larger than 100 recipients', () => {
|
||||
const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({
|
||||
emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`),
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid invitation email addresses', () => {
|
||||
const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({
|
||||
emails: ['not-an-email'],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds enrollment pages and defaults them to 50 rows', () => {
|
||||
expect(credentialGroupEnrollmentListQuerySchema.parse({})).toEqual({ limit: 50 })
|
||||
expect(credentialGroupEnrollmentListQuerySchema.safeParse({ limit: 101 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts aggregated provider connections on an enrollment', () => {
|
||||
const result = credentialGroupEnrollmentDetailSchema.parse({
|
||||
id: 'enrollment-1',
|
||||
credentialGroupId: 'group-1',
|
||||
email: 'alex@example.com',
|
||||
status: 'completed',
|
||||
expiresAt: '2026-08-18T12:00:00.000Z',
|
||||
invitedAt: '2026-08-11T12:00:00.000Z',
|
||||
sentAt: '2026-08-11T12:00:01.000Z',
|
||||
completedAt: '2026-08-11T12:05:00.000Z',
|
||||
revokedAt: null,
|
||||
expired: false,
|
||||
createdAt: '2026-08-11T12:00:00.000Z',
|
||||
updatedAt: '2026-08-11T12:05:00.000Z',
|
||||
connections: [{ provider: 'gmail', status: 'active', count: 2 }],
|
||||
})
|
||||
|
||||
expect(result.connections).toEqual([{ provider: 'gmail', status: 'active', count: 2 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
import { z } from 'zod'
|
||||
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import {
|
||||
CREDENTIAL_GROUP_PROVIDER_IDS,
|
||||
CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS,
|
||||
} from '@/lib/credential-groups/providers'
|
||||
|
||||
export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS)
|
||||
export const credentialGroupStatusSchema = z.enum(['active', 'disabled'])
|
||||
export const credentialGroupEnrollmentStatusSchema = z.enum([
|
||||
'invited',
|
||||
'delivery_failed',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'revoked',
|
||||
])
|
||||
export const credentialGroupOptionConfigurationStatusSchema = z.enum([
|
||||
'not_configured',
|
||||
'ready',
|
||||
'needs_update',
|
||||
])
|
||||
|
||||
const credentialGroupOptionFields = {
|
||||
label: z.string().trim().min(1, 'Option label is required').max(100),
|
||||
required: z.boolean(),
|
||||
} as const
|
||||
|
||||
const standardOAuthCredentialGroupOptionInputSchema = z
|
||||
.object({
|
||||
provider: z.enum(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS),
|
||||
...credentialGroupOptionFields,
|
||||
})
|
||||
.strict()
|
||||
|
||||
const slackCredentialGroupOptionInputSchema = z
|
||||
.object({
|
||||
provider: z.literal('slack'),
|
||||
...credentialGroupOptionFields,
|
||||
slackBotCredentialId: z.string().uuid('Select a custom Slack bot'),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const credentialGroupOptionInputSchema = z.discriminatedUnion('provider', [
|
||||
standardOAuthCredentialGroupOptionInputSchema,
|
||||
slackCredentialGroupOptionInputSchema,
|
||||
])
|
||||
|
||||
export const credentialGroupOptionSchema = z.discriminatedUnion('provider', [
|
||||
standardOAuthCredentialGroupOptionInputSchema.extend({
|
||||
id: z.string().min(1),
|
||||
status: z.enum(['active', 'disabled']),
|
||||
configurationStatus: credentialGroupOptionConfigurationStatusSchema,
|
||||
}),
|
||||
slackCredentialGroupOptionInputSchema.extend({
|
||||
id: z.string().min(1),
|
||||
status: z.enum(['active', 'disabled']),
|
||||
configurationStatus: credentialGroupOptionConfigurationStatusSchema,
|
||||
}),
|
||||
])
|
||||
|
||||
export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('provider', [
|
||||
standardOAuthCredentialGroupOptionInputSchema.extend({
|
||||
id: z.string().min(1).max(128).optional(),
|
||||
}),
|
||||
slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }),
|
||||
])
|
||||
|
||||
export const credentialGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
options: z.array(credentialGroupOptionSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length),
|
||||
status: credentialGroupStatusSchema,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
export type CredentialGroup = z.output<typeof credentialGroupSchema>
|
||||
export type CredentialGroupOption = z.output<typeof credentialGroupOptionSchema>
|
||||
export type CredentialGroupOptionInput = z.input<typeof credentialGroupOptionInputSchema>
|
||||
|
||||
export const credentialGroupEnrollmentSchema = z.object({
|
||||
id: z.string(),
|
||||
credentialGroupId: z.string(),
|
||||
email: z.string().email(),
|
||||
status: credentialGroupEnrollmentStatusSchema,
|
||||
expiresAt: z.string(),
|
||||
invitedAt: z.string(),
|
||||
sentAt: z.string().nullable(),
|
||||
completedAt: z.string().nullable(),
|
||||
revokedAt: z.string().nullable(),
|
||||
expired: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
export type CredentialGroupEnrollment = z.output<typeof credentialGroupEnrollmentSchema>
|
||||
|
||||
export const credentialGroupEnrollmentConnectionSchema = z.object({
|
||||
provider: credentialGroupProviderSchema,
|
||||
status: z.enum(['active', 'needs_reauth', 'revoked']),
|
||||
count: z.number().int().positive(),
|
||||
})
|
||||
|
||||
export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({
|
||||
connections: z
|
||||
.array(credentialGroupEnrollmentConnectionSchema)
|
||||
.max(CREDENTIAL_GROUP_PROVIDER_IDS.length * 3),
|
||||
})
|
||||
|
||||
export type CredentialGroupEnrollmentConnection = z.output<
|
||||
typeof credentialGroupEnrollmentConnectionSchema
|
||||
>
|
||||
export type CredentialGroupEnrollmentDetail = z.output<typeof credentialGroupEnrollmentDetailSchema>
|
||||
|
||||
export const credentialGroupWorkspaceParamsSchema = z.object({
|
||||
id: workspaceIdSchema,
|
||||
})
|
||||
|
||||
export const credentialGroupDetailParamsSchema = credentialGroupWorkspaceParamsSchema.extend({
|
||||
groupId: z.string().min(1, 'Credential group ID is required').max(128),
|
||||
})
|
||||
|
||||
export const credentialGroupEnrollmentParamsSchema = credentialGroupDetailParamsSchema.extend({
|
||||
enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128),
|
||||
})
|
||||
|
||||
export const publicCredentialGroupEnrollmentParamsSchema = z.object({
|
||||
token: z.string().min(1, 'Invitation token is required').max(128),
|
||||
})
|
||||
|
||||
export const startCredentialGroupOAuthParamsSchema =
|
||||
publicCredentialGroupEnrollmentParamsSchema.extend({
|
||||
optionId: z.string().min(1, 'Credential option ID is required').max(128),
|
||||
})
|
||||
|
||||
export const credentialGroupOAuthCallbackQuerySchema = z
|
||||
.object({
|
||||
state: z.string().min(1, 'OAuth state is required').max(512),
|
||||
code: z.string().min(1).max(2048).optional(),
|
||||
error: z.string().min(1).max(256).optional(),
|
||||
error_description: z.string().max(1000).optional(),
|
||||
})
|
||||
.superRefine((query, ctx) => {
|
||||
if (!query.code && !query.error) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['code'],
|
||||
message: 'OAuth callback must include a code or error',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const credentialGroupOAuthCallbackParamsSchema = z.object({
|
||||
provider: credentialGroupProviderSchema,
|
||||
})
|
||||
|
||||
export const startSlackCredentialGroupConfigurationBodySchema = z
|
||||
.object({
|
||||
slackBotCredentialId: z.string().uuid('Select a custom Slack bot'),
|
||||
clientId: z.string().trim().min(1, 'Slack Client ID is required').max(256),
|
||||
clientSecret: z.string().trim().min(1, 'Slack Client Secret is required').max(512),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const slackCredentialGroupConfigurationCallbackQuerySchema =
|
||||
credentialGroupOAuthCallbackQuerySchema
|
||||
|
||||
export const credentialGroupEnrollmentListQuerySchema = z.object({
|
||||
cursor: z.string().min(1, 'Enrollment cursor cannot be empty').max(128).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
|
||||
export const inviteCredentialGroupEnrollmentsBodySchema = z
|
||||
.object({
|
||||
emails: z
|
||||
.array(z.string().trim().email('Enter a valid email address').max(320))
|
||||
.min(1, 'At least one email address is required')
|
||||
.max(100, 'You can invite at most 100 people at once'),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type InviteCredentialGroupEnrollmentsBody = z.input<
|
||||
typeof inviteCredentialGroupEnrollmentsBodySchema
|
||||
>
|
||||
|
||||
export const credentialGroupEnrollmentInviteResultSchema = z.discriminatedUnion('success', [
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
success: z.literal(true),
|
||||
enrollment: credentialGroupEnrollmentSchema,
|
||||
}),
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
])
|
||||
|
||||
export const createCredentialGroupBodySchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(100),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
options: z.array(credentialGroupOptionInputSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((body, ctx) => {
|
||||
const labels = new Set<string>()
|
||||
const providers = new Set<string>()
|
||||
for (const [index, option] of body.options.entries()) {
|
||||
if (option.provider === 'slack') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['options', index],
|
||||
message: 'Create the Credential Group before configuring Slack',
|
||||
})
|
||||
}
|
||||
const normalized = option.label.toLocaleLowerCase()
|
||||
if (labels.has(normalized)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['options', index, 'label'],
|
||||
message: 'Credential option labels must be unique within a group',
|
||||
})
|
||||
}
|
||||
labels.add(normalized)
|
||||
if (providers.has(option.provider)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['options', index, 'provider'],
|
||||
message: 'Each provider can only be added once',
|
||||
})
|
||||
}
|
||||
providers.add(option.provider)
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateCredentialGroupBody = z.input<typeof createCredentialGroupBodySchema>
|
||||
|
||||
export const updateCredentialGroupBodySchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(100).optional(),
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
options: z
|
||||
.array(credentialGroupOptionUpdateInputSchema)
|
||||
.max(CREDENTIAL_GROUP_PROVIDER_IDS.length)
|
||||
.optional(),
|
||||
status: credentialGroupStatusSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((body, ctx) => {
|
||||
if (Object.keys(body).length === 0) {
|
||||
ctx.addIssue({ code: 'custom', message: 'At least one field must be updated' })
|
||||
}
|
||||
if (!body.options) return
|
||||
const labels = new Set<string>()
|
||||
const optionIds = new Set<string>()
|
||||
const providers = new Set<string>()
|
||||
for (const [index, option] of body.options.entries()) {
|
||||
const normalized = option.label.toLowerCase()
|
||||
if (labels.has(normalized)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['options', index, 'label'],
|
||||
message: 'Credential option labels must be unique within a group',
|
||||
})
|
||||
}
|
||||
labels.add(normalized)
|
||||
if (providers.has(option.provider)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['options', index, 'provider'],
|
||||
message: 'Each provider can only be added once',
|
||||
})
|
||||
}
|
||||
providers.add(option.provider)
|
||||
if (option.id && optionIds.has(option.id)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['options', index, 'id'],
|
||||
message: 'Credential option IDs must be unique within a group',
|
||||
})
|
||||
}
|
||||
if (option.id) optionIds.add(option.id)
|
||||
}
|
||||
})
|
||||
|
||||
export type UpdateCredentialGroupBody = z.input<typeof updateCredentialGroupBodySchema>
|
||||
|
||||
export const listCredentialGroupsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/credential-groups',
|
||||
params: credentialGroupWorkspaceParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }),
|
||||
},
|
||||
})
|
||||
|
||||
export const createCredentialGroupContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/credential-groups',
|
||||
params: credentialGroupWorkspaceParamsSchema,
|
||||
body: createCredentialGroupBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
status: 201,
|
||||
schema: z.object({ credentialGroup: credentialGroupSchema }),
|
||||
},
|
||||
})
|
||||
|
||||
export const getCredentialGroupContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/credential-groups/[groupId]',
|
||||
params: credentialGroupDetailParamsSchema,
|
||||
query: credentialGroupEnrollmentListQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
credentialGroup: credentialGroupSchema,
|
||||
enrollments: z.array(credentialGroupEnrollmentDetailSchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const inviteCredentialGroupEnrollmentsContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments',
|
||||
params: credentialGroupDetailParamsSchema,
|
||||
body: inviteCredentialGroupEnrollmentsBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
results: z.array(credentialGroupEnrollmentInviteResultSchema).min(1).max(100),
|
||||
sentCount: z.number().int().nonnegative(),
|
||||
failedCount: z.number().int().nonnegative(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const resendCredentialGroupEnrollmentContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend',
|
||||
params: credentialGroupEnrollmentParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }),
|
||||
},
|
||||
})
|
||||
|
||||
export const revokeCredentialGroupEnrollmentContract = defineRouteContract({
|
||||
method: 'DELETE',
|
||||
path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]',
|
||||
params: credentialGroupEnrollmentParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }),
|
||||
},
|
||||
})
|
||||
|
||||
export const deleteCredentialGroupContract = defineRouteContract({
|
||||
method: 'DELETE',
|
||||
path: '/api/workspaces/[id]/credential-groups/[groupId]',
|
||||
params: credentialGroupDetailParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
})
|
||||
|
||||
export const updateCredentialGroupContract = defineRouteContract({
|
||||
method: 'PATCH',
|
||||
path: '/api/workspaces/[id]/credential-groups/[groupId]',
|
||||
params: credentialGroupDetailParamsSchema,
|
||||
body: updateCredentialGroupBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({ credentialGroup: credentialGroupSchema }),
|
||||
},
|
||||
})
|
||||
|
||||
export const startSlackCredentialGroupConfigurationContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users',
|
||||
params: credentialGroupDetailParamsSchema,
|
||||
body: startSlackCredentialGroupConfigurationBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
authorizationUrl: z.string().url(),
|
||||
state: z.string().min(1),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const slackCredentialGroupConfigurationCallbackContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/credential-groups/slack-managed-users/callback',
|
||||
query: slackCredentialGroupConfigurationCallbackQuerySchema,
|
||||
response: { mode: 'text' },
|
||||
})
|
||||
|
||||
export const startCredentialGroupOAuthContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/credential-groups/enroll/[token]/oauth/[optionId]',
|
||||
params: startCredentialGroupOAuthParamsSchema,
|
||||
response: { mode: 'empty' },
|
||||
})
|
||||
|
||||
export const completeCredentialGroupEnrollmentContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/credential-groups/enroll/[token]/complete',
|
||||
params: publicCredentialGroupEnrollmentParamsSchema,
|
||||
response: { mode: 'empty' },
|
||||
})
|
||||
|
||||
export const credentialGroupOAuthCallbackContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/credential-groups/oauth/[provider]/callback',
|
||||
params: credentialGroupOAuthCallbackParamsSchema,
|
||||
query: credentialGroupOAuthCallbackQuerySchema,
|
||||
response: { mode: 'empty' },
|
||||
})
|
||||
@@ -17,9 +17,14 @@ export const workspaceCredentialTypeSchema = z.enum([
|
||||
'env_personal',
|
||||
'service_account',
|
||||
])
|
||||
const creatableWorkspaceCredentialTypeSchema = z.enum([
|
||||
'oauth',
|
||||
'env_workspace',
|
||||
'env_personal',
|
||||
'service_account',
|
||||
])
|
||||
export const workspaceCredentialRoleSchema = z.enum(['admin', 'member'])
|
||||
export const workspaceCredentialMemberStatusSchema = z.enum(['active', 'pending', 'revoked'])
|
||||
|
||||
export const workspaceCredentialSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
@@ -112,7 +117,7 @@ export const serviceAccountJsonSchema = z
|
||||
export const createCredentialBodySchema = z
|
||||
.object({
|
||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
|
||||
type: workspaceCredentialTypeSchema,
|
||||
type: creatableWorkspaceCredentialTypeSchema,
|
||||
displayName: z.string().trim().min(1).max(255).optional(),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
providerId: z.string().trim().min(1).optional(),
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
} from '@/lib/api/contracts/types'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
|
||||
export const MANAGED_OAUTH_DELEGATION_HEADER = 'x-sim-managed-oauth-delegation'
|
||||
|
||||
export const oauthAccountSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
@@ -70,6 +72,7 @@ export const oauthTokenRequestBodySchema = z
|
||||
credentialId: z.string().min(1).optional(),
|
||||
credentialAccountUserId: z.string().min(1).optional(),
|
||||
providerId: z.string().min(1).optional(),
|
||||
toolId: z.string().min(1).optional(),
|
||||
workflowId: z.string().min(1).nullish(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
impersonateEmail: impersonateEmailSchema.optional(),
|
||||
@@ -91,6 +94,10 @@ export const oauthTokenPostQuerySchema = z.object({
|
||||
userId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const oauthTokenPostHeadersSchema = z.object({
|
||||
[MANAGED_OAUTH_DELEGATION_HEADER]: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
const oauthTokenResponseSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
idToken: z.string().optional(),
|
||||
@@ -119,6 +126,7 @@ export const oauthTokenPostContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/auth/oauth/token',
|
||||
query: oauthTokenPostQuerySchema,
|
||||
headers: oauthTokenPostHeadersSchema,
|
||||
body: oauthTokenRequestBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
|
||||
@@ -256,6 +256,11 @@ export const workspaceHostContextSchema = z.object({
|
||||
isHostOrganizationMember: z.boolean(),
|
||||
isHostOrganizationAdmin: z.boolean(),
|
||||
}),
|
||||
features: z
|
||||
.object({
|
||||
credentialGroups: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type WorkspaceHostContext = z.output<typeof workspaceHostContextSchema>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { OAuth2Tokens } from '@better-auth/core/oauth2'
|
||||
import type { GenericOAuthConfig } from 'better-auth/plugins'
|
||||
import { OAuth2Client, type TokenPayload } from 'google-auth-library'
|
||||
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
|
||||
|
||||
const GOOGLE_OPENID_SCOPE = 'openid'
|
||||
const GOOGLE_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
|
||||
const GOOGLE_PROFILE_SCOPE = 'https://www.googleapis.com/auth/userinfo.profile'
|
||||
const GMAIL_READONLY_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
|
||||
const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
|
||||
const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send'
|
||||
const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
|
||||
|
||||
export interface ManagedOAuthConnectorIdentity {
|
||||
providerSubjectId: string
|
||||
providerTenantId: string | null
|
||||
email: string
|
||||
emailVerified: boolean
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
nonce?: string
|
||||
grantedScopes: string[]
|
||||
}
|
||||
|
||||
export interface ManagedOAuthConnectorConfig {
|
||||
additionalScopes: string[]
|
||||
requiresRefreshToken: boolean
|
||||
pkce: boolean
|
||||
prompt?: string
|
||||
authorizationUrlParams?: Record<string, string>
|
||||
getAuthorizationAppId(clientId: string): string
|
||||
verifyIdentity(params: {
|
||||
tokens: OAuth2Tokens
|
||||
clientId: string
|
||||
}): Promise<ManagedOAuthConnectorIdentity>
|
||||
hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean
|
||||
isTerminalRefreshError(errorCode: string | undefined): boolean
|
||||
}
|
||||
|
||||
export interface ConnectorProviderConfig extends GenericOAuthConfig {
|
||||
managedOAuth: ManagedOAuthConnectorConfig
|
||||
}
|
||||
|
||||
function canonicalGoogleScope(scope: string): string {
|
||||
if (scope === 'email') return GOOGLE_EMAIL_SCOPE
|
||||
if (scope === 'profile') return GOOGLE_PROFILE_SCOPE
|
||||
return scope
|
||||
}
|
||||
|
||||
function hasRequiredGoogleScopes(
|
||||
providerId: string,
|
||||
grantedScopes: string[],
|
||||
requiredScopes: string[]
|
||||
): boolean {
|
||||
const granted = new Set(grantedScopes.map(canonicalGoogleScope))
|
||||
return requiredScopes.every((requestedScope) => {
|
||||
const required = canonicalGoogleScope(requestedScope)
|
||||
if (granted.has(required)) return true
|
||||
return (
|
||||
providerId === 'google-email' &&
|
||||
granted.has(GMAIL_MODIFY_SCOPE) &&
|
||||
(required === GMAIL_READONLY_SCOPE ||
|
||||
required === GMAIL_SEND_SCOPE ||
|
||||
required === GMAIL_LABELS_SCOPE)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function requireVerifiedGooglePayload(payload: TokenPayload | undefined): TokenPayload & {
|
||||
sub: string
|
||||
email: string
|
||||
} {
|
||||
if (!payload?.sub || !payload.email || payload.email_verified !== true) {
|
||||
throw new Error('Google returned an invalid identity token')
|
||||
}
|
||||
return payload as TokenPayload & { sub: string; email: string }
|
||||
}
|
||||
|
||||
export function createGoogleManagedOAuthConnector(providerId: string): ManagedOAuthConnectorConfig {
|
||||
return {
|
||||
additionalScopes: [GOOGLE_OPENID_SCOPE],
|
||||
requiresRefreshToken: true,
|
||||
pkce: true,
|
||||
prompt: 'consent select_account',
|
||||
authorizationUrlParams: { include_granted_scopes: 'false' },
|
||||
getAuthorizationAppId(clientId) {
|
||||
return `google:${createHash('sha256').update(clientId).digest('hex')}`
|
||||
},
|
||||
async verifyIdentity({ tokens, clientId }) {
|
||||
if (!tokens.idToken || !tokens.accessToken) {
|
||||
throw new Error('Google returned an incomplete authorization')
|
||||
}
|
||||
const client = new OAuth2Client({ clientId })
|
||||
const ticket = await client.verifyIdToken({ idToken: tokens.idToken, audience: clientId })
|
||||
const payload = requireVerifiedGooglePayload(ticket.getPayload())
|
||||
const tokenInfo = await client.getTokenInfo(tokens.accessToken)
|
||||
if (tokenInfo.aud !== clientId || tokenInfo.sub !== payload.sub) {
|
||||
throw new Error('Google returned an access token for another identity')
|
||||
}
|
||||
return {
|
||||
providerSubjectId: payload.sub,
|
||||
providerTenantId: payload.hd ?? null,
|
||||
email: payload.email,
|
||||
emailVerified: true,
|
||||
...(payload.name ? { displayName: payload.name } : {}),
|
||||
...(payload.picture ? { avatarUrl: payload.picture } : {}),
|
||||
...(payload.nonce ? { nonce: payload.nonce } : {}),
|
||||
grantedScopes: [...new Set(tokenInfo.scopes)],
|
||||
}
|
||||
},
|
||||
hasRequiredScopes(grantedScopes, requiredScopes) {
|
||||
return hasRequiredGoogleScopes(providerId, grantedScopes, requiredScopes)
|
||||
},
|
||||
isTerminalRefreshError(errorCode) {
|
||||
return errorCode === 'invalid_grant'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getManagedOAuthConnectorProviderConfig(
|
||||
providerId: string
|
||||
): ConnectorProviderConfig | undefined {
|
||||
if (providerId !== 'google-email' && providerId !== 'google-calendar') return undefined
|
||||
const connector = buildConnectorProviders().find(
|
||||
(candidate) => candidate.providerId === providerId
|
||||
)
|
||||
if (!connector) return undefined
|
||||
return {
|
||||
...connector,
|
||||
managedOAuth: createGoogleManagedOAuthConnector(providerId),
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export interface CredentialAccessResult {
|
||||
credentialOwnerUserId?: string
|
||||
workspaceId?: string
|
||||
resolvedCredentialId?: string
|
||||
credentialType?: 'oauth' | 'service_account'
|
||||
credentialType?: 'oauth' | 'managed_oauth' | 'service_account'
|
||||
}
|
||||
|
||||
const NO_CREDENTIAL_ACCESS =
|
||||
@@ -123,6 +123,13 @@ export async function authorizeCredentialUseForAuth(
|
||||
const accessError = credentialAccessError(platformAccess)
|
||||
if (accessError) return { ok: false, error: accessError }
|
||||
|
||||
if (platformCredential.type === 'managed_oauth') {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Managed credential access requires scoped workflow delegation',
|
||||
}
|
||||
}
|
||||
|
||||
if (platformCredential.type === 'service_account') {
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -49,6 +49,19 @@ describe('principal subject users', () => {
|
||||
})
|
||||
).toThrow(PrincipalSubjectUserRequiredError)
|
||||
})
|
||||
|
||||
it('fails fast instead of fabricating a Sim user for an external enrollment', () => {
|
||||
expect(() =>
|
||||
requirePrincipalSubjectUserId({
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'person@example.com',
|
||||
invitationTokenHash: 'hash-1',
|
||||
})
|
||||
).toThrow(PrincipalSubjectUserRequiredError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('principal actors', () => {
|
||||
@@ -86,6 +99,26 @@ describe('principal actors', () => {
|
||||
actorId: null,
|
||||
actorName: 'Workspace API key',
|
||||
})
|
||||
expect(
|
||||
resolvePrincipalAuditAttribution({
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'person@example.com',
|
||||
invitationTokenHash: 'hash-1',
|
||||
})
|
||||
).toEqual({
|
||||
actor: {
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'person@example.com',
|
||||
},
|
||||
actorId: null,
|
||||
actorName: 'person@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('projects principals into their shared actor identity', () => {
|
||||
@@ -158,4 +191,17 @@ describe('principal actors', () => {
|
||||
})
|
||||
).toThrow('Workspace API key attribution requires a workspace billing owner')
|
||||
})
|
||||
|
||||
it('fails fast when external enrollment identity is used for user attribution', () => {
|
||||
expect(() =>
|
||||
resolvePrincipalAttribution({
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'person@example.com',
|
||||
invitationTokenHash: 'hash-1',
|
||||
})
|
||||
).toThrow(PrincipalSubjectUserRequiredError)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal'
|
||||
import type {
|
||||
CredentialGroupEnrollmentPrincipal,
|
||||
DelegatedPrincipal,
|
||||
DelegatedServiceId,
|
||||
Principal,
|
||||
} from '@sim/auth/principal'
|
||||
import type { PermissionType } from '@sim/platform-authz/workspace'
|
||||
import type { ApplicationOperation } from '@/lib/core/application/operation'
|
||||
|
||||
type WorkspaceApiKeyPolicy<R extends PermissionType> = R extends 'admin' ? 'deny' : 'allow' | 'deny'
|
||||
|
||||
export type PrincipalKind = Principal['kind']
|
||||
export type PrincipalKind = Exclude<Principal['kind'], 'credential_group_enrollment'>
|
||||
|
||||
type NonDelegatedPrincipalForOperation<
|
||||
O extends { readonly principalKinds: readonly PrincipalKind[] },
|
||||
> = Exclude<Extract<Principal, { kind: O['principalKinds'][number] }>, DelegatedPrincipal>
|
||||
> = Exclude<
|
||||
Extract<Principal, { kind: O['principalKinds'][number] }>,
|
||||
DelegatedPrincipal | CredentialGroupEnrollmentPrincipal
|
||||
>
|
||||
|
||||
type DelegatedPrincipalForOperation<
|
||||
O extends {
|
||||
|
||||
@@ -528,6 +528,7 @@ export const env = createEnv({
|
||||
TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route)
|
||||
TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles)
|
||||
TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu
|
||||
CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally
|
||||
|
||||
// Organizations - for self-hosted deployments
|
||||
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
|
||||
|
||||
@@ -14,6 +14,8 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({
|
||||
FORKING_ENABLED: undefined as boolean | undefined,
|
||||
DEPLOY_AS_BLOCK: undefined as boolean | undefined,
|
||||
TABLES_V2_API: undefined as boolean | undefined,
|
||||
TABLE_VIEWS: undefined as boolean | undefined,
|
||||
CREDENTIAL_GROUPS: undefined as boolean | undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -122,6 +124,8 @@ describe('isFeatureEnabled', () => {
|
||||
setEnvFlags({ isAppConfigEnabled: false })
|
||||
envRef.FORKING_ENABLED = undefined
|
||||
envRef.DEPLOY_AS_BLOCK = undefined
|
||||
envRef.CREDENTIAL_GROUPS = undefined
|
||||
envRef.TABLE_VIEWS = undefined
|
||||
})
|
||||
|
||||
describe('workspace-forking flag', () => {
|
||||
@@ -160,6 +164,20 @@ describe('isFeatureEnabled', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('credential-groups flag', () => {
|
||||
it('uses a global fallback switch off AppConfig', async () => {
|
||||
expect(await isFeatureEnabled('credential-groups')).toBe(false)
|
||||
|
||||
envRef.CREDENTIAL_GROUPS = true
|
||||
expect(await isFeatureEnabled('credential-groups')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses only the global AppConfig clause', async () => {
|
||||
withAppConfig({ 'credential-groups': { enabled: true } })
|
||||
expect(await isFeatureEnabled('credential-groups')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('table-views flag', () => {
|
||||
it('falls back to TABLE_VIEWS when AppConfig is disabled', async () => {
|
||||
envRef.TABLE_VIEWS = undefined
|
||||
|
||||
@@ -144,6 +144,12 @@ const FEATURE_FLAGS = {
|
||||
'Off-AppConfig falls back to TABLE_VIEWS.',
|
||||
fallback: 'TABLE_VIEWS',
|
||||
},
|
||||
'credential-groups': {
|
||||
description:
|
||||
'Workspace-owned collections that gather managed OAuth credentials from external users. ' +
|
||||
'Global on/off only; hosted workspaces must also have an Enterprise subscription.',
|
||||
fallback: 'CREDENTIAL_GROUPS',
|
||||
},
|
||||
} satisfies Record<string, FeatureFlagDefinition>
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Principal } from '@sim/auth/principal'
|
||||
import type {
|
||||
WorkspaceAuthorizationContext,
|
||||
WorkspaceDelegationPolicy,
|
||||
} from '@/lib/core/application'
|
||||
import type { CredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials'
|
||||
|
||||
export const CREDENTIAL_GROUP_DELEGATION_AUDIENCE = 'sim:credential-groups'
|
||||
|
||||
export interface CredentialGroupApplicationContext
|
||||
extends WorkspaceAuthorizationContext,
|
||||
CredentialGroupCredentialListContext {}
|
||||
|
||||
export const credentialGroupDelegationPolicy = {
|
||||
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
|
||||
isWithinScope: (
|
||||
principal: Extract<Principal, { kind: 'delegated' }>,
|
||||
context: CredentialGroupApplicationContext
|
||||
) => principal.resourceScope?.credentialGroupId === context.credentialGroupId,
|
||||
} satisfies WorkspaceDelegationPolicy<CredentialGroupApplicationContext>
|
||||
|
||||
export const credentialGroupWorkspaceDelegationPolicy = {
|
||||
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
|
||||
isWithinScope: (principal: Extract<Principal, { kind: 'delegated' }>) =>
|
||||
principal.resourceScope?.credentialGroupId === undefined,
|
||||
} satisfies WorkspaceDelegationPolicy<WorkspaceAuthorizationContext>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization'
|
||||
import {
|
||||
isCredentialGroupsAvailable,
|
||||
resolveCredentialGroupsAvailability,
|
||||
} from '@/lib/credential-groups/availability'
|
||||
import { loadCredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials'
|
||||
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
|
||||
|
||||
export async function requireCredentialGroupsAvailable(workspaceId: string): Promise<void> {
|
||||
const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
|
||||
const availability = await resolveCredentialGroupsAvailability(ownerBilling)
|
||||
if (!availability.available) {
|
||||
const message =
|
||||
availability.reason === 'enterprise_plan_required'
|
||||
? 'Credential Groups are not available. Enterprise plan required.'
|
||||
: 'Credential Groups are not available'
|
||||
throw new OrchestrationError('forbidden', message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireCredentialGroupSettingsAvailable(workspaceId: string): Promise<void> {
|
||||
const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
|
||||
if (!(await isCredentialGroupsAvailable(ownerBilling))) {
|
||||
throw new OrchestrationError('not_found', 'Credential Groups are not available')
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCredentialGroupWorkspaceContext(workspaceId: string) {
|
||||
const workspace = await loadActiveWorkspaceApplicationContext(workspaceId)
|
||||
if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found')
|
||||
return workspace
|
||||
}
|
||||
|
||||
export async function resolveCredentialGroupContext(
|
||||
credentialGroupId: string
|
||||
): Promise<CredentialGroupApplicationContext> {
|
||||
const group = await loadCredentialGroupCredentialListContext(credentialGroupId)
|
||||
if (!group) throw new OrchestrationError('not_found', 'Credential group not found')
|
||||
return { ...(await resolveCredentialGroupWorkspaceContext(group.workspaceId)), ...group }
|
||||
}
|
||||
|
||||
export async function resolveCredentialGroupSettingsContext(
|
||||
credentialGroupId: string,
|
||||
assertedWorkspaceId: string
|
||||
): Promise<CredentialGroupApplicationContext> {
|
||||
const context = await resolveCredentialGroupContext(credentialGroupId)
|
||||
if (context.workspaceId !== assertedWorkspaceId) {
|
||||
throw new OrchestrationError('not_found', 'Credential group not found')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
|
||||
import {
|
||||
InvalidInternalDelegationTokenError,
|
||||
verifyInternalDelegationToken,
|
||||
} from '@/lib/auth/internal'
|
||||
import {
|
||||
bindInternalExecutorDelegation,
|
||||
InvalidInternalDelegationBindingError,
|
||||
} from '@/lib/auth/internal-delegation'
|
||||
import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization'
|
||||
|
||||
export class InvalidCredentialGroupDelegationError extends Error {
|
||||
constructor() {
|
||||
super('Credential Group execution requires valid workflow delegation')
|
||||
this.name = 'InvalidCredentialGroupDelegationError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Authenticates and binds executor claims to Credential Group application scope. */
|
||||
export async function authenticateCredentialGroupDelegation(
|
||||
authorization: string,
|
||||
credentialGroupId?: string
|
||||
): Promise<WorkflowExecutionDelegatedPrincipal> {
|
||||
if (!authorization.startsWith('Bearer ')) throw new InvalidCredentialGroupDelegationError()
|
||||
|
||||
try {
|
||||
const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length))
|
||||
return await bindInternalExecutorDelegation(claims, {
|
||||
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
|
||||
...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidInternalDelegationTokenError ||
|
||||
error instanceof InvalidInternalDelegationBindingError
|
||||
) {
|
||||
throw new InvalidCredentialGroupDelegationError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CredentialGroupEnrollmentPrincipal } from '@sim/auth/principal'
|
||||
import { authenticatePublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments'
|
||||
|
||||
/** Exchanges a valid invitation bearer for its bounded external enrollment principal. */
|
||||
export async function authenticateCredentialGroupEnrollment(
|
||||
invitationToken: string
|
||||
): Promise<CredentialGroupEnrollmentPrincipal | null> {
|
||||
if (!invitationToken.trim() || invitationToken.length > 128) return null
|
||||
const identity = await authenticatePublicCredentialGroupEnrollment(invitationToken)
|
||||
if (!identity) return null
|
||||
return Object.freeze({ kind: 'credential_group_enrollment' as const, ...identity })
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ApplicationOperation } from '@/lib/core/application'
|
||||
|
||||
export interface CredentialGroupEnrollmentOperation<Id extends string = string>
|
||||
extends ApplicationOperation<Id> {
|
||||
readonly principalKind: 'credential_group_enrollment'
|
||||
}
|
||||
|
||||
function defineCredentialGroupEnrollmentOperation<const Id extends string>(
|
||||
operation: CredentialGroupEnrollmentOperation<Id>
|
||||
): CredentialGroupEnrollmentOperation<Id> {
|
||||
if (!operation.id.trim()) throw new Error('Credential Group enrollment operation ID is required')
|
||||
return Object.freeze(operation)
|
||||
}
|
||||
|
||||
export const credentialGroupEnrollmentOperations = {
|
||||
read: defineCredentialGroupEnrollmentOperation({
|
||||
id: 'credential_groups.enrollment.read',
|
||||
principalKind: 'credential_group_enrollment',
|
||||
}),
|
||||
startOAuth: defineCredentialGroupEnrollmentOperation({
|
||||
id: 'credential_groups.enrollment.oauth.start',
|
||||
principalKind: 'credential_group_enrollment',
|
||||
}),
|
||||
completeOAuth: defineCredentialGroupEnrollmentOperation({
|
||||
id: 'credential_groups.enrollment.oauth.complete',
|
||||
principalKind: 'credential_group_enrollment',
|
||||
}),
|
||||
complete: defineCredentialGroupEnrollmentOperation({
|
||||
id: 'credential_groups.enrollment.complete',
|
||||
principalKind: 'credential_group_enrollment',
|
||||
}),
|
||||
} as const
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getWorkspaceOwnerSubscriptionAccess: vi.fn(),
|
||||
listCredentials: vi.fn(),
|
||||
loadGroup: vi.fn(),
|
||||
loadWorkspace: vi.fn(),
|
||||
resolveCredentialGroupsAvailability: vi.fn(),
|
||||
resolvePermission: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/workspace-access', () => ({
|
||||
getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/availability', () => ({
|
||||
resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/credentials', () => ({
|
||||
CredentialGroupCredentialCursorNotFoundError: class extends Error {
|
||||
constructor() {
|
||||
super('Credential group credential cursor not found')
|
||||
this.name = 'CredentialGroupCredentialCursorNotFoundError'
|
||||
}
|
||||
},
|
||||
listCredentialGroupCredentialReferences: mocks.listCredentials,
|
||||
loadCredentialGroupCredentialListContext: mocks.loadGroup,
|
||||
MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE: 100,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
|
||||
loadActiveWorkspaceApplicationContext: mocks.loadWorkspace,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workspace', () => ({
|
||||
permissionSatisfies: (permission: string | null, required: string) =>
|
||||
permission === 'admin' || permission === 'write' || permission === required,
|
||||
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
|
||||
}))
|
||||
|
||||
import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials'
|
||||
import { CredentialGroupCredentialCursorNotFoundError } from '@/lib/credential-groups/credentials'
|
||||
|
||||
const groupContext = {
|
||||
credentialGroupId: 'group-1',
|
||||
workspaceId: 'workspace-1',
|
||||
name: 'Credential Group',
|
||||
status: 'active' as const,
|
||||
options: [
|
||||
{
|
||||
id: 'option-1',
|
||||
provider: 'gmail' as const,
|
||||
label: 'Work Gmail',
|
||||
authorizationAppId: 'google:client-1',
|
||||
requiredScopes: ['gmail.readonly'],
|
||||
scopeVersion: 1,
|
||||
required: true,
|
||||
status: 'active' as const,
|
||||
},
|
||||
{
|
||||
id: 'option-disabled',
|
||||
provider: 'gmail' as const,
|
||||
label: 'Old Gmail',
|
||||
authorizationAppId: 'google:client-1',
|
||||
requiredScopes: ['gmail.readonly'],
|
||||
scopeVersion: 1,
|
||||
required: false,
|
||||
status: 'disabled' as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
const workspaceContext = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'billing-owner-1',
|
||||
}
|
||||
const input = { credentialGroupId: 'group-1', limit: 50 }
|
||||
|
||||
function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal {
|
||||
return {
|
||||
kind: 'delegated',
|
||||
serviceId: 'executor',
|
||||
subjectUserId: 'user-1',
|
||||
workspaceId: 'workspace-1',
|
||||
delegationId: 'delegation-1',
|
||||
audience: 'sim:credential-groups',
|
||||
issuedAt: new Date(Date.now() - 1_000),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
resourceScope: { credentialGroupId },
|
||||
delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('listCredentialGroupCredentials', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.loadGroup.mockResolvedValue(groupContext)
|
||||
mocks.loadWorkspace.mockResolvedValue(workspaceContext)
|
||||
mocks.resolvePermission.mockResolvedValue('read')
|
||||
mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true })
|
||||
mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ available: true })
|
||||
mocks.listCredentials.mockResolvedValue({
|
||||
credentials: [
|
||||
{
|
||||
credentialId: 'credential-1',
|
||||
email: 'person@example.com',
|
||||
displayName: 'person@example.com',
|
||||
providerId: 'google-email',
|
||||
providerSubjectId: 'google-subject-1',
|
||||
providerTenantId: null,
|
||||
},
|
||||
],
|
||||
nextCursor: 'credential-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported principals before loading the group', async () => {
|
||||
const principal: SessionPrincipal = {
|
||||
kind: 'session',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
}
|
||||
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({ principal, input })
|
||||
).rejects.toMatchObject({ code: 'forbidden' })
|
||||
expect(mocks.loadGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects executor delegation scoped to another group', async () => {
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({
|
||||
principal: executorPrincipal('group-2'),
|
||||
input,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'forbidden' })
|
||||
expect(mocks.listCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a bounded page after current workspace and entitlement checks', async () => {
|
||||
const result = await listCredentialGroupCredentials.execute({
|
||||
principal: executorPrincipal(),
|
||||
input,
|
||||
})
|
||||
|
||||
expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, {
|
||||
forUpdate: undefined,
|
||||
})
|
||||
expect(mocks.listCredentials).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
limit: 50,
|
||||
cursor: undefined,
|
||||
email: undefined,
|
||||
credentialProviderIds: undefined,
|
||||
credentialGroupOptionIds: ['option-1'],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
credentials: [
|
||||
{
|
||||
credentialId: 'credential-1',
|
||||
email: 'person@example.com',
|
||||
displayName: 'person@example.com',
|
||||
providerId: 'google-email',
|
||||
providerSubjectId: 'google-subject-1',
|
||||
providerTenantId: null,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
hasMore: true,
|
||||
nextCursor: 'credential-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('filters by canonical providers active in the group', async () => {
|
||||
await listCredentialGroupCredentials.execute({
|
||||
principal: executorPrincipal(),
|
||||
input: { ...input, credentialProviderIds: ['google-email', 'google-email'] },
|
||||
})
|
||||
|
||||
expect(mocks.listCredentials).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ credentialProviderIds: ['google-email'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes an exact enrollment email filter', async () => {
|
||||
await listCredentialGroupCredentials.execute({
|
||||
principal: executorPrincipal(),
|
||||
input: { ...input, email: ' Person@Example.COM ' },
|
||||
})
|
||||
|
||||
expect(mocks.listCredentials).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'person@example.com' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects providers that are not active in the group before credential access', async () => {
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({
|
||||
principal: executorPrincipal(),
|
||||
input: { ...input, credentialProviderIds: ['slack'] },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'validation' })
|
||||
expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
|
||||
expect(mocks.listCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails before listing when the group is disabled', async () => {
|
||||
mocks.loadGroup.mockResolvedValue({ ...groupContext, status: 'disabled' })
|
||||
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input })
|
||||
).rejects.toMatchObject({ code: 'conflict' })
|
||||
expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
|
||||
expect(mocks.listCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails before listing when Credential Groups are unavailable', async () => {
|
||||
mocks.resolveCredentialGroupsAvailability.mockResolvedValue({
|
||||
available: false,
|
||||
reason: 'feature_disabled',
|
||||
})
|
||||
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input })
|
||||
).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
message: 'Credential Groups are not available',
|
||||
})
|
||||
expect(mocks.listCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identifies the Enterprise requirement for unavailable hosted workspaces', async () => {
|
||||
mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: false })
|
||||
mocks.resolveCredentialGroupsAvailability.mockResolvedValue({
|
||||
available: false,
|
||||
reason: 'enterprise_plan_required',
|
||||
})
|
||||
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input })
|
||||
).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
message: 'Credential Groups are not available. Enterprise plan required.',
|
||||
})
|
||||
expect(mocks.listCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects limits outside the bounded page size', async () => {
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({
|
||||
principal: executorPrincipal(),
|
||||
input: { ...input, limit: 101 },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'validation' })
|
||||
expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
|
||||
expect(mocks.listCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies a stale or cross-group cursor as invalid input', async () => {
|
||||
mocks.listCredentials.mockRejectedValueOnce(new CredentialGroupCredentialCursorNotFoundError())
|
||||
|
||||
await expect(
|
||||
listCredentialGroupCredentials.execute({
|
||||
principal: executorPrincipal(),
|
||||
input: { ...input, cursor: 'credential-other' },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'validation' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
|
||||
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization'
|
||||
import {
|
||||
requireCredentialGroupsAvailable,
|
||||
resolveCredentialGroupContext,
|
||||
} from '@/lib/credential-groups/application/context'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import {
|
||||
CredentialGroupCredentialCursorNotFoundError,
|
||||
type CredentialGroupCredentialReference,
|
||||
listCredentialGroupCredentialReferences,
|
||||
MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE,
|
||||
} from '@/lib/credential-groups/credentials'
|
||||
import {
|
||||
getCredentialGroupProviderId,
|
||||
isCredentialGroupProvider,
|
||||
} from '@/lib/credential-groups/providers'
|
||||
|
||||
export interface ListCredentialGroupCredentialsInput {
|
||||
credentialGroupId: string
|
||||
limit: number
|
||||
cursor?: string
|
||||
email?: string
|
||||
credentialProviderIds?: string[]
|
||||
}
|
||||
|
||||
export interface ListCredentialGroupCredentialsResult {
|
||||
credentials: CredentialGroupCredentialReference[]
|
||||
count: number
|
||||
hasMore: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.listCredentials,
|
||||
resolveContext: ({ input }: { input: ListCredentialGroupCredentialsInput }) =>
|
||||
resolveCredentialGroupContext(input.credentialGroupId),
|
||||
authorizationOptions: { delegation: credentialGroupDelegationPolicy },
|
||||
execute: async ({ input, context }): Promise<ListCredentialGroupCredentialsResult> => {
|
||||
if (
|
||||
!Number.isInteger(input.limit) ||
|
||||
input.limit < 1 ||
|
||||
input.limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
`Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}`
|
||||
)
|
||||
}
|
||||
if (context.status !== 'active') {
|
||||
throw new OrchestrationError('conflict', 'Credential group is disabled')
|
||||
}
|
||||
|
||||
const email = input.email ? normalizeEmail(input.email) : undefined
|
||||
if (email && !isValidEmailSyntax(email)) {
|
||||
throw new OrchestrationError('validation', 'Email must be a valid address')
|
||||
}
|
||||
|
||||
const credentialProviderIds = [...new Set(input.credentialProviderIds ?? [])]
|
||||
if (credentialProviderIds.some((providerId) => !providerId.trim())) {
|
||||
throw new OrchestrationError('validation', 'Credential provider IDs must not be empty')
|
||||
}
|
||||
const activeOptions = context.options.filter((option) => option.status === 'active')
|
||||
const activeProviderIds = new Set(
|
||||
activeOptions.map((option) => {
|
||||
if (!isCredentialGroupProvider(option.provider)) {
|
||||
throw new Error(`Credential Group provider is not registered: ${option.provider}`)
|
||||
}
|
||||
return getCredentialGroupProviderId(option.provider)
|
||||
})
|
||||
)
|
||||
const invalidProviderIds = credentialProviderIds.filter(
|
||||
(providerId) => !activeProviderIds.has(providerId)
|
||||
)
|
||||
if (invalidProviderIds.length > 0) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
`Credential providers are not active in this group: ${invalidProviderIds.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
await requireCredentialGroupsAvailable(context.workspaceId)
|
||||
|
||||
let page
|
||||
try {
|
||||
page = await listCredentialGroupCredentialReferences({
|
||||
workspaceId: context.workspaceId,
|
||||
credentialGroupId: context.credentialGroupId,
|
||||
credentialGroupOptionIds: activeOptions.map((option) => option.id),
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
email,
|
||||
credentialProviderIds: credentialProviderIds.length > 0 ? credentialProviderIds : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialGroupCredentialCursorNotFoundError) {
|
||||
throw new OrchestrationError('validation', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
credentials: page.credentials,
|
||||
count: page.credentials.length,
|
||||
hasMore: page.nextCursor !== null,
|
||||
nextCursor: page.nextCursor,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { credentialGroupWorkspaceDelegationPolicy } from '@/lib/credential-groups/application/authorization'
|
||||
import {
|
||||
requireCredentialGroupsAvailable,
|
||||
resolveCredentialGroupWorkspaceContext,
|
||||
} from '@/lib/credential-groups/application/context'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import {
|
||||
CredentialGroupCursorNotFoundError,
|
||||
type CredentialGroupSummary,
|
||||
listCredentialGroupSummaries,
|
||||
MAX_CREDENTIAL_GROUP_PAGE_SIZE,
|
||||
} from '@/lib/credential-groups/groups'
|
||||
|
||||
export interface ListCredentialGroupsInput {
|
||||
workspaceId: string
|
||||
limit: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface ListCredentialGroupsResult {
|
||||
credentialGroups: CredentialGroupSummary[]
|
||||
count: number
|
||||
hasMore: boolean
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export const listCredentialGroupsForWorkflow = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.listGroups,
|
||||
resolveContext: ({ input }: { input: ListCredentialGroupsInput }) =>
|
||||
resolveCredentialGroupWorkspaceContext(input.workspaceId),
|
||||
authorizationOptions: { delegation: credentialGroupWorkspaceDelegationPolicy },
|
||||
execute: async ({ input, context }): Promise<ListCredentialGroupsResult> => {
|
||||
if (
|
||||
!Number.isInteger(input.limit) ||
|
||||
input.limit < 1 ||
|
||||
input.limit > MAX_CREDENTIAL_GROUP_PAGE_SIZE
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
`Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_PAGE_SIZE}`
|
||||
)
|
||||
}
|
||||
await requireCredentialGroupsAvailable(context.workspaceId)
|
||||
|
||||
let page
|
||||
try {
|
||||
page = await listCredentialGroupSummaries({
|
||||
workspaceId: context.workspaceId,
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialGroupCursorNotFoundError) {
|
||||
throw new OrchestrationError('validation', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
credentialGroups: page.credentialGroups,
|
||||
count: page.credentialGroups.length,
|
||||
hasMore: page.nextCursor !== null,
|
||||
nextCursor: page.nextCursor,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
|
||||
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization'
|
||||
import {
|
||||
requireCredentialGroupsAvailable,
|
||||
resolveCredentialGroupContext,
|
||||
} from '@/lib/credential-groups/application/context'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import {
|
||||
CredentialGroupEnrollmentError,
|
||||
type CredentialGroupEnrollmentStatus,
|
||||
listCredentialGroupEnrollments,
|
||||
} from '@/lib/credential-groups/enrollments'
|
||||
|
||||
export const CREDENTIAL_GROUP_PEOPLE_STATUSES = [
|
||||
'invited',
|
||||
'delivery_failed',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'revoked',
|
||||
] as const satisfies readonly CredentialGroupEnrollmentStatus[]
|
||||
|
||||
export interface ListCredentialGroupPeopleInput {
|
||||
credentialGroupId: string
|
||||
limit: number
|
||||
cursor?: string
|
||||
email?: string
|
||||
statuses?: CredentialGroupEnrollmentStatus[]
|
||||
}
|
||||
|
||||
export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.listPeople,
|
||||
resolveContext: ({ input }: { input: ListCredentialGroupPeopleInput }) =>
|
||||
resolveCredentialGroupContext(input.credentialGroupId),
|
||||
authorizationOptions: { delegation: credentialGroupDelegationPolicy },
|
||||
execute: async ({ input, context }) => {
|
||||
if (context.status !== 'active') {
|
||||
throw new OrchestrationError('conflict', 'Credential group is disabled')
|
||||
}
|
||||
if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) {
|
||||
throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100')
|
||||
}
|
||||
const email = input.email ? normalizeEmail(input.email) : undefined
|
||||
if (email && !isValidEmailSyntax(email)) {
|
||||
throw new OrchestrationError('validation', 'Email must be a valid address')
|
||||
}
|
||||
const statuses = [...new Set(input.statuses ?? [])]
|
||||
const allowedStatuses = new Set<string>(CREDENTIAL_GROUP_PEOPLE_STATUSES)
|
||||
if (statuses.some((status) => !allowedStatuses.has(status))) {
|
||||
throw new OrchestrationError('validation', 'People status filter is invalid')
|
||||
}
|
||||
await requireCredentialGroupsAvailable(context.workspaceId)
|
||||
|
||||
try {
|
||||
const page = await listCredentialGroupEnrollments(
|
||||
context.workspaceId,
|
||||
context.credentialGroupId,
|
||||
input.limit,
|
||||
input.cursor,
|
||||
{ email, statuses: statuses.length > 0 ? statuses : undefined }
|
||||
)
|
||||
return {
|
||||
people: page.enrollments,
|
||||
count: page.enrollments.length,
|
||||
hasMore: page.nextCursor !== null,
|
||||
nextCursor: page.nextCursor,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialGroupEnrollmentError) {
|
||||
throw new OrchestrationError(
|
||||
error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal',
|
||||
error.message
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { SessionPrincipal } from '@sim/auth/principal'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invite: vi.fn(),
|
||||
loadInviter: vi.fn(),
|
||||
requireAvailable: vi.fn(),
|
||||
resolveGroup: vi.fn(),
|
||||
resolvePermission: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/context', () => ({
|
||||
requireCredentialGroupSettingsAvailable: mocks.requireAvailable,
|
||||
resolveCredentialGroupSettingsContext: mocks.resolveGroup,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/enrollments', () => ({
|
||||
CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: 404 | 409 | 502
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
},
|
||||
inviteCredentialGroupEnrollments: mocks.invite,
|
||||
loadCredentialGroupInviterIdentity: mocks.loadInviter,
|
||||
resendCredentialGroupEnrollment: vi.fn(),
|
||||
revokeCredentialGroupEnrollment: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workspace', () => ({
|
||||
permissionSatisfies: (permission: string | null, required: string) =>
|
||||
permission === 'admin' || permission === required,
|
||||
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
|
||||
}))
|
||||
|
||||
import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments'
|
||||
|
||||
const context = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'billing-owner-1',
|
||||
credentialGroupId: 'group-1',
|
||||
name: 'Support',
|
||||
status: 'active' as const,
|
||||
options: [],
|
||||
}
|
||||
const principal: SessionPrincipal = {
|
||||
kind: 'session',
|
||||
userId: 'admin-1',
|
||||
sessionId: 'session-1',
|
||||
}
|
||||
const input = {
|
||||
assertedWorkspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
emails: [' Person@Example.com ', 'person@example.com'],
|
||||
}
|
||||
|
||||
describe('Credential Group enrollment Settings operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.resolveGroup.mockResolvedValue(context)
|
||||
mocks.resolvePermission.mockResolvedValue('admin')
|
||||
mocks.requireAvailable.mockResolvedValue(undefined)
|
||||
mocks.loadInviter.mockResolvedValue({ name: 'Admin', email: 'admin@example.com' })
|
||||
mocks.invite.mockResolvedValue({ results: [], sentCount: 0, failedCount: 0 })
|
||||
})
|
||||
|
||||
it('requires current workspace-admin permission before delivery', async () => {
|
||||
mocks.resolvePermission.mockResolvedValue('write')
|
||||
|
||||
await expect(
|
||||
inviteCredentialGroupEnrollmentsSettings.execute({ principal, input })
|
||||
).rejects.toMatchObject({ code: 'forbidden' })
|
||||
expect(mocks.invite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('derives the inviter and normalizes recipients inside the application command', async () => {
|
||||
await inviteCredentialGroupEnrollmentsSettings.execute({ principal, input })
|
||||
|
||||
expect(mocks.loadInviter).toHaveBeenCalledWith('admin-1')
|
||||
expect(mocks.invite).toHaveBeenCalledWith('workspace-1', 'group-1', 'admin-1', 'Admin', {
|
||||
emails: ['person@example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unbounded batch even outside the HTTP adapter', async () => {
|
||||
await expect(
|
||||
inviteCredentialGroupEnrollmentsSettings.execute({
|
||||
principal,
|
||||
input: {
|
||||
...input,
|
||||
emails: Array.from({ length: 101 }, (_, index) => `person-${index}@example.com`),
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'validation' })
|
||||
expect(mocks.invite).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { AuditAction, AuditResourceType } from '@sim/audit'
|
||||
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import {
|
||||
requireCredentialGroupSettingsAvailable,
|
||||
resolveCredentialGroupSettingsContext,
|
||||
} from '@/lib/credential-groups/application/context'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation'
|
||||
import {
|
||||
CredentialGroupEnrollmentError,
|
||||
inviteCredentialGroupEnrollments,
|
||||
loadCredentialGroupInviterIdentity,
|
||||
resendCredentialGroupEnrollment,
|
||||
revokeCredentialGroupEnrollment,
|
||||
} from '@/lib/credential-groups/enrollments'
|
||||
|
||||
interface CredentialGroupEnrollmentSettingsInput {
|
||||
assertedWorkspaceId: string
|
||||
credentialGroupId: string
|
||||
}
|
||||
|
||||
function normalizeEnrollmentError(error: unknown): never {
|
||||
if (error instanceof CredentialGroupEnrollmentError) {
|
||||
if (error.status === 404) throw new OrchestrationError('not_found', error.message)
|
||||
if (error.status === 409) throw new OrchestrationError('conflict', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
async function requireInviterIdentity(userId: string): Promise<string> {
|
||||
const inviter = await loadCredentialGroupInviterIdentity(userId)
|
||||
const inviterName = inviter?.name?.trim() || inviter?.email
|
||||
if (!inviterName) {
|
||||
throw new OrchestrationError('conflict', 'Inviting user has no display identity')
|
||||
}
|
||||
return inviterName
|
||||
}
|
||||
|
||||
export interface InviteCredentialGroupEnrollmentsSettingsInput
|
||||
extends CredentialGroupEnrollmentSettingsInput {
|
||||
emails: string[]
|
||||
}
|
||||
|
||||
export const inviteCredentialGroupEnrollmentsSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.inviteBatch,
|
||||
resolveContext: ({ input }: { input: InviteCredentialGroupEnrollmentsSettingsInput }) =>
|
||||
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ principal, input, context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
const inviterName = await requireInviterIdentity(principal.userId)
|
||||
const emails = validateCredentialGroupInvitationEmails(input.emails)
|
||||
try {
|
||||
return await inviteCredentialGroupEnrollments(
|
||||
context.workspaceId,
|
||||
context.credentialGroupId,
|
||||
principal.userId,
|
||||
inviterName,
|
||||
{ emails }
|
||||
)
|
||||
} catch (error) {
|
||||
normalizeEnrollmentError(error)
|
||||
}
|
||||
},
|
||||
projectAudit: ({ context, result }) => ({
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: context.credentialGroupId,
|
||||
resourceName: context.name,
|
||||
description: `Sent ${result.sentCount} Credential Group invitation${result.sentCount === 1 ? '' : 's'}`,
|
||||
metadata: { sentCount: result.sentCount, failedCount: result.failedCount },
|
||||
}),
|
||||
})
|
||||
|
||||
export interface ResendCredentialGroupEnrollmentSettingsInput
|
||||
extends CredentialGroupEnrollmentSettingsInput {
|
||||
enrollmentId: string
|
||||
}
|
||||
|
||||
export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.resendEnrollment,
|
||||
resolveContext: ({ input }: { input: ResendCredentialGroupEnrollmentSettingsInput }) =>
|
||||
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ principal, input, context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
const inviterName = await requireInviterIdentity(principal.userId)
|
||||
try {
|
||||
const credentialGroupEnrollment = await resendCredentialGroupEnrollment(
|
||||
context.workspaceId,
|
||||
context.credentialGroupId,
|
||||
input.enrollmentId,
|
||||
principal.userId,
|
||||
inviterName
|
||||
)
|
||||
return { credentialGroupEnrollment }
|
||||
} catch (error) {
|
||||
normalizeEnrollmentError(error)
|
||||
}
|
||||
},
|
||||
projectAudit: ({ context, result }) => ({
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: context.credentialGroupId,
|
||||
resourceName: context.name,
|
||||
description: `Resent a Credential Group invitation to ${result.credentialGroupEnrollment.email}`,
|
||||
metadata: { enrollmentId: result.credentialGroupEnrollment.id },
|
||||
}),
|
||||
})
|
||||
|
||||
export interface RevokeCredentialGroupEnrollmentSettingsInput
|
||||
extends CredentialGroupEnrollmentSettingsInput {
|
||||
enrollmentId: string
|
||||
}
|
||||
|
||||
export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.revokeEnrollment,
|
||||
resolveContext: ({ input }: { input: RevokeCredentialGroupEnrollmentSettingsInput }) =>
|
||||
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ input, context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
try {
|
||||
const credentialGroupEnrollment = await revokeCredentialGroupEnrollment(
|
||||
context.workspaceId,
|
||||
context.credentialGroupId,
|
||||
input.enrollmentId
|
||||
)
|
||||
return { credentialGroupEnrollment }
|
||||
} catch (error) {
|
||||
normalizeEnrollmentError(error)
|
||||
}
|
||||
},
|
||||
projectAudit: ({ context, result }) => ({
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: context.credentialGroupId,
|
||||
resourceName: context.name,
|
||||
description: `Revoked Credential Group access for ${result.credentialGroupEnrollment.email}`,
|
||||
metadata: { enrollmentId: result.credentialGroupEnrollment.id },
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
requireAvailable: vi.fn(),
|
||||
resolveGroup: vi.fn(),
|
||||
resolvePermission: vi.fn(),
|
||||
resolveWorkspace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/application/context', () => ({
|
||||
requireCredentialGroupSettingsAvailable: mocks.requireAvailable,
|
||||
resolveCredentialGroupSettingsContext: mocks.resolveGroup,
|
||||
resolveCredentialGroupWorkspaceContext: mocks.resolveWorkspace,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/service', () => ({
|
||||
createCredentialGroup: mocks.create,
|
||||
deleteCredentialGroup: vi.fn(),
|
||||
getCredentialGroup: vi.fn(),
|
||||
listCredentialGroups: mocks.list,
|
||||
updateCredentialGroup: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workspace', () => ({
|
||||
permissionSatisfies: (permission: string | null, required: string) =>
|
||||
permission === 'admin' || permission === required,
|
||||
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
|
||||
}))
|
||||
|
||||
import {
|
||||
createCredentialGroupSettings,
|
||||
listCredentialGroupSettings,
|
||||
} from '@/lib/credential-groups/application/manage-groups'
|
||||
|
||||
const workspaceContext = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceOrganizationId: null,
|
||||
allowPersonalApiKeys: true,
|
||||
billedAccountUserId: 'billing-owner-1',
|
||||
}
|
||||
const sessionPrincipal: SessionPrincipal = {
|
||||
kind: 'session',
|
||||
userId: 'admin-1',
|
||||
sessionId: 'session-1',
|
||||
}
|
||||
const enrollmentPrincipal: CredentialGroupEnrollmentPrincipal = {
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'person@example.com',
|
||||
invitationTokenHash: 'hash-1',
|
||||
}
|
||||
|
||||
describe('Credential Group Settings application operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.resolveWorkspace.mockResolvedValue(workspaceContext)
|
||||
mocks.resolvePermission.mockResolvedValue('admin')
|
||||
mocks.requireAvailable.mockResolvedValue(undefined)
|
||||
mocks.list.mockResolvedValue([])
|
||||
mocks.create.mockResolvedValue({ id: 'group-1', name: 'Support' })
|
||||
})
|
||||
|
||||
it('rejects an enrollment bearer before loading workspace settings', async () => {
|
||||
await expect(
|
||||
listCredentialGroupSettings.execute({
|
||||
principal: enrollmentPrincipal,
|
||||
input: { workspaceId: 'workspace-1' },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'forbidden' })
|
||||
expect(mocks.resolveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires current workspace-admin permission before listing', async () => {
|
||||
mocks.resolvePermission.mockResolvedValue('read')
|
||||
|
||||
await expect(
|
||||
listCredentialGroupSettings.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: { workspaceId: 'workspace-1' },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'forbidden' })
|
||||
expect(mocks.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists settings only after authorization and entitlement checks', async () => {
|
||||
const result = await listCredentialGroupSettings.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: { workspaceId: 'workspace-1' },
|
||||
})
|
||||
|
||||
expect(mocks.requireAvailable).toHaveBeenCalledWith('workspace-1')
|
||||
expect(mocks.list).toHaveBeenCalledWith('workspace-1')
|
||||
expect(result).toEqual({ credentialGroups: [] })
|
||||
})
|
||||
|
||||
it('derives created-by identity from the authenticated session principal', async () => {
|
||||
await createCredentialGroupSettings.execute({
|
||||
principal: sessionPrincipal,
|
||||
input: {
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroup: { name: 'Support', options: [] },
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith('workspace-1', 'admin-1', {
|
||||
name: 'Support',
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { AuditAction, AuditResourceType } from '@sim/audit'
|
||||
import { getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import {
|
||||
requireCredentialGroupSettingsAvailable,
|
||||
resolveCredentialGroupSettingsContext,
|
||||
resolveCredentialGroupWorkspaceContext,
|
||||
} from '@/lib/credential-groups/application/context'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import {
|
||||
validateCreateCredentialGroupInput,
|
||||
validateCredentialGroupEnrollmentPage,
|
||||
validateUpdateCredentialGroupInput,
|
||||
} from '@/lib/credential-groups/application/validation'
|
||||
import {
|
||||
CredentialGroupEnrollmentError,
|
||||
listCredentialGroupEnrollments,
|
||||
} from '@/lib/credential-groups/enrollments'
|
||||
import {
|
||||
createCredentialGroup,
|
||||
deleteCredentialGroup,
|
||||
getCredentialGroup,
|
||||
listCredentialGroups,
|
||||
updateCredentialGroup,
|
||||
} from '@/lib/credential-groups/service'
|
||||
import type {
|
||||
CreateCredentialGroupInput,
|
||||
UpdateCredentialGroupInput,
|
||||
} from '@/lib/credential-groups/types'
|
||||
|
||||
function throwCredentialGroupConflict(error: unknown): never {
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
throw new OrchestrationError('conflict', 'A credential group with this name already exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
export interface ListCredentialGroupSettingsInput {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export const listCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.listSettings,
|
||||
resolveContext: ({ input }: { input: ListCredentialGroupSettingsInput }) =>
|
||||
resolveCredentialGroupWorkspaceContext(input.workspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
return { credentialGroups: await listCredentialGroups(context.workspaceId) }
|
||||
},
|
||||
})
|
||||
|
||||
export interface CreateCredentialGroupSettingsInput {
|
||||
workspaceId: string
|
||||
credentialGroup: CreateCredentialGroupInput
|
||||
}
|
||||
|
||||
export const createCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.create,
|
||||
resolveContext: ({ input }: { input: CreateCredentialGroupSettingsInput }) =>
|
||||
resolveCredentialGroupWorkspaceContext(input.workspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ principal, input, context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
try {
|
||||
const credentialGroup = await createCredentialGroup(
|
||||
context.workspaceId,
|
||||
principal.userId,
|
||||
validateCreateCredentialGroupInput(input.credentialGroup)
|
||||
)
|
||||
return { credentialGroup }
|
||||
} catch (error) {
|
||||
throwCredentialGroupConflict(error)
|
||||
}
|
||||
},
|
||||
projectAudit: ({ result }) => ({
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: result.credentialGroup.id,
|
||||
resourceName: result.credentialGroup.name,
|
||||
description: 'Created a Credential Group',
|
||||
}),
|
||||
})
|
||||
|
||||
interface CredentialGroupSettingsTargetInput {
|
||||
assertedWorkspaceId: string
|
||||
credentialGroupId: string
|
||||
}
|
||||
|
||||
export interface GetCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput {
|
||||
limit: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export const getCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.readSettings,
|
||||
resolveContext: ({ input }: { input: GetCredentialGroupSettingsInput }) =>
|
||||
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ input, context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
validateCredentialGroupEnrollmentPage(input.limit)
|
||||
const credentialGroup = await getCredentialGroup(context.workspaceId, context.credentialGroupId)
|
||||
if (!credentialGroup) throw new OrchestrationError('not_found', 'Credential group not found')
|
||||
try {
|
||||
const enrollmentPage = await listCredentialGroupEnrollments(
|
||||
context.workspaceId,
|
||||
context.credentialGroupId,
|
||||
input.limit,
|
||||
input.cursor
|
||||
)
|
||||
return { credentialGroup, ...enrollmentPage }
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialGroupEnrollmentError && error.status === 404) {
|
||||
throw new OrchestrationError('not_found', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export interface UpdateCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput {
|
||||
update: UpdateCredentialGroupInput
|
||||
}
|
||||
|
||||
export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.update,
|
||||
resolveContext: ({ input }: { input: UpdateCredentialGroupSettingsInput }) =>
|
||||
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ input, context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
try {
|
||||
const credentialGroup = await updateCredentialGroup(
|
||||
context.workspaceId,
|
||||
context.credentialGroupId,
|
||||
validateUpdateCredentialGroupInput(input.update)
|
||||
)
|
||||
if (!credentialGroup) {
|
||||
throw new OrchestrationError('not_found', 'Credential group not found')
|
||||
}
|
||||
return { credentialGroup }
|
||||
} catch (error) {
|
||||
throwCredentialGroupConflict(error)
|
||||
}
|
||||
},
|
||||
projectAudit: ({ result }) => ({
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: result.credentialGroup.id,
|
||||
resourceName: result.credentialGroup.name,
|
||||
description: 'Updated a Credential Group',
|
||||
}),
|
||||
})
|
||||
|
||||
export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.delete,
|
||||
resolveContext: ({ input }: { input: CredentialGroupSettingsTargetInput }) =>
|
||||
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ context }) {
|
||||
await requireCredentialGroupSettingsAvailable(context.workspaceId)
|
||||
const deleted = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId)
|
||||
if (!deleted) throw new OrchestrationError('not_found', 'Credential group not found')
|
||||
return { success: true as const }
|
||||
},
|
||||
projectAudit: ({ context }) => ({
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: context.credentialGroupId,
|
||||
resourceName: context.name,
|
||||
description: 'Deleted a Credential Group',
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineWorkspaceOperation } from '@/lib/core/application'
|
||||
|
||||
export const credentialGroupOperations = {
|
||||
listSettings: defineWorkspaceOperation({
|
||||
id: 'credential_groups.settings.list',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
create: defineWorkspaceOperation({
|
||||
id: 'credential_groups.create',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
readSettings: defineWorkspaceOperation({
|
||||
id: 'credential_groups.settings.read',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
update: defineWorkspaceOperation({
|
||||
id: 'credential_groups.update',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
delete: defineWorkspaceOperation({
|
||||
id: 'credential_groups.delete',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
inviteBatch: defineWorkspaceOperation({
|
||||
id: 'credential_groups.invites.send_batch',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
resendEnrollment: defineWorkspaceOperation({
|
||||
id: 'credential_groups.enrollments.resend',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
revokeEnrollment: defineWorkspaceOperation({
|
||||
id: 'credential_groups.enrollments.revoke',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
listCredentials: defineWorkspaceOperation({
|
||||
id: 'credential_groups.credentials.list',
|
||||
minimumRole: 'read',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['delegated'],
|
||||
delegatedServices: ['executor'],
|
||||
}),
|
||||
listGroups: defineWorkspaceOperation({
|
||||
id: 'credential_groups.list',
|
||||
minimumRole: 'read',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['delegated'],
|
||||
delegatedServices: ['executor'],
|
||||
}),
|
||||
listPeople: defineWorkspaceOperation({
|
||||
id: 'credential_groups.people.list',
|
||||
minimumRole: 'read',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['delegated'],
|
||||
delegatedServices: ['executor'],
|
||||
}),
|
||||
sendInvite: defineWorkspaceOperation({
|
||||
id: 'credential_groups.invites.send',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['delegated'],
|
||||
delegatedServices: ['executor'],
|
||||
}),
|
||||
startSlackConfiguration: defineWorkspaceOperation({
|
||||
id: 'credential_groups.slack_configuration.start',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
completeSlackConfiguration: defineWorkspaceOperation({
|
||||
id: 'credential_groups.slack_configuration.complete',
|
||||
minimumRole: 'admin',
|
||||
workspaceApiKey: 'deny',
|
||||
principalKinds: ['session'],
|
||||
}),
|
||||
} as const
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
completeEnrollment: vi.fn(),
|
||||
getEnrollment: vi.fn(),
|
||||
getOAuthContext: vi.fn(),
|
||||
startOAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/enrollments', () => ({
|
||||
completeAuthorizedCredentialGroupEnrollment: mocks.completeEnrollment,
|
||||
getAuthorizedCredentialGroupOAuthContext: mocks.getOAuthContext,
|
||||
getAuthorizedPublicCredentialGroupEnrollment: mocks.getEnrollment,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credential-groups/oauth', () => ({
|
||||
completeCredentialGroupOAuth: vi.fn(),
|
||||
startCredentialGroupOAuth: mocks.startOAuth,
|
||||
}))
|
||||
|
||||
import {
|
||||
readPublicCredentialGroupEnrollment,
|
||||
startPublicCredentialGroupOAuth,
|
||||
} from '@/lib/credential-groups/application/public-enrollment'
|
||||
|
||||
const invitationToken = 'invitation-token'
|
||||
const principal: CredentialGroupEnrollmentPrincipal = {
|
||||
kind: 'credential_group_enrollment',
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
enrollmentId: 'enrollment-1',
|
||||
email: 'person@example.com',
|
||||
invitationTokenHash: sha256Hex(invitationToken),
|
||||
}
|
||||
const identity = {
|
||||
workspaceId: principal.workspaceId,
|
||||
credentialGroupId: principal.credentialGroupId,
|
||||
enrollmentId: principal.enrollmentId,
|
||||
email: principal.email,
|
||||
invitationTokenHash: principal.invitationTokenHash,
|
||||
}
|
||||
|
||||
describe('public Credential Group enrollment application operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getEnrollment.mockResolvedValue({ status: 'invited', options: [] })
|
||||
mocks.getOAuthContext.mockResolvedValue({
|
||||
enrollmentId: 'enrollment-1',
|
||||
credentialGroupId: 'group-1',
|
||||
option: { id: 'option-1' },
|
||||
})
|
||||
mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize')
|
||||
})
|
||||
|
||||
it('rejects a workspace session before resolving invitation data', async () => {
|
||||
const session: SessionPrincipal = {
|
||||
kind: 'session',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
}
|
||||
|
||||
await expect(
|
||||
readPublicCredentialGroupEnrollment.execute({ principal: session, input: {} })
|
||||
).rejects.toMatchObject({ code: 'forbidden' })
|
||||
expect(mocks.getEnrollment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates the invitation identity before returning enrollment metadata', async () => {
|
||||
const result = await readPublicCredentialGroupEnrollment.execute({ principal, input: {} })
|
||||
|
||||
expect(mocks.getEnrollment).toHaveBeenCalledWith(identity)
|
||||
expect(result).toEqual({ enrollment: { status: 'invited', options: [] } })
|
||||
})
|
||||
|
||||
it('fails closed when the current invitation no longer resolves', async () => {
|
||||
mocks.getEnrollment.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
readPublicCredentialGroupEnrollment.execute({ principal, input: {} })
|
||||
).rejects.toMatchObject({ code: 'not_found' })
|
||||
})
|
||||
|
||||
it('rejects a substituted bearer before creating provider state', async () => {
|
||||
await expect(
|
||||
startPublicCredentialGroupOAuth.execute({
|
||||
principal,
|
||||
input: { invitationToken: 'different-token', optionId: 'option-1' },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'not_found' })
|
||||
expect(mocks.startOAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts OAuth only for the option bound to the current enrollment principal', async () => {
|
||||
const result = await startPublicCredentialGroupOAuth.execute({
|
||||
principal,
|
||||
input: { invitationToken, optionId: 'option-1' },
|
||||
})
|
||||
|
||||
expect(mocks.getOAuthContext).toHaveBeenCalledWith(identity, 'option-1')
|
||||
expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { CredentialGroupEnrollmentPrincipal, Principal } from '@sim/auth/principal'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import type { OperationUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations'
|
||||
import {
|
||||
completeAuthorizedCredentialGroupEnrollment,
|
||||
getAuthorizedCredentialGroupOAuthContext,
|
||||
getAuthorizedPublicCredentialGroupEnrollment,
|
||||
type PublicCredentialGroupEnrollmentIdentity,
|
||||
} from '@/lib/credential-groups/enrollments'
|
||||
import {
|
||||
completeCredentialGroupOAuth,
|
||||
startCredentialGroupOAuth,
|
||||
} from '@/lib/credential-groups/oauth'
|
||||
import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
|
||||
|
||||
interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition<O, I, C, R> {
|
||||
operation: O
|
||||
resolveContext(args: { principal: CredentialGroupEnrollmentPrincipal; input: I }): Promise<C>
|
||||
execute(args: { principal: CredentialGroupEnrollmentPrincipal; input: I; context: C }): Promise<R>
|
||||
}
|
||||
|
||||
function requireCredentialGroupEnrollmentPrincipal(
|
||||
principal: Principal
|
||||
): asserts principal is CredentialGroupEnrollmentPrincipal {
|
||||
if (principal.kind !== 'credential_group_enrollment') {
|
||||
throw new OrchestrationError(
|
||||
'forbidden',
|
||||
'This operation requires a Credential Group invitation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function requireMatchingContext(
|
||||
principal: CredentialGroupEnrollmentPrincipal,
|
||||
context: PublicCredentialGroupEnrollmentIdentity
|
||||
): void {
|
||||
if (
|
||||
context.workspaceId !== principal.workspaceId ||
|
||||
context.credentialGroupId !== principal.credentialGroupId ||
|
||||
context.enrollmentId !== principal.enrollmentId ||
|
||||
context.email !== principal.email ||
|
||||
!safeCompare(context.invitationTokenHash, principal.invitationTokenHash)
|
||||
) {
|
||||
throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
|
||||
}
|
||||
}
|
||||
|
||||
function defineAuthorizedCredentialGroupEnrollmentUseCase<
|
||||
const O extends
|
||||
(typeof credentialGroupEnrollmentOperations)[keyof typeof credentialGroupEnrollmentOperations],
|
||||
I,
|
||||
C extends PublicCredentialGroupEnrollmentIdentity,
|
||||
R,
|
||||
>(
|
||||
definition: AuthorizedCredentialGroupEnrollmentUseCaseDefinition<O, I, C, R>
|
||||
): OperationUseCase<O, I, R> {
|
||||
async function authorize(principal: Principal, input: I) {
|
||||
requireCredentialGroupEnrollmentPrincipal(principal)
|
||||
const context = await definition.resolveContext({ principal, input })
|
||||
requireMatchingContext(principal, context)
|
||||
return { principal, input, context }
|
||||
}
|
||||
|
||||
return {
|
||||
operation: definition.operation,
|
||||
async authorize({ principal, input }) {
|
||||
await authorize(principal, input)
|
||||
},
|
||||
async execute({ principal, input }) {
|
||||
const authorized = await authorize(principal, input)
|
||||
return definition.execute(authorized)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function identityFromPrincipal(
|
||||
principal: CredentialGroupEnrollmentPrincipal
|
||||
): PublicCredentialGroupEnrollmentIdentity {
|
||||
return {
|
||||
workspaceId: principal.workspaceId,
|
||||
credentialGroupId: principal.credentialGroupId,
|
||||
enrollmentId: principal.enrollmentId,
|
||||
email: principal.email,
|
||||
invitationTokenHash: principal.invitationTokenHash,
|
||||
}
|
||||
}
|
||||
|
||||
function requireInvitationToken(
|
||||
principal: CredentialGroupEnrollmentPrincipal,
|
||||
invitationToken: string
|
||||
): void {
|
||||
if (!safeCompare(sha256Hex(invitationToken), principal.invitationTokenHash)) {
|
||||
throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
|
||||
}
|
||||
}
|
||||
|
||||
interface PublicEnrollmentContext extends PublicCredentialGroupEnrollmentIdentity {
|
||||
enrollment: NonNullable<Awaited<ReturnType<typeof getAuthorizedPublicCredentialGroupEnrollment>>>
|
||||
}
|
||||
|
||||
async function resolvePublicEnrollmentContext(
|
||||
principal: CredentialGroupEnrollmentPrincipal
|
||||
): Promise<PublicEnrollmentContext> {
|
||||
const identity = identityFromPrincipal(principal)
|
||||
const enrollment = await getAuthorizedPublicCredentialGroupEnrollment(identity)
|
||||
if (!enrollment) throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
|
||||
return { ...identity, enrollment }
|
||||
}
|
||||
|
||||
export const readPublicCredentialGroupEnrollment = defineAuthorizedCredentialGroupEnrollmentUseCase(
|
||||
{
|
||||
operation: credentialGroupEnrollmentOperations.read,
|
||||
resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal),
|
||||
async execute({ context }) {
|
||||
return { enrollment: context.enrollment }
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export const completePublicCredentialGroupEnrollment =
|
||||
defineAuthorizedCredentialGroupEnrollmentUseCase({
|
||||
operation: credentialGroupEnrollmentOperations.complete,
|
||||
resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal),
|
||||
async execute({ context }) {
|
||||
const completed = await completeAuthorizedCredentialGroupEnrollment(context)
|
||||
return { completed }
|
||||
},
|
||||
})
|
||||
|
||||
interface PublicCredentialGroupOAuthInput {
|
||||
invitationToken: string
|
||||
optionId: string
|
||||
}
|
||||
|
||||
interface PublicCredentialGroupOAuthContext extends PublicCredentialGroupEnrollmentIdentity {
|
||||
oauth: NonNullable<Awaited<ReturnType<typeof getAuthorizedCredentialGroupOAuthContext>>>
|
||||
}
|
||||
|
||||
async function resolvePublicOAuthContext(
|
||||
principal: CredentialGroupEnrollmentPrincipal,
|
||||
optionId: string
|
||||
): Promise<PublicCredentialGroupOAuthContext> {
|
||||
const identity = identityFromPrincipal(principal)
|
||||
const oauth = await getAuthorizedCredentialGroupOAuthContext(identity, optionId)
|
||||
if (!oauth) throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
|
||||
return { ...identity, oauth }
|
||||
}
|
||||
|
||||
export const startPublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({
|
||||
operation: credentialGroupEnrollmentOperations.startOAuth,
|
||||
resolveContext: ({
|
||||
principal,
|
||||
input,
|
||||
}: {
|
||||
principal: CredentialGroupEnrollmentPrincipal
|
||||
input: PublicCredentialGroupOAuthInput
|
||||
}) => resolvePublicOAuthContext(principal, input.optionId),
|
||||
async execute({ principal, input, context }) {
|
||||
requireInvitationToken(principal, input.invitationToken)
|
||||
return {
|
||||
authorizationUrl: await startCredentialGroupOAuth(context.oauth, input.invitationToken),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
interface CompletePublicCredentialGroupOAuthInput {
|
||||
attempt: CredentialGroupOAuthAttempt
|
||||
code: string
|
||||
}
|
||||
|
||||
export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({
|
||||
operation: credentialGroupEnrollmentOperations.completeOAuth,
|
||||
resolveContext: ({
|
||||
principal,
|
||||
input,
|
||||
}: {
|
||||
principal: CredentialGroupEnrollmentPrincipal
|
||||
input: CompletePublicCredentialGroupOAuthInput
|
||||
}) => resolvePublicOAuthContext(principal, input.attempt.optionId),
|
||||
async execute({ principal, input, context }) {
|
||||
requireInvitationToken(principal, input.attempt.invitationToken)
|
||||
await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code)
|
||||
return { connectedOptionId: context.oauth.option.id }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { AuditAction, AuditResourceType } from '@sim/audit'
|
||||
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
|
||||
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
|
||||
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization'
|
||||
import {
|
||||
requireCredentialGroupsAvailable,
|
||||
resolveCredentialGroupContext,
|
||||
} from '@/lib/credential-groups/application/context'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import {
|
||||
CredentialGroupEnrollmentError,
|
||||
inviteCredentialGroupEnrollment,
|
||||
loadCredentialGroupInviterIdentity,
|
||||
} from '@/lib/credential-groups/enrollments'
|
||||
|
||||
export interface SendCredentialGroupInviteInput {
|
||||
credentialGroupId: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.sendInvite,
|
||||
resolveContext: ({ input }: { input: SendCredentialGroupInviteInput }) =>
|
||||
resolveCredentialGroupContext(input.credentialGroupId),
|
||||
authorizationOptions: { delegation: credentialGroupDelegationPolicy },
|
||||
execute: async ({ principal, input, context }) => {
|
||||
if (context.status !== 'active') {
|
||||
throw new OrchestrationError('conflict', 'Credential group is disabled')
|
||||
}
|
||||
const email = normalizeEmail(input.email)
|
||||
if (!isValidEmailSyntax(email)) {
|
||||
throw new OrchestrationError('validation', 'Email must be a valid address')
|
||||
}
|
||||
await requireCredentialGroupsAvailable(context.workspaceId)
|
||||
|
||||
const userId = requirePrincipalSubjectUserId(principal)
|
||||
const inviter = await loadCredentialGroupInviterIdentity(userId)
|
||||
const inviterName = inviter?.name?.trim() || inviter?.email
|
||||
if (!inviterName) {
|
||||
throw new OrchestrationError('conflict', 'Inviting user has no display identity')
|
||||
}
|
||||
|
||||
try {
|
||||
const enrollment = await inviteCredentialGroupEnrollment(
|
||||
context.workspaceId,
|
||||
context.credentialGroupId,
|
||||
userId,
|
||||
inviterName,
|
||||
email
|
||||
)
|
||||
return { enrollment }
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialGroupEnrollmentError) {
|
||||
throw new OrchestrationError(
|
||||
error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'internal',
|
||||
error.message
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
projectAudit: ({ input, context, result }) => ({
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: context.credentialGroupId,
|
||||
resourceName: context.name,
|
||||
description: `Invited ${result.enrollment.email} to connect accounts`,
|
||||
metadata: { email: normalizeEmail(input.email), enrollmentId: result.enrollment.id },
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { AuditAction, AuditResourceType } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { credentialGroup } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
|
||||
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
|
||||
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
|
||||
import {
|
||||
consumeSlackManagedUsersAttempt,
|
||||
createSlackManagedUsersAttempt,
|
||||
exchangeAndConfigureSlackManagedUsers,
|
||||
loadSlackManagedUsersAttempt,
|
||||
type SlackManagedUsersAttempt,
|
||||
} from '@/lib/credential-groups/slack-managed-users'
|
||||
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
|
||||
|
||||
async function requireCredentialGroups(workspaceId: string): Promise<void> {
|
||||
const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
|
||||
if (!(await isCredentialGroupsAvailable(ownerBilling))) {
|
||||
throw new OrchestrationError('not_found', 'Credential Groups are not available')
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspace(workspaceId: string) {
|
||||
const context = await loadActiveWorkspaceApplicationContext(workspaceId)
|
||||
if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
|
||||
return context
|
||||
}
|
||||
|
||||
async function resolveCredentialGroup(groupId: string, assertedWorkspaceId: string) {
|
||||
const [group] = await db
|
||||
.select({ id: credentialGroup.id, workspaceId: credentialGroup.workspaceId })
|
||||
.from(credentialGroup)
|
||||
.where(eq(credentialGroup.id, groupId))
|
||||
.limit(1)
|
||||
if (!group || group.workspaceId !== assertedWorkspaceId) {
|
||||
throw new OrchestrationError('not_found', 'Credential Group not found')
|
||||
}
|
||||
return { ...(await resolveWorkspace(group.workspaceId)), credentialGroupId: group.id }
|
||||
}
|
||||
|
||||
export interface StartSlackCredentialGroupConfigurationInput {
|
||||
assertedWorkspaceId: string
|
||||
credentialGroupId: string
|
||||
slackBotCredentialId: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
export const startSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.startSlackConfiguration,
|
||||
resolveContext: ({ input }: { input: StartSlackCredentialGroupConfigurationInput }) =>
|
||||
resolveCredentialGroup(input.credentialGroupId, input.assertedWorkspaceId),
|
||||
authorizationOptions: {},
|
||||
async execute({ principal, input, context }) {
|
||||
await requireCredentialGroups(context.workspaceId)
|
||||
return createSlackManagedUsersAttempt({
|
||||
workspaceId: context.workspaceId,
|
||||
userId: principal.userId,
|
||||
credentialGroupId: context.credentialGroupId,
|
||||
slackBotCredentialId: input.slackBotCredentialId,
|
||||
clientId: input.clientId,
|
||||
clientSecret: input.clientSecret,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
interface SlackCredentialGroupConfigurationCallbackInput {
|
||||
state: string
|
||||
code?: string
|
||||
providerError?: string
|
||||
}
|
||||
|
||||
type SlackCredentialGroupConfigurationCallbackContext = Awaited<
|
||||
ReturnType<typeof resolveWorkspace>
|
||||
> & {
|
||||
attempt: SlackManagedUsersAttempt
|
||||
}
|
||||
|
||||
export const completeSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({
|
||||
operation: credentialGroupOperations.completeSlackConfiguration,
|
||||
resolveContext: async ({
|
||||
principal,
|
||||
input,
|
||||
}: {
|
||||
principal: { kind: 'session'; userId: string; sessionId: string }
|
||||
input: SlackCredentialGroupConfigurationCallbackInput
|
||||
}): Promise<SlackCredentialGroupConfigurationCallbackContext> => {
|
||||
const attempt = await loadSlackManagedUsersAttempt(input.state)
|
||||
if (!attempt) {
|
||||
throw new OrchestrationError('validation', 'Authorization state is invalid or expired')
|
||||
}
|
||||
if (attempt.userId !== principal.userId) {
|
||||
throw new OrchestrationError(
|
||||
'forbidden',
|
||||
'Authorization must be completed by the user who started it'
|
||||
)
|
||||
}
|
||||
return { ...(await resolveWorkspace(attempt.workspaceId)), attempt }
|
||||
},
|
||||
authorizationOptions: {},
|
||||
async execute({ input, context }) {
|
||||
await requireCredentialGroups(context.workspaceId)
|
||||
const attempt = await consumeSlackManagedUsersAttempt(input.state)
|
||||
if (
|
||||
!attempt ||
|
||||
attempt.workspaceId !== context.attempt.workspaceId ||
|
||||
attempt.userId !== context.attempt.userId ||
|
||||
attempt.credentialGroupId !== context.attempt.credentialGroupId ||
|
||||
attempt.slackBotCredentialId !== context.attempt.slackBotCredentialId ||
|
||||
attempt.clientId !== context.attempt.clientId ||
|
||||
attempt.createdAt !== context.attempt.createdAt
|
||||
) {
|
||||
throw new OrchestrationError('validation', 'Authorization state is invalid or expired')
|
||||
}
|
||||
if (input.providerError) return { ok: false as const, reason: 'provider_error' as const }
|
||||
if (!input.code) throw new OrchestrationError('validation', 'Authorization code is missing')
|
||||
const result = await exchangeAndConfigureSlackManagedUsers({ attempt, code: input.code })
|
||||
return { ok: true as const, reason: 'authorized' as const, result }
|
||||
},
|
||||
projectAudit: ({ result }) =>
|
||||
result.ok
|
||||
? {
|
||||
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.CREDENTIAL_GROUP,
|
||||
resourceId: result.result.credentialGroupId,
|
||||
resourceName: result.result.credentialGroupName,
|
||||
description: 'Configured Slack for a Credential Group',
|
||||
metadata: {
|
||||
slackBotCredentialId: result.result.slackBotCredentialId,
|
||||
slackAppId: result.result.appId,
|
||||
slackTeamId: result.result.teamId,
|
||||
},
|
||||
}
|
||||
: [],
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
|
||||
import { OrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import {
|
||||
CREDENTIAL_GROUP_PROVIDER_IDS,
|
||||
isCredentialGroupProvider,
|
||||
} from '@/lib/credential-groups/providers'
|
||||
import type {
|
||||
CreateCredentialGroupInput,
|
||||
CredentialGroupOptionInput,
|
||||
CredentialGroupOptionUpdateInput,
|
||||
UpdateCredentialGroupInput,
|
||||
} from '@/lib/credential-groups/types'
|
||||
|
||||
function validateOption(
|
||||
option: CredentialGroupOptionInput | CredentialGroupOptionUpdateInput,
|
||||
index: number
|
||||
): void {
|
||||
if (!isCredentialGroupProvider(option.provider)) {
|
||||
throw new OrchestrationError('validation', `Credential option ${index + 1} is unsupported`)
|
||||
}
|
||||
if (!option.label.trim() || option.label.trim().length > 100) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
`Credential option ${index + 1} requires a label of at most 100 characters`
|
||||
)
|
||||
}
|
||||
if (option.provider === 'slack' && !option.slackBotCredentialId.trim()) {
|
||||
throw new OrchestrationError('validation', 'Select a custom Slack bot')
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptions(
|
||||
options: Array<CredentialGroupOptionInput | CredentialGroupOptionUpdateInput>
|
||||
): void {
|
||||
if (options.length > CREDENTIAL_GROUP_PROVIDER_IDS.length) {
|
||||
throw new OrchestrationError('validation', 'Too many credential options')
|
||||
}
|
||||
const labels = new Set<string>()
|
||||
const providers = new Set<string>()
|
||||
const ids = new Set<string>()
|
||||
options.forEach((option, index) => {
|
||||
validateOption(option, index)
|
||||
const label = option.label.trim().toLocaleLowerCase()
|
||||
if (labels.has(label)) {
|
||||
throw new OrchestrationError('validation', 'Credential option labels must be unique')
|
||||
}
|
||||
if (providers.has(option.provider)) {
|
||||
throw new OrchestrationError('validation', 'Each provider can only be added once')
|
||||
}
|
||||
if ('id' in option && option.id) {
|
||||
if (ids.has(option.id)) {
|
||||
throw new OrchestrationError('validation', 'Credential option IDs must be unique')
|
||||
}
|
||||
ids.add(option.id)
|
||||
}
|
||||
labels.add(label)
|
||||
providers.add(option.provider)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeOption<T extends CredentialGroupOptionInput | CredentialGroupOptionUpdateInput>(
|
||||
option: T
|
||||
): T {
|
||||
return { ...option, label: option.label.trim() }
|
||||
}
|
||||
|
||||
export function validateCreateCredentialGroupInput(
|
||||
input: CreateCredentialGroupInput
|
||||
): CreateCredentialGroupInput {
|
||||
const name = input.name.trim()
|
||||
if (!name || name.length > 100) {
|
||||
throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters')
|
||||
}
|
||||
const description = input.description?.trim()
|
||||
if (description && description.length > 500) {
|
||||
throw new OrchestrationError('validation', 'Description must be at most 500 characters')
|
||||
}
|
||||
validateOptions(input.options)
|
||||
if (input.options.some((option) => option.provider === 'slack')) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
'Create the Credential Group before configuring Slack'
|
||||
)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
...(description ? { description } : {}),
|
||||
options: input.options.map(normalizeOption),
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUpdateCredentialGroupInput(
|
||||
input: UpdateCredentialGroupInput
|
||||
): UpdateCredentialGroupInput {
|
||||
if (Object.keys(input).length === 0) {
|
||||
throw new OrchestrationError('validation', 'At least one field must be updated')
|
||||
}
|
||||
const name = input.name?.trim()
|
||||
if (input.name !== undefined && (!name || name.length > 100)) {
|
||||
throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters')
|
||||
}
|
||||
const description = input.description?.trim()
|
||||
if (description && description.length > 500) {
|
||||
throw new OrchestrationError('validation', 'Description must be at most 500 characters')
|
||||
}
|
||||
if (input.options) validateOptions(input.options)
|
||||
return {
|
||||
...(name ? { name } : {}),
|
||||
...(input.description !== undefined ? { description: description || null } : {}),
|
||||
...(input.options ? { options: input.options.map(normalizeOption) } : {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCredentialGroupEnrollmentPage(limit: number): void {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
||||
throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100')
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCredentialGroupInvitationEmails(emails: string[]): string[] {
|
||||
if (emails.length < 1 || emails.length > 100) {
|
||||
throw new OrchestrationError('validation', 'Invite between 1 and 100 people at once')
|
||||
}
|
||||
const normalized = [...new Set(emails.map(normalizeEmail))]
|
||||
if (normalized.some((email) => !isValidEmailSyntax(email))) {
|
||||
throw new OrchestrationError('validation', 'Every invitation email must be valid')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockIsFeatureEnabled } = vi.hoisted(() => ({
|
||||
mockIsFeatureEnabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
isHosted: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isFeatureEnabled: mockIsFeatureEnabled,
|
||||
}))
|
||||
|
||||
import { resolveCredentialGroupsAvailability } from '@/lib/credential-groups/availability'
|
||||
|
||||
describe('resolveCredentialGroupsAvailability', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('attributes a disabled feature flag before considering the plan', async () => {
|
||||
mockIsFeatureEnabled.mockResolvedValue(false)
|
||||
|
||||
await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({
|
||||
available: false,
|
||||
reason: 'feature_disabled',
|
||||
})
|
||||
})
|
||||
|
||||
it('requires Enterprise when the hosted feature is enabled', async () => {
|
||||
mockIsFeatureEnabled.mockResolvedValue(true)
|
||||
|
||||
await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({
|
||||
available: false,
|
||||
reason: 'enterprise_plan_required',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows Enterprise workspaces when the hosted feature is enabled', async () => {
|
||||
mockIsFeatureEnabled.mockResolvedValue(true)
|
||||
|
||||
await expect(resolveCredentialGroupsAvailability({ isEnterprise: true })).resolves.toEqual({
|
||||
available: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export type CredentialGroupsAvailability =
|
||||
| { available: true }
|
||||
| { available: false; reason: 'feature_disabled' | 'enterprise_plan_required' }
|
||||
|
||||
export async function resolveCredentialGroupsAvailability(ownerBilling: {
|
||||
isEnterprise: boolean
|
||||
}): Promise<CredentialGroupsAvailability> {
|
||||
if (!(await isFeatureEnabled('credential-groups'))) {
|
||||
return { available: false, reason: 'feature_disabled' }
|
||||
}
|
||||
if (isHosted && !ownerBilling.isEnterprise) {
|
||||
return { available: false, reason: 'enterprise_plan_required' }
|
||||
}
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
/** Credential Groups are globally gated and restricted to Enterprise workspaces on Sim Cloud. */
|
||||
export async function isCredentialGroupsAvailable(ownerBilling: {
|
||||
isEnterprise: boolean
|
||||
}): Promise<boolean> {
|
||||
return (await resolveCredentialGroupsAvailability(ownerBilling)).available
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listCredentialGroupCredentialReferences } from '@/lib/credential-groups/credentials'
|
||||
|
||||
describe('listCredentialGroupCredentialReferences', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('returns the invited email associated with each managed credential', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'credential-1',
|
||||
email: 'person@example.com',
|
||||
displayName: 'Personal Gmail',
|
||||
providerId: 'google-email',
|
||||
providerSubjectId: 'google-subject-1',
|
||||
providerTenantId: null,
|
||||
createdAt: new Date('2026-08-12T12:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await listCredentialGroupCredentialReferences({
|
||||
workspaceId: 'workspace-1',
|
||||
credentialGroupId: 'group-1',
|
||||
credentialGroupOptionIds: ['option-1'],
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
credentials: [
|
||||
{
|
||||
credentialId: 'credential-1',
|
||||
email: 'person@example.com',
|
||||
displayName: 'Personal Gmail',
|
||||
providerId: 'google-email',
|
||||
providerSubjectId: 'google-subject-1',
|
||||
providerTenantId: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user