mirror of
https://github.com/supabase/supabase.git
synced 2026-05-31 18:03:33 +08:00
## Problem Now that we migrated old components to their new shadcn alternatives, we don't need the `_Shadcn_` suffix anymore. ## Solution Remove it <img width="659" height="609" alt="image" src="https://github.com/user-attachments/assets/2d7271a9-066a-4dcc-92fe-729b106d2c2f" />
576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
import { keepPreviousData } from '@tanstack/react-query'
|
|
import { useDebounce } from '@uidotdev/usehooks'
|
|
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
|
import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react'
|
|
import { useMemo, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
DropdownMenuSeparator,
|
|
Input,
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupInput,
|
|
ScrollArea,
|
|
Switch,
|
|
Tabs_Shadcn_,
|
|
TabsContent_Shadcn_,
|
|
TabsList_Shadcn_,
|
|
TabsTrigger_Shadcn_,
|
|
} from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
|
|
|
import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils'
|
|
import AlertError from '@/components/ui/AlertError'
|
|
import { InlineLink } from '@/components/ui/InlineLink'
|
|
import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query'
|
|
import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTokenHookDetails'
|
|
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
|
|
import type { ResponseError } from '@/types'
|
|
|
|
type AuthenticatorAssuranceLevels = 'aal1' | 'aal2'
|
|
|
|
export const UserImpersonationSelector = () => {
|
|
const [searchText, setSearchText] = useState('')
|
|
const [aal, setAal] = useState<AuthenticatorAssuranceLevels>('aal1')
|
|
const [externalUserId, setExternalUserId] = useState('')
|
|
const [additionalClaims, setAdditionalClaims] = useState('')
|
|
|
|
const { id: tableId } = useParams()
|
|
const [selectedTab, setSelectedTab] = useState<'user' | 'external'>('user')
|
|
|
|
const [previousSearches, setPreviousSearches] = useLocalStorage<User[]>(
|
|
LOCAL_STORAGE_KEYS.USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES(tableId!),
|
|
[]
|
|
)
|
|
|
|
const state = useRoleImpersonationStateSnapshot()
|
|
const debouncedSearchText = useDebounce(searchText, 300)
|
|
|
|
const { data: project } = useSelectedProjectQuery()
|
|
|
|
const {
|
|
data,
|
|
isSuccess,
|
|
isPending: isLoading,
|
|
isError,
|
|
error,
|
|
isFetching,
|
|
isPlaceholderData,
|
|
} = useUsersInfiniteQuery(
|
|
{
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
keywords: debouncedSearchText.trim().toLocaleLowerCase(),
|
|
},
|
|
{
|
|
placeholderData: keepPreviousData,
|
|
}
|
|
)
|
|
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
|
|
const isSearching = isPlaceholderData && isFetching
|
|
const impersonatingUser =
|
|
state.role?.type === 'postgrest' &&
|
|
state.role.role === 'authenticated' &&
|
|
state.role.userType === 'native' &&
|
|
state.role.user
|
|
|
|
// Check if we're currently impersonating an external auth user (e.g. OAuth, SAML)
|
|
// This is used to show the correct UI state and impersonation details
|
|
const isExternalAuthImpersonating =
|
|
state.role?.type === 'postgrest' &&
|
|
state.role.role === 'authenticated' &&
|
|
state.role.userType === 'external' &&
|
|
state.role.externalAuth
|
|
|
|
const customAccessTokenHookDetails = useCustomAccessTokenHookDetails(project?.ref)
|
|
|
|
const [isImpersonateLoading, setIsImpersonateLoading] = useState(false)
|
|
|
|
async function impersonateUser(user: User) {
|
|
setIsImpersonateLoading(true)
|
|
setPreviousSearches((prev) => {
|
|
// Remove if already present
|
|
const filtered = prev.filter((u) => u.id !== user.id)
|
|
// Add new user to the start of the list (last used first)
|
|
const updated = [user, ...filtered]
|
|
// Keep only the last 6
|
|
return updated.slice(0, 5)
|
|
})
|
|
|
|
if (customAccessTokenHookDetails?.type === 'https') {
|
|
toast.info(
|
|
'Please note that HTTPS custom access token hooks are not yet supported in the dashboard.'
|
|
)
|
|
}
|
|
|
|
try {
|
|
await state.setRole(
|
|
{
|
|
type: 'postgrest',
|
|
role: 'authenticated',
|
|
userType: 'native',
|
|
user,
|
|
aal,
|
|
},
|
|
customAccessTokenHookDetails
|
|
)
|
|
} catch (error) {
|
|
toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
|
|
}
|
|
|
|
setIsImpersonateLoading(false)
|
|
}
|
|
|
|
// Impersonates an external auth user (e.g. OAuth, SAML) by setting the sub and any additional claims
|
|
// This allows testing RLS policies for external auth users without needing to set up the full OAuth/SAML flow
|
|
async function impersonateExternalUser() {
|
|
setIsImpersonateLoading(true)
|
|
|
|
let parsedClaims = {}
|
|
try {
|
|
parsedClaims = additionalClaims ? JSON.parse(additionalClaims) : {}
|
|
} catch (e) {
|
|
toast.error('Invalid JSON in additional claims')
|
|
setIsImpersonateLoading(false)
|
|
return
|
|
}
|
|
try {
|
|
await state.setRole(
|
|
{
|
|
type: 'postgrest',
|
|
role: 'authenticated',
|
|
userType: 'external',
|
|
externalAuth: {
|
|
sub: externalUserId,
|
|
additionalClaims: parsedClaims,
|
|
},
|
|
aal,
|
|
},
|
|
customAccessTokenHookDetails
|
|
)
|
|
} catch (error) {
|
|
toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
|
|
}
|
|
|
|
setIsImpersonateLoading(false)
|
|
}
|
|
|
|
function stopImpersonating() {
|
|
state.setRole(undefined)
|
|
}
|
|
|
|
function toggleAalState() {
|
|
setAal((prev) => (prev === 'aal2' ? 'aal1' : 'aal2'))
|
|
}
|
|
|
|
const displayName = impersonatingUser
|
|
? getDisplayName(
|
|
impersonatingUser,
|
|
impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown'
|
|
)
|
|
: isExternalAuthImpersonating
|
|
? state.role.externalAuth.sub
|
|
: undefined
|
|
|
|
// Clear all search history
|
|
function clearSearchHistory() {
|
|
setPreviousSearches([])
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="px-5 py-3">
|
|
<p className="text-foreground text-sm">
|
|
{displayName ? `Impersonating ${displayName}` : 'Impersonate a user'}
|
|
</p>
|
|
<p className="text-sm text-foreground-light mb-1">
|
|
{!impersonatingUser && !isExternalAuthImpersonating
|
|
? "Select a user to respect your database's RLS policies for that particular user."
|
|
: "Results will respect your database's RLS policies for this user."}
|
|
</p>
|
|
|
|
{impersonatingUser && (
|
|
<UserImpersonatingRow
|
|
user={impersonatingUser}
|
|
onClick={stopImpersonating}
|
|
isImpersonating={true}
|
|
aal={aal}
|
|
isLoading={isImpersonateLoading}
|
|
/>
|
|
)}
|
|
{isExternalAuthImpersonating && (
|
|
<ExternalAuthImpersonatingRow
|
|
sub={state.role.externalAuth.sub}
|
|
onClick={stopImpersonating}
|
|
aal={aal}
|
|
isLoading={isImpersonateLoading}
|
|
/>
|
|
)}
|
|
|
|
{!impersonatingUser && !isExternalAuthImpersonating && (
|
|
<Tabs_Shadcn_ value={selectedTab} onValueChange={(value: any) => setSelectedTab(value)}>
|
|
<TabsList_Shadcn_ className="gap-x-3">
|
|
<TabsTrigger_Shadcn_ value="user">Project user</TabsTrigger_Shadcn_>
|
|
<TabsTrigger_Shadcn_ value="external" className="gap-x-1.5">
|
|
External user
|
|
<InfoTooltip side="bottom" className="flex flex-col gap-1 max-w-96">
|
|
Test RLS policies with external auth providers like Clerk or Auth0 by providing a
|
|
user ID and optional claims.
|
|
</InfoTooltip>
|
|
</TabsTrigger_Shadcn_>
|
|
</TabsList_Shadcn_>
|
|
|
|
<TabsContent_Shadcn_ value="user">
|
|
<div className="flex flex-col gap-y-2">
|
|
<InputGroup>
|
|
<InputGroupInput
|
|
size="tiny"
|
|
className="table-editor-search border-none"
|
|
placeholder="Search by id, email, phone, or name..."
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
value={searchText}
|
|
/>
|
|
<InputGroupAddon>
|
|
{isSearching ? (
|
|
<Loader2
|
|
className="animate-spin text-foreground-lighter"
|
|
size={16}
|
|
strokeWidth={1.5}
|
|
/>
|
|
) : (
|
|
<Search className="text-foreground-lighter" size={16} strokeWidth={1.5} />
|
|
)}
|
|
</InputGroupAddon>
|
|
<InputGroupAddon align="inline-end">
|
|
{searchText && (
|
|
<InputGroupButton size="tiny" type="text" onClick={() => setSearchText('')}>
|
|
<span className="sr-only">Clear search</span>
|
|
<X size={12} />
|
|
</InputGroupButton>
|
|
)}
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
{isLoading && (
|
|
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
|
<Loader2 className="animate-spin" size={24} />
|
|
<span className="text-foreground-light">Loading users...</span>
|
|
</div>
|
|
)}
|
|
|
|
{isError && <AlertError error={error} subject="Failed to retrieve users" />}
|
|
|
|
{isSuccess &&
|
|
(users.length > 0 ? (
|
|
<div>
|
|
<ul className="divide-y max-h-[150px] overflow-y-scroll" role="list">
|
|
{users.map((user) => (
|
|
<li key={user.id} role="listitem">
|
|
<UserRow
|
|
user={user}
|
|
onClick={impersonateUser}
|
|
isLoading={isImpersonateLoading}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
|
<p className="text-foreground-light text-xs" role="status">
|
|
No users found
|
|
</p>
|
|
</div>
|
|
))}
|
|
|
|
<>
|
|
{previousSearches.length > 0 && (
|
|
<div>
|
|
{previousSearches.length > 0 ? (
|
|
<>
|
|
<Collapsible className="relative">
|
|
<CollapsibleTrigger className="group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!">
|
|
<div className="flex items-center gap-x-1 w-full">
|
|
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
|
Recents
|
|
</p>
|
|
<ChevronDown
|
|
className="transition-transform duration-200"
|
|
strokeWidth={1.5}
|
|
size={14}
|
|
/>
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
|
|
<CollapsibleContent className="mt-1 flex flex-col gap-y-4">
|
|
<Button
|
|
size="tiny"
|
|
type="text"
|
|
className="absolute right-0 top-0 py-2 hover:bg-muted flex items-center text"
|
|
onClick={clearSearchHistory}
|
|
>
|
|
<span className="flex items-center">Clear</span>
|
|
</Button>
|
|
<ScrollArea
|
|
className={cn(previousSearches.length > 3 ? 'h-36' : 'h-auto')}
|
|
>
|
|
<ul className="grid gap-2 ">
|
|
{previousSearches.map((search) => (
|
|
<li key={search.id}>
|
|
<UserRow user={search} onClick={impersonateUser} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</ScrollArea>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</>
|
|
) : (
|
|
<div className="p-4 text-center text-muted-foreground">
|
|
No recent searches
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
</div>
|
|
</TabsContent_Shadcn_>
|
|
|
|
<TabsContent_Shadcn_ value="external">
|
|
<div className="flex flex-col gap-y-4">
|
|
<FormItemLayout
|
|
layout="horizontal"
|
|
label="External User ID"
|
|
description="The user ID from your external auth provider"
|
|
isReactForm={false}
|
|
>
|
|
<Input
|
|
size="small"
|
|
placeholder="e.g. user_abc123"
|
|
value={externalUserId}
|
|
onChange={(e) => setExternalUserId(e.target.value)}
|
|
/>
|
|
</FormItemLayout>
|
|
<FormItemLayout
|
|
layout="horizontal"
|
|
label="Additional Claims (JSON)"
|
|
description="Optional: Add custom claims like org_id or roles"
|
|
isReactForm={false}
|
|
>
|
|
<Input
|
|
size="small"
|
|
placeholder='e.g. {"app_metadata": {"org_id": "org_456"}}'
|
|
value={additionalClaims}
|
|
onChange={(e) => setAdditionalClaims(e.target.value)}
|
|
/>
|
|
</FormItemLayout>
|
|
<div className="flex items-center justify-end">
|
|
<Button
|
|
type="default"
|
|
disabled={!externalUserId}
|
|
onClick={impersonateExternalUser}
|
|
>
|
|
Impersonate
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TabsContent_Shadcn_>
|
|
</Tabs_Shadcn_>
|
|
)}
|
|
</div>
|
|
|
|
{/* Check for both regular user and external auth impersonation since they use different data structures but both need to be handled for displaying impersonation UI */}
|
|
{!impersonatingUser && !isExternalAuthImpersonating ? (
|
|
<>
|
|
<DropdownMenuSeparator className="m-0" />
|
|
<div className="px-5 py-2 flex flex-col gap-2 relative">
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!">
|
|
<div className="flex items-center gap-x-1 w-full">
|
|
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
|
Advanced options
|
|
</p>
|
|
<ChevronDown
|
|
className="transition-transform duration-200"
|
|
strokeWidth={1.5}
|
|
size={14}
|
|
/>
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="mt-1 flex flex-col gap-y-4">
|
|
<div className="flex flex-row items-center gap-x-4 text-sm text-foreground-light">
|
|
<div className="flex items-center gap-x-1">
|
|
<h3>MFA assurance level</h3>
|
|
<InfoTooltip side="top" className="max-w-96">
|
|
AAL1 verifies users via standard login methods, while AAL2 adds a second
|
|
authentication factor. If you're not using MFA, you can leave this on AAL1.
|
|
Learn more about MFA{' '}
|
|
<InlineLink href={`${DOCS_URL}/guides/auth/auth-mfa`}>here</InlineLink>.
|
|
</InfoTooltip>
|
|
</div>
|
|
|
|
<div className="flex flex-row items-center gap-x-2 text-xs font-bold">
|
|
<p className={aal === 'aal1' ? undefined : 'text-foreground-lighter'}>AAL1</p>
|
|
<Switch checked={aal === 'aal2'} onCheckedChange={toggleAalState} />
|
|
<p className={aal === 'aal2' ? undefined : 'text-foreground-lighter'}>AAL2</p>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Base interface for shared impersonation row props to reduce
|
|
// duplication between user and external auth impersonation displays
|
|
interface BaseImpersonatingRowProps {
|
|
onClick: () => void
|
|
aal: AuthenticatorAssuranceLevels
|
|
displayName: string
|
|
avatarUrl?: string
|
|
isImpersonating: boolean
|
|
isLoading?: boolean
|
|
}
|
|
|
|
const BaseImpersonatingRow = ({
|
|
onClick,
|
|
aal,
|
|
displayName,
|
|
avatarUrl,
|
|
isImpersonating = false,
|
|
isLoading = false,
|
|
}: BaseImpersonatingRowProps) => {
|
|
return (
|
|
<div className="flex items-center gap-3 py-2 text-foreground">
|
|
<div className="flex items-center gap-4 bg-surface-200 pr-4 pl-0.5 py-0.5 border rounded-full max-w-l">
|
|
{avatarUrl ? (
|
|
<img className="rounded-full w-5 h-5" src={avatarUrl} alt={displayName} />
|
|
) : (
|
|
<div className="rounded-full w-[21px] h-[21px] bg-surface-300 border border-strong flex items-center justify-center">
|
|
<IconUser size={12} strokeWidth={2} />
|
|
</div>
|
|
)}
|
|
|
|
<span className="text-sm truncate">
|
|
{displayName}{' '}
|
|
<span className="ml-2 text-foreground-lighter text-xs font-light">
|
|
{aal === 'aal2' ? 'AAL2' : 'AAL1'}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<Button type="default" onClick={onClick} disabled={isLoading} loading={isLoading}>
|
|
{isImpersonating ? 'Stop' : 'Impersonate'}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const UserImpersonatingRow = ({
|
|
user,
|
|
onClick,
|
|
isImpersonating = false,
|
|
isLoading = false,
|
|
aal,
|
|
}: UserRowProps & { aal: AuthenticatorAssuranceLevels }) => {
|
|
const avatarUrl = getAvatarUrl(user)
|
|
const displayName =
|
|
getDisplayName(user, user.email ?? user.phone ?? user.id ?? 'Unknown') +
|
|
(user.is_anonymous ? ' (anonymous)' : '')
|
|
|
|
return (
|
|
<BaseImpersonatingRow
|
|
onClick={() => onClick(user)}
|
|
aal={aal}
|
|
displayName={displayName}
|
|
avatarUrl={avatarUrl}
|
|
isImpersonating={isImpersonating}
|
|
isLoading={isLoading}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface ExternalAuthImpersonatingRowProps {
|
|
sub: string
|
|
onClick: () => void
|
|
aal: AuthenticatorAssuranceLevels
|
|
isLoading?: boolean
|
|
}
|
|
|
|
const ExternalAuthImpersonatingRow = ({
|
|
sub,
|
|
onClick,
|
|
aal,
|
|
isLoading = false,
|
|
}: ExternalAuthImpersonatingRowProps) => {
|
|
return (
|
|
<BaseImpersonatingRow
|
|
onClick={onClick}
|
|
aal={aal}
|
|
displayName={sub}
|
|
isImpersonating={true}
|
|
isLoading={isLoading}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface UserRowProps {
|
|
user: User
|
|
onClick: (user: User) => void
|
|
isImpersonating?: boolean
|
|
isLoading?: boolean
|
|
}
|
|
|
|
const UserRow = ({ user, onClick, isImpersonating = false, isLoading = false }: UserRowProps) => {
|
|
const avatarUrl = getAvatarUrl(user)
|
|
const emailOrPhone = user.email || user.phone
|
|
const displayName = getDisplayName(user, '')
|
|
const isAnonymous = user.is_anonymous
|
|
const showDisplayName = displayName && displayName !== emailOrPhone
|
|
|
|
return (
|
|
<div className="flex items-center justify-between py-1 text-foreground">
|
|
<div className="flex items-center gap-4">
|
|
{avatarUrl ? (
|
|
<img className="rounded-full w-5 h-5" src={avatarUrl} alt={displayName || emailOrPhone} />
|
|
) : (
|
|
<div className="rounded-full w-[21px] h-[21px] bg-surface-300 border flex items-center justify-center text-foreground-lighter">
|
|
<IconUser size={12} strokeWidth={2} />
|
|
</div>
|
|
)}
|
|
|
|
<span className="text-sm flex items-center gap-4">
|
|
{emailOrPhone}
|
|
{showDisplayName && (
|
|
<>
|
|
<span className="text-foreground-lighter">
|
|
{displayName}
|
|
{isAnonymous ? ' (anonymous)' : ''}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className="text-foreground-light bg-surface-200 dark:bg-surface-400 rounded-md px-1 py-0.5 font-mono text-xs">
|
|
{user?.id?.slice(0, 8)}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<Button type="default" onClick={() => onClick(user)} disabled={isLoading} loading={isLoading}>
|
|
{isImpersonating ? 'Stop' : 'Impersonate'}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|