mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
<!-- ccr-slack-attribution --> _Requested by **Ali Waseem** · [Slack thread](https://supabase.slack.com/archives/C063LNYJJKS/p1787146439389169)_ ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Opening `/authorize` for an OAuth app whose `name` the platform API omitted crashed the entire page with `TypeError: Cannot read properties of undefined (reading 'toLowerCase')` ([SUPABASE-APP-K7E](https://supabase.sentry.io/issues/7679644991/)). The user got a full-page error instead of a consent screen, and could neither authorize nor decline. The same class of crash hit the project-level OAuth apps list ([SUPABASE-APP-JB1](https://supabase.sentry.io/issues/7502074939/)). Typing in the search box called `.toLowerCase()` on `client_name` for every app, so one app registered without a name broke search for the whole list. The project-claim page crashed the same way, reading the first character of the name for the fallback avatar. ## What is the new behavior? The trusted-partner helpers treat a missing name as "no trusted partner matched" and return `null`. The apps filter treats a missing name or client ID as "does not match the search string". The claim page falls back to a placeholder initial instead of indexing into `undefined`. The authorize page now renders normally, minus the optional partner-impersonation caution, which cannot be evaluated without a name. Three changes: - `apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts` — `findTrustedPartnerByName` accepts `string | null | undefined` and returns `null` early on a falsy name; `getOAuthImpersonationWarning`'s `name` param widened to match (its existing `if (!namedPartner) return null` already handles the rest). - `apps/studio/components/interfaces/Auth/OAuthApps/oauthApps.utils.ts` — `filterOAuthApps` optional-chains `client_name` and `client_id` before `.toLowerCase()`, defaulting each match to `false`. - `apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx` — `{requester.name?.[0] ?? '?'}` for the fallback avatar initial. Each is a separate commit so any one can be dropped independently. ## Additional context ### Root cause, not fixed here `apps/studio/data/api-authorization/api-authorization-query.ts:37` returns `data as ApiAuthorizationResponse`, an unchecked cast with no runtime validation, even though the openapi-fetch client already types the endpoint from the generated schema. Both the generated `GetOAuthAuthorizationResponse` and the hand-written local type declare `name: string` as required, so this was invisible to TypeScript. The durable fix is to derive the type from the schema and drop the cast, which is the house pattern elsewhere in `apps/studio/data`, and to correct the OpenAPI spec at source if the API can legitimately omit `name`. Left out deliberately to keep this cherry-pickable. ### Not in scope `requester.scopes` is optional in the schema but required in the local type, and is read unguarded in several places. Defaulting it to `[]` would tell a user an app requested no permissions on a live consent screen, so it needs a product decision rather than a drive-by guard. ### Testing No local checks were run. This clone has no `node_modules` and `pnpm install` is blocked in the environment, so `npm run build`, typecheck, lint, Prettier and tests were all left to CI. Please treat CI as the verification for this PR. There is also a coverage gap worth noting: `apps/studio/tests/components/ApiAuthorization.test.tsx:48-62` hardcodes `name: 'Test App'` in `createMockAuthResponse`, and no test omits the field, which is why none of these crashes were caught. --- _Generated by [Claude Code](https://claude.ai/code/session_01P489vrPdHcJfMfzCGM9rZ5)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Ali Waseem <waseema393@gmail.com>
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
import { OAuthScope } from '@supabase/shared-types/out/constants'
|
|
import { useParams } from 'common'
|
|
import { CheckCircle2, ChevronRight, ChevronsLeftRight } from 'lucide-react'
|
|
import Image from 'next/image'
|
|
import { useRouter } from 'next/router'
|
|
import { toast } from 'sonner'
|
|
import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
|
|
import { Admonition } from 'ui-patterns/Admonition'
|
|
|
|
import { ScopeSection } from '../OAuthApps/AuthorizeRequesterDetails'
|
|
import { PERMISSIONS_DESCRIPTIONS } from '../OAuthApps/OAuthApps.constants'
|
|
import { ProjectClaimLayout } from './layout'
|
|
import { useApiAuthorizationApproveMutation } from '@/data/api-authorization/api-authorization-approve-mutation'
|
|
import { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
|
|
import { useOrganizationProjectClaimMutation } from '@/data/organizations/organization-project-claim-mutation'
|
|
import { OrganizationProjectClaimResponse } from '@/data/organizations/organization-project-claim-query'
|
|
import { useInvalidateProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
|
|
import { BASE_PATH } from '@/lib/constants'
|
|
import type { Organization } from '@/types'
|
|
|
|
export const ProjectClaimConfirm = ({
|
|
selectedOrganization,
|
|
projectClaim,
|
|
requester,
|
|
setStep,
|
|
}: {
|
|
selectedOrganization: Organization
|
|
projectClaim: OrganizationProjectClaimResponse
|
|
requester: ApiAuthorizationResponse
|
|
setStep: (step: 'choose-org' | 'benefits' | 'confirm') => void
|
|
}) => {
|
|
const router = useRouter()
|
|
const { auth_id, token: claimToken } = useParams()
|
|
const { invalidateProjectsQuery } = useInvalidateProjectsInfiniteQuery()
|
|
|
|
const { mutateAsync: approveRequest, isPending: isApproving } =
|
|
useApiAuthorizationApproveMutation()
|
|
|
|
const { mutateAsync: claimProject, isPending: isClaiming } = useOrganizationProjectClaimMutation()
|
|
|
|
const onClaimProject = async () => {
|
|
try {
|
|
const response = await approveRequest({ id: auth_id!, slug: selectedOrganization.slug })
|
|
|
|
await claimProject({
|
|
slug: selectedOrganization.slug,
|
|
token: claimToken!,
|
|
})
|
|
|
|
toast.success('Project claimed successfully')
|
|
try {
|
|
// check if the redirect url is valid. If not, redirect the user to the org dashboard
|
|
const url = new URL(response.url)
|
|
window.location.href = url.toString()
|
|
} catch {
|
|
// invalidate the org projects to force them to be refetched
|
|
await invalidateProjectsQuery()
|
|
router.push(`/org/${selectedOrganization.slug}`)
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(`Failed to claim project ${error.message}`)
|
|
}
|
|
}
|
|
|
|
const isLoading = isApproving || isClaiming
|
|
|
|
return (
|
|
<ProjectClaimLayout
|
|
title={
|
|
<>
|
|
Claim a project <span className="text-brand">{projectClaim?.project?.name}</span> from{' '}
|
|
<span className="text-brand">{requester?.name}</span>
|
|
</>
|
|
}
|
|
>
|
|
<div className="py-6 space-y-8 text-sm">
|
|
<div className="flex flex-col items-center mt-6">
|
|
<div className="flex items-center">
|
|
<div
|
|
className={cn(
|
|
'w-8 h-8 bg-center bg-no-repeat bg-cover flex items-center justify-center rounded-md'
|
|
)}
|
|
style={{
|
|
backgroundImage: !!requester.icon ? `url('${requester.icon}')` : 'none',
|
|
}}
|
|
>
|
|
{!requester.icon && (
|
|
<p className="text-foreground-light text-lg">{requester.name?.[0] ?? '?'}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center w-28 relative">
|
|
<div className="h-0.5 w-full border-2 border-dashed border-stronger" />
|
|
<div className="rounded-full border flex items-center justify-center h-10 w-full shadow-xs">
|
|
<ChevronsLeftRight className="text-muted-foreground" size={24} />
|
|
</div>
|
|
<div className="h-0.5 w-full border-2 border-dashed border-stronger z-10" />
|
|
</div>
|
|
|
|
<div className="w-8 h-8">
|
|
<Image
|
|
src={`${BASE_PATH}/img/supabase-logo.svg`}
|
|
alt="Supabase Logo"
|
|
className="w-full h-full"
|
|
width={100}
|
|
height={100}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4 text-foreground-light">
|
|
<p>
|
|
By claiming the <span className="text-foreground">{projectClaim?.project?.name}</span>{' '}
|
|
project from <span className="text-foreground">{requester?.name}</span>, the following
|
|
will happen:
|
|
</p>
|
|
<ul className="space-y-3">
|
|
<li className="flex space-x-2">
|
|
<span>
|
|
<CheckCircle2 className="text-brand h-5 w-5" />
|
|
</span>
|
|
<span>
|
|
The project will be transferred to your Supabase organization{' '}
|
|
<span className="text-foreground">{selectedOrganization.name}.</span>{' '}
|
|
<a
|
|
href="#"
|
|
onClick={() => setStep('choose-org')}
|
|
className="text-foreground-light underline"
|
|
>
|
|
Choose another organization?
|
|
</a>
|
|
</span>
|
|
</li>
|
|
<li className="flex space-x-2">
|
|
<span>
|
|
<CheckCircle2 className="text-brand h-5 w-5" />
|
|
</span>
|
|
<span>
|
|
<span className="text-foreground">{requester?.name}</span> will receive API access
|
|
(permissions listed below) to all projects within your organization to continue
|
|
providing its functionality to the application you've built.
|
|
</span>
|
|
</li>
|
|
<li className="flex space-x-2">
|
|
<span>
|
|
<CheckCircle2 className="text-brand h-5 w-5" />
|
|
</span>
|
|
<span>
|
|
You'll be responsible for maintaining the project, which may include additional
|
|
costs.
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
<Admonition type="caution">
|
|
<div className="text-foreground-light">
|
|
Upon claiming, the project may undergo a short downtime (less than 10 minutes) for
|
|
resizing.
|
|
</div>
|
|
</Admonition>
|
|
</div>
|
|
<div className="flex space-y-4 flex-col">
|
|
{requester.scopes.length === 0 ? (
|
|
<span className="text-foreground-light">
|
|
<span className="text-foreground">{requester?.name}</span> hasn't requested any
|
|
permissions to operate. This is normal and no action is needed from your side.
|
|
</span>
|
|
) : (
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="pb-3 w-full flex items-center justify-between group">
|
|
<p className="text-sm text-foreground-light text-left">
|
|
<span className="font-foreground">List of permissions</span> that{' '}
|
|
<span className="text-foreground">{requester.name}</span> will have for the{' '}
|
|
<span className="text-amber-900">
|
|
selected organization and all of its projects.
|
|
</span>
|
|
</p>
|
|
|
|
<ChevronRight
|
|
size={16}
|
|
className="text-foreground-light transition-all group-data-open:rotate-90 w-20"
|
|
strokeWidth={1}
|
|
/>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent
|
|
className={cn(
|
|
'flex flex-col gap-8 transition-all',
|
|
'data-closed:animate-collapsible-up data-open:animate-collapsible-down'
|
|
)}
|
|
>
|
|
<div>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.ANALYTICS}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.ANALYTICS_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.ANALYTICS_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.ANALYTICS_CONFIG}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.ANALYTICS_CONFIG_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.ANALYTICS_CONFIG_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.AUTH}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.AUTH_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.AUTH_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.DATABASE}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.DATABASE_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.DATABASE_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.DOMAINS}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.DOMAINS_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.DOMAINS_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.EDGE_FUNCTIONS}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.EDGE_FUNCTIONS_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.EDGE_FUNCTIONS_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.ENVIRONMENT}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.ENVIRONMENT_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.ENVIRONMENT_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.ORGANIZATIONS}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.ORGANIZATIONS_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.ORGANIZATIONS_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.PROJECTS}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.PROJECTS_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.PROJECTS_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.REST}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.REST_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.REST_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.SECRETS}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.SECRETS_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.SECRETS_WRITE)}
|
|
/>
|
|
<ScopeSection
|
|
description={PERMISSIONS_DESCRIPTIONS.STORAGE}
|
|
hasReadScope={requester.scopes.includes(OAuthScope.STORAGE_READ)}
|
|
hasWriteScope={requester.scopes.includes(OAuthScope.STORAGE_WRITE)}
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-center sticky bottom-0">
|
|
<Button size="medium" loading={isLoading} disabled={isLoading} onClick={onClaimProject}>
|
|
Claim project {projectClaim?.project?.name}
|
|
</Button>
|
|
</div>
|
|
</ProjectClaimLayout>
|
|
)
|
|
}
|