feat(permissions): extend hook to detect missing scopes to return those scopes for upgrade, update credential selector subblock (#1869)

This commit is contained in:
Waleed
2025-11-10 11:40:15 -08:00
committed by GitHub
parent 997c4639ed
commit b03f9702d2
8 changed files with 181 additions and 103 deletions

View File

@@ -18,7 +18,11 @@ const credentialsQuerySchema = z
.object({
provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
credentialId: z.string().uuid('Credential ID must be a valid UUID').nullish(),
credentialId: z
.string()
.min(1, 'Credential ID must not be empty')
.max(255, 'Credential ID is too long')
.nullish(),
})
.refine((data) => data.provider || data.credentialId, {
message: 'Provider or credentialId is required',
@@ -206,7 +210,7 @@ export async function GET(request: NextRequest) {
displayName = `${acc.accountId} (${baseProvider})`
}
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const grantedScopes = acc.scope ? acc.scope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {

View File

@@ -1,7 +1,7 @@
'use client'
import { Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn/components/button/button'
import {
Dialog,
DialogContent,
@@ -29,13 +29,13 @@ export interface OAuthRequiredModalProps {
toolName: string
requiredScopes?: string[]
serviceId?: string
newScopes?: string[]
}
const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf',
'https://www.googleapis.com/auth/gmail.labels': 'View and manage your email labels',
'https://www.googleapis.com/auth/gmail.modify': 'View and manage your email messages',
'https://www.googleapis.com/auth/gmail.readonly': 'View and read your email messages',
'https://www.googleapis.com/auth/drive.readonly': 'View and read your Google Drive files',
'https://www.googleapis.com/auth/drive.file': 'View and manage your Google Drive files',
'https://www.googleapis.com/auth/calendar': 'View and manage your calendar',
@@ -202,6 +202,7 @@ export function OAuthRequiredModal({
toolName,
requiredScopes = [],
serviceId,
newScopes = [],
}: OAuthRequiredModalProps) {
const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes)
const { baseProvider } = parseProvider(provider)
@@ -223,6 +224,11 @@ export function OAuthRequiredModal({
const displayScopes = requiredScopes.filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
const newScopesSet = new Set(
(newScopes || []).filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
)
const handleConnectDirectly = async () => {
try {
@@ -278,7 +284,14 @@ export function OAuthRequiredModal({
<div className='mt-1 rounded-full bg-muted p-0.5'>
<Check className='h-3 w-3' />
</div>
<span className='text-muted-foreground'>{getScopeDescription(scope)}</span>
<div className='text-muted-foreground'>
<span>{getScopeDescription(scope)}</span>
{newScopesSet.has(scope) && (
<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'>
New
</span>
)}
</div>
</li>
))}
</ul>
@@ -289,7 +302,12 @@ export function OAuthRequiredModal({
<Button variant='outline' onClick={onClose} className='sm:order-1'>
Cancel
</Button>
<Button type='button' onClick={handleConnectDirectly} className='sm:order-3'>
<Button
variant='primary'
type='button'
onClick={handleConnectDirectly}
className='sm:order-3'
>
Connect Now
</Button>
</DialogFooter>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn/components/button/button'
import {
Command,
CommandEmpty,
@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
getServiceIdFromScopes,
OAUTH_PROVIDERS,
@@ -25,6 +26,7 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
@@ -210,6 +212,14 @@ export function CredentialSelector({
? 'Saved by collaborator'
: undefined
// Determine if additional permissions are required for the selected credential
const hasSelection = !!selectedCredential
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
: []
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !isPreview && !isLoading
// Handle selection
const handleSelect = (credentialId: string) => {
const previousId = selectedId || (effectiveValue as string) || ''
@@ -331,13 +341,21 @@ export function CredentialSelector({
</PopoverContent>
</Popover>
{needsUpdate && (
<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'>
<span>Additional permissions required</span>
{!isForeign && <Button onClick={() => setShowOAuthModal(true)}>Update access</Button>}
</div>
)}
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)}
newScopes={missingRequiredScopes}
serviceId={effectiveServiceId}
/>
)}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn/components/button/button'
import {
Command,
CommandEmpty,
@@ -12,17 +12,19 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
type OAuthProvider,
type OAuthService,
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ToolCredentialSelector')
// Helper functions for provider icons and names
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -158,6 +160,13 @@ export function ToolCredentialSelector({
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential)
// Determine if additional permissions are required for the selected credential
const hasSelection = !!selectedCredential
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
: []
const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
return (
<>
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -240,12 +249,23 @@ export function ToolCredentialSelector({
</PopoverContent>
</Popover>
{needsUpdate && (
<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'>
<span>Additional permissions required</span>
{/* We don't have reliable foreign detection context here; always show CTA */}
<Button onClick={() => setShowOAuthModal(true)}>Update access</Button>
</div>
)}
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={handleOAuthClose}
provider={provider}
toolName={label}
requiredScopes={requiredScopes}
requiredScopes={getCanonicalScopesForProvider(
serviceId ? getProviderIdFromServiceId(serviceId) : (provider as string)
)}
newScopes={missingRequiredScopes}
serviceId={serviceId}
/>
</>

View File

@@ -48,7 +48,6 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
requiredScopes: [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
placeholder: 'Select Gmail account',
@@ -162,10 +161,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
canonicalParamId: 'folder',
provider: 'google-email',
serviceId: 'gmail',
requiredScopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
placeholder: 'Select Gmail label/folder',
dependsOn: ['credential'],
mode: 'basic',
@@ -241,10 +237,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
canonicalParamId: 'addLabelIds',
provider: 'google-email',
serviceId: 'gmail',
requiredScopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
placeholder: 'Select destination label',
dependsOn: ['credential'],
mode: 'basic',
@@ -271,10 +264,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
canonicalParamId: 'removeLabelIds',
provider: 'google-email',
serviceId: 'gmail',
requiredScopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
placeholder: 'Select label to remove',
dependsOn: ['credential'],
mode: 'basic',
@@ -327,10 +317,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
canonicalParamId: 'labelIds',
provider: 'google-email',
serviceId: 'gmail',
requiredScopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
placeholder: 'Select label',
dependsOn: ['credential'],
mode: 'basic',

View File

@@ -43,3 +43,29 @@ export function anyCredentialNeedsReauth(credentials: Credential[]): boolean {
export function getCredentialsNeedingReauth(credentials: Credential[]): Credential[] {
return credentials.filter(credentialNeedsReauth)
}
/**
* Compute which of the provided requiredScopes are NOT granted by the credential.
*/
export function getMissingRequiredScopes(
credential: Credential | undefined,
requiredScopes: string[] = []
): string[] {
if (!credential) return [...requiredScopes]
const granted = new Set((credential.scopes || []).map((s) => s))
const missing: string[] = []
for (const s of requiredScopes) {
if (!granted.has(s)) missing.push(s)
}
return missing
}
/**
* Whether a credential needs an upgrade specifically for the provided required scopes.
*/
export function needsUpgradeForRequiredScopes(
credential: Credential | undefined,
requiredScopes: string[] = []
): boolean {
return getMissingRequiredScopes(credential, requiredScopes).length > 0
}

View File

@@ -79,6 +79,7 @@ export type OAuthService =
| 'wealthbox'
| 'onedrive'
| 'webflow'
export interface OAuthProviderConfig {
id: OAuthProvider
name: string
@@ -95,6 +96,7 @@ export interface OAuthServiceConfig {
icon: (props: { className?: string }) => ReactNode
baseProviderIcon: (props: { className?: string }) => ReactNode
scopes: string[]
scopeHints?: string[]
}
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
@@ -113,9 +115,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
scopes: [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
// 'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
scopeHints: ['gmail', 'mail'],
},
'google-drive': {
id: 'google-drive',
@@ -128,6 +130,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
scopeHints: ['drive'],
},
'google-docs': {
id: 'google-docs',
@@ -140,6 +143,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
scopeHints: ['docs'],
},
'google-sheets': {
id: 'google-sheets',
@@ -152,6 +156,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
],
scopeHints: ['sheets'],
},
'google-forms': {
id: 'google-forms',
@@ -165,6 +170,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/forms.responses.readonly',
],
scopeHints: ['forms'],
},
'google-calendar': {
id: 'google-calendar',
@@ -174,6 +180,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: (props) => GoogleCalendarIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/calendar'],
scopeHints: ['calendar'],
},
'google-vault': {
id: 'google-vault',
@@ -186,6 +193,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/ediscovery',
'https://www.googleapis.com/auth/devstorage.read_only',
],
scopeHints: ['ediscovery', 'devstorage'],
},
},
defaultService: 'gmail',
@@ -244,6 +252,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'Group.ReadWrite.All',
'Team.ReadBasic.All',
'offline_access',
'Files.Read',
'Sites.Read.All',
'TeamMember.Read.All',
],
},
outlook: {
@@ -402,10 +413,41 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'read:jira-user',
'read:jira-work',
'write:jira-work',
'write:issue:jira',
'read:project:jira',
'read:issue-type:jira',
'read:me',
'offline_access',
'read:issue-meta:jira',
'read:issue-security-level:jira',
'read:issue.vote:jira',
'read:issue.changelog:jira',
'read:avatar:jira',
'read:issue:jira',
'read:status:jira',
'read:user:jira',
'read:field-configuration:jira',
'read:issue-details:jira',
'read:issue-event:jira',
'delete:issue:jira',
'write:comment:jira',
'read:comment:jira',
'delete:comment:jira',
'read:attachment:jira',
'delete:attachment:jira',
'write:issue-worklog:jira',
'read:issue-worklog:jira',
'delete:issue-worklog:jira',
'write:issue-link:jira',
'delete:issue-link:jira',
'manage:jira-webhook',
'read:webhook:jira',
'write:webhook:jira',
'delete:webhook:jira',
'read:issue.property:jira',
'read:comment.property:jira',
'read:jql:jira',
'read:field:jira',
],
},
},
@@ -493,12 +535,16 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
baseProviderIcon: (props) => SlackIcon(props),
scopes: [
'channels:read',
'channels:history',
'groups:read',
'groups:history',
'chat:write',
'chat:write.public',
'users:read',
'files:write',
'files:read',
'links:read',
'links:write',
'canvases:write',
'reactions:write',
],
},
},
@@ -516,7 +562,24 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'reddit',
icon: (props) => RedditIcon(props),
baseProviderIcon: (props) => RedditIcon(props),
scopes: ['identity', 'read'],
scopes: [
'identity',
'read',
'submit',
'vote',
'save',
'edit',
'subscribe',
'history',
'privatemessages',
'account',
'mysubreddits',
'flair',
'report',
'modposts',
'modflair',
'modmail',
],
},
},
defaultService: 'reddit',
@@ -557,7 +620,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
}
// Helper function to get a service by provider and service ID
export function getServiceByProviderAndId(
provider: OAuthProvider,
serviceId?: string
@@ -576,77 +638,29 @@ export function getServiceByProviderAndId(
)
}
// Helper function to determine service ID from scopes
export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]): string {
const providerConfig = OAUTH_PROVIDERS[provider]
const { baseProvider, featureType } = parseProvider(provider)
const providerConfig = OAUTH_PROVIDERS[baseProvider] || OAUTH_PROVIDERS[provider]
if (!providerConfig) {
return provider
}
if (provider === 'google') {
if (scopes.some((scope) => scope.includes('gmail') || scope.includes('mail'))) {
return 'gmail'
if (featureType !== 'default' && providerConfig.services[featureType]) {
return featureType
}
const normalizedScopes = (scopes || []).map((s) => s.toLowerCase())
for (const service of Object.values(providerConfig.services)) {
const hints = (service.scopeHints || []).map((h) => h.toLowerCase())
if (hints.length === 0) continue
if (normalizedScopes.some((scope) => hints.some((hint) => scope.includes(hint)))) {
return service.id
}
if (scopes.some((scope) => scope.includes('drive'))) {
return 'google-drive'
}
if (scopes.some((scope) => scope.includes('docs'))) {
return 'google-docs'
}
if (scopes.some((scope) => scope.includes('sheets'))) {
return 'google-sheets'
}
if (scopes.some((scope) => scope.includes('calendar'))) {
return 'google-calendar'
}
if (scopes.some((scope) => scope.includes('forms'))) {
return 'google-forms'
}
if (scopes.some((scope) => scope.includes('ediscovery'))) {
return 'google-vault'
}
} else if (provider === 'microsoft-teams') {
return 'microsoft-teams'
} else if (provider === 'outlook') {
return 'outlook'
} else if (provider === 'sharepoint') {
return 'sharepoint'
} else if (provider === 'microsoft-planner') {
return 'microsoft-planner'
} else if (provider === 'onedrive') {
return 'onedrive'
} else if (provider === 'github') {
return 'github'
} else if (provider === 'supabase') {
return 'supabase'
} else if (provider === 'x') {
return 'x'
} else if (provider === 'confluence') {
return 'confluence'
} else if (provider === 'jira') {
return 'jira'
} else if (provider === 'airtable') {
return 'airtable'
} else if (provider === 'notion') {
return 'notion'
} else if (provider === 'discord') {
return 'discord'
} else if (provider === 'linear') {
return 'linear'
} else if (provider === 'slack') {
return 'slack'
} else if (provider === 'reddit') {
return 'reddit'
} else if (provider === 'wealthbox') {
return 'wealthbox'
} else if (provider === 'webflow') {
return 'webflow'
}
return providerConfig.defaultService
}
// Helper function to get provider ID from service ID
export function getProviderIdFromServiceId(serviceId: string): string {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
for (const [id, service] of Object.entries(provider.services)) {
@@ -660,7 +674,6 @@ export function getProviderIdFromServiceId(serviceId: string): string {
return serviceId
}
// Helper to locate a service configuration by its providerId
export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
for (const service of Object.values(provider.services)) {
@@ -673,13 +686,11 @@ export function getServiceConfigByProviderId(providerId: string): OAuthServiceCo
return null
}
// Get the canonical scopes for a given providerId (service instance)
export function getCanonicalScopesForProvider(providerId: string): string[] {
const service = getServiceConfigByProviderId(providerId)
return service?.scopes ? [...service.scopes] : []
}
// Normalize scopes by trimming, filtering empties, and deduplicating
export function normalizeScopes(scopes: string[]): string[] {
const seen = new Set<string>()
for (const scope of scopes) {
@@ -699,7 +710,6 @@ export interface ScopeEvaluation {
requiresReauthorization: boolean
}
// Compare granted scopes with canonical ones for a providerId
export function evaluateScopeCoverage(
providerId: string,
grantedScopes: string[]
@@ -722,7 +732,6 @@ export function evaluateScopeCoverage(
}
}
// Interface for credential objects
export interface Credential {
id: string
name: string
@@ -737,7 +746,6 @@ export interface Credential {
requiresReauthorization?: boolean
}
// Interface for provider configuration
export interface ProviderConfig {
baseProvider: string
featureType: string

View File

@@ -19,10 +19,7 @@ export const gmailReadTool: ToolConfig<GmailReadParams, GmailToolResponse> = {
oauth: {
required: true,
provider: 'google-email',
additionalScopes: [
'https://www.googleapis.com/auth/gmail.labels',
'https://www.googleapis.com/auth/gmail.readonly',
],
additionalScopes: ['https://www.googleapis.com/auth/gmail.labels'],
},
params: {