Skip to content

Commit b03f970

Browse files
authored
feat(permissions): extend hook to detect missing scopes to return those scopes for upgrade, update credential selector subblock (#1869)
1 parent 997c463 commit b03f970

File tree

8 files changed

+181
-103
lines changed

8 files changed

+181
-103
lines changed

apps/sim/app/api/auth/oauth/credentials/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ const credentialsQuerySchema = z
1818
.object({
1919
provider: z.string().nullish(),
2020
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
21-
credentialId: z.string().uuid('Credential ID must be a valid UUID').nullish(),
21+
credentialId: z
22+
.string()
23+
.min(1, 'Credential ID must not be empty')
24+
.max(255, 'Credential ID is too long')
25+
.nullish(),
2226
})
2327
.refine((data) => data.provider || data.credentialId, {
2428
message: 'Provider or credentialId is required',
@@ -206,7 +210,7 @@ export async function GET(request: NextRequest) {
206210
displayName = `${acc.accountId} (${baseProvider})`
207211
}
208212

209-
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
213+
const grantedScopes = acc.scope ? acc.scope.split(/[\s,]+/).filter(Boolean) : []
210214
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
211215

212216
return {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { Check } from 'lucide-react'
4-
import { Button } from '@/components/ui/button'
4+
import { Button } from '@/components/emcn/components/button/button'
55
import {
66
Dialog,
77
DialogContent,
@@ -29,13 +29,13 @@ export interface OAuthRequiredModalProps {
2929
toolName: string
3030
requiredScopes?: string[]
3131
serviceId?: string
32+
newScopes?: string[]
3233
}
3334

3435
const SCOPE_DESCRIPTIONS: Record<string, string> = {
3536
'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf',
3637
'https://www.googleapis.com/auth/gmail.labels': 'View and manage your email labels',
3738
'https://www.googleapis.com/auth/gmail.modify': 'View and manage your email messages',
38-
'https://www.googleapis.com/auth/gmail.readonly': 'View and read your email messages',
3939
'https://www.googleapis.com/auth/drive.readonly': 'View and read your Google Drive files',
4040
'https://www.googleapis.com/auth/drive.file': 'View and manage your Google Drive files',
4141
'https://www.googleapis.com/auth/calendar': 'View and manage your calendar',
@@ -202,6 +202,7 @@ export function OAuthRequiredModal({
202202
toolName,
203203
requiredScopes = [],
204204
serviceId,
205+
newScopes = [],
205206
}: OAuthRequiredModalProps) {
206207
const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes)
207208
const { baseProvider } = parseProvider(provider)
@@ -223,6 +224,11 @@ export function OAuthRequiredModal({
223224
const displayScopes = requiredScopes.filter(
224225
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
225226
)
227+
const newScopesSet = new Set(
228+
(newScopes || []).filter(
229+
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
230+
)
231+
)
226232

227233
const handleConnectDirectly = async () => {
228234
try {
@@ -278,7 +284,14 @@ export function OAuthRequiredModal({
278284
<div className='mt-1 rounded-full bg-muted p-0.5'>
279285
<Check className='h-3 w-3' />
280286
</div>
281-
<span className='text-muted-foreground'>{getScopeDescription(scope)}</span>
287+
<div className='text-muted-foreground'>
288+
<span>{getScopeDescription(scope)}</span>
289+
{newScopesSet.has(scope) && (
290+
<span className='ml-2 rounded-[4px] border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300'>
291+
New
292+
</span>
293+
)}
294+
</div>
282295
</li>
283296
))}
284297
</ul>
@@ -289,7 +302,12 @@ export function OAuthRequiredModal({
289302
<Button variant='outline' onClick={onClose} className='sm:order-1'>
290303
Cancel
291304
</Button>
292-
<Button type='button' onClick={handleConnectDirectly} className='sm:order-3'>
305+
<Button
306+
variant='primary'
307+
type='button'
308+
onClick={handleConnectDirectly}
309+
className='sm:order-3'
310+
>
293311
Connect Now
294312
</Button>
295313
</DialogFooter>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useState } from 'react'
44
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
5-
import { Button } from '@/components/ui/button'
5+
import { Button } from '@/components/emcn/components/button/button'
66
import {
77
Command,
88
CommandEmpty,
@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
1515
import { createLogger } from '@/lib/logs/console/logger'
1616
import {
1717
type Credential,
18+
getCanonicalScopesForProvider,
1819
getProviderIdFromServiceId,
1920
getServiceIdFromScopes,
2021
OAUTH_PROVIDERS,
@@ -25,6 +26,7 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
2526
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
2627
import type { SubBlockConfig } from '@/blocks/types'
2728
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
29+
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
2830
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2931

3032
const logger = createLogger('CredentialSelector')
@@ -210,6 +212,14 @@ export function CredentialSelector({
210212
? 'Saved by collaborator'
211213
: undefined
212214

215+
// Determine if additional permissions are required for the selected credential
216+
const hasSelection = !!selectedCredential
217+
const missingRequiredScopes = hasSelection
218+
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
219+
: []
220+
const needsUpdate =
221+
hasSelection && missingRequiredScopes.length > 0 && !disabled && !isPreview && !isLoading
222+
213223
// Handle selection
214224
const handleSelect = (credentialId: string) => {
215225
const previousId = selectedId || (effectiveValue as string) || ''
@@ -331,13 +341,21 @@ export function CredentialSelector({
331341
</PopoverContent>
332342
</Popover>
333343

344+
{needsUpdate && (
345+
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
346+
<span>Additional permissions required</span>
347+
{!isForeign && <Button onClick={() => setShowOAuthModal(true)}>Update access</Button>}
348+
</div>
349+
)}
350+
334351
{showOAuthModal && (
335352
<OAuthRequiredModal
336353
isOpen={showOAuthModal}
337354
onClose={() => setShowOAuthModal(false)}
338355
provider={provider}
339356
toolName={getProviderName(provider)}
340-
requiredScopes={requiredScopes}
357+
requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)}
358+
newScopes={missingRequiredScopes}
341359
serviceId={effectiveServiceId}
342360
/>
343361
)}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useEffect, useState } from 'react'
22
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
3-
import { Button } from '@/components/ui/button'
3+
import { Button } from '@/components/emcn/components/button/button'
44
import {
55
Command,
66
CommandEmpty,
@@ -12,17 +12,19 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
1212
import { createLogger } from '@/lib/logs/console/logger'
1313
import {
1414
type Credential,
15+
getCanonicalScopesForProvider,
16+
getProviderIdFromServiceId,
1517
OAUTH_PROVIDERS,
1618
type OAuthProvider,
1719
type OAuthService,
1820
parseProvider,
1921
} from '@/lib/oauth'
2022
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
23+
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
2124
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2225

2326
const logger = createLogger('ToolCredentialSelector')
2427

25-
// Helper functions for provider icons and names
2628
const getProviderIcon = (providerName: OAuthProvider) => {
2729
const { baseProvider } = parseProvider(providerName)
2830
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -158,6 +160,13 @@ export function ToolCredentialSelector({
158160
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
159161
const isForeign = !!(selectedId && !selectedCredential)
160162

163+
// Determine if additional permissions are required for the selected credential
164+
const hasSelection = !!selectedCredential
165+
const missingRequiredScopes = hasSelection
166+
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
167+
: []
168+
const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
169+
161170
return (
162171
<>
163172
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -240,12 +249,23 @@ export function ToolCredentialSelector({
240249
</PopoverContent>
241250
</Popover>
242251

252+
{needsUpdate && (
253+
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
254+
<span>Additional permissions required</span>
255+
{/* We don't have reliable foreign detection context here; always show CTA */}
256+
<Button onClick={() => setShowOAuthModal(true)}>Update access</Button>
257+
</div>
258+
)}
259+
243260
<OAuthRequiredModal
244261
isOpen={showOAuthModal}
245262
onClose={handleOAuthClose}
246263
provider={provider}
247264
toolName={label}
248-
requiredScopes={requiredScopes}
265+
requiredScopes={getCanonicalScopesForProvider(
266+
serviceId ? getProviderIdFromServiceId(serviceId) : (provider as string)
267+
)}
268+
newScopes={missingRequiredScopes}
249269
serviceId={serviceId}
250270
/>
251271
</>

apps/sim/blocks/blocks/gmail.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
4848
requiredScopes: [
4949
'https://www.googleapis.com/auth/gmail.send',
5050
'https://www.googleapis.com/auth/gmail.modify',
51-
'https://www.googleapis.com/auth/gmail.readonly',
5251
'https://www.googleapis.com/auth/gmail.labels',
5352
],
5453
placeholder: 'Select Gmail account',
@@ -162,10 +161,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
162161
canonicalParamId: 'folder',
163162
provider: 'google-email',
164163
serviceId: 'gmail',
165-
requiredScopes: [
166-
'https://www.googleapis.com/auth/gmail.readonly',
167-
'https://www.googleapis.com/auth/gmail.labels',
168-
],
164+
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
169165
placeholder: 'Select Gmail label/folder',
170166
dependsOn: ['credential'],
171167
mode: 'basic',
@@ -241,10 +237,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
241237
canonicalParamId: 'addLabelIds',
242238
provider: 'google-email',
243239
serviceId: 'gmail',
244-
requiredScopes: [
245-
'https://www.googleapis.com/auth/gmail.readonly',
246-
'https://www.googleapis.com/auth/gmail.labels',
247-
],
240+
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
248241
placeholder: 'Select destination label',
249242
dependsOn: ['credential'],
250243
mode: 'basic',
@@ -271,10 +264,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
271264
canonicalParamId: 'removeLabelIds',
272265
provider: 'google-email',
273266
serviceId: 'gmail',
274-
requiredScopes: [
275-
'https://www.googleapis.com/auth/gmail.readonly',
276-
'https://www.googleapis.com/auth/gmail.labels',
277-
],
267+
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
278268
placeholder: 'Select label to remove',
279269
dependsOn: ['credential'],
280270
mode: 'basic',
@@ -327,10 +317,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
327317
canonicalParamId: 'labelIds',
328318
provider: 'google-email',
329319
serviceId: 'gmail',
330-
requiredScopes: [
331-
'https://www.googleapis.com/auth/gmail.readonly',
332-
'https://www.googleapis.com/auth/gmail.labels',
333-
],
320+
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
334321
placeholder: 'Select label',
335322
dependsOn: ['credential'],
336323
mode: 'basic',

apps/sim/hooks/use-oauth-scope-status.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,29 @@ export function anyCredentialNeedsReauth(credentials: Credential[]): boolean {
4343
export function getCredentialsNeedingReauth(credentials: Credential[]): Credential[] {
4444
return credentials.filter(credentialNeedsReauth)
4545
}
46+
47+
/**
48+
* Compute which of the provided requiredScopes are NOT granted by the credential.
49+
*/
50+
export function getMissingRequiredScopes(
51+
credential: Credential | undefined,
52+
requiredScopes: string[] = []
53+
): string[] {
54+
if (!credential) return [...requiredScopes]
55+
const granted = new Set((credential.scopes || []).map((s) => s))
56+
const missing: string[] = []
57+
for (const s of requiredScopes) {
58+
if (!granted.has(s)) missing.push(s)
59+
}
60+
return missing
61+
}
62+
63+
/**
64+
* Whether a credential needs an upgrade specifically for the provided required scopes.
65+
*/
66+
export function needsUpgradeForRequiredScopes(
67+
credential: Credential | undefined,
68+
requiredScopes: string[] = []
69+
): boolean {
70+
return getMissingRequiredScopes(credential, requiredScopes).length > 0
71+
}

0 commit comments

Comments
 (0)