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