feat: add oauth interstitial success screen

This commit is contained in:
kemal authored and Gildas Garcia committed 2026-10-01 11:29:07 +02:00
1 parent bbee0eb288
commit db74fa87f7
7 files changed
+190 -13

No files matched your search

@@ -0,0 +1,74 @@
import { Box, Boxes } from 'lucide-react'
import { Button, Card, CardContent } from 'ui'
import { ScopeGroupCard } from './ScopeGroupCard'
import type { OAuthAppsAuthorizeGrant } from '@/data/oauth-apps/oauth-apps-authorize-approve-mutation'
export interface AuthorizeSuccessScreenProps {
appName: string
grant: OAuthAppsAuthorizeGrant
onReturn: () => void
}
export const AuthorizeSuccessScreen = ({
appName,
grant,
onReturn,
}: AuthorizeSuccessScreenProps) => {
const projectNames = grant.projects.map((project) => project.name).join(', ')
return (
<div className="flex flex-col gap-6 px-6 pb-6">
<Card className="overflow-hidden shadow-none bg-surface-200/60 border-muted">
<CardContent className="border-none p-0 divide-y divide-muted">
<div className="relative divide-y divide-muted">
<div className="flex items-center gap-3 p-4">
<div className="w-[30px] h-[30px] shrink-0 rounded-full border border-control flex items-center justify-center text-xs">
{grant.email.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-foreground-light">Authorized by</p>
<p className="truncate text-sm text-foreground">
{grant.email} <span className="text-foreground-lighter">· {grant.role}</span>
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4">
<div className="w-[30px] h-[30px] shrink-0 rounded-full border border-control flex items-center justify-center">
<Boxes size={16} className="text-foreground-light" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-foreground-light">Organization</p>
<p className="truncate text-sm text-foreground">{grant.organization_slug}</p>
</div>
</div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-muted bg-surface-200 px-2 py-0.5">
<span className="text-[11px] uppercase tracking-wide text-foreground-lighter">
For
</span>
</div>
</div>
<div className="flex items-center gap-3 p-4">
<div className="w-[30px] h-[30px] shrink-0 rounded-full border border-control flex items-center justify-center">
<Box size={16} className="text-foreground-light" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-foreground-light">Projects</p>
<p className="truncate text-sm text-foreground">{projectNames}</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-2">
<p className="text-sm text-foreground">Permissions granted</p>
<ScopeGroupCard appName={appName} scopeGroups={grant.scope_groups} showHeading={false} />
</div>
<Button variant="default" block onClick={onReturn}>
Return to {appName}
</Button>
</div>
)
}
@@ -76,4 +76,49 @@ describe('OAuthAppsAuthorizeScreen', () => {
// Let the mock mutation's artificial delay settle before the test exits
await new Promise((resolve) => setTimeout(resolve, 350))
})
test('renders the success screen after the approve mutation resolves', async () => {
customRender(<OAuthAppsAuthorizeScreen mockState="ideal" navigate={vi.fn()} />)
await screen.findByRole('combobox')
selectProject('production')
fireEvent.click(screen.getByRole('button', { name: /Authorize Vercel/ }))
expect(await screen.findByText('Vercel is connected')).toBeInTheDocument()
expect(screen.getByText('You can return to Vercel to continue')).toBeInTheDocument()
})
test('success screen shows exactly the submitted projects and scopes', async () => {
customRender(<OAuthAppsAuthorizeScreen mockState="ideal" navigate={vi.fn()} />)
await screen.findByRole('combobox')
selectProject('staging')
fireEvent.click(screen.getByRole('button', { name: /Authorize Vercel/ }))
await screen.findByText('Vercel is connected')
expect(screen.getByText('staging')).toBeInTheDocument()
expect(screen.queryByText('production, staging')).not.toBeInTheDocument()
expect(screen.queryByText('production')).not.toBeInTheDocument()
expect(screen.getByText('Permissions granted')).toBeInTheDocument()
expect(screen.queryByText('Permissions requested')).not.toBeInTheDocument()
expect(
screen.getByText('project_settings, action_runs, logs, sql_snippets')
).toBeInTheDocument()
expect(
screen.getByText('database_webhooks, development_branches, production_branches')
).toBeInTheDocument()
})
test('unverified fixture shows a letter avatar on the success screen', async () => {
customRender(<OAuthAppsAuthorizeScreen mockState="unverified" navigate={vi.fn()} />)
await screen.findByRole('combobox')
selectProject('production')
fireEvent.click(screen.getByRole('button', { name: /Authorize kemal-bot/ }))
expect(await screen.findByText('kemal-bot is connected')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
})
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { AuthorizeSuccessScreen } from './AuthorizeSuccessScreen'
import { AuthorizingAsCard } from './AuthorizingAsCard'
import { NoProjectsNotice } from './NoProjectsNotice'
import { EMPTY_ORG_MOCK_SLUG, getMockScenarioId } from './OAuthAppsAuthorizeScreen.utils'
@@ -14,6 +15,7 @@ import {
LogoPair,
SupabaseLogo,
} from '@/components/layouts/InterstitialLayout'
import type { OAuthAppsAuthorizeApproveResponse } from '@/data/oauth-apps/oauth-apps-authorize-approve-mutation'
import { useOAuthAppsAuthorizeApproveMutation } from '@/data/oauth-apps/oauth-apps-authorize-approve-mutation'
import { useOAuthAppsAuthorizeDenyMutation } from '@/data/oauth-apps/oauth-apps-authorize-deny-mutation'
import { useOAuthAppsAuthorizeOrganizationProjectsQuery } from '@/data/oauth-apps/oauth-apps-authorize-organization-projects-query'
@@ -44,6 +46,7 @@ export const OAuthAppsAuthorizeScreen = ({
)
const [selectedProjectRefs, setSelectedProjectRefs] = useState<string[]>([])
const [projectError, setProjectError] = useState<string>()
const [approveResult, setApproveResult] = useState<OAuthAppsAuthorizeApproveResponse | null>(null)
const orgSlug = selectedOrgSlug ?? identity?.organizations[0]?.slug
const memberOrg = identity?.organizations.find((org) => org.slug === orgSlug)
@@ -57,7 +60,7 @@ export const OAuthAppsAuthorizeScreen = ({
const approveMutation = useOAuthAppsAuthorizeApproveMutation({
onSuccess: (data) => {
window.location.href = data.url
setApproveResult(data)
},
})
const denyMutation = useOAuthAppsAuthorizeDenyMutation({
@@ -71,6 +74,43 @@ export const OAuthAppsAuthorizeScreen = ({
if (!request || !identity || !orgSlug || !memberOrg) return null
// mock_state=success lets a design review load straight into the receipt screen without
// clicking through the flow first - it's a preview only, never a substitute for the real
// approve mutation's result.
const approvedResult: OAuthAppsAuthorizeApproveResponse | null =
approveResult ??
(mockState === 'success'
? {
url: request.redirect_uri,
grant: {
email: identity.email,
role: memberOrg.role,
organization_slug: memberOrg.slug,
projects: projects ?? [],
scope_groups: request.scope_groups,
},
}
: null)
if (approvedResult) {
return (
<InterstitialLayout
logo={<DestinationLogo name={request.app_name} />}
title={`${request.app_name} is connected`}
titleClassName="text-2xl"
description={`You can return to ${request.app_name} to continue`}
>
<AuthorizeSuccessScreen
appName={request.app_name}
grant={approvedResult.grant}
onReturn={() => {
window.location.href = approvedResult.url
}}
/>
</InterstitialLayout>
)
}
const handleSignOut = async () => {
await signOut()
window.location.reload()
@@ -97,7 +137,7 @@ export const OAuthAppsAuthorizeScreen = ({
return
}
setProjectError(undefined)
approveMutation.mutate({ id: scenarioId, slug: orgSlug })
approveMutation.mutate({ id: scenarioId, slug: orgSlug, projectRefs: selectedProjectRefs })
}
const handleDeny = () => {
@@ -5,6 +5,7 @@ const MOCK_STATE_SCENARIOS: Record<string, string> = {
over_role: OAUTH_APPS_MOCK_SCENARIOS.vercelReadOnly,
unverified: OAUTH_APPS_MOCK_SCENARIOS.kemalBot,
empty_org: OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper,
success: OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper,
}
export const EMPTY_ORG_MOCK_SLUG = 'contoso-labs'
@@ -1,25 +1,39 @@
import { Badge, Card, CardContent } from 'ui'
import { Badge, Card, CardContent, cn } from 'ui'
import type { OAuthScopeGroup, OAuthScopeLevel } from '@/data/oauth-apps/types'
export interface ScopeGroupCardProps {
appName: string
scopeGroups: OAuthScopeGroup[]
/** Set to false for a receipt view (e.g. the success screen) that has no intro copy of its own. */
showHeading?: boolean
}
export const ScopeGroupCard = ({ appName, scopeGroups }: ScopeGroupCardProps) => {
export const ScopeGroupCard = ({
appName,
scopeGroups,
showHeading = true,
}: ScopeGroupCardProps) => {
return (
<section className="flex flex-col">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
Permissions requested
</p>
<p className="mt-1 text-xs text-foreground-lighter">
Authorizing {appName} grants it the following access permissions to the selected projects.
</p>
</div>
{showHeading && (
<div>
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
Permissions requested
</p>
<p className="mt-1 text-xs text-foreground-lighter">
Authorizing {appName} grants it the following access permissions to the selected
projects.
</p>
</div>
)}
<Card className="overflow-hidden shadow-none bg-surface-200/60 border-muted mt-3">
<Card
className={cn(
'overflow-hidden shadow-none bg-surface-200/60 border-muted',
showHeading && 'mt-3'
)}
>
<CardContent className="border-none p-0">
<div className="divide-y divide-muted px-4">
{scopeGroups.map((scopeGroup) => (
@@ -1,3 +1,5 @@
export { AuthorizeSuccessScreen } from './AuthorizeSuccessScreen'
export type { AuthorizeSuccessScreenProps } from './AuthorizeSuccessScreen'
export { AuthorizingAsCard } from './AuthorizingAsCard'
export type { AuthorizingAsCardProps } from './AuthorizingAsCard'
export { OAuthAppsAuthorizeScreen } from './OAuthAppsAuthorizeScreen'
@@ -57,5 +57,6 @@ describe('APIAuthorizationPage', () => {
})
expect(screen.getByText('Missing authorization link')).toBeInTheDocument()
expect(screen.queryByText(/is connected/)).not.toBeInTheDocument()
})
})