Files
supabase/apps/studio/lib/external-identity-providers.ts
Alaister YoungandAlaister Young 6073c7a7e7 [FE-4193] fix(studio): show proper names for custom identity providers (#49182)
Unregistered `custom:*` identity providers (e.g. white-label
deployments' own OAuth providers) rendered their raw id — the account
preferences "Sign-in methods" list showed something like `Custom:Acme`
instead of `Acme`. `getProviderDisplay()` now derives a proper
title-cased name from any `custom:*` id, so this works generically for
every custom provider.

**Changed:**
- `getProviderDisplay()` derives a title-cased display name for
unregistered `custom:*` providers (`custom:acme` → "Acme",
`custom:my_provider` → "My Provider"), case-insensitively. Registered
ones (e.g. `custom:openai` → ChatGPT) are unaffected.
- `SignInWithCustom` reuses `getProviderDisplay()` instead of its own
`formatProviderName`, which only stripped a lowercase `custom:` prefix —
the display name also now flows into its error toast.
- Added unit tests for the new fallback branch.

## To test

- On a deployment with a custom provider (or by temporarily hardcoding
an identity with `provider: 'custom:acme'` in `AccountIdentities`),
check `/account/me` → Sign-in methods shows "Acme", not "Custom:Acme"
- Unlink dialog/toast for that identity should also say "Acme"
- Sign-in page with a custom provider configured should show "Continue
with Acme"
- `pnpm vitest run lib/external-identity-providers.test.ts` in
`apps/studio` passes

Addresses [FE-4193](https://linear.app/supabase/issue/FE-4193)

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

## Summary by CodeRabbit

* **New Features**
  * Added support for unregistered custom identity providers.
* Custom provider names are now displayed in a clearer, title-cased
format with underscores converted to spaces.
* Matching providers use the SAML icon while preserving their configured
display names.

* **Bug Fixes**
* Improved sign-in error messages and button labels for custom
providers.
  * Provider identifiers are now handled case-insensitively.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-08-18 15:33:04 +08:00

159 lines
4.9 KiB
TypeScript

import { BASE_PATH } from './constants'
export type ExternalIdentityProviderConfig = {
id: string
authProvider: string
displayName: string
iconPath: string
scopes?: string
showOnSignIn: boolean
showOnSignUp: boolean
showInAccountPreferences: boolean
}
export type IdentityProviderDisplay = {
id: string
displayName: string
iconPath: string
/** The icon is a single-color mark that should be tinted to the theme's foreground color. */
hasMonochromeIcon?: boolean
}
const BUILT_IN_IDENTITY_PROVIDERS: Record<string, IdentityProviderDisplay> = {
email: {
id: 'email',
displayName: 'Email',
iconPath: `${BASE_PATH}/img/icons/email-icon2.svg`,
},
}
// Statically supported identity providers. To add a new one, declare its config here, gate its
// visibility behind a `dashboard_auth:sign_in_with_*` feature flag in `useEnabledIdentityProviders`,
// and add the matching flag to `packages/common/enabled-features/enabled-features.json`.
export const GITHUB_IDENTITY_PROVIDER: ExternalIdentityProviderConfig = {
id: 'github',
authProvider: 'github',
displayName: 'GitHub',
iconPath: '/img/icons/github-icon.svg',
showOnSignIn: true,
showOnSignUp: true,
showInAccountPreferences: false,
}
export const CHATGPT_IDENTITY_PROVIDER: ExternalIdentityProviderConfig = {
id: 'chatgpt',
authProvider: 'custom:openai',
displayName: 'ChatGPT',
iconPath: '/img/icons/openai-icon.svg',
showOnSignIn: true,
showOnSignUp: true,
showInAccountPreferences: false,
}
// Registry of every known provider, independent of which are currently enabled. Used for config and
// display lookups (e.g. resolving the provider that a mid-flow interstitial was reached with).
const IDENTITY_PROVIDERS: ExternalIdentityProviderConfig[] = [
GITHUB_IDENTITY_PROVIDER,
CHATGPT_IDENTITY_PROVIDER,
]
export function normalizeIconPath(iconPath: string): string {
if (
iconPath.startsWith('http://') ||
iconPath.startsWith('https://') ||
iconPath.startsWith('/')
) {
return iconPath.startsWith('/') ? `${BASE_PATH}${iconPath}` : iconPath
}
return `${BASE_PATH}/${iconPath}`
}
const CUSTOM_PROVIDER_PREFIX = 'custom:'
/**
* Derives a human-readable name from a `custom:*` provider id, e.g. `custom:acme` -> "Acme" and
* `custom:my_provider` -> "My Provider". Returns undefined for non-custom providers.
*/
function getCustomProviderName(provider: string): string | undefined {
if (!provider.toLowerCase().startsWith(CUSTOM_PROVIDER_PREFIX)) return undefined
return provider
.slice(CUSTOM_PROVIDER_PREFIX.length)
.replaceAll('_', ' ')
.split(' ')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
export function getProviderDisplay(provider: string): IdentityProviderDisplay {
const config = IDENTITY_PROVIDERS.find(
({ id, authProvider }) => provider === id || provider === authProvider
)
if (config) {
return {
id: config.id,
displayName: config.displayName,
iconPath: normalizeIconPath(config.iconPath),
hasMonochromeIcon: true,
}
}
if (provider.startsWith('sso')) {
return {
id: provider,
displayName: 'SSO',
iconPath: `${BASE_PATH}/img/icons/saml-icon.svg`,
}
}
// Unregistered `custom:*` providers (e.g. white-label deployments' own OAuth providers) fall
// back to a title-cased name derived from the id: `custom:acme` -> "Acme".
const customProviderName = getCustomProviderName(provider)
if (customProviderName) {
return {
id: provider,
displayName: customProviderName,
iconPath: `${BASE_PATH}/img/icons/saml-icon.svg`,
}
}
return (
BUILT_IN_IDENTITY_PROVIDERS[provider] ?? {
id: provider,
displayName: provider.replaceAll('_', ' '),
iconPath: `${BASE_PATH}/img/icons/saml-icon.svg`,
}
)
}
/**
* Builds the absolute URL an external provider's OAuth flow redirects back to: the MFA-check page
* (`/sign-in-mfa`), tagged with the provider id as the sign-in method and an optional `returnTo`
* destination. Callers should pass the result through `buildPathWithParams` to preserve the current
* location's search params across the OAuth round-trip.
*/
export function buildProviderAuthRedirect(providerId: string, returnTo?: string): string {
const origin =
typeof window !== 'undefined' && process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
? window.location.origin
: process.env.NEXT_PUBLIC_SITE_URL
const params = new URLSearchParams({ method: providerId })
if (returnTo) params.set('returnTo', returnTo)
return `${origin}${BASE_PATH}/sign-in-mfa?${params.toString()}`
}
export function getIdentityProviderConfig(
provider: string | undefined
): ExternalIdentityProviderConfig | undefined {
if (!provider) return undefined
return IDENTITY_PROVIDERS.find(
({ id, authProvider }) => provider === id || provider === authProvider
)
}