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() }) })