Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/sim/blocks/blocks/credential-group.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GridOffset } from '@sim/emcn/icons'
import { CREDENTIAL_GROUP_EVENT_TRIGGER_ID } from '@/lib/credential-groups/trigger-constants'
import {
type CanonicalGroup,
resolveActiveCanonicalValue,
Expand All @@ -13,6 +14,7 @@ import {
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getTrigger } from '@/triggers'

const CREDENTIAL_GROUP_CANONICAL_GROUP = {
canonicalId: 'credentialGroupId',
Expand Down Expand Up @@ -256,6 +258,7 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
placeholder: 'nextCursor from a previous page',
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
},
...getTrigger(CREDENTIAL_GROUP_EVENT_TRIGGER_ID).subBlocks,
],
tools: { access: [] },
inputs: {
Expand Down Expand Up @@ -345,4 +348,8 @@ export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
},
},
triggers: {
enabled: true,
available: [CREDENTIAL_GROUP_EVENT_TRIGGER_ID],
},
}
124 changes: 120 additions & 4 deletions apps/sim/lib/credential-groups/application/public-enrollment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
completeEnrollment: vi.fn(),
completeOAuth: vi.fn(),
fireTrigger: vi.fn(),
getEnrollment: vi.fn(),
getOAuthContext: vi.fn(),
startOAuth: vi.fn(),
Expand All @@ -19,11 +21,17 @@ vi.mock('@/lib/credential-groups/enrollments', () => ({
}))

vi.mock('@/lib/credential-groups/oauth', () => ({
completeCredentialGroupOAuth: vi.fn(),
completeCredentialGroupOAuth: mocks.completeOAuth,
startCredentialGroupOAuth: mocks.startOAuth,
}))

vi.mock('@/lib/credential-groups/trigger', () => ({
fireCredentialGroupTrigger: mocks.fireTrigger,
}))

import {
completePublicCredentialGroupEnrollment,
completePublicCredentialGroupOAuth,
readPublicCredentialGroupEnrollment,
startPublicCredentialGroupOAuth,
} from '@/lib/credential-groups/application/public-enrollment'
Expand All @@ -44,15 +52,43 @@ const identity = {
email: principal.email,
invitationTokenHash: principal.invitationTokenHash,
}
const oauthAttempt = {
state: 'state-1',
provider: 'gmail' as const,
nonceHash: 'nonce-hash',
enrollmentId: principal.enrollmentId,
credentialGroupId: principal.credentialGroupId,
optionId: 'option-1',
authorizationAppId: 'google:client',
scopeVersion: 1,
requiredScopes: ['openid'],
redirectUri: 'https://sim.ai/api/auth/oauth2/callback/google-email',
invitationToken,
createdAt: Date.now(),
}

describe('public Credential Group enrollment application operations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getEnrollment.mockResolvedValue({ status: 'invited', options: [] })
mocks.getEnrollment.mockResolvedValue({
status: 'invited',
credentialGroupName: 'Credential Group',
options: [],
})
mocks.getOAuthContext.mockResolvedValue({
enrollmentId: 'enrollment-1',
credentialGroupId: 'group-1',
option: { id: 'option-1' },
credentialGroupName: 'Credential Group',
option: { id: 'option-1', provider: 'gmail' },
})
mocks.completeOAuth.mockResolvedValue({
created: true,
credentialId: 'credential-1',
credentialGroupOptionId: 'option-1',
provider: 'gmail',
providerId: 'google-email',
displayName: 'person@example.com',
enrollmentStatus: 'in_progress',
})
mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize')
})
Expand All @@ -74,7 +110,13 @@ describe('public Credential Group enrollment application operations', () => {
const result = await readPublicCredentialGroupEnrollment.execute({ principal, input: {} })

expect(mocks.getEnrollment).toHaveBeenCalledWith(identity)
expect(result).toEqual({ enrollment: { status: 'invited', options: [] } })
expect(result).toEqual({
enrollment: {
status: 'invited',
credentialGroupName: 'Credential Group',
options: [],
},
})
})

