mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 03:19:36 +08:00
Resolves FE-4014
A user with a project-scoped role opening any project integration
overview (e.g. Cron) hits an unbounded request loop — the page sits on a
skeleton forever while hammering the platform API until it gets rate
limited.
**Changed:**
- `useProjectOAuthIntegrationData` now passes `retryOnMount: false` to
its five queries, so a 403 settles as a terminal error instead of
refetching on every consumer mount
## Why
Project-scoped roles have no org-level permissions, so `GET
/platform/organizations/{slug}/oauth/apps` 403s. We don't retry 4xx, so
the query settles into `error` with no data — and an errored query with
no data is never fresh, so it refetches on *every* new observer mount.
That feeds a loop: refetch → `isLoading` true → `IntegrationPage` swaps
its whole subtree to a skeleton → `<Component />` unmounts → 403 lands →
`isLoading` false → remounts → mounts fresh observers → refetch.
Measured ~20 req/s (480 observer add/removes and 120 requests in a 6s
window) until the API 429s it, then it continues at the retry cadence
indefinitely.
The other four queries in that hook can 403 the same way for restricted
roles, and any one of them alone sustains the loop — hence the option on
all five.
Not fixed here: `IntegrationPage` tearing down its subtree whenever
`isLoading` flips
(`pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx:58-94`)
is the amplifier that turns a wasted request into a loop, and will still
reset UI state on any background refetch. Worth a follow-up.
## To test
Needs an account with a project-scoped role in a shared org (not an org
owner/admin).
- Open `/project/{ref}/integrations` for that project, click into Cron
(or any integration) → overview should render, not sit on a skeleton
- Network tab: `organizations/{slug}/oauth/apps?type=authorized` should
fire once and 403, not repeat
- Console should show 1 error, not hundreds ending in a 429
- As an org owner, integration overviews should behave exactly as before
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Prevented repeated refetching of integration data after handled
authorization/403 errors, avoiding refetch loops on remount.
* Improved consistency on integration landing screens by standardizing
how related integration queries are enabled and retried.
* **Enhancements**
* Added permission-aware loading/error handling for OAuth integration
data, showing OAuth results only when the selected organization grants
read access.
* **Chores**
* Updated permission-check typings to treat an explicitly empty project
reference as absent.
* **Tests**
* Extended integration settings tests with permission fixtures to cover
OAuth read access.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useMemo } from 'react'
|
|
|
|
import {
|
|
hasMatchingWrapper,
|
|
hasRequiredExtensions,
|
|
isOAuthInstalled,
|
|
isStripeSyncEngineInstalled,
|
|
useProjectOAuthIntegrationData,
|
|
} from './Landing.utils'
|
|
import { useAvailableIntegrations } from './useAvailableIntegrations'
|
|
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
|
import { useSchemasQuery } from '@/data/database/schemas-query'
|
|
import { useFDWsQuery } from '@/data/fdw/fdws-query'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { EMPTY_ARR } from '@/lib/void'
|
|
|
|
export const useInstalledIntegrations = () => {
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const { data: org } = useSelectedOrganizationQuery()
|
|
|
|
const { can: canReadOAuthApps } = useAsyncCheckPermissions(
|
|
PermissionAction.READ,
|
|
'oauth_apps',
|
|
undefined,
|
|
{
|
|
organizationSlug: org?.slug,
|
|
projectRef: null,
|
|
}
|
|
)
|
|
|
|
const {
|
|
data: allIntegrations = EMPTY_ARR,
|
|
error: availableIntegrationsError,
|
|
isPending: isAvailableIntegrationsLoading,
|
|
isSuccess: isSuccessAvailableIntegrations,
|
|
isError: isErrorAvailableIntegrations,
|
|
} = useAvailableIntegrations()
|
|
|
|
const hasOAuthIntegration = useMemo(() => {
|
|
return allIntegrations.some((integration) => integration.type === 'oauth')
|
|
}, [allIntegrations])
|
|
|
|
const {
|
|
data: oauthData,
|
|
error: oauthDataError,
|
|
isError: isErrorOAuthData,
|
|
isLoading: isOAuthDataLoading,
|
|
isSuccess: isSuccessOAuthData,
|
|
} = useProjectOAuthIntegrationData(project?.ref, { enabled: hasOAuthIntegration })
|
|
|
|
const {
|
|
data: wrappers = EMPTY_ARR,
|
|
error: fdwError,
|
|
isError: isErrorFDWs,
|
|
isPending: isFDWLoading,
|
|
isSuccess: isSuccessFDWs,
|
|
} = useFDWsQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
const {
|
|
data: extensions = EMPTY_ARR,
|
|
error: extensionsError,
|
|
isError: isErrorExtensions,
|
|
isPending: isExtensionsLoading,
|
|
isSuccess: isSuccessExtensions,
|
|
} = useDatabaseExtensionsQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
|
|
const {
|
|
data: schemas = EMPTY_ARR,
|
|
error: schemasError,
|
|
isError: isErrorSchemas,
|
|
isPending: isSchemasLoading,
|
|
isSuccess: isSuccessSchemas,
|
|
} = useSchemasQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
|
|
const isHooksEnabled = schemas.some((schema) => schema.name === 'supabase_functions')
|
|
|
|
const installedIntegrations = useMemo(() => {
|
|
return allIntegrations
|
|
.filter((integration) => {
|
|
if (integration.id === 'webhooks') return isHooksEnabled
|
|
if (integration.id === 'data_api') return true
|
|
if (integration.id === 'stripe_sync_engine') {
|
|
return isStripeSyncEngineInstalled(schemas)
|
|
}
|
|
if (integration.type === 'wrapper') {
|
|
return hasMatchingWrapper({ meta: integration.meta, wrappers })
|
|
}
|
|
if (integration.type === 'postgres_extension') {
|
|
return hasRequiredExtensions({ integration, extensions })
|
|
}
|
|
if (integration.type === 'oauth') {
|
|
return isOAuthInstalled({
|
|
integration,
|
|
projectData: oauthData,
|
|
})
|
|
}
|
|
return false
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
}, [allIntegrations, wrappers, extensions, schemas, isHooksEnabled, oauthData])
|
|
|
|
const error =
|
|
fdwError ||
|
|
extensionsError ||
|
|
schemasError ||
|
|
availableIntegrationsError ||
|
|
(canReadOAuthApps && hasOAuthIntegration ? oauthDataError : null)
|
|
const isLoading =
|
|
isSchemasLoading ||
|
|
isFDWLoading ||
|
|
isExtensionsLoading ||
|
|
isAvailableIntegrationsLoading ||
|
|
(hasOAuthIntegration && canReadOAuthApps && isOAuthDataLoading)
|
|
const isError =
|
|
isErrorFDWs ||
|
|
isErrorExtensions ||
|
|
isErrorSchemas ||
|
|
isErrorAvailableIntegrations ||
|
|
(hasOAuthIntegration && canReadOAuthApps && isErrorOAuthData)
|
|
const isSuccess =
|
|
isSuccessFDWs &&
|
|
isSuccessExtensions &&
|
|
isSuccessSchemas &&
|
|
isSuccessAvailableIntegrations &&
|
|
(!hasOAuthIntegration || !canReadOAuthApps || isSuccessOAuthData)
|
|
|
|
return {
|
|
// show all integrations at once instead of showing partial results
|
|
installedIntegrations: isLoading ? EMPTY_ARR : installedIntegrations,
|
|
error,
|
|
isError,
|
|
isLoading,
|
|
isSuccess,
|
|
}
|
|
}
|