UserOverview.tsx575 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import dayjs from 'dayjs'
4import { Ban, Check, Copy, Mail, ShieldOff, Trash, X } from 'lucide-react'
5import Link from 'next/link'
6import { ComponentProps, ReactNode, useEffect, useState } from 'react'
7import { toast } from 'sonner'
8import { Button, cn, Separator } from 'ui'
9import { Admonition } from 'ui-patterns/admonition'
10import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
11
12import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
13import { BanUserModal } from './BanUserModal'
14import { DeleteUserModal } from './DeleteUserModal'
15import { UserHeader } from './UserHeader'
16import { PANEL_PADDING } from './Users.constants'
17import { providerIconMap } from './Users.utils'
18import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
19import CopyButton from '@/components/ui/CopyButton'
20import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
21import { useUserDeleteMFAFactorsMutation } from '@/data/auth/user-delete-mfa-factors-mutation'
22import { useUserResetPasswordMutation } from '@/data/auth/user-reset-password-mutation'
23import { useUserSendMagicLinkMutation } from '@/data/auth/user-send-magic-link-mutation'
24import { useUserSendOTPMutation } from '@/data/auth/user-send-otp-mutation'
25import { useUserUpdateMutation } from '@/data/auth/user-update-mutation'
26import { User } from '@/data/auth/users-infinite-query'
27import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
28import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
29import { BASE_PATH } from '@/lib/constants'
30import { timeout } from '@/lib/helpers'
31
32const DATE_FORMAT = 'DD MMM, YYYY HH:mm'
33const CONTAINER_CLASS = cn(
34 'bg-surface-100 border-default text-foreground flex items-center justify-between',
35 'gap-x-4 border px-5 py-4 text-sm first:rounded-tr first:rounded-tl last:rounded-br last:rounded-bl'
36)
37
38interface UserOverviewProps {
39 user: User
40 onDeleteSuccess: () => void
41}
42
43export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
44 const { ref: projectRef } = useParams()
45 const isEmailAuth = user.email !== null
46 const isPhoneAuth = user.phone !== null
47 const isBanned = user.banned_until !== null
48 const isVerified = user.confirmed_at != null
49
50 const { authenticationSignInProviders } = useIsFeatureEnabled([
51 'authentication:sign_in_providers',
52 ])
53
54 const providers = ((user.raw_app_meta_data?.providers as string[]) ?? []).map(
55 (provider: string) => {
56 return {
57 name: provider.startsWith('sso') ? 'SAML' : provider,
58 icon:
59 provider === 'email'
60 ? `${BASE_PATH}/img/icons/email-icon2.svg`
61 : providerIconMap[provider]
62 ? `${BASE_PATH}/img/icons/${providerIconMap[provider]}.svg`
63 : undefined,
64 }
65 }
66 )
67
68 const { can: canUpdateUser } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
69 const { can: canSendMagicLink } = useAsyncCheckPermissions(
70 PermissionAction.AUTH_EXECUTE,
71 'send_magic_link'
72 )
73 const { can: canSendRecovery } = useAsyncCheckPermissions(
74 PermissionAction.AUTH_EXECUTE,
75 'send_recovery'
76 )
77 const { can: canSendOtp } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp')
78 const { can: canRemoveUser } = useAsyncCheckPermissions(
79 PermissionAction.TENANT_SQL_DELETE,
80 'auth.users'
81 )
82 const { can: canRemoveMFAFactors } = useAsyncCheckPermissions(
83 PermissionAction.TENANT_SQL_DELETE,
84 'auth.mfa_factors'
85 )
86
87 const [successAction, setSuccessAction] = useState<
88 'send_magic_link' | 'send_recovery' | 'send_otp'
89 >()
90 const [isBanModalOpen, setIsBanModalOpen] = useState(false)
91 const [isUnbanModalOpen, setIsUnbanModalOpen] = useState(false)
92 const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
93 const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false)
94
95 const { data } = useAuthConfigQuery({ projectRef })
96
97 const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0
98 const minutes = Math.floor(mailerOtpExpiry / 60)
99 const seconds = Math.floor(mailerOtpExpiry % 60)
100 const formattedExpiry = `${mailerOtpExpiry > 60 ? `${minutes} minute${minutes > 1 ? 's' : ''} ${seconds > 0 ? 'and' : ''} ` : ''}${seconds > 0 ? `${seconds} second${seconds > 1 ? 's' : ''}` : ''}`
101
102 const { mutate: resetPassword, isPending: isResettingPassword } = useUserResetPasswordMutation({
103 onSuccess: (_, vars) => {
104 setSuccessAction('send_recovery')
105 toast.success(`Sent password recovery to ${vars.user.email}`)
106 },
107 onError: (err) => {
108 toast.error(`Failed to send password recovery: ${err.message}`)
109 },
110 })
111 const { mutate: sendMagicLink, isPending: isSendingMagicLink } = useUserSendMagicLinkMutation({
112 onSuccess: (_, vars) => {
113 setSuccessAction('send_magic_link')
114 toast.success(
115 isVerified
116 ? `Sent magic link to ${vars.user.email}`
117 : `Sent confirmation email to ${vars.user.email}`
118 )
119 },
120 onError: (err) => {
121 toast.error(
122 isVerified
123 ? `Failed to send magic link: ${err.message}`
124 : `Failed to send confirmation email: ${err.message}`
125 )
126 },
127 })
128 const { mutate: sendOTP, isPending: isSendingOTP } = useUserSendOTPMutation({
129 onSuccess: (_, vars) => {
130 setSuccessAction('send_otp')
131 toast.success(`Sent OTP to ${vars.user.phone}`)
132 },
133 onError: (err) => {
134 toast.error(`Failed to send OTP: ${err.message}`)
135 },
136 })
137 const { mutate: deleteUserMFAFactors } = useUserDeleteMFAFactorsMutation({
138 onSuccess: () => {
139 toast.success("Successfully deleted the user's factors")
140 setIsDeleteFactorsModalOpen(false)
141 },
142 })
143 const { mutate: updateUser, isPending: isUpdatingUser } = useUserUpdateMutation({
144 onSuccess: () => {
145 toast.success('Successfully unbanned user')
146 setIsUnbanModalOpen(false)
147 },
148 })
149
150 const handleDeleteFactors = async () => {
151 await timeout(200)
152 if (!projectRef) return console.error('Project ref is required')
153 deleteUserMFAFactors({ projectRef, userId: user.id as string })
154 }
155
156 const handleUnban = () => {
157 if (projectRef === undefined) return console.error('Project ref is required')
158 if (user.id === undefined) {
159 return toast.error(`Failed to ban user: User ID not found`)
160 }
161
162 updateUser({
163 projectRef,
164 userId: user.id,
165 banDuration: 'none',
166 })
167 }
168
169 useEffect(() => {
170 if (successAction !== undefined) {
171 const timer = setTimeout(() => setSuccessAction(undefined), 5000)
172 return () => clearTimeout(timer)
173 }
174 }, [successAction])
175
176 return (
177 <>
178 <div>
179 <UserHeader user={user} />
180
181 {isBanned ? (
182 <Admonition
183 type="warning"
184 description={`User banned until ${dayjs(user.banned_until).format(DATE_FORMAT)}`}
185 className="border-r-0 border-l-0 rounded-none -mt-px [&_svg]:ml-0.5"
186 />
187 ) : (
188 <Separator />
189 )}
190
191 <div className={cn('flex flex-col gap-y-1', PANEL_PADDING)}>
192 <RowData property="User UID" value={user.id} />
193 <RowData
194 property="Created at"
195 value={user.created_at ? dayjs(user.created_at).format(DATE_FORMAT) : undefined}
196 />
197 <RowData
198 property="Updated at"
199 value={user.updated_at ? dayjs(user.updated_at).format(DATE_FORMAT) : undefined}
200 />
201 <RowData property="Invited at" value={user.invited_at} />
202 <RowData property="Confirmation sent at" value={user.confirmation_sent_at} />
203 <RowData
204 property="Confirmed at"
205 value={user.confirmed_at ? dayjs(user.confirmed_at).format(DATE_FORMAT) : undefined}
206 />
207 <RowData
208 property="Last signed in"
209 value={
210 user.last_sign_in_at ? dayjs(user.last_sign_in_at).format(DATE_FORMAT) : undefined
211 }
212 />
213 <RowData property="SSO" value={user.is_sso_user} />
214 </div>
215
216 <div className={cn('flex flex-col pt-0!', PANEL_PADDING)}>
217 <p>Provider Information</p>
218 <p className="text-sm text-foreground-light">The user has the following providers</p>
219 </div>
220
221 <div className={cn('flex flex-col -space-y-1 pt-0!', PANEL_PADDING)}>
222 {providers.map((provider) => {
223 const providerMeta = PROVIDERS_SCHEMAS.find(
224 (x) =>
225 ('key' in x && x.key === provider.name) || x.title.toLowerCase() === provider.name
226 )
227 const enabledProperty =
228 provider.name.toLowerCase() === 'web3'
229 ? (
230 {
231 solana: 'EXTERNAL_WEB3_SOLANA_ENABLED',
232 ethereum: 'EXTERNAL_WEB3_ETHEREUM_ENABLED',
233 } as const
234 )[
235 (
236 (user.raw_user_meta_data?.custom_claims as { chain?: string } | undefined)
237 ?.chain ?? ''
238 ).toLowerCase() as 'solana' | 'ethereum'
239 ]
240 : Object.keys(providerMeta?.properties ?? {}).find((x) =>
241 x.toLowerCase().endsWith('_enabled')
242 )
243 const providerName =
244 provider.name === 'email'
245 ? provider.name.toLowerCase()
246 : (providerMeta?.title ?? provider.name)
247 const isActive = data?.[enabledProperty as keyof typeof data] ?? false
248
249 return (
250 <div key={provider.name} className={cn(CONTAINER_CLASS, 'items-start justify-start')}>
251 {provider.icon && (
252 <img
253 width={16}
254 src={provider.icon}
255 alt={`${provider.name} auth icon`}
256 className={cn('mt-1.5', provider.name === 'github' ? 'dark:invert' : '')}
257 />
258 )}
259 <div className="grow mt-0.5">
260 <p className="capitalize">{providerName}</p>
261 <p className="text-xs text-foreground-light">
262 Signed in with a {providerName} account via{' '}
263 {providerName === 'SAML' ? 'SSO' : 'OAuth'}
264 </p>
265 {authenticationSignInProviders && (
266 <Button asChild type="default" className="mt-2">
267 <Link
268 href={`/project/${projectRef}/auth/providers?provider=${provider.name === 'SAML' ? 'SAML 2.0' : provider.name}`}
269 >
270 Configure {providerName} provider
271 </Link>
272 </Button>
273 )}
274 </div>
275 {isActive ? (
276 <div className="flex items-center gap-1 rounded-full border border-brand-400 bg-brand-200 py-1 px-1 text-xs text-brand">
277 <span className="rounded-full bg-brand p-0.5 text-xs text-brand-200">
278 <Check strokeWidth={2} size={12} />
279 </span>
280 <span className="px-1">Enabled</span>
281 </div>
282 ) : (
283 <div className="rounded-md border border-strong bg-surface-100 py-1 px-3 text-xs text-foreground-lighter">
284 Disabled
285 </div>
286 )}
287 </div>
288 )
289 })}
290 </div>
291
292 <Separator />
293
294 <div className={cn('flex flex-col -space-y-1', PANEL_PADDING)}>
295 {isEmailAuth && (
296 <>
297 <RowAction
298 title="Reset password"
299 description="Send a password recovery email to the user"
300 button={{
301 icon: <Mail />,
302 text: 'Send password recovery',
303 isLoading: isResettingPassword,
304 disabled: !canSendRecovery,
305 onClick: () => {
306 if (projectRef) resetPassword({ projectRef, user })
307 },
308 }}
309 success={
310 successAction === 'send_recovery'
311 ? {
312 title: 'Password recovery sent',
313 description: `The link in the email is valid for ${formattedExpiry}`,
314 }
315 : undefined
316 }
317 />
318 <RowAction
319 title={isVerified ? 'Send Magic Link' : 'Send confirmation email'}
320 description={
321 isVerified
322 ? 'Passwordless login via email for the user'
323 : 'Send a confirmation email to the user'
324 }
325 button={{
326 icon: <Mail />,
327 text: isVerified ? 'Send magic link' : 'Send confirmation email',
328 isLoading: isSendingMagicLink,
329 disabled: !canSendMagicLink,
330 onClick: () => {
331 if (projectRef) sendMagicLink({ projectRef, user })
332 },
333 }}
334 success={
335 successAction === 'send_magic_link'
336 ? {
337 title: isVerified ? 'Magic link sent' : 'Confirmation email sent',
338 description: isVerified
339 ? `The link in the email is valid for ${formattedExpiry}`
340 : 'The confirmation email has been sent to the user',
341 }
342 : undefined
343 }
344 />
345 </>
346 )}
347 {isPhoneAuth && (
348 <RowAction
349 title="Send OTP"
350 description="Passwordless login via phone for the user"
351 button={{
352 icon: <Mail />,
353 text: 'Send OTP',
354 isLoading: isSendingOTP,
355 disabled: !canSendOtp,
356 onClick: () => {
357 if (projectRef) sendOTP({ projectRef, user })
358 },
359 }}
360 success={
361 successAction === 'send_otp'
362 ? {
363 title: 'OTP sent',
364 description: `The link in the OTP SMS is valid for ${formattedExpiry}`,
365 }
366 : undefined
367 }
368 />
369 )}
370 </div>
371
372 <Separator />
373
374 <div className={cn('flex flex-col', PANEL_PADDING)}>
375 <p>Danger zone</p>
376 <p className="text-sm text-foreground-light">
377 Be wary of the following features as they cannot be undone.
378 </p>
379 </div>
380
381 <div className={cn('flex flex-col -space-y-1 pt-0!', PANEL_PADDING)}>
382 <RowAction
383 title="Remove MFA factors"
384 description="Removes all MFA factors associated with the user"
385 button={{
386 icon: <ShieldOff />,
387 text: 'Remove MFA factors',
388 disabled: !canRemoveMFAFactors,
389 onClick: () => setIsDeleteFactorsModalOpen(true),
390 }}
391 className="!bg border-destructive-400"
392 />
393 <RowAction
394 title={
395 isBanned
396 ? `User is banned until ${dayjs(user.banned_until).format(DATE_FORMAT)}`
397 : 'Ban user'
398 }
399 description={
400 isBanned
401 ? 'User has no access to the project until after this date'
402 : 'Revoke access to the project for a set duration'
403 }
404 button={{
405 icon: <Ban />,
406 text: isBanned ? 'Unban user' : 'Ban user',
407 disabled: !canUpdateUser,
408 onClick: () => {
409 if (isBanned) {
410 setIsUnbanModalOpen(true)
411 } else {
412 setIsBanModalOpen(true)
413 }
414 },
415 }}
416 className="!bg border-destructive-400"
417 />
418 <RowAction
419 title="Delete user"
420 description="User will no longer have access to the project"
421 button={{
422 icon: <Trash />,
423 type: 'danger',
424 text: 'Delete user',
425 disabled: !canRemoveUser,
426 onClick: () => setIsDeleteModalOpen(true),
427 }}
428 className="!bg border-destructive-400"
429 />
430 </div>
431 </div>
432
433 <DeleteUserModal
434 visible={isDeleteModalOpen}
435 selectedUser={user}
436 onClose={() => setIsDeleteModalOpen(false)}
437 onDeleteSuccess={() => {
438 setIsDeleteModalOpen(false)
439 onDeleteSuccess()
440 }}
441 />
442
443 <ConfirmationModal
444 visible={isDeleteFactorsModalOpen}
445 variant="warning"
446 title="Confirm to remove MFA factors"
447 confirmLabel="Remove factors"
448 confirmLabelLoading="Removing"
449 onCancel={() => setIsDeleteFactorsModalOpen(false)}
450 onConfirm={() => handleDeleteFactors()}
451 alert={{
452 base: { variant: 'warning' },
453 title:
454 "Removing MFA factors will drop the user's authentication assurance level (AAL) to AAL1",
455 description: 'Note that this does not sign the user out',
456 }}
457 >
458 <p className="text-sm text-foreground-light">
459 Are you sure you want to remove the MFA factors for the user{' '}
460 <span className="text-foreground">{user.email ?? user.phone ?? 'this user'}</span>?
461 </p>
462 </ConfirmationModal>
463
464 <BanUserModal visible={isBanModalOpen} user={user} onClose={() => setIsBanModalOpen(false)} />
465
466 <ConfirmationModal
467 variant="warning"
468 visible={isUnbanModalOpen}
469 title="Confirm to unban user"
470 loading={isUpdatingUser}
471 confirmLabel="Unban user"
472 confirmLabelLoading="Unbanning"
473 onCancel={() => setIsUnbanModalOpen(false)}
474 onConfirm={() => handleUnban()}
475 >
476 <p className="text-sm text-foreground-light">
477 The user will have access to your project again once unbanned. Are you sure you want to
478 unban this user?
479 </p>
480 </ConfirmationModal>
481 </>
482 )
483}
484
485export const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
486 return (
487 <>
488 <div className="flex items-center gap-x-2 group justify-between">
489 <p className=" text-foreground-lighter text-xs">{property}</p>
490 {typeof value === 'boolean' ? (
491 <div className="h-[26px] flex items-center justify-center min-w-[70px]">
492 {value ? (
493 <div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
494 <Check size={10} className="text-contrast" strokeWidth={4} />
495 </div>
496 ) : (
497 <div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
498 <X size={10} className="text-contrast" strokeWidth={4} />
499 </div>
500 )}
501 </div>
502 ) : (
503 <div className="flex items-center gap-x-2 h-[26px] font-mono min-w-[40px]">
504 <p className="text-xs">{!value ? '-' : value}</p>
505 {!!value && (
506 <CopyButton
507 iconOnly
508 type="text"
509 icon={<Copy />}
510 className="transition opacity-0 group-hover:opacity-100 px-1"
511 text={value}
512 />
513 )}
514 </div>
515 )}
516 </div>
517 <Separator />
518 </>
519 )
520}
521
522export const RowAction = ({
523 title,
524 description,
525 button,
526 success,
527 className,
528}: {
529 title: string
530 description: string
531 button: {
532 icon: ReactNode
533 type?: ComponentProps<typeof Button>['type']
534 text: string
535 disabled?: boolean
536 isLoading?: boolean
537 onClick: () => void
538 }
539 success?: {
540 title: string
541 description: string
542 }
543 className?: string
544}) => {
545 const disabled = button?.disabled ?? false
546
547 return (
548 <div className={cn(CONTAINER_CLASS, className)}>
549 <div>
550 <p>{success ? success.title : title}</p>
551 <p className="text-xs text-foreground-light">
552 {success ? success.description : description}
553 </p>
554 </div>
555
556 <ButtonTooltip
557 type={button?.type ?? 'default'}
558 icon={success ? <Check className="text-brand" /> : button.icon}
559 loading={button.isLoading ?? false}
560 onClick={button.onClick}
561 disabled={disabled}
562 tooltip={{
563 content: {
564 side: 'bottom',
565 text: disabled
566 ? `You need additional permissions to ${button.text.toLowerCase()}`
567 : undefined,
568 },
569 }}
570 >
571 {button.text}
572 </ButtonTooltip>
573 </div>
574 )
575}