Files
supabase/apps/studio/components/interfaces/SignIn/SignInRecoveryCodeForm.tsx
T
Efekan 550f18b050 fix(studio): clarify authenticator app name and add direct support link during MFA sign-in (#51531)
## Problem

When users set up two-factor authentication (TOTP) in the dashboard, the
enrollment modal asks them to name their authenticator app, with the
placeholder suggesting `e.g.: Google Authenticator`. Many users keep or
type this example name without realizing it is simply a custom nickname
for the factor.

On the sign-in MFA screen (`SignInMfaForm.tsx`), the code input
previously rendered its label as:
> `Code generated by Google Authenticator`

This phrasing can lead users to believe that Supabase has locked their
account specifically to Google's authenticator app—even when they
actually scanned the QR code with Apple Passwords / iOS Keychain,
1Password, or Bitwarden. As reported in #51502, users who rely on their
iPhone's built-in authenticator can get alarmed, thinking their
authentication setup was unexpectedly swapped.

Furthermore, if a user is genuinely locked out (e.g. lost device and
backup codes), the "Email support" option previously only surfaced after
an explicit authentication exception occurred, rather than being readily
discoverable under the *"Unable to sign in?"* help section.

Closes #51502

## Solution

1. **Clear, standard form label & descriptive subtext**:
- Replaced the ambiguous `Code generated by [Friendly Name]` label with
a standard `Authentication code` label.
   - Moved the factor's friendly name into the field description:
- When a name is configured: `From the authenticator app registered as
[Friendly Name]`
- Fallback when no name is set: `From the authenticator app registered
to this account`
- This makes it immediately obvious that `[Friendly Name]` is just the
custom label they chose, rather than a hard vendor requirement.

2. **Direct support escape hatch**:
- Added an **"Email support to regain access"** link directly into the
*"Unable to sign in?"* list in both `SignInMfaForm` and
`SignInRecoveryCodeForm`.
- Users who are stuck now have an immediate, obvious way to reach out
without needing to trigger errors or hunt down the support email.

3. **Extracted testable helper logic**:
- Extracted friendly-name normalization and support mailto generation
into a dedicated `SignInMfa.utils.ts` module.
- Added unit test coverage in `SignInMfa.utils.test.ts` (9 tests
covering trims, null/empty cases, and mailto parameters).

## Review instructions

1. Navigate to `/sign-in-mfa` (with an account that has a TOTP factor
enrolled).
2. Verify that the input label reads `Authentication code`.
3. Check the subtext: verify that it clarifies `From the authenticator
app registered as [Name]`.
4. Inspect the *"Unable to sign in?"* section at the bottom:
- Confirm that "Email support to regain access" is visible and opens a
pre-addressed email to `support@supabase.com` with subject `Unable to
sign in via MFA`.
5. Check `/sign-in-recovery-code` and verify the same support link is
present.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] Unit tests added and passing (`vitest run
components/interfaces/SignIn/SignInMfa.utils.test.ts`)
- [x] Code passes Prettier formatting check
- [x] Code passes ESLint without adding warnings
2026-10-13 09:59:32 +02:00

