mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
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:
1 parent
06727423bd
commit
b14ff6b261
12 files changed
+533
-329
No files matched your search
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user