Files
supabase/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx
T
Joshen Lim 6af9ff6061 Chore/user management v2 panel (#29468)
* Midway implementing user management v2 panel

* Do up overview panel less provider information

* Midway provider info

* Wrap up provider information

* Add banned until banner

* Slight adjustment to search input

* Fix

* Fix provider icons in data grid

* Fix

* Moar fixes

* Add loading line

* Address some feedback

* Reorder some elements in the user overview panel

* Fix icons going over cell headers
2024-09-25 12:40:18 +08:00

501 lines
18 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import dayjs from 'dayjs'
import { Check, Copy, Mail, ShieldOff, Trash, X } from 'lucide-react'
import Link from 'next/link'
import { ComponentProps, ReactNode, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import CopyButton from 'components/ui/CopyButton'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useUserDeleteMFAFactorsMutation } from 'data/auth/user-delete-mfa-factors-mutation'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { useUserResetPasswordMutation } from 'data/auth/user-reset-password-mutation'
import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mutation'
import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation'
import { User } from 'data/auth/users-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
import { timeout } from 'lib/helpers'
import { Admonition, Button, cn, Separator } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
import { PANEL_PADDING } from './UserPanel'
import { getDisplayName, providerIconMap } from './Users.utils'
const DATE_FORMAT = 'DD MMM, YYYY HH:mm'
const CONTAINER_CLASS = cn(
'bg-surface-100 border-default text-foreground flex items-center justify-between',
'gap-x-4 border px-5 py-4 text-sm first:rounded-tr first:rounded-tl last:rounded-br last:rounded-bl'
)
interface UserOverviewProps {
user: User
onDeleteSuccess: () => void
}
export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
const { ref: projectRef } = useParams()
const displayName = getDisplayName(user)
const hasDisplayName = displayName !== '-'
const isEmailAuth = user.email !== null
const isPhoneAuth = user.phone !== null
const isAnonUser = user.is_anonymous
const providers = (user.raw_app_meta_data?.providers ?? []).map((provider) => {
return {
name: provider,
icon:
provider === 'email'
? `${BASE_PATH}/img/icons/email-icon2.svg`
: providerIconMap[provider]
? `${BASE_PATH}/img/icons/${providerIconMap[provider]}.svg`
: undefined,
}
})
const canSendMagicLink = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_magic_link')
const canSendRecovery = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_recovery')
const canSendOtp = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp')
const canRemoveUser = useCheckPermissions(PermissionAction.TENANT_SQL_DELETE, 'auth.users')
const canRemoveMFAFactors = useCheckPermissions(
PermissionAction.TENANT_SQL_DELETE,
'auth.mfa_factors'
)
const [successAction, setSuccessAction] = useState<
'send_magic_link' | 'send_recovery' | 'send_otp'
>()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false)
const { data } = useAuthConfigQuery({ projectRef })
const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0
const minutes = Math.floor(mailerOtpExpiry / 60)
const seconds = Math.floor(mailerOtpExpiry % 60)
const formattedExpiry = `${mailerOtpExpiry > 60 ? `${minutes} minute${minutes > 1 ? 's' : ''} ${seconds > 0 ? 'and' : ''} ` : ''}${seconds > 0 ? `${seconds} second${seconds > 1 ? 's' : ''}` : ''}`
const { mutate: resetPassword, isLoading: isResettingPassword } = useUserResetPasswordMutation({
onSuccess: (_, vars) => {
setSuccessAction('send_recovery')
toast.success(`Sent password recovery to ${vars.user.email}`)
},
onError: (err) => {
toast.error(`Failed to send password recovery: ${err.message}`)
},
})
const { mutate: sendMagicLink, isLoading: isSendingMagicLink } = useUserSendMagicLinkMutation({
onSuccess: (_, vars) => {
setSuccessAction('send_magic_link')
toast.success(`Sent magic link to ${vars.user.email}`)
},
onError: (err) => {
toast.error(`Failed to send magic link: ${err.message}`)
},
})
const { mutate: sendOTP, isLoading: isSendingOTP } = useUserSendOTPMutation({
onSuccess: (_, vars) => {
setSuccessAction('send_otp')
toast.success(`Sent OTP to ${vars.user.phone}`)
},
onError: (err) => {
toast.error(`Failed to send OTP: ${err.message}`)
},
})
const { mutate: deleteUser } = useUserDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted ${user?.email}`)
setIsDeleteModalOpen(false)
onDeleteSuccess()
},
})
const { mutate: deleteUserMFAFactors } = useUserDeleteMFAFactorsMutation({
onSuccess: () => {
toast.success("Successfully deleted the user's factors")
setIsDeleteFactorsModalOpen(false)
},
})
const handleDelete = async () => {
await timeout(200)
if (!projectRef) return console.error('Project ref is required')
deleteUser({ projectRef, user })
}
const handleDeleteFactors = async () => {
await timeout(200)
if (!projectRef) return console.error('Project ref is required')
deleteUserMFAFactors({ projectRef, userId: user.id as string })
}
useEffect(() => {
if (successAction !== undefined) {
const timer = setTimeout(() => setSuccessAction(undefined), 5000)
return () => clearTimeout(timer)
}
}, [successAction])
return (
<>
<div>
<div className={cn(PANEL_PADDING)}>
{isPhoneAuth ? (
<div className="flex items-center gap-x-1">
<p>{user.phone}</p>
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="px-1"
text={user?.phone ?? ''}
/>
</div>
) : isAnonUser ? (
<>
<p>Anonymous user</p>
<div className="flex items-center gap-x-1">
<p className="text-foreground-light text-sm">{user.id}</p>
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="px-1"
text={user?.id ?? ''}
/>
</div>
</>
) : (
<>
{hasDisplayName && <p>{displayName}</p>}
<div className="flex items-center gap-x-1">
<p
className={cn(
hasDisplayName ? 'text-foreground-light text-sm' : 'text-foreground'
)}
>
{user.email}
</p>
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="px-1"
text={user?.email ?? ''}
/>
</div>
</>
)}
</div>
{!!user.banned_until ? (
<Admonition
type="warning"
label={`User banned until ${dayjs(user.banned_until).format(DATE_FORMAT)}`}
className="border-r-0 border-l-0 rounded-none -mt-px [&_svg]:ml-0.5 mb-0"
/>
) : (
<Separator />
)}
<div className={cn('flex flex-col', PANEL_PADDING)}>
<RowData property="User UID" value={user.id} />
<RowData
property="Created at"
value={user.created_at ? dayjs(user.created_at).format(DATE_FORMAT) : undefined}
/>
<RowData
property="Updated at"
value={user.updated_at ? dayjs(user.updated_at).format(DATE_FORMAT) : undefined}
/>
<RowData property="Invited at" value={user.invited_at} />
<RowData property="Confirmation sent at" value={user.confirmation_sent_at} />
<RowData
property="Confirmed at"
value={user.confirmed_at ? dayjs(user.confirmed_at).format(DATE_FORMAT) : undefined}
/>
<RowData
property="Last signed in"
value={
user.last_sign_in_at ? dayjs(user.last_sign_in_at).format(DATE_FORMAT) : undefined
}
/>
<RowData property="SSO" value={user.is_sso_user} />
</div>
<div className={cn('flex flex-col !pt-0', PANEL_PADDING)}>
<p>Provider Information</p>
<p className="text-sm text-foreground-light">The user has the following providers</p>
</div>
<div className={cn('flex flex-col -space-y-1 !pt-0', PANEL_PADDING)}>
{providers.map((provider) => {
const providerMeta = PROVIDERS_SCHEMAS.find(
(x) => x.title.toLowerCase() === provider.name
)
const enabledProperty = Object.keys(providerMeta?.properties ?? {}).find((x) =>
x.toLowerCase().endsWith('_enabled')
)
const providerName =
provider.name === 'email' ? 'email' : providerMeta?.title ?? provider.name
const isActive = data?.[enabledProperty as keyof typeof data] ?? false
return (
<div key={provider.name} className={cn(CONTAINER_CLASS, 'items-start justify-start')}>
{provider.icon && (
<img
width={16}
src={provider.icon}
alt={`${provider.name} auth icon`}
className={cn('mt-1.5', provider.name === 'github' ? 'dark:invert' : '')}
/>
)}
<div className="flex-grow mt-0.5">
<p className="capitalize">{provider.name}</p>
<p className="text-xs text-foreground-light">
Signed in with a {providerName} account via OAuth
</p>
<Button asChild type="default" className="mt-2">
<Link href={`/project/${projectRef}/auth/providers?provider=${provider.name}`}>
Configure {providerName} provider
</Link>
</Button>
</div>
{isActive ? (
<div className="flex items-center gap-1 rounded-full border border-brand-400 bg-brand-200 py-1 px-1 text-xs text-brand">
<span className="rounded-full bg-brand p-0.5 text-xs text-brand-200">
<Check strokeWidth={2} size={12} />
</span>
<span className="px-1">Enabled</span>
</div>
) : (
<div className="rounded-md border border-strong bg-surface-100 py-1 px-3 text-xs text-foreground-lighter">
Disabled
</div>
)}
</div>
)
})}
</div>
<Separator />
<div className={cn('flex flex-col -space-y-1', PANEL_PADDING)}>
{isEmailAuth && (
<>
<RowAction
title="Reset password"
description="Send a password recovery email to the user"
button={{
icon: <Mail />,
text: 'Send password recovery',
isLoading: isResettingPassword,
disabled: !canSendRecovery,
onClick: () => {
if (projectRef) resetPassword({ projectRef, user })
},
}}
success={
successAction === 'send_recovery'
? {
title: 'Password recovery sent',
description: `The link in the email is valid for ${formattedExpiry}`,
}
: undefined
}
/>
<RowAction
title="Send magic link"
description="Passwordless login via email for the user"
button={{
icon: <Mail />,
text: 'Send magic link',
isLoading: isSendingMagicLink,
disabled: !canSendMagicLink,
onClick: () => {
if (projectRef) sendMagicLink({ projectRef, user })
},
}}
success={
successAction === 'send_magic_link'
? {
title: 'Magic link sent',
description: `The link in the email is valid for ${formattedExpiry}`,
}
: undefined
}
/>
</>
)}
{isPhoneAuth && (
<RowAction
title="Send OTP"
description="Passwordless login via phone for the user"
button={{
icon: <Mail />,
text: 'Send OTP',
isLoading: isSendingOTP,
disabled: !canSendOtp,
onClick: () => {
if (projectRef) sendOTP({ projectRef, user })
},
}}
success={
successAction === 'send_otp'
? {
title: 'OTP sent',
description: `The link in the OTP SMS is valid for ${formattedExpiry}`,
}
: undefined
}
/>
)}
</div>
<Separator />
<div className={cn('flex flex-col', PANEL_PADDING)}>
<p>Danger zone</p>
<p className="text-sm text-foreground-light">
Be wary of the following features as they cannot be undone.
</p>
</div>
<div className={cn('flex flex-col -space-y-1 !pt-0', PANEL_PADDING)}>
<RowAction
title="Remove MFA factors"
description="This will log the user out of all active sessions"
button={{
icon: <ShieldOff />,
text: 'Remove MFA factors',
disabled: !canRemoveMFAFactors,
onClick: () => setIsDeleteFactorsModalOpen(true),
}}
className="!bg border-destructive-400"
/>
<RowAction
title="Delete user"
description="User will no longer have access to the project"
button={{
icon: <Trash />,
type: 'danger',
text: 'Delete user',
disabled: !canRemoveUser,
onClick: () => setIsDeleteModalOpen(true),
}}
className="!bg border-destructive-400"
/>
</div>
</div>
<ConfirmationModal
visible={isDeleteModalOpen}
title="Confirm to delete"
confirmLabel="Delete"
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={() => handleDelete()}
>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to delete user {user.email}?
</p>
</ConfirmationModal>
<ConfirmationModal
visible={isDeleteFactorsModalOpen}
title="Confirm to remove MFA factors"
confirmLabel="Remove factors"
confirmLabelLoading="Removing"
onCancel={() => setIsDeleteFactorsModalOpen(false)}
onConfirm={() => handleDeleteFactors()}
>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to remove the user's MFA factors?
</p>
</ConfirmationModal>
</>
)
}
const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
return (
<div className="flex items-center gap-x-2 group">
<p className="w-36 text-foreground-light text-sm">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={11} className="text-contrast" strokeWidth={4} />
</div>
) : (
<X size={13} className="text-foreground-light" />
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px]">
<p className="text-sm">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
)
}
const RowAction = ({
title,
description,
button,
success,
className,
}: {
title: string
description: string
button: {
icon: ReactNode
type?: ComponentProps<typeof Button>['type']
text: string
disabled?: boolean
isLoading?: boolean
onClick: () => void
}
success?: {
title: string
description: string
}
className?: string
}) => {
return (
<div className={cn(CONTAINER_CLASS, className)}>
<div>
<p>{success ? success.title : title}</p>
<p className="text-xs text-foreground-light">
{success ? success.description : description}
</p>
</div>
<ButtonTooltip
type={button?.type ?? 'default'}
icon={success ? <Check className="text-brand" /> : button.icon}
loading={button.isLoading ?? false}
onClick={button.onClick}
disabled={button?.disabled ?? false}
tooltip={{
content: {
side: 'bottom',
text: `You need additional permissions to ${button.text.toLowerCase()}`,
},
}}
>
{button.text}
</ButtonTooltip>
</div>
)
}