UserImpersonationSelector.tsx575 lines · main
| 1 | import { keepPreviousData } from '@tanstack/react-query' |
| 2 | import { useDebounce } from '@uidotdev/usehooks' |
| 3 | import { LOCAL_STORAGE_KEYS, useParams } from 'common' |
| 4 | import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react' |
| 5 | import { useMemo, useState } from 'react' |
| 6 | import { toast } from 'sonner' |
| 7 | import { |
| 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' |
| 26 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 27 | import { InfoTooltip } from 'ui-patterns/info-tooltip' |
| 28 | |
| 29 | import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils' |
| 30 | import AlertError from '@/components/ui/AlertError' |
| 31 | import { InlineLink } from '@/components/ui/InlineLink' |
| 32 | import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query' |
| 33 | import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTokenHookDetails' |
| 34 | import { useLocalStorage } from '@/hooks/misc/useLocalStorage' |
| 35 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 36 | import { DOCS_URL } from '@/lib/constants' |
| 37 | import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' |
| 38 | import type { ResponseError } from '@/types' |
| 39 | |
| 40 | type AuthenticatorAssuranceLevels = 'aal1' | 'aal2' |
| 41 | |
| 42 | export 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 |
| 438 | interface BaseImpersonatingRowProps { |
| 439 | onClick: () => void |
| 440 | aal: AuthenticatorAssuranceLevels |
| 441 | displayName: string |
| 442 | avatarUrl?: string |
| 443 | isImpersonating: boolean |
| 444 | isLoading?: boolean |
| 445 | } |
| 446 | |
| 447 | const 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 | |
| 481 | const 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 | |
| 505 | interface ExternalAuthImpersonatingRowProps { |
| 506 | sub: string |
| 507 | onClick: () => void |
| 508 | aal: AuthenticatorAssuranceLevels |
| 509 | isLoading?: boolean |
| 510 | } |
| 511 | |
| 512 | const 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 | |
| 529 | interface UserRowProps { |
| 530 | user: User |
| 531 | onClick: (user: User) => void |
| 532 | isImpersonating?: boolean |
| 533 | isLoading?: boolean |
| 534 | } |
| 535 | |
| 536 | const 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 | } |