From 35df5703421a60b94fd71e496f918f07f227ac3a Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:51:04 -0600 Subject: [PATCH] feat(studio): move /authorize to connect interstitial (#46359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!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 \") for non-localhost redirect URIs, so users can verify the destination before approving. No extra friction for localhost flows (local MCP servers) | Before | After | | --- | --- | | Authorize API access
Supabase-F6C3747A-5077-43D8-A509-3E16B1DDC168 | Authorize API Access
Supabase-FE6FD8B3-1159-4EA5-94D7-EA5CEA7A25F3 | | Authorize API access
Supabase-86742351-3521-4B62-AF87-403CB7E7F4F5 | Authorize Cursor
Supabase-B665B4A4-600F-462B-8C97-84B171EC3103 | | Authorize API access
Supabase-C73DC3D0-8646-4E6E-A259-3E84AE46DAF2 | Authorize Cursor
Supabase-FEA86625-27D5-4DB5-B4D4-1A2CB804E56E | | Authorize API access
Supabase-48E0C7CB-DDDD-4305-B821-F3BEB52C4A4E | Authorize Cursor
Supabase-CE8F9905-FAE0-4C06-B77A-9F269B2100FE | | Authorize API access
Supabase-E37D2CD5-476F-4F49-A5FB-631B265025DC | Authorize Cursor
Supabase-DF078AEB-BB78-4647-9FA2-5D5403CCA5D6 | | Authorize API access
Supabase-D6F6817F-D8DD-4D55-85BB-A15100814AAB | Authorize Cursor
Supabase-E457B580-9786-43AD-9CF9-FE4F5BB8E785 | | Authorize API access
Supabase-4F3D6AA4-E2E3-4526-B391-49B6E0861911 | Authorize Cursor
Supabase-CA9FFCC9-4CA2-4718-AD49-B02D86C6EF6A | | Authorize API access
Supabase-E507B7A5-9AD0-4F17-8743-63A7B47D171A | Authorize Cursor
Supabase-9844BB27-2429-4BA6-BD36-1AB54099F44F | | Authorize API access
Supabase-27684173-9DBB-4F6E-9F7F-87EFD4E10A5F | Authorize Cursor
Supabase-04E31F7B-D098-4814-A394-01CE3D3E5A51 | | Authorize API access
Supabase-207CBC69-4957-499C-92E8-163F2B34C8AD | Authorize API Access
Supabase-C1627071-4AE2-4012-8F7C-4E6D883618A3 | ## 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=`. 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. ## 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 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) --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Ali Waseem --- .../ApiAuthorization.Approved.tsx | 63 +-- .../ApiAuthorization.Error.tsx | 36 +- .../ApiAuthorization.Form.tsx | 379 +++++++-------- .../ApiAuthorization.Loading.tsx | 50 +- .../ApiAuthorization.Valid.tsx | 55 ++- .../ApiAuthorization/ApiAuthorization.tsx | 3 +- .../OAuthApps/AuthorizeRequesterDetails.tsx | 446 +++++++++++++----- .../components/layouts/InterstitialLayout.tsx | 77 +-- apps/studio/components/ui/InlineLink.tsx | 2 +- .../api-authorization-query.ts | 1 + apps/studio/pages/authorize.tsx | 23 +- .../components/ApiAuthorization.test.tsx | 76 ++- 12 files changed, 714 insertions(+), 497 deletions(-) diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx index b2dd9fe93c5..6a3f0da6607 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Approved.tsx @@ -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 ( - - Authorize API access for {requester.name} - - - - This authorization request has been approved - -

- {requester.name} has been approved access to the organization " - {organization?.name ?? 'Unknown'}" and all of its projects for the following scopes: -

- -

- Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')} -

-
-
-
-
+ } + right={} + /> + } + title={requester.name} + description="is authorized for Supabase" + > +
+ + +

+ Approved on {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}. +

