feat(studio): move /authorize to connect interstitial (#46359)

> [!CAUTION]
> The `do-not-merge` label has been applied because this contains mocks
for easier review and testing. I'll remove those mocks before merging.

## What kind of change does this PR introduce?

Feature. Part of the shared Connect UI (interstitial) rollout. Previous
slices: #46058, #45909, #45862.

## What is the current behavior?

The `/authorize` MCP/OAuth consent screen uses the old `Card`/`Alert`
layout.

## What is the new behavior?

- Wraps all `/authorize` states in `InterstitialLayout` (the shared
full-screen centered card used across Connect flows)
- Shows a quiet footnote below the Cancel button ("Authorizing will
redirect you to \<url\>") for non-localhost redirect URIs, so users can
verify the destination before approving. No extra friction for localhost
flows (local MCP servers)

| Before | After |
| --- | --- |
| <img width="692" height="997" alt="Authorize API access
Supabase-F6C3747A-5077-43D8-A509-3E16B1DDC168"
src="https://github.com/user-attachments/assets/e86dde34-94cb-48ef-b026-66aac9122df6"
/> | <img width="692" height="997" alt="Authorize API Access
Supabase-FE6FD8B3-1159-4EA5-94D7-EA5CEA7A25F3"
src="https://github.com/user-attachments/assets/c1a94a44-51d9-40d8-8046-f3104a27b929"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-86742351-3521-4B62-AF87-403CB7E7F4F5"
src="https://github.com/user-attachments/assets/41cff7af-b9e4-4a20-a979-7148b4220265"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-B665B4A4-600F-462B-8C97-84B171EC3103"
src="https://github.com/user-attachments/assets/804286f2-ce51-45ab-bb3f-315f8ac62445"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-C73DC3D0-8646-4E6E-A259-3E84AE46DAF2"
src="https://github.com/user-attachments/assets/8f285edb-438f-4262-9faa-f1133c679ed4"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-FEA86625-27D5-4DB5-B4D4-1A2CB804E56E"
src="https://github.com/user-attachments/assets/b54f2ceb-e1cf-4c7e-be3f-8e1b0942e9a4"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-48E0C7CB-DDDD-4305-B821-F3BEB52C4A4E"
src="https://github.com/user-attachments/assets/7d123c57-e05d-408c-8df9-d747a3afd714"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-CE8F9905-FAE0-4C06-B77A-9F269B2100FE"
src="https://github.com/user-attachments/assets/9f403b83-5de3-43c8-a592-c3022e041243"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-E37D2CD5-476F-4F49-A5FB-631B265025DC"
src="https://github.com/user-attachments/assets/3d235315-d7c0-4279-b23f-e8b595888511"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-DF078AEB-BB78-4647-9FA2-5D5403CCA5D6"
src="https://github.com/user-attachments/assets/53d51718-8707-4b97-9cbe-8e523f4ce0e0"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-D6F6817F-D8DD-4D55-85BB-A15100814AAB"
src="https://github.com/user-attachments/assets/c80c5579-772a-4dfe-a247-b0b9772b9690"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-E457B580-9786-43AD-9CF9-FE4F5BB8E785"
src="https://github.com/user-attachments/assets/30c47b05-edf5-4380-a2f1-aedb99482540"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-4F3D6AA4-E2E3-4526-B391-49B6E0861911"
src="https://github.com/user-attachments/assets/ffbe5b65-6eef-49d7-95f1-c29072c320b8"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-CA9FFCC9-4CA2-4718-AD49-B02D86C6EF6A"
src="https://github.com/user-attachments/assets/8fd7ff39-19f5-4414-af13-3821290735b2"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-E507B7A5-9AD0-4F17-8743-63A7B47D171A"
src="https://github.com/user-attachments/assets/1639b5cc-69c4-4a43-b049-6f989e2cdbb1"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-9844BB27-2429-4BA6-BD36-1AB54099F44F"
src="https://github.com/user-attachments/assets/a94b88e2-9c2f-4941-840a-5182342bb335"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-27684173-9DBB-4F6E-9F7F-87EFD4E10A5F"
src="https://github.com/user-attachments/assets/91794c96-8a81-4d83-9c97-01d134639676"
/> | <img width="692" height="997" alt="Authorize Cursor
Supabase-04E31F7B-D098-4814-A394-01CE3D3E5A51"
src="https://github.com/user-attachments/assets/ba0284a3-363c-4aa5-9e4a-c378aed9c42c"
/> |
| <img width="692" height="997" alt="Authorize API access
Supabase-207CBC69-4957-499C-92E8-163F2B34C8AD"
src="https://github.com/user-attachments/assets/1bafedd2-bba8-473c-ba57-637289f1c940"
/> | <img width="692" height="997" alt="Authorize API Access
Supabase-C1627071-4AE2-4012-8F7C-4E6D883618A3"
src="https://github.com/user-attachments/assets/a6fc6125-3c1e-4b8c-821a-c3c9f32f3cc0"
/> |

## To test

A mock toolbar is included for easy local testing. Navigate to
`/authorize?mock=loading` and then switch between the following
variants:

| State | What to check |
| --- | --- |
| `loading` | Shimmer skeleton inside the card |
| `ready` | Regular waiting state |
| `approving` | Authorize button shows spinner, both buttons disabled |
| `approved` | Success admonition: "Authorization approved" |
| `expired` | Warning admonition: "Authorization request expired", no
action buttons |
| `organizations-loading` | Org selector shimmer, no action buttons |
| `organizations-error` | "Unable to load organizations" admonition, no
action buttons |
| `empty` | "No organizations found" admonition, no action buttons |
| `not-member` | "Organization unavailable" admonition, no action
buttons |
| `error` | "Unable to load authorization" error screen |

Then please test the `organization_slug` prefill:
`/authorize?mock=ready&organization_slug=<your-org-name-here>`. That org
selector should be pre-selected and locked.

To test against a real OAuth app, use a registered app on
`supabase.green` — the mock states cover all edge cases but a live
round-trip confirms the approve/decline API calls.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added mock preview functionality for testing API authorization and
Connect flows
* Introduced collapsible, grouped permissions view for OAuth
authorization requests

* **Refactor**
* Redesigned API authorization screens with improved layout and
messaging
  * Restructured permissions display for better organization and clarity

* **Bug Fixes**
  * Fixed inline link underline decoration color

* **Tests**
  * Updated authorization flow test assertions to match new UI behavior

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46359?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-06-08 10:51:04 -06:00
1 parent 02422bed2c
commit 35df570342
12 files changed
+714 -497

No files matched your search

@@ -1,8 +1,12 @@
import dayjs from 'dayjs'
import type { ReactNode } from 'react'
import { Alert, AlertDescription, AlertTitle, Card, CardContent, CardHeader, CheckIcon } from 'ui'
import { Admonition } from 'ui-patterns'
import { AuthorizeRequesterDetails } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import {
AuthorizeRequesterDetails,
RequesterLogo,
} from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import { InterstitialLayout, LogoPair, SupabaseLogo } from '@/components/layouts/InterstitialLayout'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import type { Organization } from '@/types'
@@ -15,31 +19,36 @@ export function ApiAuthorizationApprovedScreen({
requester,
organization,
}: ApiAuthorizationApprovedScreenProps): ReactNode {
const organizationName = organization?.name ?? 'Unknown'
return (
<Card>
<CardHeader>Authorize API access for {requester.name}</CardHeader>
<CardContent className="p-0">
<Alert className="border-0 rounded-t-none">
<CheckIcon />
<AlertTitle>This authorization request has been approved</AlertTitle>
<AlertDescription>
<p>
{requester.name} has been approved access to the organization "
{organization?.name ?? 'Unknown'}" and all of its projects for the following scopes:
</p>
<AuthorizeRequesterDetails
showOnlyScopes
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
<p className="mt-2">
Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}
</p>
</AlertDescription>
</Alert>
</CardContent>
</Card>
<InterstitialLayout
logo={
<LogoPair
left={<RequesterLogo icon={requester.icon} name={requester.name} />}
right={<SupabaseLogo />}
/>
}
title={requester.name}
description="is authorized for Supabase"
>
<div className="flex flex-col gap-5 px-6 pb-6">
<Admonition
type="success"
title="Authorization approved"
description={`${requester.name} has access to ${organizationName} and its projects.`}
/>
<AuthorizeRequesterDetails
showOnlyScopes
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
<p className="text-center text-xs text-foreground-lighter">
Approved on {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}.
</p>
</div>
</InterstitialLayout>
)
}
@@ -1,6 +1,9 @@
import Link from 'next/link'
import type { ReactNode } from 'react'
import { Alert, AlertDescription, AlertTitle, Card, CardContent, CardHeader, WarningIcon } from 'ui'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout'
import type { ResourceError } from '@/data/api-authorization/api-authorization-query'
export interface ApiAuthorizationErrorScreenProps {
@@ -11,18 +14,23 @@ export function ApiAuthorizationErrorScreen({
error,
}: ApiAuthorizationErrorScreenProps): ReactNode {
return (
<Card>
<CardHeader>Authorize API access</CardHeader>
<CardContent className="p-0">
<Alert variant="warning" className="border-0 rounded-t-none">
<WarningIcon />
<AlertTitle>Failed to fetch details for API authorization request</AlertTitle>
<AlertDescription>
<p>Please retry your authorization request from the requesting app</p>
{error && <p className="mt-2">Error: {error?.message}</p>}
</AlertDescription>
</Alert>
</CardContent>
</Card>
<InterstitialLayout logo={<SupabaseLogo />} title="Unable to load authorization">
<div className="flex flex-col gap-3 px-6 pb-6">
<Admonition
type="warning"
description={
<>
Retry the authorization request from the requesting app.
{error && (
<span className="mt-1 block text-foreground-lighter">Error: {error.message}</span>
)}
</>
}
/>
<Button type="default" block asChild>
<Link href="/">Back to dashboard</Link>
</Button>
</div>
</InterstitialLayout>
)
}
@@ -1,34 +1,28 @@
import dayjs from 'dayjs'
import Link from 'next/link'
import { useMemo, type ReactNode } from 'react'
import { type ReactNode } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardFooter,
CardHeader,
Form,
FormControl,
FormField,
FormItem,
FormMessage,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
WarningIcon,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import { Admonition, ShimmeringLoader } from 'ui-patterns'
import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema'
import { AuthorizeRequesterDetails } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import {
AuthorizeRequesterDetails,
RequesterLogo,
} from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
import { InterstitialLayout, LogoPair, SupabaseLogo } from '@/components/layouts/InterstitialLayout'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import { BASE_PATH } from '@/lib/constants'
import type { Organization, ResponseError } from '@/types'
type OrganizationsState_Loading = {
@@ -53,13 +47,22 @@ type OrganizationsState_Success = {
organizations: Array<Organization>
}
type OrganizationsState =
export type OrganizationsState =
| OrganizationsState_Loading
| OrganizationsState_Error
| OrganizationsState_Empty
| OrganizationsState_NotMember
| OrganizationsState_Success
function isExternalRedirectUrl(url: string): boolean {
try {
const { hostname } = new URL(url)
return hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== '::1'
} catch {
return false
}
}
export interface ApiAuthorizationMainViewProps {
approvalState: ApprovalState
form: UseFormReturn<IApprovalFormSchema>
@@ -79,89 +82,87 @@ export function ApiAuthorizationMainView({
onApprove,
onDecline,
}: ApiAuthorizationMainViewProps): ReactNode {
const isMcpClient = requester.registration_type === 'dynamic'
const isExpired = dayjs().isAfter(dayjs(requester.expires_at))
const showReadyContent = !isExpired && organizations._tag === 'success'
const redirectUrl = requester.redirect_uri ?? requester.website
const externalRedirectUrl = isExternalRedirectUrl(redirectUrl) ? redirectUrl : undefined
return (
<FormShell title={`Authorize API access for ${requester.name}`}>
{isMcpClient && <McpNotice />}
<AuthorizeRequesterDetails
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
{isExpired && <ExpiredNotice />}
{organizations._tag === 'loading' && <OrganizationsLoader />}
{organizations._tag === 'error' && <OrganizationsErrorNotice error={organizations.error} />}
{organizations._tag === 'empty' && <OrganizationsEmptyState />}
{organizations._tag === 'not_member' && <NotMemberOfOrganizationNotice />}
{organizations._tag === 'success' && (
<OrganizationSelector
form={form}
disabled={isExpired || !!requestedOrganizationSlug}
requester={requester}
organizations={organizations.organizations}
requestedOrganizationSlug={requestedOrganizationSlug}
<InterstitialLayout
logo={
<LogoPair
left={<RequesterLogo icon={requester.icon} name={requester.name} />}
right={<SupabaseLogo />}
/>
)}
<FormFooter
disabled={isExpired || organizations._tag !== 'success'}
approvalState={approvalState}
requester={requester}
organizations={organizations}
onApprove={onApprove}
onDecline={onDecline}
/>
</FormShell>
)
}
interface FormShellProps {
title: string
children: ReactNode
}
function FormShell({ title, children }: FormShellProps): ReactNode {
return (
<Card>
<CardHeader>{title}</CardHeader>
<CardContent className="space-y-8">{children}</CardContent>
</Card>
)
}
function McpNotice(): ReactNode {
return (
<Alert variant="warning">
<WarningIcon />
<AlertTitle>MCP Client Connection</AlertTitle>
<AlertDescription>
This is an MCP (Model Context Protocol) client designed to connect with AI applications.
Please ensure you trust this application before granting access to your organization's data.
</AlertDescription>
</Alert>
}
title={`Authorize ${requester.name}`}
description="This application wants to access your Supabase account"
>
<div className="px-6 pb-6">
<span className="sr-only">Authorize API access for {requester.name}</span>
<div className="flex flex-col gap-5">
{isExpired ? (
<ExpiredNotice />
) : (
<>
{organizations._tag === 'loading' && <OrganizationsLoader />}
{organizations._tag === 'error' && (
<OrganizationsErrorNotice error={organizations.error} />
)}
{organizations._tag === 'empty' && <OrganizationsEmptyState />}
{organizations._tag === 'not_member' && <NotMemberOfOrganizationNotice />}
{organizations._tag === 'success' && (
<OrganizationSelector
form={form}
disabled={!!requestedOrganizationSlug}
requester={requester}
organizations={organizations.organizations}
requestedOrganizationSlug={requestedOrganizationSlug}
/>
)}
{showReadyContent && (
<>
<AuthorizeRequesterDetails
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
<FormFooter
approvalState={approvalState}
requester={requester}
redirectUrl={externalRedirectUrl}
onApprove={onApprove}
onDecline={onDecline}
/>
</>
)}
</>
)}
</div>
</div>
</InterstitialLayout>
)
}
function ExpiredNotice(): ReactNode {
return (
<Alert variant="warning">
<WarningIcon />
<AlertTitle>This authorization request is expired</AlertTitle>
<AlertDescription>
Please retry your authorization request from the requesting app
</AlertDescription>
</Alert>
<Admonition
type="warning"
title="Authorization request expired"
description="Retry the authorization request from the requesting app."
/>
)
}
function OrganizationsLoader(): ReactNode {
return (
<div className="py-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
<section className="space-y-2" aria-label="Organizations">
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
Organization
</p>
<ShimmeringLoader className="h-[34px] w-full rounded-md py-0" />
</section>
)
}
@@ -171,40 +172,38 @@ interface OrganizationsErrorNoticeProps {
function OrganizationsErrorNotice({ error }: OrganizationsErrorNoticeProps): ReactNode {
return (
<Alert variant="warning">
<WarningIcon />
<AlertTitle>There was an error loading your organizations</AlertTitle>
<AlertDescription>
Please try again. If the problem persists, contact support.
{error && <p className="mt-2">Error: {error.message}</p>}
</AlertDescription>
</Alert>
<Admonition
type="warning"
title="Unable to load organizations"
description={
<>
Please try again. If the problem persists, contact support.
{error && (
<span className="mt-1 block text-foreground-lighter">Error: {error.message}</span>
)}
</>
}
/>
)
}
function OrganizationsEmptyState(): ReactNode {
return (
<Alert variant="warning">
<WarningIcon />
<AlertTitle>Organization is needed for installing an integration</AlertTitle>
<AlertDescription>
Your account isn't associated with any organizations. To use this integration, it must be
installed within an organization. You'll be redirected to create an organization first.
</AlertDescription>
</Alert>
<Admonition
type="warning"
title="No organizations found"
description="Create an organization before authorizing this request."
/>
)
}
function NotMemberOfOrganizationNotice(): ReactNode {
return (
<Alert variant="warning">
<WarningIcon />
<AlertTitle>Organization is needed for installing an integration</AlertTitle>
<AlertDescription>
Your account is not a member of the pre-selected organization. To use this integration, it
must be installed within an organization your account is associated with.
</AlertDescription>
</Alert>
<Admonition
type="warning"
title="Organization unavailable"
description="Your account is not a member of the pre-selected organization."
/>
)
}
@@ -228,40 +227,47 @@ function OrganizationSelector({
<FormField
control={form.control}
name="selectedOrgSlug"
render={({ field }) => (
<FormItem>
<FormLayout
label="Organization to grant API access to"
description={
requestedOrganizationSlug
? `This organization has been pre-selected by ${requester.name}.`
: undefined
}
isReactForm
>
<FormControl>
<Select
value={field.value || undefined}
disabled={disabled}
onValueChange={field.onChange}
render={({ field, fieldState }) => (
<FormItem className="space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
Organization
</p>
{requestedOrganizationSlug && (
<p className="text-xs text-foreground-lighter">Pre-selected by {requester.name}</p>
)}
</div>
<FormControl>
<Select
value={field.value ?? ''}
disabled={disabled}
onValueChange={(value) => {
field.onChange(value)
form.trigger('selectedOrgSlug')
}}
>
<SelectTrigger
size="small"
aria-label="Organization to grant API access to"
aria-invalid={fieldState.invalid}
aria-describedby={fieldState.error ? 'organization-selection-error' : undefined}
>
<SelectTrigger size="small">
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => (
<SelectItem
key={organization.slug}
value={organization.slug}
className="text-xs"
>
{organization.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormLayout>
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => (
<SelectItem
key={organization.slug}
value={organization.slug}
className="text-xs"
>
{organization.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage id="organization-selection-error" className="text-xs" />
</FormItem>
)}
/>
@@ -270,87 +276,45 @@ function OrganizationSelector({
}
interface FormFooterProps {
disabled?: boolean
approvalState: ApprovalState
requester: ApiAuthorizationResponse
organizations: OrganizationsState
redirectUrl?: string
onDecline: () => void
onApprove: () => void
}
function FormFooter({
disabled = false,
approvalState,
requester,
organizations,
redirectUrl,
onDecline,
onApprove,
}: FormFooterProps): ReactNode {
const showApprovalButton = organizations._tag === 'success' || organizations._tag === 'not_member'
return (
<CardFooter className="justify-end space-x-2">
<div className="flex flex-col gap-2">
<ApprovalButton
disabled={approvalState !== 'indeterminate'}
approvalState={approvalState}
requester={requester}
onApprove={onApprove}
/>
<Button
type="default"
type="text"
block
loading={approvalState === 'declining'}
disabled={disabled || approvalState !== 'indeterminate'}
disabled={approvalState !== 'indeterminate'}
onClick={onDecline}
>
Decline
Cancel
</Button>
{organizations._tag === 'loading' && (
<LoadingApprovalButton>Authorize {requester.name}</LoadingApprovalButton>
{redirectUrl && (
<div className="mt-3 border-t border-muted pt-5">
<p className="text-center text-xs text-foreground-lighter">
Authorizing will redirect you to <span className="text-foreground">{redirectUrl}</span>
</p>
</div>
)}
{organizations._tag === 'empty' && <CreateOrganizationLink />}
{showApprovalButton && (
<ApprovalButton
disabled={disabled || approvalState !== 'indeterminate'}
approvalState={approvalState}
requester={requester}
onApprove={onApprove}
/>
)}
</CardFooter>
)
}
interface LoadingApprovalButtonProps {
children: ReactNode
}
function LoadingApprovalButton({ children }: LoadingApprovalButtonProps): ReactNode {
return <Button loading={true}>{children}</Button>
}
function createReturnToSearchParam(): string | null {
if (typeof window === 'undefined') {
return null
}
const basePath = BASE_PATH
let pathname = basePath ? location.pathname.replace(basePath, '') : location.pathname
if (location.search) {
pathname += location.search
}
return pathname
}
function CreateOrganizationLink(): ReactNode {
const searchParamString = useMemo(function createSearchParams() {
const searchParams = new URLSearchParams()
const returnTo = createReturnToSearchParam()
if (returnTo) {
searchParams.set('returnTo', returnTo)
}
return searchParams.toString()
}, [])
return (
<Button asChild>
<Link href={`/new?${searchParamString}`}>Create an organization</Link>
</Button>
</div>
)
}
@@ -368,7 +332,14 @@ function ApprovalButton({
onApprove,
}: ApprovalButtonProps): ReactNode {
return (
<Button loading={approvalState === 'approving'} disabled={disabled} onClick={onApprove}>
<Button
type="primary"
block
loading={approvalState === 'approving'}
disabled={disabled}
aria-label={`Authorize ${requester.name}`}
onClick={onApprove}
>
Authorize {requester.name}
</Button>
)
@@ -1,28 +1,40 @@
import type { ReactNode } from 'react'
import { Card, CardContent, CardHeader } from 'ui'
import { Card, CardContent } from 'ui'
import { ShimmeringLoader } from 'ui-patterns'
import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout'
export function ApiAuthorizationLoadingScreen(): ReactNode {
return (
<Card>
<CardHeader>Authorize API access</CardHeader>
<CardContent>
<InterstitialLayout
logo={<SupabaseLogo />}
title={<ShimmeringLoader className="mx-auto h-7 w-28 max-w-full py-0" />}
description={<ShimmeringLoader className="mx-auto h-4 w-56 max-w-full py-0" />}
>
<div className="px-6 pb-6">
<span className="sr-only">Loading...</span>
<div className="flex gap-x-4 items-center">
<ShimmeringLoader className="w-12 h-12 md:w-14 md:h-14" />
<ShimmeringLoader className="h-6 w-64" />
<div className="flex flex-col gap-5">
<Card className="shadow-none">
<CardContent className="flex items-center gap-3 border-none px-4 py-3">
<ShimmeringLoader className="size-10 flex-shrink-0 rounded-lg py-0" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<ShimmeringLoader className="h-4 w-28 py-0" />
<ShimmeringLoader className="h-3 w-20 py-0" />
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardContent className="space-y-3 border-none px-4 py-4">
<ShimmeringLoader className="h-4 w-full py-0" />
<ShimmeringLoader className="h-4 w-4/5 py-0" />
</CardContent>
</Card>
<div className="flex flex-col gap-2">
<ShimmeringLoader className="h-10 w-full py-0" />
<ShimmeringLoader className="h-10 w-full py-0" />
</div>
</div>
<div className="flex flex-col gap-y-2 mt-4">
<ShimmeringLoader className="w-1/4" />
<ShimmeringLoader />
</div>
<div className="flex flex-col gap-y-2 mt-8">
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader />
</div>
</CardContent>
</Card>
</div>
</InterstitialLayout>
)
}
@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import Head from 'next/head'
import { useEffect, useEffectEvent, useMemo, useState, type ReactNode } from 'react'
import { useForm, type UseFormReturn } from 'react-hook-form'
import { toast } from 'sonner'
@@ -16,6 +17,7 @@ import { useApiAuthorizationApproveMutation } from '@/data/api-authorization/api
import { useApiAuthorizationDeclineMutation } from '@/data/api-authorization/api-authorization-decline-mutation'
import { useApiAuthorizationQuery } from '@/data/api-authorization/api-authorization-query'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { buildStudioPageTitle } from '@/lib/page-title'
import type { Organization } from '@/types'
function getMatchingOrganization(
@@ -45,16 +47,19 @@ function preselectOrganizationSlug({
}
}
function useOrganizationsState(organization_slug: string | undefined) {
function useOrganizationsState(organization_slug: string | undefined, enabled = true) {
const {
data: organizations,
isPending: isLoadingOrganizations,
isError: isErrorOrganizations,
error: organizationsError,
} = useOrganizationsQuery()
} = useOrganizationsQuery({ enabled })
const organizationsState = useMemo(
function calculateOrganizationsState() {
if (!enabled) {
return { _tag: 'loading' as const }
}
if (isLoadingOrganizations) {
return { _tag: 'loading' as const }
}
@@ -73,6 +78,7 @@ function useOrganizationsState(organization_slug: string | undefined) {
return { _tag: 'success' as const, organizations }
},
[
enabled,
isLoadingOrganizations,
isErrorOrganizations,
organizationsError,
@@ -179,28 +185,47 @@ export function ApiAuthorizationValidScreen({
return <ApiAuthorizationErrorScreen error={error} />
}
const effectiveRequester = requester
const effectiveOrganizationsState = organizationsState
const effectiveOrganizationSlug = organization_slug
const effectiveApprovalState = approvalState
const pageTitle = effectiveRequester
? buildStudioPageTitle({ section: `Authorize ${effectiveRequester.name}`, brand: 'Supabase' })
: undefined
if (!effectiveRequester) return null
if (isApproved) {
const approvedOrganization =
organizationsState._tag === 'success'
? organizationsState.organizations.find(
(org) => org.slug === requester.approved_organization_slug
effectiveOrganizationsState._tag === 'success'
? effectiveOrganizationsState.organizations.find(
(org) => org.slug === effectiveRequester.approved_organization_slug
)
: undefined
return (
<ApiAuthorizationApprovedScreen requester={requester} organization={approvedOrganization} />
<>
<Head>{pageTitle && <title>{pageTitle}</title>}</Head>
<ApiAuthorizationApprovedScreen
requester={effectiveRequester}
organization={approvedOrganization}
/>
</>
)
}
return (
<ApiAuthorizationMainView
approvalState={approvalState}
form={form}
requester={requester}
requestedOrganizationSlug={organization_slug}
organizations={organizationsState}
onApprove={onApproveRequest}
onDecline={onDeclineRequest}
/>
<>
<Head>{pageTitle && <title>{pageTitle}</title>}</Head>
<ApiAuthorizationMainView
approvalState={effectiveApprovalState}
form={form}
requester={effectiveRequester}
requestedOrganizationSlug={effectiveOrganizationSlug}
organizations={effectiveOrganizationsState}
onApprove={onApproveRequest}
onDecline={onDeclineRequest}
/>
</>
)
}
@@ -12,8 +12,7 @@ export function ApiAuthorizationScreen({
organization_slug,
navigate,
}: ApiAuthorizationScreenProps) {
const valid = !!auth_id
if (!valid) {
if (!auth_id) {
return <ApiAuthorizationInvalidScreen missingParameters={['auth_id']} />
}
@@ -1,7 +1,25 @@
import { OAuthScope } from '@supabase/shared-types/out/constants'
import { Check } from 'lucide-react'
import { Check, ChevronDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
Badge,
Card,
CardContent,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants'
import { LogoBox } from '@/components/layouts/InterstitialLayout'
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
const OAUTH_SCOPES_DOCS_URL = `${DOCS_URL}/guides/platform/oauth-apps/oauth-scopes`
const PERMISSION_DETAILS_TRIGGER_CLASSNAME =
'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground'
export interface AuthorizeRequesterDetailsProps {
icon: string | null
@@ -20,141 +38,307 @@ export const ScopeSection = ({
hasReadScope: boolean
hasWriteScope: boolean
}) => {
if (hasReadScope || hasWriteScope) {
const perms = [hasReadScope ? 'Read' : null, hasWriteScope ? 'Write' : null]
.filter(Boolean)
if (!hasReadScope && !hasWriteScope) return null
.map((str) => (
<span key={str} className="font-semibold text-foreground">
{str}
</span>
))
.reduce((acc, v) => (
<>
{acc}
<span> and </span>
{v}
</>
))
const permissions = [hasReadScope ? 'Read' : null, hasWriteScope ? 'Write' : null]
.filter(Boolean)
.join(' and ')
return (
<div className="first:border-t border-b flex flex-row space-x-1 text-sm text-foreground-light py-2 px-1">
<div className="pt-0.5">
<Check stroke="green" height={18} width={18} strokeWidth={1.5} />
</div>
<div>
{perms} {description}
</div>
</div>
)
}
return null
}
export const AuthorizeRequesterDetails = ({
icon,
name,
domain,
scopes,
showOnlyScopes = false,
}: AuthorizeRequesterDetailsProps) => {
return (
<div className="flex gap-y-4 flex-col">
{!showOnlyScopes && (
<div className="flex flex-row gap-x-4 items-center">
<div className="flex items-center">
<div
className="w-12 h-12 md:w-14 md:h-14 bg-center bg-no-repeat bg-cover flex items-center justify-center rounded-md border border-control"
style={{
backgroundImage: !!icon ? `url('${icon}')` : 'none',
}}
>
{!icon && <p className="text-foreground-light text-lg">{name[0]}</p>}
</div>
</div>
<p className="text-foreground">
{name} ({domain}) is requesting API access to an organization.
</p>
</div>
)}
<div className="first:border-t border-b flex flex-row space-x-1 text-sm text-foreground-light py-2 px-1">
<div className="pt-0.5">
<Check className="size-4 text-brand" strokeWidth={1.5} />
</div>
<div>
{!showOnlyScopes && (
<>
<h3>Permissions</h3>
<p className="text-sm text-foreground-light">
The following scopes will apply for the{' '}
<span className="text-amber-900">selected organization and all of its projects.</span>
</p>
</>
)}
<div className="pt-2">
{scopes.length === 0 && (
<p className="text-foreground-lighter text-sm">
No permissions requested, {name} will not have access to your organization or projects
</p>
)}
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.ANALYTICS}
hasReadScope={scopes.includes(OAuthScope.ANALYTICS_READ)}
hasWriteScope={scopes.includes(OAuthScope.ANALYTICS_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.ANALYTICS_CONFIG}
hasReadScope={scopes.includes(OAuthScope.ANALYTICS_CONFIG_READ)}
hasWriteScope={scopes.includes(OAuthScope.ANALYTICS_CONFIG_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.AUTH}
hasReadScope={scopes.includes(OAuthScope.AUTH_READ)}
hasWriteScope={scopes.includes(OAuthScope.AUTH_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.DATABASE}
hasReadScope={scopes.includes(OAuthScope.DATABASE_READ)}
hasWriteScope={scopes.includes(OAuthScope.DATABASE_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.DOMAINS}
hasReadScope={scopes.includes(OAuthScope.DOMAINS_READ)}
hasWriteScope={scopes.includes(OAuthScope.DOMAINS_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.EDGE_FUNCTIONS}
hasReadScope={scopes.includes(OAuthScope.EDGE_FUNCTIONS_READ)}
hasWriteScope={scopes.includes(OAuthScope.EDGE_FUNCTIONS_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.ENVIRONMENT}
hasReadScope={scopes.includes(OAuthScope.ENVIRONMENT_READ)}
hasWriteScope={scopes.includes(OAuthScope.ENVIRONMENT_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.ORGANIZATIONS}
hasReadScope={scopes.includes(OAuthScope.ORGANIZATIONS_READ)}
hasWriteScope={scopes.includes(OAuthScope.ORGANIZATIONS_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.PROJECTS}
hasReadScope={scopes.includes(OAuthScope.PROJECTS_READ)}
hasWriteScope={scopes.includes(OAuthScope.PROJECTS_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.REST}
hasReadScope={scopes.includes(OAuthScope.REST_READ)}
hasWriteScope={scopes.includes(OAuthScope.REST_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.SECRETS}
hasReadScope={scopes.includes(OAuthScope.SECRETS_READ)}
hasWriteScope={scopes.includes(OAuthScope.SECRETS_WRITE)}
/>
<ScopeSection
description={PERMISSIONS_DESCRIPTIONS.STORAGE}
hasReadScope={scopes.includes(OAuthScope.STORAGE_READ)}
hasWriteScope={scopes.includes(OAuthScope.STORAGE_WRITE)}
/>
</div>
<span className="font-semibold text-foreground">{permissions}</span> {description}
</div>
</div>
)
}
type PermissionLevel = 'read' | 'write' | 'read-write'
type PermissionItem = {
label: string
description: string
read?: OAuthScope
write?: OAuthScope
}
type PermissionGroup = {
label: string
items: PermissionItem[]
}
const PERMISSION_GROUPS: PermissionGroup[] = [
{
label: 'Data & authentication',
items: [
{
label: 'Database',
description: PERMISSIONS_DESCRIPTIONS.DATABASE,
read: OAuthScope.DATABASE_READ,
write: OAuthScope.DATABASE_WRITE,
},
{
label: 'Secrets',
description: PERMISSIONS_DESCRIPTIONS.SECRETS,
read: OAuthScope.SECRETS_READ,
write: OAuthScope.SECRETS_WRITE,
},
{
label: 'Auth',
description: PERMISSIONS_DESCRIPTIONS.AUTH,
read: OAuthScope.AUTH_READ,
write: OAuthScope.AUTH_WRITE,
},
],
},
{
label: 'Code execution',
items: [
{
label: 'Edge Functions',
description: PERMISSIONS_DESCRIPTIONS.EDGE_FUNCTIONS,
read: OAuthScope.EDGE_FUNCTIONS_READ,
write: OAuthScope.EDGE_FUNCTIONS_WRITE,
},
],
},
{
label: 'Platform management',
items: [
{
label: 'Environment',
description: PERMISSIONS_DESCRIPTIONS.ENVIRONMENT,
read: OAuthScope.ENVIRONMENT_READ,
write: OAuthScope.ENVIRONMENT_WRITE,
},
{
label: 'Organizations',
description: PERMISSIONS_DESCRIPTIONS.ORGANIZATIONS,
read: OAuthScope.ORGANIZATIONS_READ,
write: OAuthScope.ORGANIZATIONS_WRITE,
},
{
label: 'Projects',
description: PERMISSIONS_DESCRIPTIONS.PROJECTS,
read: OAuthScope.PROJECTS_READ,
write: OAuthScope.PROJECTS_WRITE,
},
{
label: 'Domains',
description: PERMISSIONS_DESCRIPTIONS.DOMAINS,
read: OAuthScope.DOMAINS_READ,
write: OAuthScope.DOMAINS_WRITE,
},
{
label: 'PostgREST',
description: PERMISSIONS_DESCRIPTIONS.REST,
read: OAuthScope.REST_READ,
write: OAuthScope.REST_WRITE,
},
],
},
{
label: 'Files & monitoring',
items: [
{
label: 'Analytics',
description: PERMISSIONS_DESCRIPTIONS.ANALYTICS,
read: OAuthScope.ANALYTICS_READ,
write: OAuthScope.ANALYTICS_WRITE,
},
{
label: 'Analytics configuration',
description: PERMISSIONS_DESCRIPTIONS.ANALYTICS_CONFIG,
read: OAuthScope.ANALYTICS_CONFIG_READ,
write: OAuthScope.ANALYTICS_CONFIG_WRITE,
},
{
label: 'Storage',
description: PERMISSIONS_DESCRIPTIONS.STORAGE,
read: OAuthScope.STORAGE_READ,
write: OAuthScope.STORAGE_WRITE,
},
],
},
]
export const RequesterLogo = ({ icon, name }: { icon: string | null; name: string }) => (
<LogoBox>
{icon ? (
<img alt={name} src={icon} className="size-full object-cover" />
) : (
<span className="text-lg font-medium text-foreground-light">{name.slice(0, 1)}</span>
)}
</LogoBox>
)
export const AuthorizeRequesterDetails = ({
name,
scopes,
showOnlyScopes = false,
}: AuthorizeRequesterDetailsProps) => {
const [showDetails, setShowDetails] = useState(showOnlyScopes)
const requestedPermissions = useMemo(() => getRequestedPermissions(scopes), [scopes])
const writablePermissions = requestedPermissions.filter(({ level }) => level !== 'read')
const readOnlyPermissions = requestedPermissions.filter(({ level }) => level === 'read')
return (
<section className="flex flex-col">
{requestedPermissions.length === 0 ? (
<Card className="shadow-none">
<CardContent className="border-none px-4 py-3 text-sm text-foreground-lighter">
No permissions requested.
</CardContent>
</Card>
) : (
<>
{!showOnlyScopes && (
<>
<div>
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
Permissions
</p>
<p className="mt-1 text-xs text-foreground-lighter">
Authorizing {name} grants it the following{' '}
<InlineLink href={OAUTH_SCOPES_DOCS_URL}>access permissions</InlineLink> to the
selected organization. Only continue if you trust this app.
</p>
</div>
<Card className="overflow-hidden shadow-none bg-surface-200/60 border-muted mt-3">
<CardContent className="border-none p-0">
<div className="divide-y divide-muted px-4">
{writablePermissions.length > 0 && (
<PermissionSummaryRow permissions={writablePermissions} level="read-write" />
)}
{readOnlyPermissions.length > 0 && (
<PermissionSummaryRow permissions={readOnlyPermissions} level="read" />
)}
</div>
</CardContent>
</Card>
</>
)}
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-2 space-y-2">
<CollapsibleTrigger className={PERMISSION_DETAILS_TRIGGER_CLASSNAME}>
<span>{showDetails ? 'Hide detailed permissions' : 'Show detailed permissions'}</span>
<ChevronDown
className={cn('size-3.5 transition-transform', showDetails && 'rotate-180')}
/>
</CollapsibleTrigger>
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down overflow-hidden">
<PermissionDetails requestedPermissions={requestedPermissions} />
</CollapsibleContent>
</Collapsible>
</>
)}
</section>
)
}
function PermissionSummaryRow({
permissions,
level,
}: {
permissions: Array<RequestedPermission>
level: PermissionLevel
}) {
return (
<div className="flex items-center justify-between gap-3 py-3 text-sm">
<p className="min-w-0 leading-tight">
<span className="text-foreground">{permissions.map(({ label }) => label).join(', ')}</span>
</p>
<Badge variant={getPermissionLevelBadgeVariant(level)} className="shrink-0">
{formatPermissionLevel(level)}
</Badge>
</div>
)
}
function PermissionDetails({
requestedPermissions,
}: {
requestedPermissions: Array<RequestedPermission>
}) {
const requestedByLabel = new Map(
requestedPermissions.map((permission) => [permission.label, permission])
)
return (
<Card className="overflow-hidden shadow-none bg-surface-200/60 border-muted">
<CardContent className="border-none p-0">
{PERMISSION_GROUPS.map((group) => {
const groupPermissions = group.items
.map((item) => requestedByLabel.get(item.label))
.filter(Boolean) as RequestedPermission[]
if (groupPermissions.length === 0) return null
return (
<div key={group.label} className="border-b border-muted last:border-b-0 px-4 py-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-foreground-lighter">
{group.label}
</p>
<div className="divide-y divide-muted">
{groupPermissions.map((permission) => (
<div
key={permission.label}
className="flex items-center justify-between gap-3 py-2"
>
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-sm text-foreground">{permission.label}</p>
<InfoTooltip side="right" className="max-w-64 text-xs">
{formatPermissionDescription(permission.description)}
</InfoTooltip>
</div>
<Badge variant={getPermissionLevelBadgeVariant(permission.level)}>
{formatPermissionLevel(permission.level)}
</Badge>
</div>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
type RequestedPermission = PermissionItem & {
level: PermissionLevel
}
function getRequestedPermissions(scopes: OAuthScope[]): RequestedPermission[] {
return PERMISSION_GROUPS.flatMap((group) =>
group.items.flatMap((item) => {
const hasReadScope = !!item.read && scopes.includes(item.read)
const hasWriteScope = !!item.write && scopes.includes(item.write)
if (!hasReadScope && !hasWriteScope) return []
return {
...item,
level: hasReadScope && hasWriteScope ? 'read-write' : hasWriteScope ? 'write' : 'read',
}
})
)
}
function formatPermissionLevel(level: PermissionLevel) {
if (level === 'read') return 'Read'
if (level === 'write') return 'Write'
return 'Read + Write'
}
function formatPermissionDescription(description: string) {
return description.charAt(0).toUpperCase() + description.slice(1)
}
function getPermissionLevelBadgeVariant(level: PermissionLevel) {
return level === 'read' ? 'default' : 'warning'
}
@@ -18,6 +18,13 @@ interface InterstitialLayoutProps {
descriptionClassName?: string
}
/**
* Minimal full-screen centered layout for interstitial flows:
* partner authorization, org invites, CLI auth, credit redemption, etc.
*
* The logo, title, and description render inside the card (above children),
* so every consumer gets a consistent header for free.
*/
export const InterstitialLayout = ({
logo,
title,
@@ -31,6 +38,28 @@ export const InterstitialLayout = ({
const TitleElement = typeof title === 'string' ? 'h1' : 'div'
const DescriptionElement = typeof description === 'string' ? 'p' : 'div'
const titleElement = title ? (
<TitleElement
className={cn(
'font-sans tracking-tight text-balance text-lg font-medium normal-case text-foreground',
titleClassName
)}
>
{title}
</TitleElement>
) : null
const descriptionElement = description ? (
<DescriptionElement
className={cn(
'!m-0 px-3 !text-balance text-sm text-foreground-lighter leading-tight',
descriptionClassName
)}
>
{description}
</DescriptionElement>
) : null
return (
<div
className={cn(
@@ -41,33 +70,15 @@ export const InterstitialLayout = ({
<MotionCard
layout="size"
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className={cn('mx-auto w-full max-w-[400px] overflow-hidden', cardClassName)}
className={cn('overflow-hidden max-w-[400px] w-full mx-auto', cardClassName)}
>
{(logo || title || description) && (
<CardHeader className="items-center gap-0 space-y-0 border-0 px-6 py-6 text-center font-normal [--card-padding-x:1.5rem]">
<CardHeader className="font-normal items-center gap-0 space-y-0 px-6 py-6 text-center [--card-padding-x:1.5rem] border-0">
{logo && <div className="mb-4 flex justify-center">{logo}</div>}
{(title || description) && (
{(titleElement || descriptionElement) && (
<div className="flex flex-col items-center gap-1">
{title && (
<TitleElement
className={cn(
'font-sans text-lg font-medium tracking-tight text-balance text-foreground',
titleClassName
)}
>
{title}
</TitleElement>
)}
{description && (
<DescriptionElement
className={cn(
'!m-0 px-3 text-sm leading-tight !text-balance text-foreground-lighter',
descriptionClassName
)}
>
{description}
</DescriptionElement>
)}
{titleElement}
{descriptionElement}
</div>
)}
</CardHeader>
@@ -78,6 +89,11 @@ export const InterstitialLayout = ({
)
}
/**
* Standard rounded-rect logo container (48x48).
* Partner logos fill edge-to-edge (see `PartnerLogo`); the Supabase symbol and
* Lucide icons sit inset (sized at `size-7`).
*/
export const LogoBox = ({ children, className }: { children: ReactNode; className?: string }) => (
<div
className={cn(
@@ -89,6 +105,7 @@ export const LogoBox = ({ children, className }: { children: ReactNode; classNam
</div>
)
/** Two pre-boxed logos side-by-side with a swap separator. */
export const LogoPair = ({ left, right }: { left: ReactNode; right: ReactNode }) => (
<div className="flex items-center justify-center gap-2.5">
{left}
@@ -97,18 +114,20 @@ export const LogoPair = ({ left, right }: { left: ReactNode; right: ReactNode })
</div>
)
export const SupabaseLogo = () => (
<LogoBox>
<img alt="Supabase" src={`${BASE_PATH}/img/supabase-logo.svg`} className="size-7" />
</LogoBox>
)
/** Partner logo rendered edge-to-edge inside a LogoBox. */
export const PartnerLogo = ({ src, alt }: { src: string; alt: string }) => (
<LogoBox>
<img alt={alt} src={src} className="size-full object-cover" />
</LogoBox>
)
/** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */
export const SupabaseLogo = () => (
<LogoBox>
<img alt="Supabase" src={`${BASE_PATH}/img/supabase-logo.svg`} className="size-7" />
</LogoBox>
)
export const InterstitialAccountRow = ({
avatarUrl,
displayName,
+1 -1
View File
@@ -12,7 +12,7 @@ interface InlineLinkProps {
}
export const InlineLinkClassName =
'underline transition underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-inherit hover:text-foreground'
'underline transition underline-offset-2 decoration-inherit hover:decoration-foreground text-inherit hover:text-foreground'
export const InlineLink = ({
href,
@@ -14,6 +14,7 @@ export type ApiAuthorizationResponse = {
website: string
icon: string | null
domain: string
redirect_uri?: string
scopes: OAuthScope[]
expires_at: string
approved_at: string | null
+13 -10
View File
@@ -3,10 +3,12 @@ import Head from 'next/head'
import { useRouter } from 'next/router'
import { ApiAuthorizationScreen } from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout'
import { withAuth } from '@/hooks/misc/withAuth'
import { buildStudioPageTitle } from '@/lib/page-title'
import type { NextPageWithLayout } from '@/types'
const PAGE_TITLE = buildStudioPageTitle({ section: 'Authorize API Access', brand: 'Supabase' })
const APIAuthorizationPage: NextPageWithLayout = () => {
const router = useRouter()
const routerReady = router.isReady
@@ -17,16 +19,17 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
}
return (
<ApiAuthorizationScreen
auth_id={auth_id}
organization_slug={organization_slug}
navigate={(destination) => router.push(destination)}
/>
<>
<Head>
<title>{PAGE_TITLE}</title>
</Head>
<ApiAuthorizationScreen
auth_id={auth_id}
organization_slug={organization_slug}
navigate={(destination) => router.push(destination)}
/>
</>
)
}
APIAuthorizationPage.getLayout = (page) => (
<APIAuthorizationLayout HeadProvider={Head}>{page}</APIAuthorizationLayout>
)
export default withAuth(APIAuthorizationPage)
@@ -150,7 +150,7 @@ describe('ApiAuthorizationScreen', () => {
response: () => HttpResponse.json<APIErrorBody>({ message: 'Not found' }, { status: 404 }),
})
renderScreen()
await screen.findByText('Failed to fetch details for API authorization request')
await screen.findByText('Unable to load authorization')
})
describe('when already approved', () => {
@@ -162,8 +162,8 @@ describe('ApiAuthorizationScreen', () => {
})
)
renderScreen()
await screen.findByText('This authorization request has been approved')
expect(screen.getByText(/organization "My Org"/)).toBeInTheDocument()
await screen.findByText('Authorization approved')
expect(screen.getByText(/has access to My Org/)).toBeInTheDocument()
})
test('shows Unknown when approved organization is not in the user organizations list', async () => {
@@ -174,14 +174,14 @@ describe('ApiAuthorizationScreen', () => {
})
)
renderScreen()
await screen.findByText('This authorization request has been approved')
expect(screen.getByText(/organization "Unknown"/)).toBeInTheDocument()
await screen.findByText('Authorization approved')
expect(screen.getByText(/has access to Unknown/)).toBeInTheDocument()
})
})
describe('main authorization form', () => {
describe('organizations states', () => {
test('disables action buttons while organizations are being fetched', async () => {
test('does not show action buttons while organizations are being fetched', async () => {
mockAuthEndpoint(createMockAuthResponse())
addAPIMock({
method: 'get',
@@ -190,11 +190,13 @@ describe('ApiAuthorizationScreen', () => {
})
renderScreen()
await screen.findByText('Authorize API access for Test App')
expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
})
test('shows error notice, disables decline button, and hides accept button when organizations query fails', async () => {
test('shows error notice and hides action buttons when organizations query fails', async () => {
mockAuthEndpoint(createMockAuthResponse())
addAPIMock({
method: 'get',
@@ -203,8 +205,8 @@ describe('ApiAuthorizationScreen', () => {
HttpResponse.json<APIErrorBody>({ message: 'Server error' }, { status: 500 }),
})
renderScreen()
await screen.findByText('There was an error loading your organizations')
expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
await screen.findByText('Unable to load organizations')
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
@@ -213,9 +215,8 @@ describe('ApiAuthorizationScreen', () => {
test('shows empty state when user has no organizations', async () => {
mockBothEndpoints(createMockAuthResponse(), [])
renderScreen()
await screen.findByText(/Your account isn't associated with any organizations/)
expect(screen.getByRole('link', { name: 'Create an organization' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
await screen.findByText('No organizations found')
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
@@ -225,8 +226,10 @@ describe('ApiAuthorizationScreen', () => {
mockBothEndpoints()
renderScreen({ organization_slug: 'nonexistent-org' })
await screen.findByText(/Your account is not a member of the pre-selected organization/)
expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
})
})
@@ -237,7 +240,7 @@ describe('ApiAuthorizationScreen', () => {
await screen.findByText('Authorize API access for My OAuth App')
expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
test('auto-selects the only organization when no organization_slug is provided', async () => {
@@ -253,45 +256,28 @@ describe('ApiAuthorizationScreen', () => {
const combobox = await screen.findByRole('combobox')
expect(combobox).toHaveTextContent('Second Org')
expect(combobox).not.toHaveTextContent('My Org')
expect(
screen.getByText('This organization has been pre-selected by Test App.')
).toBeInTheDocument()
})
})
describe('MCP client warning', () => {
test('shows MCP warning when registration_type is dynamic', async () => {
mockBothEndpoints(createMockAuthResponse({ registration_type: 'dynamic' }))
renderScreen()
await screen.findByText('MCP Client Connection')
})
test('does not show MCP warning for non-dynamic registration type', async () => {
mockBothEndpoints()
renderScreen()
await screen.findByText('Authorize API access for Test App')
expect(screen.queryByText('MCP Client Connection')).not.toBeInTheDocument()
expect(screen.getByText('Pre-selected by Test App')).toBeInTheDocument()
})
})
describe('expiration', () => {
test('shows expiration warning and disables buttons when request has expired', async () => {
test('shows expiration warning and hides action buttons when request has expired', async () => {
mockBothEndpoints(
createMockAuthResponse({ expires_at: dayjs().subtract(1, 'hour').toISOString() })
)
renderScreen()
await screen.findByText('This authorization request is expired')
expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled()
expect(screen.getByRole('button', { name: /Authorize Test App/ })).toBeDisabled()
await screen.findByText('Authorization request expired')
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Authorize Test App/ })
).not.toBeInTheDocument()
})
test('does not show expiration warning when request has not expired', async () => {
mockBothEndpoints()
renderScreen()
await screen.findByText('Authorize API access for Test App')
expect(
screen.queryByText('This authorization request is expired')
).not.toBeInTheDocument()
expect(screen.queryByText('Authorization request expired')).not.toBeInTheDocument()
})
})
@@ -317,7 +303,7 @@ describe('ApiAuthorizationScreen', () => {
})
describe('decline action', () => {
test('navigates to /organizations after declining', async () => {
test('navigates to /organizations after cancelling', async () => {
const user = userEvent.setup()
const declineHandler = vi.fn(() =>
HttpResponse.json<DeclineAuthorizationResponse>({ id: 'test-auth-id' })
@@ -329,8 +315,8 @@ describe('ApiAuthorizationScreen', () => {
response: declineHandler,
})
const { navigate } = renderScreen()
await screen.findByRole('button', { name: 'Decline' })
await user.click(screen.getByRole('button', { name: 'Decline' }))
await screen.findByRole('button', { name: 'Cancel' })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(declineHandler).toHaveBeenCalled())
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'))
})