feat: update scoped oauth apps interstitials branch (#50891)

Updates the scoped oauth apps mocks to reflect latest direction for the
backend.
This commit is contained in:
Samir Ketema authored and GitHub committed 2026-09-30 11:44:18 +02:00
1 parent 06727423bd
commit b14ff6b261
12 files changed
+533 -329

No files matched your search

+16 -3
View File
@@ -6,7 +6,20 @@ export const oauthAppsKeys = {
['oauth-apps', 'authorize', id, 'organizations', slug, 'projects'] as const,
orgAppDetails: (slug: string | undefined, appId: string | undefined) =>
['oauth-apps', 'organizations', slug, 'apps', appId, 'grant'] as const,
authorizedApps: (slug: string | undefined) => ['oauth-apps', 'authorized', slug] as const,
appMemberGrants: (slug: string | undefined, appId: string | undefined) =>
['oauth-apps', 'authorized', slug, appId, 'member-grants'] as const,
preflightValidation: (slug: string | undefined, appId: string | undefined) =>
['oauth-apps', 'organizations', slug, 'apps', appId, 'preflight-validation'] as const,
// cursor omitted (rather than passed as `undefined`) so the cursor-less key is a true prefix
// of every paginated key — invalidating without a cursor clears every cached page.
approvals: (slug: string | undefined, cursor?: string) =>
cursor === undefined
? (['oauth-apps', 'approvals', slug] as const)
: (['oauth-apps', 'approvals', slug, cursor] as const),
appMemberGrants: (slug: string | undefined, appId: string | undefined, cursor?: string) =>
cursor === undefined
? (['oauth-apps', 'authorized', slug, appId, 'member-grants'] as const)
: (['oauth-apps', 'authorized', slug, appId, 'member-grants', cursor] as const),
grants: (cursor?: string) =>
cursor === undefined
? (['oauth-apps', 'grants'] as const)
: (['oauth-apps', 'grants', cursor] as const),
}
+145 -88
View File
@@ -2,18 +2,19 @@ import { describe, expect, test } from 'vitest'
import {
getMockOAuthAppGrants,
getMockOAuthApprovals,
getMockOAuthAppsAuthorizeApproveResult,
getMockOAuthAppsAuthorizeIdentity,
getMockOAuthAppsAuthorizeOrganizationProjects,
getMockOAuthAppsAuthorizeRequest,
getMockOAuthAppsOverview,
getMockOAuthBlockedApps,
getMockOAuthAppsPreflightValidation,
getMockOAuthOrgAppDetails,
getMockOAuthOwnGrants,
OAUTH_APPS_MOCK_SCENARIOS,
READ_ONLY_ROLE,
USE_MOCKS,
} from './mocks'
import { getFailedProjects, isRoleValidationFailure } from './types'
import { getFailedProjects, isPreflightValidationFailure, isRoleValidationFailure } from './types'
const NORTHWIND_SLUG = 'northwind-traders'
const TAILSPIN_SLUG = 'tailspin-toys'
@@ -66,7 +67,7 @@ describe('oauth-apps mocks', () => {
expect(kinds).toEqual(new Set(['organization_bound', 'member_bound']))
})
test('a member-bound app exists for every project scoping mode', () => {
test('a member-bound app exists both with and without project scoping', () => {
const modes = new Set(
scenarios
.map((scenario) => getMockOAuthAppsAuthorizeRequest(scenario))
@@ -74,15 +75,15 @@ describe('oauth-apps mocks', () => {
.map((request) => request.project_scoping_mode)
)
expect(modes).toEqual(new Set(['off', 'optional', 'required']))
expect(modes).toEqual(new Set([true, false]))
})
test('a dynamic client has a fixture', () => {
const dynamic = scenarios.filter(
(scenario) => getMockOAuthAppsAuthorizeRequest(scenario).registration_type === 'dynamic'
)
test('a DCR-registered app is forced org-bound and unscoped', () => {
const dynamic = getMockOAuthAppsAuthorizeRequest(OAUTH_APPS_MOCK_SCENARIOS.dynamicMcpClient)
expect(dynamic.length).toBeGreaterThan(0)
expect(dynamic.registration_type).toBe('dynamic')
expect(dynamic.grant_kind).toBe('organization_bound')
expect(dynamic.project_scoping_mode).toBe(false)
})
test('app_name mirrors the live name field', () => {
@@ -100,28 +101,8 @@ describe('oauth-apps mocks', () => {
})
describe('org app details fixtures', () => {
test('an unknown pair degrades to an unblocked, grant-less default', () => {
const details = getMockOAuthOrgAppDetails('not-an-org', 'not-an-app')
expect(details.blocked_reason).toBeNull()
expect(details.existing_grant).toBeNull()
expect(details.organization_settings.require_project_scoping).toBe(false)
})
test('every blocked reason has a fixture', () => {
expect(getMockOAuthOrgAppDetails('fabrikam-industries', 'vercel-org-wide').blocked_reason).toBe(
'org_requires_project_scoping'
)
expect(getMockOAuthOrgAppDetails('litware-inc', 'vercel').blocked_reason).toBe(
'app_blocked_for_organization'
)
})
test('an org that requires project scoping does not block a project-scoped app', () => {
const details = getMockOAuthOrgAppDetails('fabrikam-industries', 'vercel')
expect(details.organization_settings.require_project_scoping).toBe(true)
expect(details.blocked_reason).toBeNull()
test('an unknown pair degrades to a grant-less default', () => {
expect(getMockOAuthOrgAppDetails('not-an-org', 'not-an-app').existing_grant).toBeNull()
})
test('the re-consent scenario carries an existing grant with a stale and a live ref', () => {
@@ -140,9 +121,8 @@ describe('org app details fixtures', () => {
test('the existing grant scopes differ from the scopes the app requests today', () => {
const request = getMockOAuthAppsAuthorizeRequest(OAUTH_APPS_MOCK_SCENARIOS.vercelReconsent)
const grant = getMockOAuthOrgAppDetails(TAILSPIN_SLUG, request.app_id).existing_grant
const requestedScopes = request.scopes.flatMap((group) => group.scopes).sort()
expect([...(grant?.approved_scopes ?? [])].sort()).not.toEqual(requestedScopes)
expect([...(grant?.approved_scopes ?? [])].sort()).not.toEqual([...request.scopes].sort())
})
test('an existing grant without project refs has a fixture', () => {
@@ -156,9 +136,61 @@ describe('org app details fixtures', () => {
})
})
describe('preflight validation fixtures', () => {
test('an unknown org or app degrades to success', () => {
expect(
isPreflightValidationFailure(getMockOAuthAppsPreflightValidation('not-an-org', 'vercel'))
).toBe(false)
expect(
isPreflightValidationFailure(
getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'not-an-app')
)
).toBe(false)
})
test('an org-bound app fails the organization branch for a non-admin', () => {
const result = getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'kemal-bot')
if (!isPreflightValidationFailure(result)) throw new Error('expected a preflight failure')
expect(result.validation.scope_target).toBe('organization')
if (result.validation.scope_target !== 'organization') return
expect(result.validation.role.id).toBe(READ_ONLY_ROLE.id)
expect('failed_scopes' in result.validation).toBe(false)
})
test('an org-bound app passes the organization branch for an admin', () => {
const result = getMockOAuthAppsPreflightValidation(TAILSPIN_SLUG, 'kemal-bot')
expect(isPreflightValidationFailure(result)).toBe(false)
})
test('a member-bound app fails the all-projects branch for a read-only member', () => {
const result = getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'vercel')
if (!isPreflightValidationFailure(result)) throw new Error('expected a preflight failure')
expect(result.validation.scope_target).toBe('all_projects')
if (result.validation.scope_target !== 'all_projects') return
expect(result.validation.role.id).toBe(READ_ONLY_ROLE.id)
expect(result.validation.failed_scopes.length).toBeGreaterThan(0)
})
test('a member-bound app passes the all-projects branch for an admin', () => {
const result = getMockOAuthAppsPreflightValidation(TAILSPIN_SLUG, 'vercel')
expect(isPreflightValidationFailure(result)).toBe(false)
})
test('preflight never returns a project-level failure', () => {
const result = getMockOAuthAppsPreflightValidation(NORTHWIND_SLUG, 'vercel')
if (!isPreflightValidationFailure(result)) throw new Error('expected a preflight failure')
expect(result.validation.scope_target).not.toBe('projects')
})
})
describe('post-submit role validation', () => {
const approve = (authId: string, projectRefs: string[] | undefined) =>
getMockOAuthAppsAuthorizeApproveResult(authId, { slug: NORTHWIND_SLUG, projectRefs })
const approve = (authId: string, slug: string, projectRefs: string[] | undefined) =>
getMockOAuthAppsAuthorizeApproveResult(authId, { slug, projectRefs })
const readOnlyRefs = () =>
getMockOAuthAppsAuthorizeOrganizationProjects(NORTHWIND_SLUG)
@@ -176,7 +208,11 @@ describe('post-submit role validation', () => {
})
test('rejects read-only projects when write scopes are requested', () => {
const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, readOnlyRefs())
const result = approve(
OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation,
NORTHWIND_SLUG,
readOnlyRefs()
)
if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure')
@@ -192,10 +228,14 @@ describe('post-submit role validation', () => {
})
test('every failed scope is one the app actually requested', () => {
const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, readOnlyRefs())
const result = approve(
OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation,
NORTHWIND_SLUG,
readOnlyRefs()
)
const requestedScopes = getMockOAuthAppsAuthorizeRequest(
OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation
).scopes.flatMap((group) => group.scopes)
).scopes
if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure')
@@ -205,7 +245,7 @@ describe('post-submit role validation', () => {
})
test('rejects only the read-only refs out of a mixed selection', () => {
const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, [
const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, NORTHWIND_SLUG, [
...readOnlyRefs(),
...writableRefs(),
])
@@ -222,33 +262,57 @@ describe('post-submit role validation', () => {
test('approves a selection of writable projects', () => {
expect(
isRoleValidationFailure(
approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, writableRefs())
approve(OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, NORTHWIND_SLUG, writableRefs())
)
).toBe(false)
})
test('scenarios outside the role-validated set approve read-only projects', () => {
expect(
isRoleValidationFailure(approve(OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper, readOnlyRefs()))
isRoleValidationFailure(
approve(OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper, NORTHWIND_SLUG, readOnlyRefs())
)
).toBe(false)
})
test('an org-wide approval by a read-only member fails on the organization branch', () => {
const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, undefined)
test('a member-bound, all-projects approval by a read-only member fails on the all_projects branch', () => {
const result = approve(OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, NORTHWIND_SLUG, undefined)
if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure')
expect(result.validation.scope_target).toBe('organization')
if (result.validation.scope_target !== 'organization') return
expect(result.validation.scope_target).toBe('all_projects')
if (result.validation.scope_target !== 'all_projects') return
expect(result.validation.role.id).toBe(READ_ONLY_ROLE.id)
expect(result.validation.failed_scopes.length).toBeGreaterThan(0)
expect(getFailedProjects(result)).toEqual([])
})
test('an org-wide approval by a writable member succeeds', () => {
const result = getMockOAuthAppsAuthorizeApproveResult(
test('a member-bound, all-projects approval by a writable member succeeds', () => {
const result = approve(
OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects,
{ slug: 'fabrikam-industries', projectRefs: undefined }
'fabrikam-industries',
undefined
)
expect(isRoleValidationFailure(result)).toBe(false)
})
test('an org-bound approval by a non-admin fails on the organization branch, without failed_scopes', () => {
const result = approve(OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide, NORTHWIND_SLUG, undefined)
if (!isRoleValidationFailure(result)) throw new Error('expected a role validation failure')
expect(result.validation.scope_target).toBe('organization')
if (result.validation.scope_target !== 'organization') return
expect(result.validation.role.name).toBe('Developer')
expect('failed_scopes' in result.validation).toBe(false)
})
test('an org-bound approval by an admin succeeds', () => {
const result = approve(
OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide,
'fabrikam-industries',
undefined
)
expect(isRoleValidationFailure(result)).toBe(false)
@@ -256,47 +320,22 @@ describe('post-submit role validation', () => {
})
describe('authorized apps overview fixtures', () => {
test('both statuses the table renders have a fixture', () => {
const statuses = new Set(getMockOAuthAppsOverview().data.map((app) => app.status))
test('an app with an organization grant has a fixture', () => {
const withOrgGrant = getMockOAuthApprovals().data.find((app) => app.org_grant !== null)
expect(statuses).toEqual(new Set(['active', 'legacy']))
})
test('the legacy app carries an organization grant', () => {
const legacy = getMockOAuthAppsOverview().data.find((app) => app.status === 'legacy')
expect(legacy?.org_grant).not.toBeNull()
})
test('a singular and a plural grant count both have a fixture', () => {
const counts = getMockOAuthAppsOverview().data.map((app) => app.member_grant_count)
expect(counts).toContain(1)
expect(counts.some((count) => count > 1)).toBe(true)
expect(withOrgGrant).toBeDefined()
})
test('app ids are unique so the table can key rows on them', () => {
const apps = getMockOAuthAppsOverview().data
const apps = getMockOAuthApprovals().data
expect(new Set(apps.map((app) => app.id)).size).toBe(apps.length)
})
})
describe('blocked apps fixtures', () => {
test('lists at least one blocked app with who blocked it', () => {
const blocked = getMockOAuthBlockedApps().data
expect(blocked.length).toBeGreaterThan(0)
blocked.forEach((app) => {
expect(app.blocked_at).toBeTruthy()
expect(app.blocked_by.email).toBeTruthy()
})
})
})
describe('app grants fixtures', () => {
test('every app in the overview resolves a grant list', () => {
getMockOAuthAppsOverview().data.forEach((app) => {
getMockOAuthApprovals().data.forEach((app) => {
expect(Array.isArray(getMockOAuthAppGrants(app.id).data)).toBe(true)
})
})
@@ -305,28 +344,46 @@ describe('app grants fixtures', () => {
expect(getMockOAuthAppGrants('not-an-app').data).toEqual([])
})
test('an organization-bound grant has no user and no project refs', () => {
const grants = getMockOAuthAppsOverview().data.flatMap(
(app) => getMockOAuthAppGrants(app.id).data
)
test('an organization-bound grant has no user and no projects', () => {
const grants = getMockOAuthApprovals().data.flatMap((app) => getMockOAuthAppGrants(app.id).data)
const orgBound = grants.filter((grant) => grant.kind === 'organization_bound')
expect(orgBound.length).toBeGreaterThan(0)
orgBound.forEach((grant) => {
expect(grant.user).toBeNull()
expect(grant.project_refs).toBeNull()
expect(grant.projects).toBeNull()
})
})
test('member-bound grants carry a user', () => {
const grants = getMockOAuthAppsOverview().data.flatMap(
(app) => getMockOAuthAppGrants(app.id).data
)
test('member-bound grants carry a user and hydrated projects', () => {
const grants = getMockOAuthApprovals().data.flatMap((app) => getMockOAuthAppGrants(app.id).data)
const memberBound = grants.filter((grant) => grant.kind === 'member_bound')
expect(memberBound.length).toBeGreaterThan(0)
memberBound.forEach((grant) => {
expect(grant.user?.email).toBeTruthy()
expect(grant.projects?.length).toBeGreaterThan(0)
grant.projects?.forEach((project) => {
expect(project.ref).toBeTruthy()
expect(project.name).toBeTruthy()
})
})
})
})
describe('own grants fixtures', () => {
test("lists at least one grant across the member's orgs", () => {
const grants = getMockOAuthOwnGrants().data
expect(grants.length).toBeGreaterThan(0)
grants.forEach((grant) => {
expect(grant.app.name).toBeTruthy()
expect(grant.organization.slug).toBeTruthy()
expect(grant.approved_scopes.length).toBeGreaterThan(0)
grant.projects?.forEach((project) => {
expect(project.ref).toBeTruthy()
expect(project.name).toBeTruthy()
})
})
})
})
+149 -131
View File
@@ -1,18 +1,18 @@
import type { OAuthAppsAuthorizeIdentity } from './oauth-apps-authorize-organizations-query'
import type {
ListAppGrantsResponse,
ListBlockedAppsResponse,
ListOAuthAppsOverviewResponse,
ListOAuthApprovalsResponse,
ListOrgAppGrantsResponse,
ListOwnGrantsResponse,
OAuthAppsAuthorizeApproveResult,
OAuthAppsAuthorizeOrganizationProject,
OAuthAppsAuthorizePreflightResult,
OAuthAppsAuthorizeRequest,
OAuthExistingGrant,
OAuthOrganizationRole,
OAuthOrgAppDetails,
OAuthScope,
OAuthScopeGroup,
OAuthScopeLevel,
} from './types'
import { isPreflightValidationFailure, isWriteScope } from './types'
import type { OrganizationRole } from '@/data/organization-members/organization-roles-query'
const ENABLE_MOCKS = true
@@ -25,7 +25,6 @@ export const OAUTH_APPS_MOCK_SCENARIOS = {
vercelOrgAdmin: 'mock-vercel-org-admin',
vercelManyProjects: 'mock-vercel-many-projects',
vercelRoleValidation: 'mock-vercel-role-validation',
vercelBlocked: 'mock-vercel-blocked',
vercelOptionalProjects: 'mock-vercel-optional-projects',
vercelAllProjects: 'mock-vercel-all-projects',
vercelReconsentAllProjects: 'mock-vercel-reconsent-all-projects',
@@ -66,24 +65,16 @@ export const READ_ONLY_ROLE: OrganizationRole = {
projects: [],
}
const VERCEL_SCOPE_GROUPS: OAuthScopeGroup[] = [
{
name: 'Database, Environment, Secrets',
level: 'read_write',
scopes: [
'database:read',
'database:write',
'environment:read',
'environment:write',
'secrets:read',
'secrets:write',
],
},
{
name: 'Projects, Edge Functions, Storage',
level: 'read',
scopes: ['projects:read', 'edge_functions:read', 'storage:read'],
},
const VERCEL_SCOPES: OAuthScope[] = [
'database:read',
'database:write',
'environment:read',
'environment:write',
'secrets:read',
'secrets:write',
'projects:read',
'edge_functions:read',
'storage:read',
]
const VERCEL_REQUEST: OAuthAppsAuthorizeRequest = {
@@ -96,22 +87,20 @@ const VERCEL_REQUEST: OAuthAppsAuthorizeRequest = {
registration_type: 'manual',
expires_at: '2026-09-17T12:00:00.000Z',
grant_kind: 'member_bound',
project_scoping_mode: 'required',
allow_partial_grants: false,
scopes: VERCEL_SCOPE_GROUPS,
project_scoping_mode: true,
scopes: VERCEL_SCOPES,
}
const VERCEL_OPTIONAL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = {
...VERCEL_REQUEST,
app_id: 'vercel-optional',
project_scoping_mode: 'optional',
allow_partial_grants: true,
project_scoping_mode: true,
}
const VERCEL_ALL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = {
...VERCEL_REQUEST,
app_id: 'vercel-org-wide',
project_scoping_mode: 'off',
project_scoping_mode: false,
}
const DYNAMIC_MCP_CLIENT_REQUEST: OAuthAppsAuthorizeRequest = {
@@ -123,6 +112,9 @@ const DYNAMIC_MCP_CLIENT_REQUEST: OAuthAppsAuthorizeRequest = {
domain: 'mcp.northwind.example',
redirect_uri: 'https://mcp.northwind.example/callback',
registration_type: 'dynamic',
// DCR (MCP) oauth apps are forced org-bound, unscoped.
grant_kind: 'organization_bound',
project_scoping_mode: false,
}
const KEMAL_BOT_REQUEST: OAuthAppsAuthorizeRequest = {
@@ -135,21 +127,14 @@ const KEMAL_BOT_REQUEST: OAuthAppsAuthorizeRequest = {
registration_type: 'manual',
expires_at: '2026-09-17T12:00:00.000Z',
grant_kind: 'organization_bound',
project_scoping_mode: 'required',
allow_partial_grants: false,
scopes: [
{
name: 'Projects',
level: 'read',
scopes: ['projects:read'],
},
],
project_scoping_mode: true,
scopes: ['projects:read'],
}
const KEMAL_BOT_ORG_WIDE_REQUEST: OAuthAppsAuthorizeRequest = {
...KEMAL_BOT_REQUEST,
app_id: 'kemal-bot-org-wide',
project_scoping_mode: 'off',
project_scoping_mode: false,
}
const MOCK_AUTHORIZE_REQUESTS: Record<string, OAuthAppsAuthorizeRequest> = {
@@ -159,7 +144,6 @@ const MOCK_AUTHORIZE_REQUESTS: Record<string, OAuthAppsAuthorizeRequest> = {
[OAUTH_APPS_MOCK_SCENARIOS.vercelOrgAdmin]: VERCEL_REQUEST,
[OAUTH_APPS_MOCK_SCENARIOS.vercelManyProjects]: VERCEL_REQUEST,
[OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation]: VERCEL_REQUEST,
[OAUTH_APPS_MOCK_SCENARIOS.vercelBlocked]: VERCEL_REQUEST,
[OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST,
[OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects]: VERCEL_ALL_PROJECTS_REQUEST,
[OAUTH_APPS_MOCK_SCENARIOS.vercelReconsentAllProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST,
@@ -234,10 +218,6 @@ const MOCK_IDENTITIES: Record<string, OAuthAppsAuthorizeIdentity> = {
email: 'admin@example.com',
organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS],
},
[OAUTH_APPS_MOCK_SCENARIOS.vercelBlocked]: {
email: 'admin@example.com',
organizations: [LITWARE_DEVELOPER, NORTHWIND_TRADERS_DEVELOPER],
},
[OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: {
email: 'admin@example.com',
organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS],
@@ -309,93 +289,66 @@ const TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT: OAuthExistingGrant = {
}
const DEFAULT_ORG_APP_DETAILS: OAuthOrgAppDetails = {
organization_settings: { require_project_scoping: false },
blocked_reason: null,
existing_grant: null,
}
const FABRIKAM_ORG_APP_DETAILS: OAuthOrgAppDetails = {
organization_settings: { require_project_scoping: true },
blocked_reason: null,
existing_grant: null,
}
const MOCK_ORG_APP_DETAILS: Record<string, Record<string, OAuthOrgAppDetails>> = {
'tailspin-toys': {
vercel: { ...DEFAULT_ORG_APP_DETAILS, existing_grant: TAILSPIN_VERCEL_EXISTING_GRANT },
'vercel-optional': {
...DEFAULT_ORG_APP_DETAILS,
existing_grant: TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT,
},
},
'fabrikam-industries': {
vercel: FABRIKAM_ORG_APP_DETAILS,
'vercel-org-wide': {
...FABRIKAM_ORG_APP_DETAILS,
blocked_reason: 'org_requires_project_scoping',
},
'kemal-bot-org-wide': {
...FABRIKAM_ORG_APP_DETAILS,
blocked_reason: 'org_requires_project_scoping',
},
},
'litware-inc': {
vercel: { ...DEFAULT_ORG_APP_DETAILS, blocked_reason: 'app_blocked_for_organization' },
vercel: { existing_grant: TAILSPIN_VERCEL_EXISTING_GRANT },
'vercel-optional': { existing_grant: TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT },
},
}
const MOCK_APPS_OVERVIEW: ListOAuthAppsOverviewResponse = {
// Real oauth_apps config, keyed by app_id — preflight-validation is org+app scoped, not tied to
// an in-flight authorization request.
const MOCK_APPS_BY_ID: Record<string, OAuthAppsAuthorizeRequest> = {
vercel: VERCEL_REQUEST,
'vercel-optional': VERCEL_OPTIONAL_PROJECTS_REQUEST,
'vercel-org-wide': VERCEL_ALL_PROJECTS_REQUEST,
'dynamic-mcp-client': DYNAMIC_MCP_CLIENT_REQUEST,
'kemal-bot': KEMAL_BOT_REQUEST,
'kemal-bot-org-wide': KEMAL_BOT_ORG_WIDE_REQUEST,
}
// The current member's org-level role, keyed by org slug — same reasoning as MOCK_APPS_BY_ID.
const MOCK_ORGANIZATIONS_BY_SLUG: Record<string, OAuthOrganizationRole> = {
'northwind-traders': NORTHWIND_TRADERS_READ_ONLY,
'tailspin-toys': TAILSPIN_TOYS_ADMIN,
'fabrikam-industries': FABRIKAM_OWNER,
'contoso-labs': CONTOSO_LABS,
'wingtip-toys': WINGTIP_TOYS_DEVELOPER,
'litware-inc': LITWARE_DEVELOPER,
}
const MOCK_APPROVALS: ListOAuthApprovalsResponse = {
data: [
{
id: 'vercel',
name: 'Vercel',
icon: null,
status: 'active',
member_grant_count: 33,
last_used_at: '2026-09-16T08:12:00.000Z',
org_grant: null,
},
{
id: 'dynamic-mcp-client',
name: 'Northwind MCP',
icon: null,
status: 'active',
member_grant_count: 1,
last_used_at: '2026-09-01T08:45:00.000Z',
org_grant: null,
},
{
id: 'contoso-analytics',
name: 'Contoso Analytics',
icon: null,
status: 'legacy',
member_grant_count: 0,
last_used_at: '2026-07-02T10:00:00.000Z',
org_grant: {
grant_id: 'grant-contoso-analytics-org',
approved_scopes: ['analytics:read', 'projects:read'],
approved_at: '2025-11-03T14:20:00.000Z',
last_used_at: '2026-07-02T10:00:00.000Z',
},
},
],
pagination: { next_cursor: null },
}
const MOCK_BLOCKED_APPS: ListBlockedAppsResponse = {
data: [
{
app_id: 'kemal-bot',
name: 'kemal-bot',
icon: null,
blocked_at: '2026-09-10T15:30:00.000Z',
blocked_by: { gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000001', email: 'admin@example.com' },
},
],
pagination: { next_cursor: null },
}
const MOCK_APP_GRANTS: Record<string, ListAppGrantsResponse> = {
const MOCK_APP_GRANTS: Record<string, ListOrgAppGrantsResponse> = {
vercel: {
data: [
{
@@ -405,10 +358,12 @@ const MOCK_APP_GRANTS: Record<string, ListAppGrantsResponse> = {
gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000001',
email: 'admin@example.com',
},
project_refs: ['northwindstorefront1', 'northwindcms1'],
projects: [
{ ref: 'northwindstorefront1', name: 'northwind-storefront' },
{ ref: 'northwindcms1', name: 'northwind-cms' },
],
approved_scopes: ['database:read', 'database:write', 'projects:read'],
approved_at: '2026-08-18T09:12:00.000Z',
last_used_at: '2026-09-16T08:12:00.000Z',
},
{
grant_id: 'grant-vercel-developer',
@@ -418,10 +373,9 @@ const MOCK_APP_GRANTS: Record<string, ListAppGrantsResponse> = {
email: 'developer@example.com',
avatar_url: 'https://avatars.example/developer.png',
},
project_refs: ['northwindcms1'],
projects: [{ ref: 'northwindcms1', name: 'northwind-cms' }],
approved_scopes: ['projects:read'],
approved_at: '2026-08-16T11:30:00.000Z',
last_used_at: null,
},
],
pagination: { next_cursor: null },
@@ -435,10 +389,9 @@ const MOCK_APP_GRANTS: Record<string, ListAppGrantsResponse> = {
gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000003',
email: 'ops@example.com',
},
project_refs: ['northwindstorefront1'],
projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }],
approved_scopes: ['database:read', 'database:write'],
approved_at: '2026-09-01T08:45:00.000Z',
last_used_at: '2026-09-01T08:45:00.000Z',
},
],
pagination: { next_cursor: null },
@@ -449,28 +402,54 @@ const MOCK_APP_GRANTS: Record<string, ListAppGrantsResponse> = {
grant_id: 'grant-contoso-analytics-org',
kind: 'organization_bound',
user: null,
project_refs: null,
projects: null,
approved_scopes: ['analytics:read', 'projects:read'],
approved_at: '2025-11-03T14:20:00.000Z',
last_used_at: '2026-07-02T10:00:00.000Z',
},
],
pagination: { next_cursor: null },
},
}
export function getMockOAuthAppsOverview(): ListOAuthAppsOverviewResponse {
return MOCK_APPS_OVERVIEW
const MOCK_OWN_GRANTS: ListOwnGrantsResponse = {
data: [
{
grant_id: 'grant-vercel-admin',
app: { id: 'vercel', name: 'Vercel', icon: null },
organization: { slug: 'northwind-traders', name: 'Northwind Traders' },
projects: [
{ ref: 'northwindstorefront1', name: 'northwind-storefront' },
{ ref: 'northwindcms1', name: 'northwind-cms' },
],
approved_scopes: ['database:read', 'database:write', 'projects:read'],
approved_at: '2026-08-18T09:12:00.000Z',
},
{
grant_id: 'grant-northwind-mcp-ops',
app: { id: 'dynamic-mcp-client', name: 'Northwind MCP', icon: null },
organization: { slug: 'northwind-traders', name: 'Northwind Traders' },
projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }],
approved_scopes: ['database:read', 'database:write'],
approved_at: '2026-09-01T08:45:00.000Z',
},
],
pagination: { next_cursor: null },
}
export function getMockOAuthBlockedApps(): ListBlockedAppsResponse {
return MOCK_BLOCKED_APPS
// Fixture lists are small and static — cursor is accepted for real-endpoint parity but not
// used to actually paginate the mock data.
export function getMockOAuthApprovals(_cursor?: string): ListOAuthApprovalsResponse {
return MOCK_APPROVALS
}
export function getMockOAuthAppGrants(appId: string): ListAppGrantsResponse {
export function getMockOAuthAppGrants(appId: string, _cursor?: string): ListOrgAppGrantsResponse {
return MOCK_APP_GRANTS[appId] ?? { data: [], pagination: { next_cursor: null } }
}
export function getMockOAuthOwnGrants(_cursor?: string): ListOwnGrantsResponse {
return MOCK_OWN_GRANTS
}
export function getMockOAuthAppsAuthorizeRequest(authId: string): OAuthAppsAuthorizeRequest {
const request = MOCK_AUTHORIZE_REQUESTS[authId]
if (!request) throw new Error(`No mock authorize request for id "${authId}"`)
@@ -493,6 +472,12 @@ export function getMockOAuthOrgAppDetails(slug: string, appId: string): OAuthOrg
return MOCK_ORG_APP_DETAILS[slug]?.[appId] ?? DEFAULT_ORG_APP_DETAILS
}
function findMockOrganization(authId: string, slug: string): OAuthOrganizationRole | undefined {
return getMockOAuthAppsAuthorizeIdentity(authId).organizations.find(
(candidate) => candidate.slug === slug
)
}
const MOCK_OAUTH_STATE = 'mock_state_9f2c1b'
function buildMockRedirectUrl(redirectUri: string, params: Record<string, string>) {
@@ -523,14 +508,57 @@ export function getMockOAuthAppsAuthorizeRedirect(
const ROLE_VALIDATED_SCENARIOS = new Set<string>([
OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation,
OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects,
OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide,
])
const WRITE_SCOPE_LEVELS: OAuthScopeLevel[] = ['write', 'read_write']
function isReadOnlyRole(role: OrganizationRole) {
return role.id === READ_ONLY_ROLE.id
}
function isOwnerOrAdmin(role: OrganizationRole) {
return role.id === OWNER_ROLE.id || role.id === ADMINISTRATOR_ROLE.id
}
// Upfront org-level check shared by preflight-validation and the "all projects" branch of approve.
// Never checks project-scoped roles (that's approve's job once project_refs are known).
function evaluateGrantEligibility(
request: OAuthAppsAuthorizeRequest,
organization: OAuthOrganizationRole
): OAuthAppsAuthorizePreflightResult {
if (request.grant_kind === 'organization_bound') {
if (isOwnerOrAdmin(organization.default_role)) return { ok: true }
return {
error_code: 'role_validation_failed',
message: `Your ${organization.default_role.name} role cannot install this app for the organization.`,
validation: { scope_target: 'organization', role: organization.default_role },
}
}
const failedScopes = request.scopes.filter(isWriteScope)
if (failedScopes.length === 0 || !isReadOnlyRole(organization.default_role)) return { ok: true }
return {
error_code: 'role_validation_failed',
message: `Your ${organization.default_role.name} role cannot satisfy this app's scopes for all projects.`,
validation: {
scope_target: 'all_projects',
role: organization.default_role,
failed_scopes: failedScopes,
},
}
}
export function getMockOAuthAppsPreflightValidation(
slug: string,
appId: string
): OAuthAppsAuthorizePreflightResult {
const request = MOCK_APPS_BY_ID[appId]
const organization = MOCK_ORGANIZATIONS_BY_SLUG[slug]
if (!request || !organization) return { ok: true }
return evaluateGrantEligibility(request, organization)
}
export function getMockOAuthAppsAuthorizeApproveResult(
authId: string,
{ slug, projectRefs }: { slug: string; projectRefs: string[] | undefined }
@@ -538,28 +566,18 @@ export function getMockOAuthAppsAuthorizeApproveResult(
const approved = getMockOAuthAppsAuthorizeRedirect(authId, { approved: true })
if (!ROLE_VALIDATED_SCENARIOS.has(authId)) return approved
const failedScopes: OAuthScope[] = getMockOAuthAppsAuthorizeRequest(authId)
.scopes.filter((scopeGroup) => WRITE_SCOPE_LEVELS.includes(scopeGroup.level))
.flatMap((scopeGroup) => scopeGroup.scopes)
if (failedScopes.length === 0) return approved
const request = getMockOAuthAppsAuthorizeRequest(authId)
const organization = findMockOrganization(authId, slug)
if (!organization) return approved
if (projectRefs === undefined) {
const organization = getMockOAuthAppsAuthorizeIdentity(authId).organizations.find(
(candidate) => candidate.slug === slug
)
if (!organization || !isReadOnlyRole(organization.default_role)) return approved
return {
error_code: 'role_validation_failed',
message: `Your ${organization.default_role.name} role cannot grant write access to this organization.`,
validation: {
scope_target: 'organization',
role: organization.default_role,
failed_scopes: failedScopes,
},
}
if (projectRefs === undefined || request.grant_kind === 'organization_bound') {
const result = evaluateGrantEligibility(request, organization)
return isPreflightValidationFailure(result) ? result : approved
}
const failedScopes = request.scopes.filter(isWriteScope)
if (failedScopes.length === 0) return approved
const blocked = getMockOAuthAppsAuthorizeOrganizationProjects(slug).filter(
(project) => projectRefs.includes(project.ref) && isReadOnlyRole(project.role)
)
@@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query'
import { oauthAppsKeys } from './keys'
import { getMockOAuthApprovals, USE_MOCKS } from './mocks'
import type { ListOAuthApprovalsResponse } from './types'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type OAuthApprovalsVariables = {
slug?: string
cursor?: string
}
export type { ListOAuthApprovalsResponse, OAuthApprovalItem } from './types'
export async function getOAuthApprovals({
slug,
cursor,
}: OAuthApprovalsVariables): Promise<ListOAuthApprovalsResponse> {
if (!slug) throw new Error('Organization slug is required')
if (!USE_MOCKS) throw new Error('OAuth app approvals are not yet implemented')
return getMockOAuthApprovals(cursor)
}
export type OAuthApprovalsData = Awaited<ReturnType<typeof getOAuthApprovals>>
export type OAuthApprovalsError = ResponseError
export const useOAuthApprovalsQuery = <TData = OAuthApprovalsData>(
{ slug, cursor }: OAuthApprovalsVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<OAuthApprovalsData, OAuthApprovalsError, TData> = {}
) =>
useQuery<OAuthApprovalsData, OAuthApprovalsError, TData>({
queryKey: oauthAppsKeys.approvals(slug, cursor),
queryFn: () => getOAuthApprovals({ slug, cursor }),
enabled: enabled && USE_MOCKS && Boolean(slug),
...options,
})
@@ -1,38 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { oauthAppsKeys } from './keys'
import { getMockOAuthAppsOverview, USE_MOCKS } from './mocks'
import type { ListOAuthAppsOverviewResponse } from './types'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type OAuthAuthorizedAppsVariables = {
slug?: string
}
export type { ListOAuthAppsOverviewResponse, OAuthAppOverviewItem } from './types'
export async function getOAuthAuthorizedApps({
slug,
}: OAuthAuthorizedAppsVariables): Promise<ListOAuthAppsOverviewResponse> {
if (!slug) throw new Error('Organization slug is required')
if (!USE_MOCKS) throw new Error('OAuth authorized apps are not yet implemented')
return getMockOAuthAppsOverview()
}
export type OAuthAuthorizedAppsData = Awaited<ReturnType<typeof getOAuthAuthorizedApps>>
export type OAuthAuthorizedAppsError = ResponseError
export const useOAuthAuthorizedAppsQuery = <TData = OAuthAuthorizedAppsData>(
{ slug }: OAuthAuthorizedAppsVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<OAuthAuthorizedAppsData, OAuthAuthorizedAppsError, TData> = {}
) =>
useQuery<OAuthAuthorizedAppsData, OAuthAuthorizedAppsError, TData>({
queryKey: oauthAppsKeys.authorizedApps(slug),
queryFn: () => getOAuthAuthorizedApps({ slug }),
enabled: enabled && USE_MOCKS && Boolean(slug),
...options,
})
@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query'
import { oauthAppsKeys } from './keys'
import { getMockOAuthOwnGrants, USE_MOCKS } from './mocks'
import type { ListOwnGrantsResponse } from './types'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type OAuthGrantsVariables = {
cursor?: string
}
export type { ListOwnGrantsResponse, MemberOauthGrantItem, OAuthGrantProject } from './types'
export async function getOAuthGrants({
cursor,
}: OAuthGrantsVariables = {}): Promise<ListOwnGrantsResponse> {
if (!USE_MOCKS) throw new Error('OAuth grants are not yet implemented')
return getMockOAuthOwnGrants(cursor)
}
export type OAuthGrantsData = Awaited<ReturnType<typeof getOAuthGrants>>
export type OAuthGrantsError = ResponseError
export const useOAuthGrantsQuery = <TData = OAuthGrantsData>(
{ cursor }: OAuthGrantsVariables = {},
{
enabled = true,
...options
}: UseCustomQueryOptions<OAuthGrantsData, OAuthGrantsError, TData> = {}
) =>
useQuery<OAuthGrantsData, OAuthGrantsError, TData>({
queryKey: oauthAppsKeys.grants(cursor),
queryFn: () => getOAuthGrants({ cursor }),
enabled: enabled && USE_MOCKS,
...options,
})
@@ -2,40 +2,42 @@ import { useQuery } from '@tanstack/react-query'
import { oauthAppsKeys } from './keys'
import { getMockOAuthAppGrants, USE_MOCKS } from './mocks'
import type { ListAppGrantsResponse } from './types'
import type { ListOrgAppGrantsResponse } from './types'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type OAuthAppMemberGrantsVariables = {
slug?: string
appId?: string
cursor?: string
}
export type { ListAppGrantsResponse, OAuthGrantItem } from './types'
export type { ListOrgAppGrantsResponse, OAuthGrantItem, OAuthGrantProject } from './types'
export async function getOAuthAppMemberGrants({
slug,
appId,
}: OAuthAppMemberGrantsVariables): Promise<ListAppGrantsResponse> {
cursor,
}: OAuthAppMemberGrantsVariables): Promise<ListOrgAppGrantsResponse> {
if (!slug) throw new Error('Organization slug is required')
if (!appId) throw new Error('App id is required')
if (!USE_MOCKS) throw new Error('OAuth app member grants are not yet implemented')
return getMockOAuthAppGrants(appId)
return getMockOAuthAppGrants(appId, cursor)
}
export type OAuthAppMemberGrantsData = Awaited<ReturnType<typeof getOAuthAppMemberGrants>>
export type OAuthAppMemberGrantsError = ResponseError
export const useOAuthAppMemberGrantsQuery = <TData = OAuthAppMemberGrantsData>(
{ slug, appId }: OAuthAppMemberGrantsVariables,
{ slug, appId, cursor }: OAuthAppMemberGrantsVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<OAuthAppMemberGrantsData, OAuthAppMemberGrantsError, TData> = {}
) =>
useQuery<OAuthAppMemberGrantsData, OAuthAppMemberGrantsError, TData>({
queryKey: oauthAppsKeys.appMemberGrants(slug, appId),
queryFn: () => getOAuthAppMemberGrants({ slug, appId }),
queryKey: oauthAppsKeys.appMemberGrants(slug, appId, cursor),
queryFn: () => getOAuthAppMemberGrants({ slug, appId, cursor }),
enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId),
...options,
})
@@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query'
import { oauthAppsKeys } from './keys'
import { getMockOAuthAppsPreflightValidation, USE_MOCKS } from './mocks'
import type { OAuthAppsAuthorizePreflightResult } from './types'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type OAuthAppsPreflightValidationVariables = {
slug?: string
appId?: string
}
export type { OAuthAppsAuthorizePreflightResult } from './types'
export async function getOAuthAppsPreflightValidation({
slug,
appId,
}: OAuthAppsPreflightValidationVariables): Promise<OAuthAppsAuthorizePreflightResult> {
if (!slug) throw new Error('Organization slug is required')
if (!appId) throw new Error('App id is required')
if (!USE_MOCKS) throw new Error('OAuth app preflight validation is not yet implemented')
return getMockOAuthAppsPreflightValidation(slug, appId)
}
export type OAuthAppsPreflightValidationData = Awaited<
ReturnType<typeof getOAuthAppsPreflightValidation>
>
export type OAuthAppsPreflightValidationError = ResponseError
export const useOAuthAppsPreflightValidationQuery = <TData = OAuthAppsPreflightValidationData>(
{ slug, appId }: OAuthAppsPreflightValidationVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<
OAuthAppsPreflightValidationData,
OAuthAppsPreflightValidationError,
TData
> = {}
) =>
useQuery<OAuthAppsPreflightValidationData, OAuthAppsPreflightValidationError, TData>({
queryKey: oauthAppsKeys.preflightValidation(slug, appId),
queryFn: () => getOAuthAppsPreflightValidation({ slug, appId }),
enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId),
...options,
})
@@ -0,0 +1,48 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { oauthAppsKeys } from './keys'
import { USE_MOCKS } from './mocks'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type OAuthGrantRevokeVariables = {
slug: string
grantId: string
}
export async function revokeOAuthGrant({ slug, grantId }: OAuthGrantRevokeVariables) {
if (!slug) throw new Error('Organization slug is required')
if (!grantId) throw new Error('Grant id is required')
if (!USE_MOCKS) throw new Error('OAuth grant revocation is not yet implemented')
// 204 on success — no response body.
}
type OAuthGrantRevokeData = Awaited<ReturnType<typeof revokeOAuthGrant>>
export const useOAuthGrantRevokeMutation = ({
onError,
onSuccess,
...options
}: Omit<
UseCustomMutationOptions<OAuthGrantRevokeData, ResponseError, OAuthGrantRevokeVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<OAuthGrantRevokeData, ResponseError, OAuthGrantRevokeVariables>({
mutationFn: (vars) => revokeOAuthGrant(vars),
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to revoke OAuth grant: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -33,9 +33,16 @@ export const useOAuthAppRevokeMutation = ({
return useMutation<OAuthAppRevokeData, ResponseError, OAuthAppRevokeVariables>({
mutationFn: (vars) => revokeOAuthApp(vars),
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({
queryKey: oauthAppsKeys.authorizedApps(variables.slug),
})
// Revoking an app deletes every row for (app_id, organization_id), both kinds — the
// org's approvals overview, this app's grant list, and any member's own-grants view are
// all stale.
await Promise.all([
queryClient.invalidateQueries({ queryKey: oauthAppsKeys.approvals(variables.slug) }),
queryClient.invalidateQueries({
queryKey: oauthAppsKeys.appMemberGrants(variables.slug, variables.appId),
}),
queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
+2 -6
View File
@@ -97,7 +97,7 @@ describe('isRoleValidationFailure', () => {
isRoleValidationFailure({
error_code: 'role_validation_failed',
message: 'nope',
validation: { scope_target: 'organization', role: READ_ONLY_ROLE, failed_scopes: [] },
validation: { scope_target: 'organization', role: READ_ONLY_ROLE },
})
).toBe(true)
})
@@ -130,11 +130,7 @@ describe('getFailedProjects', () => {
getFailedProjects({
error_code: 'role_validation_failed',
message: 'nope',
validation: {
scope_target: 'organization',
role: READ_ONLY_ROLE,
failed_scopes: ['database:write'],
},
validation: { scope_target: 'organization', role: READ_ONLY_ROLE },
})
).toEqual([])
})
+30 -53
View File
@@ -9,30 +9,20 @@ export type OAuthAppsAuthorizeLiveFields = Pick<
'name' | 'website' | 'domain' | 'icon' | 'redirect_uri' | 'registration_type' | 'expires_at'
>
// TODO(rfc): RFC has one OAuthScope type; confirm the vocabulary is unchanged from the live coarse enum.
export type OAuthScope = NonNullable<LiveAuthorizeRequest['scopes']>[number]
export type OAuthScopeLevel = 'read' | 'write' | 'read_write'
// TODO(rfc): OAuthScopeGroup is referenced but undefined in the RFC; shape below is our guess and drives the scope badges.
export type OAuthScopeGroup = {
name: string
level: OAuthScopeLevel
scopes: OAuthScope[]
export function isWriteScope(scope: OAuthScope): boolean {
return scope.endsWith(':write')
}
export type OAuthGrantKind = 'organization_bound' | 'member_bound'
export type OAuthProjectScopingMode = 'off' | 'optional' | 'required'
export type OAuthAppsAuthorizeRequest = OAuthAppsAuthorizeLiveFields & {
app_id: string
// TODO(rfc): confirm whether app_name supersedes the live name field.
app_name: string
grant_kind: OAuthGrantKind
project_scoping_mode: OAuthProjectScopingMode
allow_partial_grants: boolean
scopes: OAuthScopeGroup[]
project_scoping_mode: boolean
scopes: OAuthScope[]
}
export type OAuthOrganizationRole = {
@@ -49,24 +39,14 @@ export type OAuthAppsAuthorizeOrganizationProject = {
export type OAuthExistingGrant = {
approved_scopes: OAuthScope[] | null
// TODO(rfc): non-nullable here but OAuthGrantItem uses null for "all projects"; confirm how an all-projects grant is represented.
project_refs: string[]
approved_at: string
}
export type OAuthBlockedReason = 'org_requires_project_scoping' | 'app_blocked_for_organization'
export type OAuthOrgAppDetails = {
organization_settings: { require_project_scoping: boolean }
blocked_reason: OAuthBlockedReason | null
existing_grant: OAuthExistingGrant | null
}
export type OAuthOrgScopeCheck = {
role: OrganizationRole
failed_scopes: OAuthScope[]
}
export function getPreselectedProjectRefs({
existingGrant,
projectRef,
@@ -86,6 +66,8 @@ export function getPreselectedProjectRefs({
}
export type OAuthAuthorizeApproveRequest = {
// not allowed when project_scoping_mode is false.
// when project_scoping_mode is true, if omitted, this means all projects
project_refs?: string[]
}
@@ -96,6 +78,10 @@ export type OAuthScopeValidationResult =
| {
scope_target: 'organization'
role: OrganizationRole
}
| {
scope_target: 'all_projects'
role: OrganizationRole
failed_scopes: OAuthScope[]
}
| {
@@ -136,51 +122,49 @@ export function getFailedProjects(
return failure.validation.scope_target === 'projects' ? failure.validation.failures : []
}
export type OAuthAppOverviewItem = {
export type OAuthAppsAuthorizePreflightSuccess = { ok: true }
export type OAuthAppsAuthorizePreflightResult =
| OAuthAppsAuthorizePreflightSuccess
| OAuthAppsAuthorizeRoleValidationFailure
export function isPreflightValidationFailure(
result: OAuthAppsAuthorizePreflightResult
): result is OAuthAppsAuthorizeRoleValidationFailure {
return 'error_code' in result && result.error_code === 'role_validation_failed'
}
export type OAuthApprovalItem = {
id: string
name: string
icon: string | null
status: 'active' | 'legacy'
// TODO(rfc): may become string ("50+"); RFC undecided.
member_grant_count: number
last_used_at: string | null
org_grant: {
grant_id: string
approved_scopes: string[]
approved_at: string
last_used_at: string | null
} | null
}
export type ListOAuthAppsOverviewResponse = {
data: OAuthAppOverviewItem[]
export type ListOAuthApprovalsResponse = {
data: OAuthApprovalItem[]
pagination: { next_cursor: string | null }
}
export type OAuthBlockedAppItem = {
app_id: string
export type OAuthGrantProject = {
ref: string
name: string
icon: string | null
blocked_at: string
blocked_by: { gotrue_id: string; email: string }
}
export type ListBlockedAppsResponse = {
data: OAuthBlockedAppItem[]
pagination: { next_cursor: string | null }
}
export type OAuthGrantItem = {
grant_id: string
kind: OAuthGrantKind
user: { gotrue_id: string; email: string; avatar_url?: string } | null
project_refs: string[] | null
projects: OAuthGrantProject[] | null
approved_scopes: string[]
approved_at: string
last_used_at: string | null
}
export type ListAppGrantsResponse = {
export type ListOrgAppGrantsResponse = {
data: OAuthGrantItem[]
pagination: { next_cursor: string | null }
}
@@ -189,19 +173,12 @@ export type MemberOauthGrantItem = {
grant_id: string
app: { id: string; name: string; icon: string | null }
organization: { slug: string; name: string }
project_refs: string[] | null
projects: OAuthGrantProject[] | null
approved_scopes: string[]
approved_at: string
last_used_at: string | null
access_affected: boolean
access_affected_reason: 'role_below_granted_scopes' | 'project_access_revoked' | null
}
export type ListOwnGrantsResponse = {
data: MemberOauthGrantItem[]
pagination: { next_cursor: string | null }
}
export type OAuthOrganizationSettings = { require_project_scoping: boolean }
export type OAuthOrganizationSettingsUpdate = { require_project_scoping: boolean }