UserImpersonationSelector.tsx575 lines · main
1import { keepPreviousData } from '@tanstack/react-query'
2import { useDebounce } from '@uidotdev/usehooks'
3import { LOCAL_STORAGE_KEYS, useParams } from 'common'
4import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react'
5import { useMemo, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 Button,
9 cn,
10 Collapsible,
11 CollapsibleContent,
12 CollapsibleTrigger,
13 DropdownMenuSeparator,
14 Input,
15 InputGroup,
16 InputGroupAddon,
17 InputGroupButton,
18 InputGroupInput,
19 ScrollArea,
20 Switch,
21 Tabs_Shadcn_,
22 TabsContent_Shadcn_,
23 TabsList_Shadcn_,
24 TabsTrigger_Shadcn_,
25} from 'ui'
26import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
27import { InfoTooltip } from 'ui-patterns/info-tooltip'
28
29import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils'
30import AlertError from '@/components/ui/AlertError'
31import { InlineLink } from '@/components/ui/InlineLink'
32import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query'
33import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTokenHookDetails'
34import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
35import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
36import { DOCS_URL } from '@/lib/constants'
37import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
38import type { ResponseError } from '@/types'
39
40type AuthenticatorAssuranceLevels = 'aal1' | 'aal2'
41
42export const UserImpersonationSelector = () => {
43 const [searchText, setSearchText] = useState('')
44 const [aal, setAal] = useState<AuthenticatorAssuranceLevels>('aal1')
45 const [externalUserId, setExternalUserId] = useState('')
46 const [additionalClaims, setAdditionalClaims] = useState('')
47
48 const { id: tableId } = useParams()
49 const [selectedTab, setSelectedTab] = useState<'user' | 'external'>('user')
50
51 const [previousSearches, setPreviousSearches] = useLocalStorage<User[]>(
52 LOCAL_STORAGE_KEYS.USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES(tableId!),
53 []
54 )
55
56 const state = useRoleImpersonationStateSnapshot()
57 const debouncedSearchText = useDebounce(searchText, 300)
58
59 const { data: project } = useSelectedProjectQuery()
60
61 const {
62 data,
63 isSuccess,
64 isPending: isLoading,
65 isError,
66 error,
67 isFetching,
68 isPlaceholderData,
69 } = useUsersInfiniteQuery(
70 {
71 projectRef: project?.ref,
72 connectionString: project?.connectionString,
73 keywords: debouncedSearchText.trim().toLocaleLowerCase(),
74 },
75 {
76 placeholderData: keepPreviousData,
77 }
78 )
79 const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
80 const isSearching = isPlaceholderData && isFetching
81 const impersonatingUser =
82 state.role?.type === 'postgrest' &&
83 state.role.role === 'authenticated' &&
84 state.role.userType === 'native' &&
85 state.role.user
86
87 // Check if we're currently impersonating an external auth user (e.g. OAuth, SAML)
88 // This is used to show the correct UI state and impersonation details
89 const isExternalAuthImpersonating =
90 state.role?.type === 'postgrest' &&
91 state.role.role === 'authenticated' &&
92 state.role.userType === 'external' &&
93 state.role.externalAuth
94
95 const customAccessTokenHookDetails = useCustomAccessTokenHookDetails(project?.ref)
96
97 const [isImpersonateLoading, setIsImpersonateLoading] = useState(false)
98
99 async function impersonateUser(user: User) {
100 setIsImpersonateLoading(true)
101 setPreviousSearches((prev) => {
102 // Remove if already present
103 const filtered = prev.filter((u) => u.id !== user.id)
104 // Add new user to the start of the list (last used first)
105 const updated = [user, ...filtered]
106 // Keep only the last 6
107 return updated.slice(0, 5)
108 })
109
110 if (customAccessTokenHookDetails?.type === 'https') {
111 toast.info(
112 'Please note that HTTPS custom access token hooks are not yet supported in the dashboard.'
113 )
114 }
115
116 try {
117 await state.setRole(
118 {
119 type: 'postgrest',
120 role: 'authenticated',
121 userType: 'native',
122 user,
123 aal,
124 },
125 customAccessTokenHookDetails
126 )
127 } catch (error) {
128 toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
129 }
130
131 setIsImpersonateLoading(false)
132 }
133
134 // Impersonates an external auth user (e.g. OAuth, SAML) by setting the sub and any additional claims
135 // This allows testing RLS policies for external auth users without needing to set up the full OAuth/SAML flow
136 async function impersonateExternalUser() {
137 setIsImpersonateLoading(true)
138
139 let parsedClaims = {}
140 try {
141 parsedClaims = additionalClaims ? JSON.parse(additionalClaims) : {}
142 } catch (e) {
143 toast.error('Invalid JSON in additional claims')
144 setIsImpersonateLoading(false)
145 return
146 }
147 try {
148 await state.setRole(
149 {
150 type: 'postgrest',
151 role: 'authenticated',
152 userType: 'external',
153 externalAuth: {
154 sub: externalUserId,
155 additionalClaims: parsedClaims,
156 },
157 aal,
158 },
159 customAccessTokenHookDetails
160 )
161 } catch (error) {
162 toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
163 }
164
165 setIsImpersonateLoading(false)
166 }
167
168 function stopImpersonating() {
169 state.setRole(undefined)
170 }
171
172 function toggleAalState() {
173 setAal((prev) => (prev === 'aal2' ? 'aal1' : 'aal2'))
174 }
175
176 const displayName = impersonatingUser
177 ? getDisplayName(
178 impersonatingUser,
179 impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown'
180 )
181 : isExternalAuthImpersonating
182 ? state.role.externalAuth.sub
183 : undefined
184
185 // Clear all search history
186 function clearSearchHistory() {
187 setPreviousSearches([])
188 }
189
190 return (
191 <>
192 <div className="px-5 py-3">
193 <p className="text-foreground text-sm">
194 {displayName ? `Impersonating ${displayName}` : 'Impersonate a user'}
195 </p>
196 <p className="text-sm text-foreground-light mb-1">
197 {!impersonatingUser && !isExternalAuthImpersonating
198 ? "Select a user to respect your database's RLS policies for that particular user."
199 : "Results will respect your database's RLS policies for this user."}
200 </p>
201
202 {impersonatingUser && (
203 <UserImpersonatingRow
204 user={impersonatingUser}
205 onClick={stopImpersonating}
206 isImpersonating={true}
207 aal={aal}
208 isLoading={isImpersonateLoading}
209 />
210 )}
211 {isExternalAuthImpersonating && (
212 <ExternalAuthImpersonatingRow
213 sub={state.role.externalAuth.sub}
214 onClick={stopImpersonating}
215 aal={aal}
216 isLoading={isImpersonateLoading}
217 />
218 )}
219
220 {!impersonatingUser && !isExternalAuthImpersonating && (
221 <Tabs_Shadcn_ value={selectedTab} onValueChange={(value: any) => setSelectedTab(value)}>
222 <TabsList_Shadcn_ className="gap-x-3">
223 <TabsTrigger_Shadcn_ value="user">Project user</TabsTrigger_Shadcn_>
224 <TabsTrigger_Shadcn_ value="external" className="gap-x-1.5">
225 External user
226 <InfoTooltip side="bottom" className="flex flex-col gap-1 max-w-96">
227 Test RLS policies with external auth providers like Clerk or Auth0 by providing a
228 user ID and optional claims.
229 </InfoTooltip>
230 </TabsTrigger_Shadcn_>
231 </TabsList_Shadcn_>
232
233 <TabsContent_Shadcn_ value="user">
234 <div className="flex flex-col gap-y-2">
235 <InputGroup>
236 <InputGroupInput
237 size="tiny"
238 className="table-editor-search border-none"
239 placeholder="Search by id, email, phone, or name..."
240 onChange={(e) => setSearchText(e.target.value)}
241 value={searchText}
242 />
243 <InputGroupAddon>
244 {isSearching ? (
245 <Loader2
246 className="animate-spin text-foreground-lighter"
247 size={16}
248 strokeWidth={1.5}
249 />
250 ) : (
251 <Search className="text-foreground-lighter" size={16} strokeWidth={1.5} />
252 )}
253 </InputGroupAddon>
254 <InputGroupAddon align="inline-end">
255 {searchText && (
256 <InputGroupButton size="tiny" type="text" onClick={() => setSearchText('')}>
257 <span className="sr-only">Clear search</span>
258 <X size={12} />
259 </InputGroupButton>
260 )}
261 </InputGroupAddon>
262 </InputGroup>
263 {isLoading && (
264 <div className="flex flex-col gap-2 items-center justify-center h-24">
265 <Loader2 className="animate-spin" size={24} />
266 <span className="text-foreground-light">Loading users...</span>
267 </div>
268 )}
269
270 {isError && <AlertError error={error} subject="Failed to retrieve users" />}
271
272 {isSuccess &&
273 (users.length > 0 ? (
274 <div>
275 <ul className="divide-y max-h-[150px] overflow-y-scroll" role="list">
276 {users.map((user) => (
277 <li key={user.id} role="listitem">
278 <UserRow
279 user={user}
280 onClick={impersonateUser}
281 isLoading={isImpersonateLoading}
282 />
283 </li>
284 ))}
285 </ul>
286 </div>
287 ) : (
288 <div className="flex flex-col gap-2 items-center justify-center h-24">
289 <p className="text-foreground-light text-xs" role="status">
290 No users found
291 </p>
292 </div>
293 ))}
294
295 <>
296 {previousSearches.length > 0 && (
297 <div>
298 {previousSearches.length > 0 ? (
299 <>
300 <Collapsible className="relative">
301 <CollapsibleTrigger className="group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!">
302 <div className="flex items-center gap-x-1 w-full">
303 <p className="text-xs text-foreground-light group-hover:text-foreground transition">
304 Recents
305 </p>
306 <ChevronDown
307 className="transition-transform duration-200"
308 strokeWidth={1.5}
309 size={14}
310 />
311 </div>
312 </CollapsibleTrigger>
313
314 <CollapsibleContent className="mt-1 flex flex-col gap-y-4">
315 <Button
316 size="tiny"
317 type="text"
318 className="absolute right-0 top-0 py-2 hover:bg-muted flex items-center text"
319 onClick={clearSearchHistory}
320 >
321 <span className="flex items-center">Clear</span>
322 </Button>
323 <ScrollArea
324 className={cn(previousSearches.length > 3 ? 'h-36' : 'h-auto')}
325 >
326 <ul className="grid gap-2 ">
327 {previousSearches.map((search) => (
328 <li key={search.id}>
329 <UserRow user={search} onClick={impersonateUser} />
330 </li>
331 ))}
332 </ul>
333 </ScrollArea>
334 </CollapsibleContent>
335 </Collapsible>
336 </>
337 ) : (
338 <div className="p-4 text-center text-muted-foreground">
339 No recent searches
340 </div>
341 )}
342 </div>
343 )}
344 </>
345 </div>
346 </TabsContent_Shadcn_>
347
348 <TabsContent_Shadcn_ value="external">
349 <div className="flex flex-col gap-y-4">
350 <FormItemLayout
351 layout="horizontal"
352 label="External User ID"
353 description="The user ID from your external auth provider"
354 isReactForm={false}
355 >
356 <Input
357 size="small"
358 placeholder="e.g. user_abc123"
359 value={externalUserId}
360 onChange={(e) => setExternalUserId(e.target.value)}
361 />
362 </FormItemLayout>
363 <FormItemLayout
364 layout="horizontal"
365 label="Additional Claims (JSON)"
366 description="Optional: Add custom claims like org_id or roles"
367 isReactForm={false}
368 >
369 <Input
370 size="small"
371 placeholder='e.g. {"app_metadata": {"org_id": "org_456"}}'
372 value={additionalClaims}
373 onChange={(e) => setAdditionalClaims(e.target.value)}
374 />
375 </FormItemLayout>
376 <div className="flex items-center justify-end">
377 <Button
378 type="default"
379 disabled={!externalUserId}
380 onClick={impersonateExternalUser}
381 >
382 Impersonate
383 </Button>
384 </div>
385 </div>
386 </TabsContent_Shadcn_>
387 </Tabs_Shadcn_>
388 )}
389 </div>
390
391 {/* 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 */}
392 {!impersonatingUser && !isExternalAuthImpersonating ? (
393 <>
394 <DropdownMenuSeparator className="m-0" />
395 <div className="px-5 py-2 flex flex-col gap-2 relative">
396 <Collapsible>
397 <CollapsibleTrigger className="group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!">
398 <div className="flex items-center gap-x-1 w-full">
399 <p className="text-xs text-foreground-light group-hover:text-foreground transition">
400 Advanced options
401 </p>
402 <ChevronDown
403 className="transition-transform duration-200"
404 strokeWidth={1.5}
405 size={14}
406 />
407 </div>
408 </CollapsibleTrigger>
409 <CollapsibleContent className="mt-1 flex flex-col gap-y-4">
410 <div className="flex flex-row items-center gap-x-4 text-sm text-foreground-light">
411 <div className="flex items-center gap-x-1">
412 <h3>MFA assurance level</h3>
413 <InfoTooltip side="top" className="max-w-96">
414 AAL1 verifies users via standard login methods, while AAL2 adds a second
415 authentication factor. If you're not using MFA, you can leave this on AAL1.
416 Learn more about MFA{' '}
417 <InlineLink href={`${DOCS_URL}/guides/auth/auth-mfa`}>here</InlineLink>.
418 </InfoTooltip>
419 </div>
420
421 <div className="flex flex-row items-center gap-x-2 text-xs font-bold">
422 <p className={aal === 'aal1' ? undefined : 'text-foreground-lighter'}>AAL1</p>
423 <Switch checked={aal === 'aal2'} onCheckedChange={toggleAalState} />
424 <p className={aal === 'aal2' ? undefined : 'text-foreground-lighter'}>AAL2</p>
425 </div>
426 </div>
427 </CollapsibleContent>
428 </Collapsible>
429 </div>
430 </>
431 ) : null}
432 </>
433 )
434}
435
436// Base interface for shared impersonation row props to reduce
437// duplication between user and external auth impersonation displays
438interface BaseImpersonatingRowProps {
439 onClick: () => void
440 aal: AuthenticatorAssuranceLevels
441 displayName: string
442 avatarUrl?: string
443 isImpersonating: boolean
444 isLoading?: boolean
445}
446
447const BaseImpersonatingRow = ({
448 onClick,
449 aal,
450 displayName,
451 avatarUrl,
452 isImpersonating = false,
453 isLoading = false,
454}: BaseImpersonatingRowProps) => {
455 return (
456 <div className="flex items-center gap-3 py-2 text-foreground">
457 <div className="flex items-center gap-4 bg-surface-200 pr-4 pl-0.5 py-0.5 border rounded-full max-w-l">
458 {avatarUrl ? (
459 <img className="rounded-full w-5 h-5" src={avatarUrl} alt={displayName} />
460 ) : (
461 <div className="rounded-full w-[21px] h-[21px] bg-surface-300 border border-strong flex items-center justify-center">
462 <IconUser size={12} strokeWidth={2} />
463 </div>
464 )}
465
466 <span className="text-sm truncate">
467 {displayName}{' '}
468 <span className="ml-2 text-foreground-lighter text-xs font-light">
469 {aal === 'aal2' ? 'AAL2' : 'AAL1'}
470 </span>
471 </span>
472 </div>
473
474 <Button type="default" onClick={onClick} disabled={isLoading} loading={isLoading}>
475 {isImpersonating ? 'Stop' : 'Impersonate'}
476 </Button>
477 </div>
478 )
479}
480
481const UserImpersonatingRow = ({
482 user,
483 onClick,
484 isImpersonating = false,
485 isLoading = false,
486 aal,
487}: UserRowProps & { aal: AuthenticatorAssuranceLevels }) => {
488 const avatarUrl = getAvatarUrl(user)
489 const displayName =
490 getDisplayName(user, user.email ?? user.phone ?? user.id ?? 'Unknown') +
491 (user.is_anonymous ? ' (anonymous)' : '')
492
493 return (
494 <BaseImpersonatingRow
495 onClick={() => onClick(user)}
496 aal={aal}
497 displayName={displayName}
498 avatarUrl={avatarUrl}
499 isImpersonating={isImpersonating}
500 isLoading={isLoading}
501 />
502 )
503}
504
505interface ExternalAuthImpersonatingRowProps {
506 sub: string
507 onClick: () => void
508 aal: AuthenticatorAssuranceLevels
509 isLoading?: boolean
510}
511
512const ExternalAuthImpersonatingRow = ({
513 sub,
514 onClick,
515 aal,
516 isLoading = false,
517}: ExternalAuthImpersonatingRowProps) => {
518 return (
519 <BaseImpersonatingRow
520 onClick={onClick}
521 aal={aal}
522 displayName={sub}
523 isImpersonating={true}
524 isLoading={isLoading}
525 />
526 )
527}
528
529interface UserRowProps {
530 user: User
531 onClick: (user: User) => void
532 isImpersonating?: boolean
533 isLoading?: boolean
534}
535
536const UserRow = ({ user, onClick, isImpersonating = false, isLoading = false }: UserRowProps) => {
537 const avatarUrl = getAvatarUrl(user)
538 const emailOrPhone = user.email || user.phone
539 const displayName = getDisplayName(user, '')
540 const isAnonymous = user.is_anonymous
541 const showDisplayName = displayName && displayName !== emailOrPhone
542
543 return (
544 <div className="flex items-center justify-between py-1 text-foreground">
545 <div className="flex items-center gap-4">
546 {avatarUrl ? (
547 <img className="rounded-full w-5 h-5" src={avatarUrl} alt={displayName || emailOrPhone} />
548 ) : (
549 <div className="rounded-full w-[21px] h-[21px] bg-surface-300 border flex items-center justify-center text-foreground-lighter">
550 <IconUser size={12} strokeWidth={2} />
551 </div>
552 )}
553
554 <span className="text-sm flex items-center gap-4">
555 {emailOrPhone}
556 {showDisplayName && (
557 <>
558 <span className="text-foreground-lighter">
559 {displayName}
560 {isAnonymous ? ' (anonymous)' : ''}
561 </span>
562 </>
563 )}
564 <span className="text-foreground-light bg-surface-200 dark:bg-surface-400 rounded-md px-1 py-0.5 font-mono text-xs">
565 {user?.id?.slice(0, 8)}
566 </span>
567 </span>
568 </div>
569
570 <Button type="default" onClick={() => onClick(user)} disabled={isLoading} loading={isLoading}>
571 {isImpersonating ? 'Stop' : 'Impersonate'}
572 </Button>
573 </div>
574 )
575}