Files
supabase/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx
Gildas Garcia 0713a1efc1 chore: remove shadcn suffix for Input, Textarea, Alert and Collapsible (#45867)
## 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"
/>
2026-05-15 14:55:37 +02:00

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>
)
}