+
+
) } diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx index f52235cc76f..4a1f5cfe1c9 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx @@ -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 ( - - Authorize API access - - - - Failed to fetch details for API authorization request - -

Please retry your authorization request from the requesting app

- {error &&

Error: {error?.message}

} -
-
-
-
+ } title="Unable to load authorization"> +
+ + Retry the authorization request from the requesting app. + {error && ( + Error: {error.message} + )} + + } + /> + +
+
) } diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx index ddbff61db74..f22b2601c19 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Form.tsx @@ -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 } -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 @@ -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 ( - - {isMcpClient && } - - {isExpired && } - {organizations._tag === 'loading' && } - {organizations._tag === 'error' && } - {organizations._tag === 'empty' && } - {organizations._tag === 'not_member' && } - {organizations._tag === 'success' && ( - } + right={} /> - )} - - - ) -} - -interface FormShellProps { - title: string - children: ReactNode -} - -function FormShell({ title, children }: FormShellProps): ReactNode { - return ( - - {title} - {children} - - ) -} - -function McpNotice(): ReactNode { - return ( - - - MCP Client Connection - - 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. - - + } + title={`Authorize ${requester.name}`} + description="This application wants to access your Supabase account" + > +
+ Authorize API access for {requester.name} +
+ {isExpired ? ( + + ) : ( + <> + {organizations._tag === 'loading' && } + {organizations._tag === 'error' && ( + + )} + {organizations._tag === 'empty' && } + {organizations._tag === 'not_member' && } + {organizations._tag === 'success' && ( + + )} + {showReadyContent && ( + <> + + + + )} + + )} +
+
+ ) } function ExpiredNotice(): ReactNode { return ( - - - This authorization request is expired - - Please retry your authorization request from the requesting app - - + ) } function OrganizationsLoader(): ReactNode { return ( -
- - -
+
+

+ Organization +

+ +
) } @@ -171,40 +172,38 @@ interface OrganizationsErrorNoticeProps { function OrganizationsErrorNotice({ error }: OrganizationsErrorNoticeProps): ReactNode { return ( - - - There was an error loading your organizations - - Please try again. If the problem persists, contact support. - {error &&

Error: {error.message}

} -
-
+ + Please try again. If the problem persists, contact support. + {error && ( + Error: {error.message} + )} + + } + /> ) } function OrganizationsEmptyState(): ReactNode { return ( - - - Organization is needed for installing an integration - - 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. - - + ) } function NotMemberOfOrganizationNotice(): ReactNode { return ( - - - Organization is needed for installing an integration - - 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. - - + ) } @@ -228,40 +227,47 @@ function OrganizationSelector({ ( - - - - { + field.onChange(value) + form.trigger('selectedOrgSlug') + }} + > + - - - - - {organizations.map((organization) => ( - - {organization.name} - - ))} - - - - + + + + {organizations.map((organization) => ( + + {organization.name} + + ))} + + + + )} /> @@ -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 ( - +
+ - {organizations._tag === 'loading' && ( - Authorize {requester.name} + {redirectUrl && ( +
+

+ Authorizing will redirect you to {redirectUrl} +

+
)} - {organizations._tag === 'empty' && } - {showApprovalButton && ( - - )} - - ) -} - -interface LoadingApprovalButtonProps { - children: ReactNode -} - -function LoadingApprovalButton({ children }: LoadingApprovalButtonProps): ReactNode { - return -} - -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 ( - +
) } @@ -368,7 +332,14 @@ function ApprovalButton({ onApprove, }: ApprovalButtonProps): ReactNode { return ( - ) diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx index 89e6c6ff0a2..a28e51b7e20 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Loading.tsx @@ -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 ( - - Authorize API access - + } + title={} + description={} + > +
Loading... -
- - +
+ + + +
+ + +
+
+
+ + + + + + +
+ + +
- -
- - -
- -
- - -
- - +
+ ) } diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx index 8f6ebdf5517..cb85776418a 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx @@ -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 } + 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 ( - + <> + {pageTitle && {pageTitle}} + + ) } return ( - + <> + {pageTitle && {pageTitle}} + + ) } diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx index 43ff848e09d..7e710d72e68 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.tsx @@ -12,8 +12,7 @@ export function ApiAuthorizationScreen({ organization_slug, navigate, }: ApiAuthorizationScreenProps) { - const valid = !!auth_id - if (!valid) { + if (!auth_id) { return } diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx index dc8aca5c40c..9d5aad764c8 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -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) => ( - - {str} - - )) - .reduce((acc, v) => ( - <> - {acc} - and - {v} - - )) + const permissions = [hasReadScope ? 'Read' : null, hasWriteScope ? 'Write' : null] + .filter(Boolean) + .join(' and ') - return ( -
-
- -
-
- {perms} {description} -
-
- ) - } - return null -} - -export const AuthorizeRequesterDetails = ({ - icon, - name, - domain, - scopes, - showOnlyScopes = false, -}: AuthorizeRequesterDetailsProps) => { return ( -
- {!showOnlyScopes && ( -
-
-
- {!icon &&

{name[0]}

} -
-
-

- {name} ({domain}) is requesting API access to an organization. -

-
- )} +
+
+ +
- {!showOnlyScopes && ( - <> -

Permissions

-

- The following scopes will apply for the{' '} - selected organization and all of its projects. -

- - )} -
- {scopes.length === 0 && ( -

- No permissions requested, {name} will not have access to your organization or projects -

- )} - - - - - - - - - - - - -
+ {permissions} {description}
) } + +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 }) => ( + + {icon ? ( + {name} + ) : ( + {name.slice(0, 1)} + )} + +) + +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 ( +
+ {requestedPermissions.length === 0 ? ( + + + No permissions requested. + + + ) : ( + <> + {!showOnlyScopes && ( + <> +
+

+ Permissions +

+

+ Authorizing {name} grants it the following{' '} + access permissions to the + selected organization. Only continue if you trust this app. +

+
+ + + +
+ {writablePermissions.length > 0 && ( + + )} + {readOnlyPermissions.length > 0 && ( + + )} +
+
+
+ + )} + + + + {showDetails ? 'Hide detailed permissions' : 'Show detailed permissions'} + + + + + + + + )} +
+ ) +} + +function PermissionSummaryRow({ + permissions, + level, +}: { + permissions: Array + level: PermissionLevel +}) { + return ( +
+

+ {permissions.map(({ label }) => label).join(', ')} +

+ + {formatPermissionLevel(level)} + +
+ ) +} + +function PermissionDetails({ + requestedPermissions, +}: { + requestedPermissions: Array +}) { + const requestedByLabel = new Map( + requestedPermissions.map((permission) => [permission.label, permission]) + ) + + return ( + + + {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 ( +
+

+ {group.label} +

+
+ {groupPermissions.map((permission) => ( +
+
+

{permission.label}

+ + {formatPermissionDescription(permission.description)} + +
+ + {formatPermissionLevel(permission.level)} + +
+ ))} +
+
+ ) + })} +
+
+ ) +} + +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' +} diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index 7259e6ca7d3..486e2246c21 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -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 ? ( + + {title} + + ) : null + + const descriptionElement = description ? ( + + {description} + + ) : null + return (
{(logo || title || description) && ( - + {logo &&
{logo}
} - {(title || description) && ( + {(titleElement || descriptionElement) && (
- {title && ( - - {title} - - )} - {description && ( - - {description} - - )} + {titleElement} + {descriptionElement}
)}
@@ -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 }) => (
) +/** Two pre-boxed logos side-by-side with a swap separator. */ export const LogoPair = ({ left, right }: { left: ReactNode; right: ReactNode }) => (
{left} @@ -97,18 +114,20 @@ export const LogoPair = ({ left, right }: { left: ReactNode; right: ReactNode })
) -export const SupabaseLogo = () => ( - - Supabase - -) - +/** Partner logo rendered edge-to-edge inside a LogoBox. */ export const PartnerLogo = ({ src, alt }: { src: string; alt: string }) => ( {alt} ) +/** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */ +export const SupabaseLogo = () => ( + + Supabase + +) + export const InterstitialAccountRow = ({ avatarUrl, displayName, diff --git a/apps/studio/components/ui/InlineLink.tsx b/apps/studio/components/ui/InlineLink.tsx index bdd788a16f9..69a3693f1e6 100644 --- a/apps/studio/components/ui/InlineLink.tsx +++ b/apps/studio/components/ui/InlineLink.tsx @@ -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, diff --git a/apps/studio/data/api-authorization/api-authorization-query.ts b/apps/studio/data/api-authorization/api-authorization-query.ts index f9cc58cfd63..9faf70396c2 100644 --- a/apps/studio/data/api-authorization/api-authorization-query.ts +++ b/apps/studio/data/api-authorization/api-authorization-query.ts @@ -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 diff --git a/apps/studio/pages/authorize.tsx b/apps/studio/pages/authorize.tsx index 4d9a6be479a..4dc816778d9 100644 --- a/apps/studio/pages/authorize.tsx +++ b/apps/studio/pages/authorize.tsx @@ -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 ( - router.push(destination)} - /> + <> + + {PAGE_TITLE} + + router.push(destination)} + /> + ) } -APIAuthorizationPage.getLayout = (page) => ( - {page} -) - export default withAuth(APIAuthorizationPage) diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx index d1d6bfd5282..2ac4757a4f9 100644 --- a/apps/studio/tests/components/ApiAuthorization.test.tsx +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -150,7 +150,7 @@ describe('ApiAuthorizationScreen', () => { response: () => HttpResponse.json({ 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({ 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({ 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')) })