it('fails closed when the current invitation no longer resolves', async () => {
Expand Down Expand Up @@ -104,4 +146,78 @@ describe('public Credential Group enrollment application operations', () => {
expect(mocks.getOAuthContext).toHaveBeenCalledWith(identity, 'option-1')
expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' })
})

it('fires form submitted only for the first completion transition', async () => {
mocks.completeEnrollment.mockResolvedValue({ completed: true, transitioned: true })

const result = await completePublicCredentialGroupEnrollment.execute({
principal,
input: {},
})

expect(result).toEqual({ completed: true })
expect(mocks.fireTrigger).toHaveBeenCalledWith({
event: 'form_submitted',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
credentialGroupName: 'Credential Group',
enrollmentId: 'enrollment-1',
email: 'person@example.com',
enrollmentStatus: 'completed',
})

vi.clearAllMocks()
mocks.getEnrollment.mockResolvedValue({
status: 'completed',
credentialGroupName: 'Credential Group',
options: [],
})
mocks.completeEnrollment.mockResolvedValue({ completed: true, transitioned: false })

await completePublicCredentialGroupEnrollment.execute({ principal, input: {} })

expect(mocks.fireTrigger).not.toHaveBeenCalled()
})

it('distinguishes a new credential from a reconnection', async () => {
await completePublicCredentialGroupOAuth.execute({
principal,
input: { attempt: oauthAttempt, code: 'authorization-code' },
})

expect(mocks.fireTrigger).toHaveBeenCalledWith(
expect.objectContaining({
event: 'credential_added',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
credential: expect.objectContaining({ credentialId: 'credential-1' }),
})
)

vi.clearAllMocks()
mocks.getOAuthContext.mockResolvedValue({
enrollmentId: 'enrollment-1',
credentialGroupId: 'group-1',
credentialGroupName: 'Credential Group',
option: { id: 'option-1', provider: 'gmail' },
})
mocks.completeOAuth.mockResolvedValue({
created: false,
credentialId: 'credential-1',
credentialGroupOptionId: 'option-1',
provider: 'gmail',
providerId: 'google-email',
displayName: 'person@example.com',
enrollmentStatus: 'completed',
})

await completePublicCredentialGroupOAuth.execute({
principal,
input: { attempt: oauthAttempt, code: 'authorization-code' },
})

expect(mocks.fireTrigger).toHaveBeenCalledWith(
expect.objectContaining({ event: 'credential_reconnected', enrollmentStatus: 'completed' })
)
})
})
34 changes: 31 additions & 3 deletions apps/sim/lib/credential-groups/application/public-enrollment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
startCredentialGroupOAuth,
} from '@/lib/credential-groups/oauth'
import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
import { fireCredentialGroupTrigger } from '@/lib/credential-groups/trigger'

interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition<O, I, C, R> {
operation: O
Expand Down Expand Up @@ -125,8 +126,19 @@ export const completePublicCredentialGroupEnrollment =
operation: credentialGroupEnrollmentOperations.complete,
resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal),
async execute({ context }) {
const completed = await completeAuthorizedCredentialGroupEnrollment(context)
return { completed }
const completion = await completeAuthorizedCredentialGroupEnrollment(context)
if (completion?.transitioned) {
await fireCredentialGroupTrigger({
event: 'form_submitted',
workspaceId: context.workspaceId,
credentialGroupId: context.credentialGroupId,
credentialGroupName: context.enrollment.credentialGroupName,
enrollmentId: context.enrollmentId,
email: context.email,
enrollmentStatus: 'completed',
})
}
return { completed: completion?.completed ?? null }
},
})

