Files
supabase/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx
Danny White ec53175b8a refactor(ui): rename text-brand to text-primary (#50564)
## What kind of change does this PR introduce?

Refactor. Follow-up to #49871.

## What is the current behavior?

Branded (green) text still uses the `text-brand` classname while the
colour comes from `--primary`.

## What is the new behavior?

**Rename-only:** `text-brand` → `text-primary` across callsites and
docs. Leftover `bg-brand` / `border-brand` alias to `brand-default`. No
intentional colour changes in this PR.

This better matches how we treat our green in other components and
props, like `variant="primary"` for green buttons.

## To test

On light mode: smoke-test that branded text still looks like #49871
(readable green, not the bright fill).

-
[Homepage](https://zone-www-dot-com-git-dnywh-depr-316-text-brand-de2380-supabase.vercel.app/):
“Scale to millions” uses `text-primary`
- [Docs
homepage](https://docs-git-dnywh-depr-316-text-brand-to-primary-supabase.vercel.app/docs):
branded links like “More on self-hosting” are still readable
- [Typography
docs](https://design-system-git-dnywh-depr-316-text-brand-to-primary-supabase.vercel.app/design-system/docs/typography):
documents `text-primary`
- [Colour
usage](https://design-system-git-dnywh-depr-316-text-brand-to-primary-supabase.vercel.app/design-system/docs/color-usage):
`text-primary` is visibly darker than `bg-brand-default` _on light mode_
- [Studio auth
providers](https://studio-staging-git-dnywh-depr-316-text-brand-to-60fa6c-supabase.vercel.app/dashboard/project/_/auth/providers):
enabled provider badge text readable; status dot stays bright green

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Updated branded text, link hover states, icons, badges, indicators,
and highlighted content across the Design System, Docs, Studio, Learn,
UI Library, and marketing experiences to use the primary theme color.
* Updated syntax highlighting and table-of-contents styling for
consistent primary-color presentation.
  * Refined brand color fallback behavior for bright fills and borders.

* **Documentation**
* Updated color-usage and typography guidance to recommend the primary
text utility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 16:19:03 +10:00

418 lines
13 KiB
TypeScript

import { OAuthScope } from '@supabase/shared-types/out/constants'
import { Check, ChevronDown } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useMemo, useState } from 'react'
import {
Badge,
Card,
CardContent,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants'
import { getOAuthImpersonationWarning, getRequesterLogo } from './OAuthApps.utils'
import {
CONNECT_LOGO_LIGHT_TILE_CLASSNAME,
LogoBox,
LogoPair,
SupabaseLogo,
} 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 {
name: string
domain: string
scopes: OAuthScope[]
showOnlyScopes?: boolean
}
export const ScopeSection = ({
description,
hasReadScope,
hasWriteScope,
}: {
description: string
hasReadScope: boolean
hasWriteScope: boolean
}) => {
if (!hasReadScope && !hasWriteScope) return null
const permissions = [hasReadScope ? 'Read' : null, hasWriteScope ? 'Write' : null]
.filter(Boolean)
.join(' and ')
return (
<div className="first:border-t border-b flex flex-row space-x-1 text-sm text-foreground-light py-2 px-1">
<div className="pt-0.5">
<Check className="size-4 text-primary" strokeWidth={1.5} />
</div>
<div>
<span className="font-semibold text-foreground">{permissions}</span> {description}
</div>
</div>
)
}
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,
},
],
},
]
/**
* Connect interstitial header mark for `/authorize`.
* Curated logos resolve from allowlisted redirect_uri hosts; otherwise pair only
* when a usable remote icon is present, else show Supabase alone.
*
* Uploaded / unknown bitmaps have no light/dark metadata, so both tiles use
* fixed light chrome (`forceLight`) across Studio themes. Curated partners keep
* theme-reactive tiles and may swap dark assets when available.
*/
export const AuthorizeConnectLogo = ({
icon,
name,
redirectUri,
}: {
icon: string | null
name: string
redirectUri?: string | null
}) => {
const [failedIcon, setFailedIcon] = useState<string | null>(null)
const { resolvedTheme } = useTheme()
const logo = useMemo(
() =>
getRequesterLogo({
icon,
name,
redirectUri,
useDarkVariant: resolvedTheme === 'dark',
}),
[icon, name, redirectUri, resolvedTheme]
)
const hasUsableLogo = Boolean(logo.src) && failedIcon !== logo.src
if (!hasUsableLogo) {
return <SupabaseLogo />
}
const forceLightPair = !logo.isKnownClient
return (
<LogoPair
left={
<LogoBox className={forceLightPair ? CONNECT_LOGO_LIGHT_TILE_CLASSNAME : 'bg-surface-75'}>
<img
alt={name}
src={logo.src}
className={cn(logo.isKnownClient ? 'size-7 object-contain' : 'size-full object-cover')}
onError={() => setFailedIcon(logo.src)}
/>
</LogoBox>
}
right={<SupabaseLogo forceLight={forceLightPair} />}
/>
)
}
export const AuthorizeImpersonationWarning = ({
name,
redirectUri,
}: {
name: string
redirectUri?: string | null
}) => {
const warning = getOAuthImpersonationWarning({ name, redirectUri })
if (!warning) return null
return (
<Admonition
type="caution"
title="Check this redirect before authorizing"
description={`This request uses the name ${warning.brandDisplayName}, but after you authorize you will be redirected to ${warning.redirectHost}, not ${warning.brandDisplayName}.`}
/>
)
}
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 (
<section className="flex flex-col">
{requestedPermissions.length === 0 ? (
<Card className="shadow-none">
<CardContent className="border-none px-4 py-3 text-sm text-foreground-lighter">
No permissions requested.
</CardContent>
</Card>
) : (
<>
{!showOnlyScopes && (
<>
<div>
<p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
Permissions
</p>
<p className="mt-1 text-xs text-foreground-lighter">
Authorizing {name} grants it the following{' '}
<InlineLink href={OAUTH_SCOPES_DOCS_URL}>access permissions</InlineLink> to the
selected organization. Only continue if you trust this app.
</p>
</div>
<Card className="overflow-hidden shadow-none bg-surface-200/60 border-muted mt-3">
<CardContent className="border-none p-0">
<div className="divide-y divide-muted px-4">
{writablePermissions.length > 0 && (
<PermissionSummaryRow permissions={writablePermissions} level="read-write" />
)}
{readOnlyPermissions.length > 0 && (
<PermissionSummaryRow permissions={readOnlyPermissions} level="read" />
)}
</div>
</CardContent>
</Card>
</>
)}
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-2 space-y-2">
<CollapsibleTrigger className={PERMISSION_DETAILS_TRIGGER_CLASSNAME}>
<span>{showDetails ? 'Hide detailed permissions' : 'Show detailed permissions'}</span>
<ChevronDown
className={cn('size-3.5 transition-transform', showDetails && 'rotate-180')}
/>
</CollapsibleTrigger>
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down overflow-hidden">
<PermissionDetails requestedPermissions={requestedPermissions} />
</CollapsibleContent>
</Collapsible>
</>
)}
</section>
)
}
function PermissionSummaryRow({
permissions,
level,
}: {
permissions: Array<RequestedPermission>
level: PermissionLevel
}) {
return (
<div className="flex items-center justify-between gap-3 py-3 text-sm">
<p className="min-w-0 leading-tight">
<span className="text-foreground">{permissions.map(({ label }) => label).join(', ')}</span>
</p>
<Badge variant={getPermissionLevelBadgeVariant(level)} className="shrink-0">
{formatPermissionLevel(level)}
</Badge>
</div>
)
}
function PermissionDetails({
requestedPermissions,
}: {
requestedPermissions: Array<RequestedPermission>
}) {
const requestedByLabel = new Map(
requestedPermissions.map((permission) => [permission.label, permission])
)
return (
<Card className="overflow-hidden shadow-none bg-surface-200/60 border-muted">
<CardContent className="border-none p-0">
{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 (
<div key={group.label} className="border-b border-muted last:border-b-0 px-4 py-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-foreground-lighter">
{group.label}
</p>
<div className="divide-y divide-muted">
{groupPermissions.map((permission) => (
<div
key={permission.label}
className="flex items-center justify-between gap-3 py-2"
>
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-sm text-foreground">{permission.label}</p>
<InfoTooltip side="right" className="max-w-64 text-xs">
{formatPermissionDescription(permission.description)}
</InfoTooltip>
</div>
<Badge variant={getPermissionLevelBadgeVariant(permission.level)}>
{formatPermissionLevel(permission.level)}
</Badge>
</div>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
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'
}