fix(studio): show custom OAuth providers in Users table (#45658)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

show provider info in Authentication -> Users page if user is auth'd
with custom providers


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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added comprehensive support for custom authentication providers with
appropriate type labeling and visual identification
* Improved provider display formatting and icon rendering to properly
recognize custom authentication options
* Enhanced authentication provider interface operations for better
custom provider information handling and management

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

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Cemal KılıçandJoshen Lim authored and GitHub committed 2026-07-14 17:52:32 +02:00
1 parent 3ed7c8f522
commit e90c91e498
1 file changed
+89 -55
@@ -22,6 +22,10 @@ import { BASE_PATH } from '@/lib/constants'
const GITHUB_AVATAR_URL = 'https://avatars.githubusercontent.com'
const SUPPORTED_CSP_AVATAR_URLS = [GITHUB_AVATAR_URL, 'https://lh3.googleusercontent.com']
const isCustomProvider = (provider: string) => provider.startsWith('custom:')
type FormattedUserRow = ReturnType<typeof formatUsersData>[number]
export const formatUsersData = (users: User[]) => {
return users.map((user) => {
const provider: string = (user.raw_app_meta_data?.provider as string) ?? ''
@@ -39,25 +43,31 @@ export const formatUsersData = (users: User[]) => {
providers: user.is_anonymous ? '-' : providers,
provider_icons: providers
.map((p) => {
return p === 'email'
? `${BASE_PATH}/img/icons/email-icon2.svg`
: p === 'SAML'
? `${BASE_PATH}/img/icons/saml-icon.svg`
: providerIconMap[p]
? `${BASE_PATH}/img/icons/${providerIconMap[p]}.svg`
: undefined
const isCustom = isCustomProvider(p)
return isCustom
? 'custom'
: p === 'email'
? `${BASE_PATH}/img/icons/email-icon2.svg`
: p === 'SAML'
? `${BASE_PATH}/img/icons/saml-icon.svg`
: providerIconMap[p]
? `${BASE_PATH}/img/icons/${providerIconMap[p]}.svg`
: undefined
})
.filter(Boolean),
.filter((icon): icon is string => Boolean(icon)),
// I think it's alright to just check via the main provider since email and phone should be mutually exclusive
provider_type: user.is_anonymous
? 'Anonymous'
: provider === 'email'
? '-'
: socialProviders.includes(provider)
? 'Social'
: phoneProviders.includes(provider)
? 'Phone'
: '-',
: isCustomProvider(provider)
? 'Custom'
: socialProviders.includes(provider)
? 'Social'
: phoneProviders.includes(provider)
? 'Phone'
: '-',
// [Joshen] Note that the images might not load due to CSP issues
img: getAvatarUrl(user),
name: getDisplayName(user),
@@ -168,7 +178,7 @@ export function getDisplayName(user: User, fallback = '-'): string {
last_name: cc_last_name,
firstName: ccFirstName,
first_name: cc_first_name,
} = (custom_claims ?? {}) as any
} = (custom_claims ?? {}) as Record<string, unknown>
const last = toPrettyJsonString(
familyName ||
@@ -253,6 +263,17 @@ export function getAvatarUrl(user: User): string | undefined {
}
}
const formatUserProviders = (providers: string[]) => {
return providers
.map((x) => {
const provider = PROVIDERS_SCHEMAS.find(
(y) => ('key' in y && y.key === x) || y.title.toLowerCase() === x
)
return provider?.title ?? (isCustomProvider(x) ? x.split('custom:')[1] : x)
})
.join(', ')
}
export const formatUserColumns = ({
specificFilterColumn,
columns,
@@ -276,7 +297,7 @@ export const formatUserColumns = ({
let gridColumns = columns.map((col) => {
const savedConfig = config.find((c) => c.id === col.id)
const res: Column<any> = {
const res: Column<FormattedUserRow> = {
key: col.id,
name: col.name,
resizable: col.resizable ?? true,
@@ -304,24 +325,22 @@ export const formatUserColumns = ({
// eslint-disable-next-line react-hooks/rules-of-hooks
const { isRowSelected, onRowSelectionChange } = useRowSelection()
const value = row?.[col.id]
const value = row?.[col.id as keyof FormattedUserRow]
const user = users?.find((u) => u.id === row.id)
const formattedValue =
value !== null && ['created_at', 'last_sign_in_at'].includes(col.id)
? dayjs(value).format('ddd DD MMM YYYY HH:mm:ss [GMT]ZZ')
: Array.isArray(value)
? col.id === 'providers'
? value
.map((x) => {
const meta = PROVIDERS_SCHEMAS.find(
(y) => ('key' in y && y.key === x) || y.title.toLowerCase() === x
)
return meta?.title
})
.join(', ')
: value.join(', ')
: value
const isConfirmed = !!user?.confirmed_at
const isDateBasedValue =
value !== null &&
['created_at', 'last_sign_in_at'].includes(col.id) &&
typeof value === 'string'
const formattedValue = isDateBasedValue
? dayjs(value).format('ddd DD MMM YYYY HH:mm:ss [GMT]ZZ')
: Array.isArray(value)
? col.id === 'providers'
? formatUserProviders(value)
: value.join(', ')
: value
if (col.id === 'img') {
return (
@@ -363,26 +382,38 @@ export const formatUserColumns = ({
{col.id === 'providers' &&
row.provider_icons.map((icon: string, idx: number) => {
const provider = row.providers[idx]
return (
<div
key={`${user?.id}-${provider}-wrapper`}
className="min-w-6 min-h-6 rounded-full border flex items-center justify-center bg-surface-75"
style={{
marginLeft: idx === 0 ? 0 : `-8px`,
zIndex: row.provider_icons.length - idx,
}}
>
<img
key={`${user?.id}-${provider}`}
width={16}
src={icon}
alt={`${provider} auth icon`}
className={cn(
(provider === 'github' || provider === 'x') && 'dark:invert'
)}
/>
</div>
)
const isCustom = isCustomProvider(provider)
if (isCustom) {
return (
<div
key={`${user?.id}-${provider}-wrapper`}
className="min-w-6 min-h-6 rounded-full border flex items-center justify-center bg-surface-75"
>
{provider.split('custom:')[1][0]}
</div>
)
} else
return (
<div
key={`${user?.id}-${provider}-wrapper`}
className="min-w-6 min-h-6 rounded-full border flex items-center justify-center bg-surface-75"
style={{
marginLeft: idx === 0 ? 0 : `-8px`,
zIndex: row.provider_icons.length - idx,
}}
>
<img
key={`${user?.id}-${provider}`}
width={16}
src={icon}
alt={`${provider} auth icon`}
className={cn(
(provider === 'github' || provider === 'x') && 'dark:invert'
)}
/>
</div>
)
})}
{col.id === 'last_sign_in_at' && !isConfirmed ? (
<p className="text-foreground-lighter">Waiting for verification</p>
@@ -398,8 +429,11 @@ export const formatUserColumns = ({
className="gap-x-2"
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => {
const value = col.id === 'providers' ? row.providers.join(', ') : formattedValue
copyToClipboard(value)
const value =
col.id === 'providers' && Array.isArray(row.providers)
? row.providers.join(', ')
: formattedValue
if (value) copyToClipboard(value)
}}
>
<Copy size={12} />
@@ -455,7 +489,7 @@ export const formatUserColumns = ({
if (columnOrder.length > 0) {
gridColumns = gridColumns
.filter((col) => columnOrder.includes(col.key))
.sort((a: any, b: any) => {
.sort((a, b) => {
return columnOrder.indexOf(a.key) - columnOrder.indexOf(b.key)
})
}
@@ -464,5 +498,5 @@ export const formatUserColumns = ({
? gridColumns
: ([profileImageColumn].concat(
gridColumns.filter((col) => visibleColumns.includes(col.key))
) as Column<any>[])
) as Column<FormattedUserRow>[])
}