| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- import dayjs from 'dayjs'
- import { SqlEditor, TableEditor } from 'icons'
- import { Copy, Trash, UserIcon } from 'lucide-react'
- import { Column, useRowSelection } from 'react-data-grid'
- import {
- Checkbox,
- cn,
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
- copyToClipboard,
- } from 'ui'
- import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
- import { ColumnConfiguration, UsersTableColumn } from './Users.constants'
- import { HeaderCell } from './UsersGridComponents'
- import { User } from '@/data/auth/users-infinite-query'
- import { BASE_PATH } from '@/lib/constants'
- const GITHUB_AVATAR_URL = 'https://avatars.githubusercontent.com'
- const SUPPORTED_CSP_AVATAR_URLS = [GITHUB_AVATAR_URL, 'https://lh3.googleusercontent.com']
- export const formatUsersData = (users: User[]) => {
- return users.map((user) => {
- const provider: string = (user.raw_app_meta_data?.provider as string) ?? ''
- const providers: string[] = user.providers.map((x: string) => {
- if (x.startsWith('sso')) return 'SAML'
- return x
- })
- return {
- id: user.id,
- email: user.email,
- phone: user.phone,
- created_at: user.created_at,
- last_sign_in_at: user.last_sign_in_at,
- providers: user.is_anonymous ? '-' : providers,
- provider_icons: providers
- .map((p) => {
- return p === 'email'
- ? `${BASE_PATH}/img/icons/email-icon2.svg`
- : p === 'SAML'
- ? `${BASE_PATH}/img/icons/saml-icon.svg`
- : providerIconMap[p]
- ? `${BASE_PATH}/img/icons/${providerIconMap[p]}.svg`
- : undefined
- })
- .filter(Boolean),
- // I think it's alright to just check via the main provider since email and phone should be mutually exclusive
- provider_type: user.is_anonymous
- ? 'Anonymous'
- : provider === 'email'
- ? '-'
- : socialProviders.includes(provider)
- ? 'Social'
- : phoneProviders.includes(provider)
- ? 'Phone'
- : '-',
- // [Joshen] Note that the images might not load due to CSP issues
- img: getAvatarUrl(user),
- name: getDisplayName(user),
- }
- })
- }
- const providers = {
- social: [
- { email: 'email-icon2' },
- { apple: 'apple-icon' },
- { azure: 'microsoft-icon' },
- { bitbucket: 'bitbucket-icon' },
- { discord: 'discord-icon' },
- { facebook: 'facebook-icon' },
- { figma: 'figma-icon' },
- { github: 'github-icon' },
- { gitlab: 'gitlab-icon' },
- { google: 'google-icon' },
- { kakao: 'kakao-icon' },
- { keycloak: 'keycloak-icon' },
- { linkedin_oidc: 'linkedin-icon' },
- { notion: 'notion-icon' },
- { twitch: 'twitch-icon' },
- { twitter: 'twitter-icon' },
- { x: 'x-icon-light' },
- { slack_oidc: 'slack-icon' },
- { slack: 'slack-icon' },
- { spotify: 'spotify-icon' },
- { workos: 'workos-icon' },
- { zoom: 'zoom-icon' },
- ],
- phone: [
- { twilio: 'twilio-icon' },
- { messagebird: 'messagebird-icon' },
- { textlocal: 'messagebird-icon' },
- { vonage: 'messagebird-icon' },
- { twilioverify: 'twilio-verify-icon' },
- ],
- }
- // [Joshen] Just FYI this is not stress tested as I'm not sure what
- // all the potential values for each provider is under user.raw_app_meta_data.provider
- // Will need to go through one by one to properly verify https://supabase.com/docs/guides/auth/social-login
- // But I've made the UI handle to not render any icon if nothing matches in this map
- export const providerIconMap: { [key: string]: string } = Object.values([
- ...providers.social,
- ...providers.phone,
- ]).reduce((a, b) => {
- const [[key, value]] = Object.entries(b)
- return { ...a, [key]: value }
- }, {})
- const socialProviders = providers.social.map((x) => {
- const [key] = Object.keys(x)
- return key
- })
- const phoneProviders = providers.phone.map((x) => {
- const [key] = Object.keys(x)
- return key
- })
- function toPrettyJsonString(value: unknown): string | undefined {
- if (!value) return undefined
- if (typeof value === 'string') return value
- if (Array.isArray(value)) return value.map((item) => toPrettyJsonString(item)).join(' ')
- try {
- return JSON.stringify(value)
- } catch (error) {
- // ignore the error
- }
- return undefined
- }
- export function getDisplayName(user: User, fallback = '-'): string {
- const {
- custom_claims,
- displayName,
- display_name,
- fullName,
- full_name,
- familyName,
- family_name,
- givenName,
- given_name,
- surname,
- lastName,
- last_name,
- firstName,
- first_name,
- name,
- } = user.raw_user_meta_data ?? {}
- const {
- displayName: ccDisplayName,
- display_name: cc_display_name,
- fullName: ccFullName,
- full_name: cc_full_name,
- familyName: ccFamilyName,
- family_name: cc_family_name,
- givenName: ccGivenName,
- given_name: cc_given_name,
- surname: ccSurname,
- lastName: ccLastName,
- last_name: cc_last_name,
- firstName: ccFirstName,
- first_name: cc_first_name,
- } = (custom_claims ?? {}) as any
- const last = toPrettyJsonString(
- familyName ||
- family_name ||
- surname ||
- lastName ||
- last_name ||
- ccFamilyName ||
- cc_family_name ||
- ccSurname ||
- ccLastName ||
- cc_last_name
- )
- const first = toPrettyJsonString(
- givenName ||
- given_name ||
- firstName ||
- first_name ||
- ccGivenName ||
- cc_given_name ||
- ccFirstName ||
- cc_first_name
- )
- return (
- toPrettyJsonString(
- name ||
- displayName ||
- display_name ||
- ccDisplayName ||
- cc_display_name ||
- fullName ||
- full_name ||
- ccFullName ||
- cc_full_name ||
- (first && last && `${first} ${last}`) ||
- last ||
- first
- ) || fallback
- )
- }
- export function getAvatarUrl(user: User): string | undefined {
- const {
- avatarUrl,
- avatarURL,
- avatar_url,
- profileUrl,
- profileURL,
- profile_url,
- profileImage,
- profile_image,
- profileImageUrl,
- profileImageURL,
- profile_image_url,
- } = user.raw_user_meta_data ?? {}
- const url = (avatarUrl ||
- avatarURL ||
- avatar_url ||
- profileImage ||
- profile_image ||
- profileUrl ||
- profileURL ||
- profile_url ||
- profileImageUrl ||
- profileImageURL ||
- profile_image_url ||
- '') as unknown
- if (typeof url !== 'string') return undefined
- const isSupported = SUPPORTED_CSP_AVATAR_URLS.some((x) => url.startsWith(x))
- // [Joshen] Only for GH, not entirely sure whats the image transformation equiv for Google
- try {
- const _url = new URL(url)
- _url.searchParams.set('s', '24')
- return isSupported ? (url.startsWith(GITHUB_AVATAR_URL) ? _url.href : url) : undefined
- } catch (error) {
- return isSupported ? url : undefined
- }
- }
- export const formatUserColumns = ({
- specificFilterColumn,
- columns,
- config,
- users,
- visibleColumns = [],
- setSortByValue,
- onSelectDeleteUser,
- onSelectImpersonateUser,
- }: {
- specificFilterColumn: string
- columns: UsersTableColumn[]
- config: ColumnConfiguration[]
- users: User[]
- visibleColumns?: string[]
- setSortByValue: (val: string) => void
- onSelectDeleteUser: (user: User) => void
- onSelectImpersonateUser: (user: User, destination: 'sql' | 'table-editor') => Promise<void>
- }) => {
- const columnOrder = config.map((c) => c.id) ?? columns.map((c) => c.id)
- let gridColumns = columns.map((col) => {
- const savedConfig = config.find((c) => c.id === col.id)
- const res: Column<any> = {
- key: col.id,
- name: col.name,
- resizable: col.resizable ?? true,
- sortable: false,
- draggable: true,
- width: savedConfig?.width ?? col.width,
- minWidth: col.minWidth ?? 120,
- headerCellClass: 'z-50 outline-hidden shadow-none!',
- renderHeaderCell: () => {
- // [Joshen] I'm on the fence to support "Select all" for users, as the results are infinitely paginated
- // "Select all" wouldn't be an accurate representation if not all the pages have been fetched, but if decide
- // to support - the component is ready as such: Just pass selectedUsers and allRowsSelected as props from parent
- // <SelectHeaderCell selectedUsers={selectedUsers} allRowsSelected={allRowsSelected} />
- if (col.id === 'img') return undefined
- return (
- <HeaderCell
- col={col}
- specificFilterColumn={specificFilterColumn}
- setSortByValue={setSortByValue}
- />
- )
- },
- renderCell: ({ row }) => {
- // This is actually a valid React component, so we can use hooks here
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const { isRowSelected, onRowSelectionChange } = useRowSelection()
- const value = row?.[col.id]
- const user = users?.find((u) => u.id === row.id)
- const formattedValue =
- value !== null && ['created_at', 'last_sign_in_at'].includes(col.id)
- ? dayjs(value).format('ddd DD MMM YYYY HH:mm:ss [GMT]ZZ')
- : Array.isArray(value)
- ? col.id === 'providers'
- ? value
- .map((x) => {
- const meta = PROVIDERS_SCHEMAS.find(
- (y) => ('key' in y && y.key === x) || y.title.toLowerCase() === x
- )
- return meta?.title
- })
- .join(', ')
- : value.join(', ')
- : value
- const isConfirmed = !!user?.confirmed_at
- if (col.id === 'img') {
- return (
- <div className="flex items-center justify-center gap-x-2">
- <Checkbox
- checked={isRowSelected}
- onClick={(e) => {
- e.stopPropagation()
- onRowSelectionChange({
- row,
- checked: !isRowSelected,
- isShiftClick: e.shiftKey,
- })
- }}
- />
- <div
- className={cn(
- 'flex items-center justify-center w-6 h-6 rounded-full bg-center bg-cover bg-no-repeat',
- !row.img ? 'bg-selection' : 'border'
- )}
- style={{ backgroundImage: row.img ? `url('${row.img}')` : 'none' }}
- >
- {!row.img && <UserIcon size={12} />}
- </div>
- </div>
- )
- }
- return (
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className={cn(
- 'w-full flex items-center text-xs',
- col.id.includes('provider') ? 'capitalize' : ''
- )}
- >
- {/* [Joshen] Not convinced this is the ideal way to display the icons, but for now */}
- {col.id === 'providers' &&
- row.provider_icons.map((icon: string, idx: number) => {
- const provider = row.providers[idx]
- return (
- <div
- key={`${user?.id}-${provider}-wrapper`}
- className="min-w-6 min-h-6 rounded-full border flex items-center justify-center bg-surface-75"
- style={{
- marginLeft: idx === 0 ? 0 : `-8px`,
- zIndex: row.provider_icons.length - idx,
- }}
- >
- <img
- key={`${user?.id}-${provider}`}
- width={16}
- src={icon}
- alt={`${provider} auth icon`}
- className={cn(
- (provider === 'github' || provider === 'x') && 'dark:invert'
- )}
- />
- </div>
- )
- })}
- {col.id === 'last_sign_in_at' && !isConfirmed ? (
- <p className="text-foreground-lighter">Waiting for verification</p>
- ) : (
- <p className={cn(col.id === 'providers' && 'ml-1')}>
- {formattedValue === null ? '-' : formattedValue}
- </p>
- )}
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent onClick={(e) => e.stopPropagation()}>
- <ContextMenuItem
- className="gap-x-2"
- onFocusCapture={(e) => e.stopPropagation()}
- onSelect={() => {
- const value = col.id === 'providers' ? row.providers.join(', ') : formattedValue
- copyToClipboard(value)
- }}
- >
- <Copy size={12} />
- <span>Copy {col.id === 'id' ? col.name : col.name.toLowerCase()}</span>
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- className="gap-x-2"
- onFocusCapture={(e) => e.stopPropagation()}
- onSelect={() => {
- if (user) onSelectImpersonateUser(user, 'table-editor')
- }}
- >
- <TableEditor size={12} />
- <span>View data as user</span>
- </ContextMenuItem>
- <ContextMenuItem
- className="gap-x-2"
- onFocusCapture={(e) => e.stopPropagation()}
- onSelect={() => {
- if (user) onSelectImpersonateUser(user, 'sql')
- }}
- >
- <SqlEditor size={12} />
- <span>Run SQL as user</span>
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- className="gap-x-2"
- onFocusCapture={(e) => e.stopPropagation()}
- onSelect={() => {
- if (user) onSelectDeleteUser(user)
- }}
- >
- <Trash size={12} />
- <span>Delete user</span>
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- )
- },
- }
- return res
- })
- const profileImageColumn = gridColumns.find((col) => col.key === 'img')
- if (columnOrder.length > 0) {
- gridColumns = gridColumns
- .filter((col) => columnOrder.includes(col.key))
- .sort((a: any, b: any) => {
- return columnOrder.indexOf(a.key) - columnOrder.indexOf(b.key)
- })
- }
- return visibleColumns.length === 0
- ? gridColumns
- : ([profileImageColumn].concat(
- gridColumns.filter((col) => visibleColumns.includes(col.key))
- ) as Column<any>[])
- }
|