mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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 --> [](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:
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']} />
|
||||
}
|
||||
|
||||
|
||||
+315
-131
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user