253 lines
8.5 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { useQueryClient } from '@tanstack/react-query'
import { useAuthError, useFeatureFlags, useFlag } from 'common'
import { EyeIcon, EyeOffIcon, LockIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import {
Button,
Form,
FormControl,
FormField,
FormInputGroupInput,
InputGroup,
InputGroupAddon,
InputGroupButton,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import z from 'zod'
import { MFA_SUPPORT_EMAIL_HREF } from './SignInMfa.utils'
import { AlertError } from '@/components/ui/AlertError'
import { useAddLoginEvent } from '@/data/misc/audit-login-mutation'
import { useRecoveryCodesStatusQuery } from '@/data/recovery-codes/recovery-codes-status-query'
import { useRecoveryCodesVerifyMutation } from '@/data/recovery-codes/recovery-codes-verify-mutation'
import { useSignOut } from '@/lib/auth'
import { getReturnToPath } from '@/lib/gotrue'
import { useTrack } from '@/lib/telemetry/track'
const schema = z.object({
code: z.string().min(1, 'Recovery Code is required'),
})
const formId = 'sign-in-recovery-code-form'
export const SignInRecoveryCodeForm = () => {
const router = useRouter()
const signOut = useSignOut()
const queryClient = useQueryClient()
const { hasLoaded } = useFeatureFlags()
const enableAuthRecoveryCodes = useFlag('enableAuthRecoveryCodes')
const {
data: recoveryCodesStatus,
isPending: recoveryCodesStatusIsPending,
isError: recoveryCodesStatusIsError,
error: recoveryCodesStatusError,
} = useRecoveryCodesStatusQuery({
enabled: enableAuthRecoveryCodes,
})
const track = useTrack()
const { mutate: addLoginEvent } = useAddLoginEvent()
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { code: '' },
})
const {
mutate: verifyRecoveryCode,
isPending: isVerifying,
isSuccess,
} = useRecoveryCodesVerifyMutation({
onSuccess: async () => {
track('sign_in', { category: 'account', method: 'recovery-code' })
addLoginEvent({})
await queryClient.resetQueries()
router.push(getReturnToPath())
},
})
const onClickLogout = async () => {
if (isVerifying) return
await signOut()
await router.replace('/sign-in')
}
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async ({ code }) => {
if (isVerifying) return
verifyRecoveryCode({ code })
}
useEffect(() => {
// if users wander into this page and he has no recovery code setup or is on platform where it's not enabled
if (hasLoaded && (!enableAuthRecoveryCodes || recoveryCodesStatus?.status === 'unenrolled')) {
queryClient.resetQueries().then(() => router.push(getReturnToPath()))
}
}, [hasLoaded, enableAuthRecoveryCodes, recoveryCodesStatus, router, queryClient])
const error = useAuthError()
const [isCodeRevealed, setIsCodeRevealed] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
if (error) {
return (
<AlertError
error={error}
subject="Error while signing in"
hideContactSupport
additionalActions={
<>
<Button asChild>
<Link href="/sign-in">Back to sign in</Link>
</Button>
<Button asChild>
<a href={MFA_SUPPORT_EMAIL_HREF}>Email support</a>
</Button>
</>
}
/>
)
}
return (
<>
{recoveryCodesStatusIsPending && <GenericSkeletonLoader />}
{recoveryCodesStatusIsError && (
<AlertError
error={recoveryCodesStatusError}
subject="Failed to retrieve recovery codes status"
description="Try refreshing your browser. If the issue persists, email support@supabase.com."
hideContactSupport
/>
)}
{recoveryCodesStatus?.status === 'available' && (
<Form {...form}>
<form
ref={formRef}
id={formId}
method="POST"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
key="code"
name="code"
control={form.control}
render={({ field }) => (
<FormItemLayout name="code" label="Recovery code">
<InputGroup>
<FormControl>
<FormInputGroupInput
{...field}
className="pl-10 font-mono"
autoFocus
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
placeholder="****************"
type={isCodeRevealed ? 'text' : 'password'}
onPaste={(event) => {
// If users paste something, it's most probably a valid code so we can safely try submitting it immediately
// onPaste is triggered before the paste operation is complete so we have to emulate it to avoid using an unreliable setTimeout
event.preventDefault()
field.onChange(event.clipboardData.getData('text'))
formRef.current?.dispatchEvent(
new Event('submit', { cancelable: true, bubbles: true })
)
}}
/>
</FormControl>
<InputGroupAddon align="inline-start">
<LockIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end" className="pr-1 has-[>button]:mr-0">
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton
aria-label={isCodeRevealed ? 'Hide code' : 'Show code'}
aria-describedby={undefined}
onClick={() => setIsCodeRevealed((previous) => !previous)}
>
{isCodeRevealed ? (
<EyeIcon className="size-4" />
) : (
<EyeOffIcon className="size-4" />
)}
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>
{isCodeRevealed ? 'Hide code' : 'Show code'}
</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</FormItemLayout>
)}
/>
<div className="flex items-center justify-between gap-x-2">
<Button
block
variant="outline"
size="large"
onClick={onClickLogout}
disabled={isVerifying || isSuccess}
className="opacity-80 hover:opacity-100 transition"
>
Cancel
</Button>
<Button
variant="primary"
block
form={formId}
type="submit"
size="large"
loading={isVerifying || isSuccess}
>
{isVerifying ? 'Verifying' : isSuccess ? 'Signing in' : 'Verify'}
</Button>
</div>
</form>
</Form>
)}
<div className="my-8">
<div className="text-sm">
<span className="text-foreground-light">Unable to sign in?</span>{' '}
</div>
<ul className="list-disc pl-6">
<li>
<Link
href="/logout"
className="text-sm transition text-foreground-light hover:text-foreground"
>
Force sign out and clear cookies
</Link>
</li>
<li>
<a
href={MFA_SUPPORT_EMAIL_HREF}
className="text-sm transition text-foreground-light hover:text-foreground"
>
Email support to regain access
</a>
</li>
</ul>
</div>
</>
)
}