Files
Danny White 5bfbae22a9 chore(studio + design-system): more flexible Admonition (#45302)
## What kind of change does this PR introduce?

Feature and design-system cleanup. Resolves DEPR-551.

## What is the current behavior?

Admonition supports several overlapping content shapes, but it
previously did not support a first-class success state or
description-only usage cleanly. Title-only usage was also possible,
which made some callouts read like floating headings without body copy.

Docs MDX Admonitions could also pick up prose spacing around rich
children, while the design-system Tailwind config emitted an
ESM/CommonJS warning in the design-system app.

## What is the new behavior?

Adds a `success` Admonition type, description-only support, and a
stricter content contract: `title` or legacy `label` now requires either
`description` or `children`. Existing title-only Studio callsites have
been converted to description-only callouts.

The design-system docs now include examples for description-only and
success Admonitions, plus guidance for `title`, `description`,
`children`, and legacy `label` usage.

This also tightens Admonition body spacing so rich MDX children keep
docs link/code styling without inheriting excessive prose margins, and
renames the design-system Tailwind config to `tailwind.config.cjs` so it
matches its CommonJS syntax.

Warning and destructive alerts now explicitly set `text-foreground`,
preventing nested Admonition titles from inheriting muted
form-description colour after the Tailwind v4 cascade changes.

| Before | After |
| --- | --- |
| <img width="1818" height="388" alt="Image"
src="https://github.com/user-attachments/assets/283a1853-348a-4d74-a408-013957350e5e"
/> | <img width="1380" height="462" alt="Image"
src="https://github.com/user-attachments/assets/e5761e8e-3697-423b-805b-45110205099a"
/> |
| <img width="1398" height="550" alt="CleanShot 2026-04-28 at 15 12
41@2x"
src="https://github.com/user-attachments/assets/982694d9-5461-4362-8bae-a6e2b4c60e8b"
/> | <img width="1402" height="450" alt="CleanShot 2026-04-28 at 15 13
09@2x"
src="https://github.com/user-attachments/assets/0b1257c4-6b58-4c39-a182-4861a9e378ee"
/> |
| <img width="1640" height="716" alt="CleanShot 2026-04-28 at 15 17
25@2x"
src="https://github.com/user-attachments/assets/a5be4d5f-2bf7-4dc2-b396-56129fe64ec9"
/> | <img width="1630" height="716" alt="CleanShot 2026-04-28 at 15 16
00@2x"
src="https://github.com/user-attachments/assets/0d589252-aaf8-4efc-9d81-15ec4f99ec61"
/> |

| Design System Docs |
| --- |
| <img width="1646" height="1864" alt="CleanShot 2026-04-28 at 14 59
15@2x"
src="https://github.com/user-attachments/assets/12d13595-8972-4fb2-a04a-fb916388ebb6"
/> |


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a "success" admonition variant and new example previews
demonstrating success and description-only usages.

* **Documentation**
* Clarified admonition guidance: when to use title vs description vs
children; added example sections for short callouts and success
messages.

* **Refactor**
* Standardized UI by moving short/advisory text into description across
the app and harmonized trailing punctuation.

* **Style**
* Ensured warning/destructive admonitions use consistent foreground text
styling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-01 07:15:00 -06:00

576 lines
21 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import dayjs from 'dayjs'
import { Ban, 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 { Button, cn, Separator } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
import { BanUserModal } from './BanUserModal'
import { DeleteUserModal } from './DeleteUserModal'
import { UserHeader } from './UserHeader'
import { PANEL_PADDING } from './Users.constants'
import { providerIconMap } from './Users.utils'
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 { 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 { useUserUpdateMutation } from '@/data/auth/user-update-mutation'
import { User } from '@/data/auth/users-infinite-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { BASE_PATH } from '@/lib/constants'
import { timeout } from '@/lib/helpers'
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 isEmailAuth = user.email !== null
const isPhoneAuth = user.phone !== null
const isBanned = user.banned_until !== null
const isVerified = user.confirmed_at != null
const { authenticationSignInProviders } = useIsFeatureEnabled([
'authentication:sign_in_providers',
])
const providers = ((user.raw_app_meta_data?.providers as string[]) ?? []).map(
(provider: string) => {
return {
name: provider.startsWith('sso') ? 'SAML' : provider,
icon:
provider === 'email'
? `${BASE_PATH}/img/icons/email-icon2.svg`
: providerIconMap[provider]
? `${BASE_PATH}/img/icons/${providerIconMap[provider]}.svg`
: undefined,
}
}
)
const { can: canUpdateUser } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
const { can: canSendMagicLink } = useAsyncCheckPermissions(
PermissionAction.AUTH_EXECUTE,
'send_magic_link'
)
const { can: canSendRecovery } = useAsyncCheckPermissions(
PermissionAction.AUTH_EXECUTE,
'send_recovery'
)
const { can: canSendOtp } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp')
const { can: canRemoveUser } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_DELETE,
'auth.users'
)
const { can: canRemoveMFAFactors } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_DELETE,
'auth.mfa_factors'
)
const [successAction, setSuccessAction] = useState<
'send_magic_link' | 'send_recovery' | 'send_otp'
>()
const [isBanModalOpen, setIsBanModalOpen] = useState(false)
const [isUnbanModalOpen, setIsUnbanModalOpen] = useState(false)
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, isPending: 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, isPending: isSendingMagicLink } = useUserSendMagicLinkMutation({
onSuccess: (_, vars) => {
setSuccessAction('send_magic_link')
toast.success(
isVerified
? `Sent magic link to ${vars.user.email}`
: `Sent confirmation email to ${vars.user.email}`
)
},
onError: (err) => {
toast.error(
isVerified
? `Failed to send magic link: ${err.message}`
: `Failed to send confirmation email: ${err.message}`
)
},
})
const { mutate: sendOTP, isPending: 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: deleteUserMFAFactors } = useUserDeleteMFAFactorsMutation({
onSuccess: () => {
toast.success("Successfully deleted the user's factors")
setIsDeleteFactorsModalOpen(false)
},
})
const { mutate: updateUser, isPending: isUpdatingUser } = useUserUpdateMutation({
onSuccess: () => {
toast.success('Successfully unbanned user')
setIsUnbanModalOpen(false)
},
})
const handleDeleteFactors = async () => {
await timeout(200)
if (!projectRef) return console.error('Project ref is required')
deleteUserMFAFactors({ projectRef, userId: user.id as string })
}
const handleUnban = () => {
if (projectRef === undefined) return console.error('Project ref is required')
if (user.id === undefined) {
return toast.error(`Failed to ban user: User ID not found`)
}
updateUser({
projectRef,
userId: user.id,
banDuration: 'none',
})
}
useEffect(() => {
if (successAction !== undefined) {
const timer = setTimeout(() => setSuccessAction(undefined), 5000)
return () => clearTimeout(timer)
}
}, [successAction])
return (
<>
<div>
<UserHeader user={user} />
{isBanned ? (
<Admonition
type="warning"
description={`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"
/>
) : (
<Separator />
)}
<div className={cn('flex flex-col gap-y-1', 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) =>
('key' in x && x.key === provider.name) || x.title.toLowerCase() === provider.name
)
const enabledProperty =
provider.name.toLowerCase() === 'web3'
? (
{
solana: 'EXTERNAL_WEB3_SOLANA_ENABLED',
ethereum: 'EXTERNAL_WEB3_ETHEREUM_ENABLED',
} as const
)[
(
(user.raw_user_meta_data?.custom_claims as { chain?: string } | undefined)
?.chain ?? ''
).toLowerCase() as 'solana' | 'ethereum'
]
: Object.keys(providerMeta?.properties ?? {}).find((x) =>
x.toLowerCase().endsWith('_enabled')
)
const providerName =
provider.name === 'email'
? provider.name.toLowerCase()
: (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="grow mt-0.5">
<p className="capitalize">{providerName}</p>
<p className="text-xs text-foreground-light">
Signed in with a {providerName} account via{' '}
{providerName === 'SAML' ? 'SSO' : 'OAuth'}
</p>
{authenticationSignInProviders && (
<Button asChild type="default" className="mt-2">
<Link
href={`/project/${projectRef}/auth/providers?provider=${provider.name === 'SAML' ? 'SAML 2.0' : 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={isVerified ? 'Send Magic Link' : 'Send confirmation email'}
description={
isVerified
? 'Passwordless login via email for the user'
: 'Send a confirmation email to the user'
}
button={{
icon: <Mail />,
text: isVerified ? 'Send magic link' : 'Send confirmation email',
isLoading: isSendingMagicLink,
disabled: !canSendMagicLink,
onClick: () => {
if (projectRef) sendMagicLink({ projectRef, user })
},
}}
success={
successAction === 'send_magic_link'
? {
title: isVerified ? 'Magic link sent' : 'Confirmation email sent',
description: isVerified
? `The link in the email is valid for ${formattedExpiry}`
: 'The confirmation email has been sent to the user',
}
: 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="Removes all MFA factors associated with the user"
button={{
icon: <ShieldOff />,
text: 'Remove MFA factors',
disabled: !canRemoveMFAFactors,
onClick: () => setIsDeleteFactorsModalOpen(true),
}}
className="!bg border-destructive-400"
/>
<RowAction
title={
isBanned
? `User is banned until ${dayjs(user.banned_until).format(DATE_FORMAT)}`
: 'Ban user'
}
description={
isBanned
? 'User has no access to the project until after this date'
: 'Revoke access to the project for a set duration'
}
button={{
icon: <Ban />,
text: isBanned ? 'Unban user' : 'Ban user',
disabled: !canUpdateUser,
onClick: () => {
if (isBanned) {
setIsUnbanModalOpen(true)
} else {
setIsBanModalOpen(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>
<DeleteUserModal
visible={isDeleteModalOpen}
selectedUser={user}
onClose={() => setIsDeleteModalOpen(false)}
onDeleteSuccess={() => {
setIsDeleteModalOpen(false)
onDeleteSuccess()
}}
/>
<ConfirmationModal
visible={isDeleteFactorsModalOpen}
variant="warning"
title="Confirm to remove MFA factors"
confirmLabel="Remove factors"
confirmLabelLoading="Removing"
onCancel={() => setIsDeleteFactorsModalOpen(false)}
onConfirm={() => handleDeleteFactors()}
alert={{
base: { variant: 'warning' },
title:
"Removing MFA factors will drop the user's authentication assurance level (AAL) to AAL1",
description: 'Note that this does not sign the user out',
}}
>
<p className="text-sm text-foreground-light">
Are you sure you want to remove the MFA factors for the user{' '}
<span className="text-foreground">{user.email ?? user.phone ?? 'this user'}</span>?
</p>
</ConfirmationModal>
<BanUserModal visible={isBanModalOpen} user={user} onClose={() => setIsBanModalOpen(false)} />
<ConfirmationModal
variant="warning"
visible={isUnbanModalOpen}
title="Confirm to unban user"
loading={isUpdatingUser}
confirmLabel="Unban user"
confirmLabelLoading="Unbanning"
onCancel={() => setIsUnbanModalOpen(false)}
onConfirm={() => handleUnban()}
>
<p className="text-sm text-foreground-light">
The user will have access to your project again once unbanned. Are you sure you want to
unban this user?
</p>
</ConfirmationModal>
</>
)
}
export const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
return (
<>
<div className="flex items-center gap-x-2 group justify-between">
<p className=" text-foreground-lighter text-xs">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center min-w-[70px]">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={10} className="text-contrast" strokeWidth={4} />
</div>
) : (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<X size={10} className="text-contrast" strokeWidth={4} />
</div>
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px] font-mono min-w-[40px]">
<p className="text-xs">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
<Separator />
</>
)
}
export 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
}) => {
const disabled = button?.disabled ?? false
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={disabled}
tooltip={{
content: {
side: 'bottom',
text: disabled
? `You need additional permissions to ${button.text.toLowerCase()}`
: undefined,
},
}}
>
{button.text}
</ButtonTooltip>
</div>
)
}