Improve UI for org invites if MFA is enforced (#47067)

## Context

When opening an invite to join an organization that's enforced MFA for
their members, if a member does not have MFA enabled yet, they'll see
this UI which is confusing as there's no clear direction on what to do
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/ca2d1047-20bf-40ca-a9ea-91e81c390e40"
/>

## Changes involved
- Updating the UI to consider this error message and prompt users to set
up MFA
<img width="501" height="317" alt="image"
src="https://github.com/user-attachments/assets/d074ac6d-fd74-4fe0-9078-473fd2401045"
/>
- Small UI nudges to account security page
  - Tight copywriting to explicitly say MFA
- Opt to use Card instead of Collapsible (collapsible seems unnecessary
given that this is the only UI on this page)
  - Before:
<img width="811" height="360" alt="image"
src="https://github.com/user-attachments/assets/1412da3b-3903-4966-85ea-46e0ff443177"
/>
  - After:
<img width="817" height="370" alt="image"
src="https://github.com/user-attachments/assets/02d5a2f5-8c1f-4f78-8a20-10c7a4ff563c"
/>
- Tiny change to the user dropdown, say "account" instead of "account
preferences" + change icon
- This imo aligns better as the account page covers more than just
preferences
  - Before:
<img width="307" height="178" alt="image"
src="https://github.com/user-attachments/assets/fea43cac-9b0c-4fe4-94a3-946ed0925901"
/>
  - After:
<img width="300" height="183" alt="image"
src="https://github.com/user-attachments/assets/800357fe-222f-49b8-b52b-ce4fabff7b95"
/>

## To test
- [ ] Have an organization on paid plan with MFA enforced
- [ ] Invite a user that doesn't have MFA enabled
- [ ] Try to join the organization with that user

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

## Summary by CodeRabbit

* **New Features**
* Organization invites now detect and handle MFA requirements with
specific error messaging
* Redesigned Multi-factor authentication section on account security
page

* **Improvements**
  * Updated TOTP authenticator help text for clarity
  * Updated account menu navigation label

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
Joshen LimandAlaister Young authored and GitHub committed 2026-06-18 18:50:40 +08:00
1 parent 0d14e05cc5
commit 37b072aac2
5 files changed
+54 -54

No files matched your search

@@ -19,8 +19,7 @@ export const TOTPFactors = () => {
<>
<section className="space-y-3">
<p className="text-sm text-foreground-light">
Generate one-time passwords via authenticator apps like 1Password, Authy, etc. as a second
factor to verify your identity during sign-in.
Use an authenticator app (like 1Password or Authy) to verify your identity at sign-in.
</p>
<div>
{isLoading && <GenericSkeletonLoader />}
@@ -59,6 +59,7 @@ export const OrganizationInvite = () => {
const isInvitationLoading = inviteStatus === 'loading'
const inviteContent = getOrganizationInviteContent({
data,
error,
isSignUpEnabled,
status: inviteStatus,
})
@@ -66,6 +67,8 @@ export const OrganizationInvite = () => {
const loginRedirectLink = `/sign-in?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}`
const signupRedirectLink = `/sign-up?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}`
const mfaRequiredError = error?.message.includes('MFA required')
const { mutate: joinOrganization, isPending: isJoining } =
useOrganizationAcceptInvitationMutation({
onSuccess: () => {
@@ -85,21 +88,21 @@ export const OrganizationInvite = () => {
const withLayout = (children: ReactNode) => (
<InterstitialLayout
logo={<SupabaseLogo />}
titleClassName="text-xl"
title={
isInvitationLoading ? (
<ShimmeringLoader className="mx-auto h-7 w-36 max-w-full py-0" />
) : inviteContent.title ? (
) : (
inviteContent.title
) : undefined
)
}
description={
isInvitationLoading ? (
<ShimmeringLoader className="mx-auto h-4 w-48 max-w-full py-0" />
) : inviteContent.description ? (
) : (
inviteContent.description
) : undefined
)
}
titleClassName="text-xl"
>
<div className="px-6 pb-6">{children}</div>
</InterstitialLayout>
@@ -154,6 +157,16 @@ export const OrganizationInvite = () => {
)
}
if (mfaRequiredError) {
return withLayout(
<div className="flex flex-col gap-3">
<Button variant="default" block asChild>
<Link href="/account/security">Go to account settings</Link>
</Button>
</div>
)
}
if (hasError) {
return withLayout(
<OrganizationInviteError
@@ -13,12 +13,6 @@ type OrganizationInviteStatusVariables = {
profileExists: boolean
}
type OrganizationInviteContentVariables = {
data?: OrganizationInviteByToken
isSignUpEnabled: boolean
status: OrganizationInviteStatus
}
export type OrganizationInviteStatus =
| 'signed-out'
| 'loading'
@@ -65,9 +59,15 @@ export function getOrganizationInviteStatus({
export function getOrganizationInviteContent({
data,
error,
isSignUpEnabled,
status,
}: OrganizationInviteContentVariables) {
}: {
data?: OrganizationInviteByToken
error?: ResponseError | null
isSignUpEnabled: boolean
status: OrganizationInviteStatus
}) {
const signedOutDescription = `Sign in${
isSignUpEnabled ? ' or create an account' : ''
} to view this invitation`
@@ -90,7 +90,14 @@ export function getOrganizationInviteContent({
if (status === 'expired') return { title: 'Invite expired' }
if (status === 'invalid') return { title: 'Invite invalid' }
if (status === 'no-longer-valid') return { title: 'Invite no longer available' }
if (status === 'error') return { title: 'Unable to load invitation' }
if (status === 'error') {
if (error?.message.includes('MFA required')) {
return {
title: 'Set up MFA for your account',
description: 'MFA needs to be enabled on your account to join this organization',
}
} else return { title: 'Unable to load invitation' }
}
return {}
}
@@ -1,5 +1,5 @@
import { useFlag } from 'common'
import { FlaskConical, Loader2, ScrollText, Settings } from 'lucide-react'
import { FlaskConical, Loader2, ScrollText, User2 } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useRouter } from 'next/router'
@@ -116,14 +116,13 @@ export function UserDropdown({
}
}}
>
<Settings size={14} strokeWidth={1.5} className="text-foreground-lighter" />
Account preferences
<User2 size={14} strokeWidth={1.5} className="text-foreground-lighter" />
Account
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="flex gap-2 cursor-pointer"
onClick={() => toggleFeaturePreviewModal(true)}
// onSelect={() => toggleFeaturePreviewModal(true)}
>
<FlaskConical size={14} strokeWidth={1.5} className="text-foreground-lighter" />
Feature previews
+17 -35
View File
@@ -1,5 +1,5 @@
import { Smartphone } from 'lucide-react'
import { Badge, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
import { Lock } from 'lucide-react'
import { Badge, Card, CardContent, CardHeader } from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
@@ -18,19 +18,6 @@ import { useMfaListFactorsQuery } from '@/data/profile/mfa-list-factors-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import type { NextPageWithLayout } from '@/types'
const collapsibleClasses = [
'bg-surface-100',
'hover:bg-surface-200',
'data-open:bg-surface-200',
'border-default',
'hover:border-strong data-open:border-strong',
'data-open:pb-px col-span-12 rounded-sm',
'-space-y-px overflow-hidden',
'border shadow-sm',
'transition',
'hover:z-50',
]
const Security: NextPageWithLayout = () => {
const showSecuritySettings = useIsFeatureEnabled('account:show_security_settings')
@@ -53,28 +40,23 @@ const Security: NextPageWithLayout = () => {
</PageHeaderMeta>
</PageHeader>
<PageContainer size="small">
<Collapsible className={cn('mt-8', collapsibleClasses)}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center justify-between rounded-sm py-3 px-4 md:px-6 text-foreground"
>
<div className="flex flex-row gap-4 items-center py-1">
<Smartphone strokeWidth={1.5} />
<span className="text-sm">Authenticator app</span>
</div>
<Card className="mt-8">
<CardHeader className="py-3 flex flex-row items-center justify-between">
<div className="flex flex-row gap-4 items-center py-1 mb-0">
<Lock size={18} strokeWidth={1.5} />
<span className="text-sm">Multi-factor authentication (MFA)</span>
</div>
{data ? (
<Badge variant={data.totp.length === 0 ? 'default' : 'success'}>
{data.totp.length} app{data.totp.length === 1 ? '' : 's'} configured
</Badge>
) : null}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="group border-t border-default bg-surface-100 py-6 px-4 md:px-6 text-foreground">
{data ? (
<Badge variant={data.totp.length === 0 ? 'default' : 'success'}>
{data.totp.length} app{data.totp.length === 1 ? '' : 's'} configured
</Badge>
) : null}
</CardHeader>
<CardContent>
<TOTPFactors />
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
</PageContainer>
</>
)