mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
## 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 -->
418 lines
13 KiB
TypeScript
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'
|
|
}
|