Skip to content

Commit 86fa5e0

Browse files
fix(credential-groups): make enrollment connections optional
1 parent 8e68dbf commit 86fa5e0

7 files changed

Lines changed: 20 additions & 107 deletions

File tree

apps/sim/app/api/credential-groups/enroll/[token]/complete/route.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('credential group enrollment completion route', () => {
4949
mocks.complete.mockResolvedValue({ completed: true })
5050
})
5151

52-
it('submits a fully connected enrollment through its invitation principal', async () => {
52+
it('submits optional account selections through its invitation principal', async () => {
5353
const enrollmentRequest = request()
5454
const response = await POST(enrollmentRequest, context)
5555

@@ -64,13 +64,13 @@ describe('credential group enrollment completion route', () => {
6464
})
6565
})
6666

67-
it('redirects an incomplete enrollment without marking it complete', async () => {
68-
mocks.complete.mockResolvedValue({ completed: false })
67+
it('returns to an unavailable enrollment when completion loses authorization', async () => {
68+
mocks.complete.mockResolvedValue({ completed: null })
6969

7070
const response = await POST(request(), context)
7171

7272
expect(response.headers.get('location')).toBe(
73-
'/credential-groups/enroll/invitation-token?oauth=incomplete'
73+
'/credential-groups/enroll/invitation-token?oauth=unavailable'
7474
)
7575
})
7676

apps/sim/app/api/credential-groups/enroll/[token]/complete/route.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ export const POST = withRouteHandler(
3939
if (completed === null) {
4040
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
4141
}
42-
if (completed) return createCredentialGroupCompletionRedirect()
43-
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'incomplete' })
42+
return createCredentialGroupCompletionRedirect()
4443
}
4544
)

apps/sim/app/credential-groups/enroll/[token]/page.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ const OAUTH_MESSAGES = {
6868
permissions_required: 'All requested permissions are required to connect this account.',
6969
configuration_changed: 'This credential option changed. Reload the page and try again.',
7070
rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.',
71-
incomplete: 'Connect every account before submitting.',
7271
unavailable: 'Account authorization is temporarily unavailable. Please try again.',
7372
failed: 'Account authorization did not complete. Please try again.',
7473
} as const
@@ -125,12 +124,6 @@ export default async function CredentialGroupEnrollmentPage({
125124
: oauthMessage
126125
? { message: oauthMessage, variant: 'error' as const }
127126
: null
128-
const allConnected =
129-
activeOptions.length > 0 &&
130-
activeOptions.every(
131-
(option) => option.connections.length === 1 && option.connections[0]?.status === 'connected'
132-
)
133-
134127
return (
135128
<PageShell>
136129
{notification && (
@@ -177,11 +170,7 @@ export default async function CredentialGroupEnrollmentPage({
177170
method='post'
178171
className='mt-6 flex justify-end'
179172
>
180-
<Chip
181-
type='submit'
182-
variant='primary'
183-
disabled={!allConnected || enrollment.status === 'completed'}
184-
>
173+
<Chip type='submit' variant='primary' disabled={enrollment.status === 'completed'}>
185174
{enrollment.status === 'completed' ? 'Submitted' : 'Submit'}
186175
</Chip>
187176
</form>

apps/sim/ee/credential-groups/components/credential-group-details.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function toOptionUpdateInput(
4848
const common = {
4949
id: option.id,
5050
label: getCredentialGroupProviderService(option.provider).name,
51-
required: true,
51+
required: false,
5252
}
5353
if (option.provider !== 'slack') return { ...common, provider: option.provider }
5454
return {
@@ -101,7 +101,7 @@ export function CredentialGroupDetails({
101101
const nextOption: NonNullable<UpdateCredentialGroupBody['options']>[number] = {
102102
provider,
103103
label: service.name,
104-
required: true,
104+
required: false,
105105
}
106106
return updateOptions([...existing, nextOption], `${service.name} added`)
107107
}

apps/sim/lib/credential-groups/enrollments.test.ts

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,6 @@ describe('completeCredentialGroupEnrollment', () => {
229229
beforeEach(() => {
230230
vi.clearAllMocks()
231231
resetDbChainMock()
232-
adapter.getPolicy.mockResolvedValue({
233-
provider: 'gmail',
234-
providerId: 'google-email',
235-
authorizationAppId: 'google:client',
236-
requiredScopes: ['scope'],
237-
scopeVersion: 1,
238-
})
239-
adapter.hasRequiredScopes.mockReturnValue(true)
240232
})
241233

242234
it('returns unavailable when revocation wins before completion acquires the lifecycle lock', async () => {
@@ -261,18 +253,6 @@ describe('completeCredentialGroupEnrollment', () => {
261253
inviterName: 'Inviter',
262254
},
263255
])
264-
queueTableRows(schemaMock.credential, [
265-
{
266-
optionId: 'option-1',
267-
status: 'active',
268-
scopeVersion: 1,
269-
authorizationAppId: 'google:client',
270-
grantedScopes: ['scope'],
271-
displayName: 'alex@example.com',
272-
metadata: { email: 'alex@example.com' },
273-
grantedAt: new Date('2026-08-11T12:05:00.000Z'),
274-
},
275-
])
276256
queueTableRows(schemaMock.credentialGroupEnrollment, [
277257
{
278258
status: 'revoked',
@@ -287,10 +267,10 @@ describe('completeCredentialGroupEnrollment', () => {
287267
expect(dbChainMockFns.update).not.toHaveBeenCalled()
288268
})
289269

290-
it('refuses completion when a connection needs reauthorization under the row locks', async () => {
270+
it('completes when the recipient skips every optional account', async () => {
291271
queueTableRows(schemaMock.credentialGroupEnrollment, [
292272
{
293-
enrollment: { ...ENROLLMENT, status: 'in_progress' },
273+
enrollment: { ...ENROLLMENT, status: 'invited' },
294274
groupId: 'group-1',
295275
groupName: 'Group',
296276
groupStatus: 'active',
@@ -311,7 +291,7 @@ describe('completeCredentialGroupEnrollment', () => {
311291
])
312292
queueTableRows(schemaMock.credentialGroupEnrollment, [
313293
{
314-
status: 'in_progress',
294+
status: 'invited',
315295
invitationTokenHash: ENROLLMENT.invitationTokenHash,
316296
invitationExpiresAt: ENROLLMENT.invitationExpiresAt,
317297
},
@@ -330,20 +310,12 @@ describe('completeCredentialGroupEnrollment', () => {
330310
],
331311
},
332312
])
333-
queueTableRows(schemaMock.credential, [
334-
{
335-
optionId: 'option-1',
336-
status: 'needs_reauth',
337-
scopeVersion: 1,
338-
authorizationAppId: 'google:client',
339-
grantedScopes: ['scope'],
340-
grantedAt: new Date('2026-08-11T12:05:00.000Z'),
341-
},
342-
])
313+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ENROLLMENT.id }])
343314

344-
await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(false)
315+
await expect(completeCredentialGroupEnrollment('invitation-token')).resolves.toBe(true)
345316

346-
expect(dbChainMockFns.update).not.toHaveBeenCalled()
317+
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment)
318+
expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.credential)
347319
expect(adapter.getPolicy).not.toHaveBeenCalled()
348320
})
349321
})

apps/sim/lib/credential-groups/enrollments.ts

Lines changed: 4 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,8 @@ async function buildPublicCredentialGroupEnrollment(
779779
}
780780
}
781781

782-
/** Finalizes an enrollment only after every active credential option has one usable connection. */
783-
export async function completeCredentialGroupEnrollment(token: string): Promise<boolean | null> {
782+
/** Finalizes an enrollment after the recipient finishes their optional account selections. */
783+
export async function completeCredentialGroupEnrollment(token: string): Promise<true | null> {
784784
const row = await resolvePublicEnrollmentRowByIdentity({
785785
invitationTokenHash: hashInvitationToken(token),
786786
})
@@ -790,7 +790,7 @@ export async function completeCredentialGroupEnrollment(token: string): Promise<
790790

791791
export async function completeAuthorizedCredentialGroupEnrollment(
792792
identity: PublicCredentialGroupEnrollmentIdentity
793-
): Promise<boolean | null> {
793+
): Promise<true | null> {
794794
const row = await resolveAuthorizedPublicEnrollmentRow(identity)
795795
if (!row) return null
796796
return completeResolvedCredentialGroupEnrollment(row, identity)
@@ -799,7 +799,7 @@ export async function completeAuthorizedCredentialGroupEnrollment(
799799
async function completeResolvedCredentialGroupEnrollment(
800800
row: NonNullable<Awaited<ReturnType<typeof resolvePublicEnrollmentRowByIdentity>>>,
801801
identity: PublicCredentialGroupEnrollmentIdentity
802-
): Promise<boolean | null> {
802+
): Promise<true | null> {
803803
return db.transaction(async (tx) => {
804804
await lockCredentialGroupEnrollmentLifecycle(tx, row.enrollment.id)
805805
const now = new Date()
@@ -825,7 +825,6 @@ async function completeResolvedCredentialGroupEnrollment(
825825
const [group] = await tx
826826
.select({
827827
status: credentialGroup.status,
828-
options: credentialGroup.options,
829828
})
830829
.from(credentialGroup)
831830
.where(
@@ -838,52 +837,6 @@ async function completeResolvedCredentialGroupEnrollment(
838837
.for('update')
839838
if (!group || group.status !== 'active') return null
840839

841-
const activeOptions = group.options.filter((option) => option.status === 'active')
842-
if (activeOptions.length === 0) return false
843-
const connections = await tx
844-
.select({
845-
optionId: credential.credentialGroupOptionId,
846-
status: credential.managedOauthStatus,
847-
scopeVersion: credential.managedOauthScopeVersion,
848-
authorizationAppId: credential.authorizationAppId,
849-
grantedScopes: credential.grantedScopes,
850-
grantedAt: credential.grantedAt,
851-
})
852-
.from(credential)
853-
.where(
854-
and(
855-
eq(credential.type, 'managed_oauth'),
856-
eq(credential.credentialGroupEnrollmentId, row.enrollment.id)
857-
)
858-
)
859-
.for('update')
860-
861-
for (const option of activeOptions) {
862-
if (!isCredentialGroupProvider(option.provider)) {
863-
throw new Error(`Unsupported Credential Group provider: ${option.provider}`)
864-
}
865-
const matchingConnections = connections.filter(
866-
(connection) => connection.optionId === option.id
867-
)
868-
if (matchingConnections.length !== 1) return false
869-
const [connection] = matchingConnections
870-
if (!connection || connection.status !== 'active' || !connection.grantedAt) return false
871-
872-
const adapter = getCredentialGroupProviderAdapter(option.provider)
873-
const policy = await adapter.getPolicy(option, {
874-
workspaceId: identity.workspaceId,
875-
credentialGroupId: identity.credentialGroupId,
876-
executor: tx,
877-
})
878-
if (
879-
connection.authorizationAppId !== policy.authorizationAppId ||
880-
connection.scopeVersion !== policy.scopeVersion ||
881-
!adapter.hasRequiredScopes(connection.grantedScopes ?? [], policy.requiredScopes)
882-
) {
883-
return false
884-
}
885-
}
886-
887840
const [completed] = await tx
888841
.update(credentialGroupEnrollment)
889842
.set({ status: 'completed', completedAt: now, updatedAt: now })

apps/sim/lib/credential-groups/slack-managed-users.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: {
671671
authorizationAppId,
672672
requiredScopes: [...SLACK_MANAGED_USER_SCOPES],
673673
scopeVersion,
674-
required: existingOption?.required ?? true,
674+
required: false,
675675
status: existingOption?.status ?? ('active' as const),
676676
}
677677
const options = existingOption

0 commit comments

Comments
 (0)