Users.utils.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. import dayjs from 'dayjs'
  2. import { SqlEditor, TableEditor } from 'icons'
  3. import { Copy, Trash, UserIcon } from 'lucide-react'
  4. import { Column, useRowSelection } from 'react-data-grid'
  5. import {
  6. Checkbox,
  7. cn,
  8. ContextMenu,
  9. ContextMenuContent,
  10. ContextMenuItem,
  11. ContextMenuSeparator,
  12. ContextMenuTrigger,
  13. copyToClipboard,
  14. } from 'ui'
  15. import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
  16. import { ColumnConfiguration, UsersTableColumn } from './Users.constants'
  17. import { HeaderCell } from './UsersGridComponents'
  18. import { User } from '@/data/auth/users-infinite-query'
  19. import { BASE_PATH } from '@/lib/constants'
  20. const GITHUB_AVATAR_URL = 'https://avatars.githubusercontent.com'
  21. const SUPPORTED_CSP_AVATAR_URLS = [GITHUB_AVATAR_URL, 'https://lh3.googleusercontent.com']
  22. export const formatUsersData = (users: User[]) => {
  23. return users.map((user) => {
  24. const provider: string = (user.raw_app_meta_data?.provider as string) ?? ''
  25. const providers: string[] = user.providers.map((x: string) => {
  26. if (x.startsWith('sso')) return 'SAML'
  27. return x
  28. })
  29. return {
  30. id: user.id,
  31. email: user.email,
  32. phone: user.phone,
  33. created_at: user.created_at,
  34. last_sign_in_at: user.last_sign_in_at,
  35. providers: user.is_anonymous ? '-' : providers,
  36. provider_icons: providers
  37. .map((p) => {
  38. return p === 'email'
  39. ? `${BASE_PATH}/img/icons/email-icon2.svg`
  40. : p === 'SAML'
  41. ? `${BASE_PATH}/img/icons/saml-icon.svg`
  42. : providerIconMap[p]
  43. ? `${BASE_PATH}/img/icons/${providerIconMap[p]}.svg`
  44. : undefined
  45. })
  46. .filter(Boolean),
  47. // I think it's alright to just check via the main provider since email and phone should be mutually exclusive
  48. provider_type: user.is_anonymous
  49. ? 'Anonymous'
  50. : provider === 'email'
  51. ? '-'
  52. : socialProviders.includes(provider)
  53. ? 'Social'
  54. : phoneProviders.includes(provider)
  55. ? 'Phone'
  56. : '-',
  57. // [Joshen] Note that the images might not load due to CSP issues
  58. img: getAvatarUrl(user),
  59. name: getDisplayName(user),
  60. }
  61. })
  62. }
  63. const providers = {
  64. social: [
  65. { email: 'email-icon2' },
  66. { apple: 'apple-icon' },
  67. { azure: 'microsoft-icon' },
  68. { bitbucket: 'bitbucket-icon' },
  69. { discord: 'discord-icon' },
  70. { facebook: 'facebook-icon' },
  71. { figma: 'figma-icon' },
  72. { github: 'github-icon' },
  73. { gitlab: 'gitlab-icon' },
  74. { google: 'google-icon' },
  75. { kakao: 'kakao-icon' },
  76. { keycloak: 'keycloak-icon' },
  77. { linkedin_oidc: 'linkedin-icon' },
  78. { notion: 'notion-icon' },
  79. { twitch: 'twitch-icon' },
  80. { twitter: 'twitter-icon' },
  81. { x: 'x-icon-light' },
  82. { slack_oidc: 'slack-icon' },
  83. { slack: 'slack-icon' },
  84. { spotify: 'spotify-icon' },
  85. { workos: 'workos-icon' },
  86. { zoom: 'zoom-icon' },
  87. ],
  88. phone: [
  89. { twilio: 'twilio-icon' },
  90. { messagebird: 'messagebird-icon' },
  91. { textlocal: 'messagebird-icon' },
  92. { vonage: 'messagebird-icon' },
  93. { twilioverify: 'twilio-verify-icon' },
  94. ],
  95. }
  96. // [Joshen] Just FYI this is not stress tested as I'm not sure what
  97. // all the potential values for each provider is under user.raw_app_meta_data.provider
  98. // Will need to go through one by one to properly verify https://supabase.com/docs/guides/auth/social-login
  99. // But I've made the UI handle to not render any icon if nothing matches in this map
  100. export const providerIconMap: { [key: string]: string } = Object.values([
  101. ...providers.social,
  102. ...providers.phone,
  103. ]).reduce((a, b) => {
  104. const [[key, value]] = Object.entries(b)
  105. return { ...a, [key]: value }
  106. }, {})
  107. const socialProviders = providers.social.map((x) => {
  108. const [key] = Object.keys(x)
  109. return key
  110. })
  111. const phoneProviders = providers.phone.map((x) => {
  112. const [key] = Object.keys(x)
  113. return key
  114. })
  115. function toPrettyJsonString(value: unknown): string | undefined {
  116. if (!value) return undefined
  117. if (typeof value === 'string') return value
  118. if (Array.isArray(value)) return value.map((item) => toPrettyJsonString(item)).join(' ')
  119. try {
  120. return JSON.stringify(value)
  121. } catch (error) {
  122. // ignore the error
  123. }
  124. return undefined
  125. }
  126. export function getDisplayName(user: User, fallback = '-'): string {
  127. const {
  128. custom_claims,
  129. displayName,
  130. display_name,
  131. fullName,
  132. full_name,
  133. familyName,
  134. family_name,
  135. givenName,
  136. given_name,
  137. surname,
  138. lastName,
  139. last_name,
  140. firstName,
  141. first_name,
  142. name,
  143. } = user.raw_user_meta_data ?? {}
  144. const {
  145. displayName: ccDisplayName,
  146. display_name: cc_display_name,
  147. fullName: ccFullName,
  148. full_name: cc_full_name,
  149. familyName: ccFamilyName,
  150. family_name: cc_family_name,
  151. givenName: ccGivenName,
  152. given_name: cc_given_name,
  153. surname: ccSurname,
  154. lastName: ccLastName,
  155. last_name: cc_last_name,
  156. firstName: ccFirstName,
  157. first_name: cc_first_name,
  158. } = (custom_claims ?? {}) as any
  159. const last = toPrettyJsonString(
  160. familyName ||
  161. family_name ||
  162. surname ||
  163. lastName ||
  164. last_name ||
  165. ccFamilyName ||
  166. cc_family_name ||
  167. ccSurname ||
  168. ccLastName ||
  169. cc_last_name
  170. )
  171. const first = toPrettyJsonString(
  172. givenName ||
  173. given_name ||
  174. firstName ||
  175. first_name ||
  176. ccGivenName ||
  177. cc_given_name ||
  178. ccFirstName ||
  179. cc_first_name
  180. )
  181. return (
  182. toPrettyJsonString(
  183. name ||
  184. displayName ||
  185. display_name ||
  186. ccDisplayName ||
  187. cc_display_name ||
  188. fullName ||
  189. full_name ||
  190. ccFullName ||
  191. cc_full_name ||
  192. (first && last && `${first} ${last}`) ||
  193. last ||
  194. first
  195. ) || fallback
  196. )
  197. }
  198. export function getAvatarUrl(user: User): string | undefined {
  199. const {
  200. avatarUrl,
  201. avatarURL,
  202. avatar_url,
  203. profileUrl,
  204. profileURL,
  205. profile_url,
  206. profileImage,
  207. profile_image,
  208. profileImageUrl,
  209. profileImageURL,
  210. profile_image_url,
  211. } = user.raw_user_meta_data ?? {}
  212. const url = (avatarUrl ||
  213. avatarURL ||
  214. avatar_url ||
  215. profileImage ||
  216. profile_image ||
  217. profileUrl ||
  218. profileURL ||
  219. profile_url ||
  220. profileImageUrl ||
  221. profileImageURL ||
  222. profile_image_url ||
  223. '') as unknown
  224. if (typeof url !== 'string') return undefined
  225. const isSupported = SUPPORTED_CSP_AVATAR_URLS.some((x) => url.startsWith(x))
  226. // [Joshen] Only for GH, not entirely sure whats the image transformation equiv for Google
  227. try {
  228. const _url = new URL(url)
  229. _url.searchParams.set('s', '24')
  230. return isSupported ? (url.startsWith(GITHUB_AVATAR_URL) ? _url.href : url) : undefined
  231. } catch (error) {
  232. return isSupported ? url : undefined
  233. }
  234. }
  235. export const formatUserColumns = ({
  236. specificFilterColumn,
  237. columns,
  238. config,
  239. users,
  240. visibleColumns = [],
  241. setSortByValue,
  242. onSelectDeleteUser,
  243. onSelectImpersonateUser,
  244. }: {
  245. specificFilterColumn: string
  246. columns: UsersTableColumn[]
  247. config: ColumnConfiguration[]
  248. users: User[]
  249. visibleColumns?: string[]
  250. setSortByValue: (val: string) => void
  251. onSelectDeleteUser: (user: User) => void
  252. onSelectImpersonateUser: (user: User, destination: 'sql' | 'table-editor') => Promise<void>
  253. }) => {
  254. const columnOrder = config.map((c) => c.id) ?? columns.map((c) => c.id)
  255. let gridColumns = columns.map((col) => {
  256. const savedConfig = config.find((c) => c.id === col.id)
  257. const res: Column<any> = {
  258. key: col.id,
  259. name: col.name,
  260. resizable: col.resizable ?? true,
  261. sortable: false,
  262. draggable: true,
  263. width: savedConfig?.width ?? col.width,
  264. minWidth: col.minWidth ?? 120,
  265. headerCellClass: 'z-50 outline-hidden shadow-none!',
  266. renderHeaderCell: () => {
  267. // [Joshen] I'm on the fence to support "Select all" for users, as the results are infinitely paginated
  268. // "Select all" wouldn't be an accurate representation if not all the pages have been fetched, but if decide
  269. // to support - the component is ready as such: Just pass selectedUsers and allRowsSelected as props from parent
  270. // <SelectHeaderCell selectedUsers={selectedUsers} allRowsSelected={allRowsSelected} />
  271. if (col.id === 'img') return undefined
  272. return (
  273. <HeaderCell
  274. col={col}
  275. specificFilterColumn={specificFilterColumn}
  276. setSortByValue={setSortByValue}
  277. />
  278. )
  279. },
  280. renderCell: ({ row }) => {
  281. // This is actually a valid React component, so we can use hooks here
  282. // eslint-disable-next-line react-hooks/rules-of-hooks
  283. const { isRowSelected, onRowSelectionChange } = useRowSelection()
  284. const value = row?.[col.id]
  285. const user = users?.find((u) => u.id === row.id)
  286. const formattedValue =
  287. value !== null && ['created_at', 'last_sign_in_at'].includes(col.id)
  288. ? dayjs(value).format('ddd DD MMM YYYY HH:mm:ss [GMT]ZZ')
  289. : Array.isArray(value)
  290. ? col.id === 'providers'
  291. ? value
  292. .map((x) => {
  293. const meta = PROVIDERS_SCHEMAS.find(
  294. (y) => ('key' in y && y.key === x) || y.title.toLowerCase() === x
  295. )
  296. return meta?.title
  297. })
  298. .join(', ')
  299. : value.join(', ')
  300. : value
  301. const isConfirmed = !!user?.confirmed_at
  302. if (col.id === 'img') {
  303. return (
  304. <div className="flex items-center justify-center gap-x-2">
  305. <Checkbox
  306. checked={isRowSelected}
  307. onClick={(e) => {
  308. e.stopPropagation()
  309. onRowSelectionChange({
  310. row,
  311. checked: !isRowSelected,
  312. isShiftClick: e.shiftKey,
  313. })
  314. }}
  315. />
  316. <div
  317. className={cn(
  318. 'flex items-center justify-center w-6 h-6 rounded-full bg-center bg-cover bg-no-repeat',
  319. !row.img ? 'bg-selection' : 'border'
  320. )}
  321. style={{ backgroundImage: row.img ? `url('${row.img}')` : 'none' }}
  322. >
  323. {!row.img && <UserIcon size={12} />}
  324. </div>
  325. </div>
  326. )
  327. }
  328. return (
  329. <ContextMenu>
  330. <ContextMenuTrigger asChild>
  331. <div
  332. className={cn(
  333. 'w-full flex items-center text-xs',
  334. col.id.includes('provider') ? 'capitalize' : ''
  335. )}
  336. >
  337. {/* [Joshen] Not convinced this is the ideal way to display the icons, but for now */}
  338. {col.id === 'providers' &&
  339. row.provider_icons.map((icon: string, idx: number) => {
  340. const provider = row.providers[idx]
  341. return (
  342. <div
  343. key={`${user?.id}-${provider}-wrapper`}
  344. className="min-w-6 min-h-6 rounded-full border flex items-center justify-center bg-surface-75"
  345. style={{
  346. marginLeft: idx === 0 ? 0 : `-8px`,
  347. zIndex: row.provider_icons.length - idx,
  348. }}
  349. >
  350. <img
  351. key={`${user?.id}-${provider}`}
  352. width={16}
  353. src={icon}
  354. alt={`${provider} auth icon`}
  355. className={cn(
  356. (provider === 'github' || provider === 'x') && 'dark:invert'
  357. )}
  358. />
  359. </div>
  360. )
  361. })}
  362. {col.id === 'last_sign_in_at' && !isConfirmed ? (
  363. <p className="text-foreground-lighter">Waiting for verification</p>
  364. ) : (
  365. <p className={cn(col.id === 'providers' && 'ml-1')}>
  366. {formattedValue === null ? '-' : formattedValue}
  367. </p>
  368. )}
  369. </div>
  370. </ContextMenuTrigger>
  371. <ContextMenuContent onClick={(e) => e.stopPropagation()}>
  372. <ContextMenuItem
  373. className="gap-x-2"
  374. onFocusCapture={(e) => e.stopPropagation()}
  375. onSelect={() => {
  376. const value = col.id === 'providers' ? row.providers.join(', ') : formattedValue
  377. copyToClipboard(value)
  378. }}
  379. >
  380. <Copy size={12} />
  381. <span>Copy {col.id === 'id' ? col.name : col.name.toLowerCase()}</span>
  382. </ContextMenuItem>
  383. <ContextMenuSeparator />
  384. <ContextMenuItem
  385. className="gap-x-2"
  386. onFocusCapture={(e) => e.stopPropagation()}
  387. onSelect={() => {
  388. if (user) onSelectImpersonateUser(user, 'table-editor')
  389. }}
  390. >
  391. <TableEditor size={12} />
  392. <span>View data as user</span>
  393. </ContextMenuItem>
  394. <ContextMenuItem
  395. className="gap-x-2"
  396. onFocusCapture={(e) => e.stopPropagation()}
  397. onSelect={() => {
  398. if (user) onSelectImpersonateUser(user, 'sql')
  399. }}
  400. >
  401. <SqlEditor size={12} />
  402. <span>Run SQL as user</span>
  403. </ContextMenuItem>
  404. <ContextMenuSeparator />
  405. <ContextMenuItem
  406. className="gap-x-2"
  407. onFocusCapture={(e) => e.stopPropagation()}
  408. onSelect={() => {
  409. if (user) onSelectDeleteUser(user)
  410. }}
  411. >
  412. <Trash size={12} />
  413. <span>Delete user</span>
  414. </ContextMenuItem>
  415. </ContextMenuContent>
  416. </ContextMenu>
  417. )
  418. },
  419. }
  420. return res
  421. })
  422. const profileImageColumn = gridColumns.find((col) => col.key === 'img')
  423. if (columnOrder.length > 0) {
  424. gridColumns = gridColumns
  425. .filter((col) => columnOrder.includes(col.key))
  426. .sort((a: any, b: any) => {
  427. return columnOrder.indexOf(a.key) - columnOrder.indexOf(b.key)
  428. })
  429. }
  430. return visibleColumns.length === 0
  431. ? gridColumns
  432. : ([profileImageColumn].concat(
  433. gridColumns.filter((col) => visibleColumns.includes(col.key))
  434. ) as Column<any>[])
  435. }