UserImpersonationSelector.tsx 21 KB

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