diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/AuthorizeSuccessScreen.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/AuthorizeSuccessScreen.tsx
new file mode 100644
index 00000000000..d2d896325aa
--- /dev/null
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/AuthorizeSuccessScreen.tsx
@@ -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 (
+
+
+
+
+
+
+ {grant.email.charAt(0).toUpperCase()}
+
+
+
Authorized by
+
+ {grant.email} ยท {grant.role}
+
+
+
+
+
+
+
+
+
Organization
+
{grant.organization_slug}
+
+
+
+
+ For
+
+
+
+
+
+
+
+
+
+
Projects
+
{projectNames}
+
+
+
+
+
+
+
Permissions granted
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.test.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.test.tsx
index 8270b7061ce..d15495a1e00 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.test.tsx
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
})
diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.tsx
index e09c8bd1923..9ed732f8c2b 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.tsx
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.tsx
@@ -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([])
const [projectError, setProjectError] = useState()
+ const [approveResult, setApproveResult] = useState(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 (
+ }
+ title={`${request.app_name} is connected`}
+ titleClassName="text-2xl"
+ description={`You can return to ${request.app_name} to continue`}
+ >
+ {
+ window.location.href = approvedResult.url
+ }}
+ />
+
+ )
+ }
+
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 = () => {
diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.utils.ts b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.utils.ts
index 279ce80c971..9538c6b62a3 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.utils.ts
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/OAuthAppsAuthorizeScreen.utils.ts
@@ -5,6 +5,7 @@ const MOCK_STATE_SCENARIOS: Record = {
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'
diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ScopeGroupCard.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ScopeGroupCard.tsx
index bf26ce2911c..c89f5d38644 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ScopeGroupCard.tsx
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/ScopeGroupCard.tsx
@@ -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 (
-
-
- Permissions requested
-
-
- Authorizing {appName} grants it the following access permissions to the selected projects.
-
-
+ {showHeading && (
+
+
+ Permissions requested
+
+
+ Authorizing {appName} grants it the following access permissions to the selected
+ projects.
+
+
+ )}
-
+
{scopeGroups.map((scopeGroup) => (
diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/index.ts b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/index.ts
index 096521ebcf0..1afe8c5be44 100644
--- a/apps/studio/components/interfaces/Organization/OAuthApps/Consent/index.ts
+++ b/apps/studio/components/interfaces/Organization/OAuthApps/Consent/index.ts
@@ -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'
diff --git a/apps/studio/tests/pages/authorize.test.tsx b/apps/studio/tests/pages/authorize.test.tsx
index 41556375bc3..906ee539032 100644
--- a/apps/studio/tests/pages/authorize.test.tsx
+++ b/apps/studio/tests/pages/authorize.test.tsx
@@ -57,5 +57,6 @@ describe('APIAuthorizationPage', () => {
})
expect(screen.getByText('Missing authorization link')).toBeInTheDocument()
+ expect(screen.queryByText(/is connected/)).not.toBeInTheDocument()
})
})