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 && (
-
-
-
- {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.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 = () => (
-
-
-
-)
-
+/** Partner logo rendered edge-to-edge inside a LogoBox. */
export const PartnerLogo = ({ src, alt }: { src: string; alt: string }) => (
)
+/** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */
+export const SupabaseLogo = () => (
+
+
+
+)
+
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'))
})