Expand Down Expand Up @@ -182,7 +194,23 @@ export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGrou
}) => resolvePublicOAuthContext(principal, input.attempt.optionId),
async execute({ principal, input, context }) {
requireInvitationToken(principal, input.attempt.invitationToken)
await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code)
const completion = await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code)
await fireCredentialGroupTrigger({
event: completion.created ? 'credential_added' : 'credential_reconnected',
workspaceId: context.workspaceId,
credentialGroupId: context.credentialGroupId,
credentialGroupName: context.oauth.credentialGroupName,
enrollmentId: context.enrollmentId,
email: context.email,
enrollmentStatus: completion.enrollmentStatus,
credential: {
credentialId: completion.credentialId,
credentialGroupOptionId: completion.credentialGroupOptionId,
provider: completion.provider,
providerId: completion.providerId,
displayName: completion.displayName,
},
})
return { connectedOptionId: context.oauth.option.id }
},
})
27 changes: 22 additions & 5 deletions apps/sim/lib/credential-groups/enrollments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface PublicCredentialGroupEnrollment {
export interface CredentialGroupOAuthContext {
enrollmentId: string
credentialGroupId: string
credentialGroupName: string
workspaceId: string
workspaceName: string
workspaceOwnerId: string
Expand All @@ -111,6 +112,11 @@ export interface PublicCredentialGroupEnrollmentIdentity {
invitationTokenHash: string
}

export interface CredentialGroupEnrollmentCompletion {
completed: true
transitioned: boolean
}

/** Serializes OAuth grant persistence and administrative revocation for one enrollment. */
export async function lockCredentialGroupEnrollmentLifecycle(
executor: DbOrTx,
Expand Down Expand Up @@ -854,12 +860,16 @@ export async function completeCredentialGroupEnrollment(token: string): Promise<
invitationTokenHash: hashInvitationToken(token),
})
if (!row) return null
return completeResolvedCredentialGroupEnrollment(row, identityForPublicEnrollmentRow(row))
const result = await completeResolvedCredentialGroupEnrollment(
row,
identityForPublicEnrollmentRow(row)
)
return result?.completed ?? null
}

export async function completeAuthorizedCredentialGroupEnrollment(
identity: PublicCredentialGroupEnrollmentIdentity
): Promise<true | null> {
): Promise<CredentialGroupEnrollmentCompletion | null> {
const row = await resolveAuthorizedPublicEnrollmentRow(identity)
if (!row) return null
return completeResolvedCredentialGroupEnrollment(row, identity)
Expand All @@ -868,7 +878,7 @@ export async function completeAuthorizedCredentialGroupEnrollment(
async function completeResolvedCredentialGroupEnrollment(
row: NonNullable<Awaited<ReturnType<typeof resolvePublicEnrollmentRowByIdentity>>>,
identity: PublicCredentialGroupEnrollmentIdentity
): Promise<true | null> {
): Promise<CredentialGroupEnrollmentCompletion | null> {
return db.transaction(async (tx) => {
await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id)
const now = new Date()
Expand Down Expand Up @@ -906,9 +916,15 @@ async function completeResolvedCredentialGroupEnrollment(
.for('update')
if (!group || group.status !== 'active') return null

const transitioned = current.status !== 'completed'

const [completed] = await tx
.update(credentialGroupEnrollment)
.set({ status: 'completed', completedAt: now, updatedAt: now })
.set({
status: 'completed',
...(transitioned ? { completedAt: now } : {}),
updatedAt: now,
})
.where(
and(
eq(credentialGroupEnrollment.id, row.enrollment.id),
Expand All @@ -917,7 +933,7 @@ async function completeResolvedCredentialGroupEnrollment(
)
.returning({ id: credentialGroupEnrollment.id })
if (!completed) throw new Error('Credential group enrollment completion returned no row')
return true
return { completed: true, transitioned }
})
}

Expand Down Expand Up @@ -953,6 +969,7 @@ function credentialGroupOAuthContextFromRow(
return {
enrollmentId: row.enrollment.id,
credentialGroupId: row.groupId,
credentialGroupName: row.groupName,
workspaceId: row.workspaceId,
workspaceName: row.workspaceName,
workspaceOwnerId: row.workspaceOwnerId,
Expand Down
Loading
Loading