mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
feat: add oauth interstitial success screen
This commit is contained in:
1 parent
bbee0eb288
commit
db74fa87f7
7 files changed
+190
-13
No files matched your search
+74
@@ -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>
|
||||
)
|
||||
}
|
||||
+45
@@ -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()
|
||||
})
|
||||
})
|
||||
+42
-2
@@ -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 = () => {
|
||||
|
||||
+1
@@ -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'
|
||||
|
||||
+25
-11
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user