UserOverview.tsx 21 KB

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