feat(auth): inline, specific error states on login and signup (#1440)

* feat(auth): inline, specific error states on login and signup

Auth failures now render inline next to the form instead of as a
top-right toast: a persistent alert with role=alert, aria-invalid
field highlighting, and focus returned to the offending field.

Login maps GoTrue error codes (invalid_credentials, email_not_confirmed,
rate limits, user_banned) to specific Swedish/English messages, with a
reset-password link embedded in the credentials error. The credentials
message stays 'wrong email or password' by design: GoTrue returns one
code for both cases to prevent account enumeration.

Signup gets a live password-requirements checklist, field-level errors
for weak/mismatched passwords, and inline handling of email-exists,
invalid-email and rate-limit responses with a sign-in link where that
is the recovery path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): treat email_provider_disabled as signup-disabled with specific copy

Review follow-up: GoTrue signals disabled email/password signups with
email_provider_disabled as well as signup_disabled; classify both (plus
the message-string fallback for older GoTrue) and give the register form
a specific inline message instead of the generic fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-06 21:24:36 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent a0ca692fed
commit 0bb0b89353
9 changed files with 420 additions and 99 deletions
+86 -40
View File
@@ -1,6 +1,6 @@
'use client'
import { Suspense, useState, useEffect } from 'react'
import { Suspense, useState, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLocale, useTranslations } from 'next-intl'
@@ -22,6 +22,8 @@ import {
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import {
isSessionAuthMethod,
@@ -57,6 +59,10 @@ function LoginPageContent() {
const [resetCooldownUntil, setResetCooldownUntil] = useState<number | null>(null)
const [resetCooldownRemaining, setResetCooldownRemaining] = useState(0)
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
// Auth failures render inline next to the form (see AuthFormError), never
// as a toast: `kind` drives field highlighting and the recovery action.
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid'; message: string } | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
@@ -82,6 +88,26 @@ function LoginPageContent() {
if (timeoutReason) resetAnalyticsIdentity()
}, [timeoutReason])
// After a failed credentials attempt, put the caret back in the password
// field with the old value selected so the user can retype immediately.
// Runs post-render: the inputs are disabled while the request is in flight.
useEffect(() => {
if (formError?.kind === 'invalid_credentials') {
passwordInputRef.current?.focus()
passwordInputRef.current?.select()
}
}, [formError])
const openResetForm = () => {
setFormError(null)
setShowResetPassword(true)
}
const closeResetForm = () => {
setFormError(null)
setShowResetPassword(false)
}
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
@@ -128,11 +154,7 @@ function LoginPageContent() {
}
if (result.error) {
toast({
title: tAuth('login_failed_title'),
description: tAuth('login_failed_bankid'),
variant: 'destructive',
})
setFormError({ kind: 'bankid', message: tAuth('login_failed_bankid') })
return
}
@@ -145,11 +167,7 @@ function LoginPageContent() {
if (error) {
console.error('[login] BankID verifyOtp failed', error)
toast({
title: tAuth('login_failed_title'),
description: tAuth('login_failed_bankid'),
variant: 'destructive',
})
setFormError({ kind: 'bankid', message: tAuth('login_failed_bankid') })
return
}
@@ -174,10 +192,9 @@ function LoginPageContent() {
router.refresh()
} catch (error) {
console.error('[login] BankID complete error', error)
toast({
title: tAuth('login_failed_title'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
setFormError({
kind: 'bankid',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
}
}
@@ -185,6 +202,7 @@ function LoginPageContent() {
const handlePasswordLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
setIsLoading(true)
const formData = new FormData(e.currentTarget)
@@ -198,12 +216,18 @@ function LoginPageContent() {
})
if (error) {
toast({
title: tAuth('login_failed_title'),
description: getErrorMessage(error) === 'Invalid login credentials'
? tAuth('login_invalid_credentials')
: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
const kind = classifyAuthError(error)
const messageByKind: Partial<Record<AuthErrorKind, string>> = {
invalid_credentials: tAuth('login_invalid_credentials'),
email_not_confirmed: tAuth('login_error_email_not_confirmed'),
rate_limited: tAuth('login_error_rate_limited'),
user_banned: tAuth('login_error_user_banned'),
}
setFormError({
kind,
message:
messageByKind[kind] ??
getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
return
}
@@ -239,10 +263,9 @@ function LoginPageContent() {
router.push('/')
router.refresh()
} catch (error) {
toast({
title: tAuth('login_failed_title'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(false)
@@ -251,6 +274,7 @@ function LoginPageContent() {
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
setIsLoading(true)
const formData = new FormData(e.currentTarget)
@@ -262,26 +286,26 @@ function LoginPageContent() {
})
if (error) {
toast({
title: tAuth('reset_failed_title'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
const kind = classifyAuthError(error)
setFormError({
kind,
message:
kind === 'rate_limited'
? tAuth('login_error_rate_limited')
: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
return
}
// The full-screen "check your email" confirmation below is the
// feedback; no toast needed on top of it.
setEmail(emailValue)
setResetCooldownUntil(Date.now() + 60_000)
setIsEmailSent(true)
toast({
title: tAuth('reset_sent_title'),
description: tAuth('reset_sent_body'),
})
} catch (error) {
toast({
title: tAuth('reset_failed_title'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(false)
@@ -377,6 +401,7 @@ function LoginPageContent() {
<div className="rounded-lg border bg-card p-6">
<form onSubmit={handleResetPassword} className="space-y-5">
{formError && <AuthFormError message={formError.message} />}
<div className="space-y-2">
<Label htmlFor="email">{tAuth('email_label')}</Label>
<Input
@@ -410,7 +435,7 @@ function LoginPageContent() {
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
onClick={() => setShowResetPassword(false)}
onClick={closeResetForm}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{tAuth('back_to_login')}
@@ -454,7 +479,7 @@ function LoginPageContent() {
{tAuth('callback_error_body')}{' '}
<button
type="button"
onClick={() => setShowResetPassword(true)}
onClick={openResetForm}
className="font-medium underline underline-offset-2"
>
{tAuth('request_new_reset_link')}
@@ -529,6 +554,24 @@ function LoginPageContent() {
</p>
</div>
)}
{formError && (
<div className="mb-5">
<AuthFormError
message={formError.message}
action={
formError.kind === 'invalid_credentials' ? (
<button
type="button"
onClick={openResetForm}
className="font-medium underline underline-offset-2"
>
{tAuth('login_error_reset_link')}
</button>
) : undefined
}
/>
</div>
)}
{showPasswordLogin && (
<>
<form onSubmit={handlePasswordLogin} className="space-y-5">
@@ -544,6 +587,7 @@ function LoginPageContent() {
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
aria-invalid={formError?.kind === 'invalid_credentials' || undefined}
className="h-11"
/>
</div>
@@ -552,13 +596,14 @@ function LoginPageContent() {
<Label htmlFor="password">{tAuth('password_label')}</Label>
<button
type="button"
onClick={() => setShowResetPassword(true)}
onClick={openResetForm}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
>
{tAuth('forgot_password')}
</button>
</div>
<Input
ref={passwordInputRef}
id="password"
name="password"
type="password"
@@ -568,6 +613,7 @@ function LoginPageContent() {
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
aria-invalid={formError?.kind === 'invalid_credentials' || undefined}
className="h-11"
/>
</div>
+150 -54
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useState, useEffect, useRef, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
import { Check, Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
@@ -22,6 +22,9 @@ import {
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import { cn } from '@/lib/utils'
const branding = getBranding()
@@ -61,6 +64,14 @@ function RegisterPageContent() {
const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null)
const [bankIdSessionId, setBankIdSessionId] = useState<string | null>(null)
const [bankIdEmail, setBankIdEmail] = useState('')
// Signup failures render inline next to the form (see AuthFormError), never
// as a toast. Field-level problems attach to their field; everything else
// goes to the form-level alert above the form.
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid'; message: string } | null>(null)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [confirmError, setConfirmError] = useState<string | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const confirmInputRef = useRef<HTMLInputElement>(null)
const { toast } = useToast()
const router = useRouter()
const supabase = createClient()
@@ -120,20 +131,18 @@ function RegisterPageContent() {
}
if (result.error) {
toast({
title: t('bankid_failed_title'),
description: t('bankid_failed_description'),
variant: 'destructive',
})
setFormError({ kind: 'bankid', message: t('bankid_failed_description') })
return
}
// BankID verified: store sessionId and show email form
setFormError(null)
setBankIdUser({ givenName: result.givenName, surname: result.surname })
if (result.sessionId) setBankIdSessionId(result.sessionId)
}
const handleBankIdSignup = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
setIsLoading(true)
const formData = new FormData(e.currentTarget)
@@ -154,23 +163,17 @@ function RegisterPageContent() {
if (!res.ok) {
if (json.error === 'already_linked') {
toast({
title: t('bankid_already_linked_title'),
description: t('bankid_already_linked_description'),
variant: 'destructive',
})
// email_exists kind: the alert renders a sign-in link, which is the
// recovery path for both "BankID taken" and "email taken".
setFormError({ kind: 'email_exists', message: t('bankid_already_linked_description') })
} else if (json.error === 'account_exists') {
toast({
title: t('account_exists_title'),
description: t('account_exists_description'),
variant: 'destructive',
})
router.push('/login')
// Inline with a sign-in link instead of yanking the user to /login
// mid-read: they keep the context and choose when to leave.
setFormError({ kind: 'email_exists', message: t('account_exists_description') })
} else {
toast({
title: t('register_failed_title'),
description: json.message || json.error || t('register_failed_default'),
variant: 'destructive',
setFormError({
kind: 'unknown',
message: json.message || json.error || t('register_failed_default'),
})
}
return
@@ -184,10 +187,9 @@ function RegisterPageContent() {
if (error) {
console.error('[register] BankID verifyOtp failed', error.message)
toast({
title: t('register_failed_complete'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
return
}
@@ -206,16 +208,24 @@ function RegisterPageContent() {
router.refresh()
} catch (error) {
console.error('[register] BankID signup error', error instanceof Error ? error.message : String(error))
toast({
title: t('register_failed_title'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(false)
}
}
// The live checklist under the password field mirrors these rules; the
// aggregate check gates submission.
const passwordChecks = [
{ key: 'password_req_length', met: password.length >= 8 },
{ key: 'password_req_case', met: /[a-z]/.test(password) && /[A-Z]/.test(password) },
{ key: 'password_req_number', met: /[0-9]/.test(password) },
{ key: 'password_req_special', met: /[^a-zA-Z0-9]/.test(password) },
] as const
function isStrongPassword(pw: string): boolean {
return pw.length >= 8
&& /[a-z]/.test(pw)
@@ -226,33 +236,32 @@ function RegisterPageContent() {
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setFormError(null)
setPasswordError(null)
setConfirmError(null)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('email') as string) || email
const passwordValue = (formData.get('password') as string) || password
const confirmValue = (formData.get('confirm_password') as string) || confirmPassword
// Client-side checks run before isLoading so the inputs are still enabled
// when focus moves to the offending field.
if (!isStrongPassword(passwordValue)) {
toast({
title: t('weak_password_title'),
description: t('weak_password_description'),
variant: 'destructive',
})
setIsLoading(false)
setPasswordError(t('password_error_requirements'))
passwordInputRef.current?.focus()
return
}
if (passwordValue !== confirmValue) {
toast({
title: t('password_mismatch_title'),
description: t('password_mismatch_description'),
variant: 'destructive',
})
setIsLoading(false)
setConfirmError(t('password_mismatch_description'))
confirmInputRef.current?.focus()
confirmInputRef.current?.select()
return
}
setIsLoading(true)
try {
const { data, error } = await supabase.auth.signUp({
email: emailValue,
@@ -264,11 +273,25 @@ function RegisterPageContent() {
if (error) {
console.error('[register] signUp error', error.message)
toast({
title: t('register_failed_title'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
})
const kind = classifyAuthError(error)
if (kind === 'weak_password') {
// Server-side password policy rejection: same field, same message
// as the client-side check.
setPasswordError(t('password_error_requirements'))
} else {
const messageByKind: Partial<Record<AuthErrorKind, string>> = {
email_exists: t('account_exists_description'),
email_invalid: t('error_email_invalid'),
rate_limited: t('error_rate_limited'),
signup_disabled: t('error_signup_disabled'),
}
setFormError({
kind,
message:
messageByKind[kind] ??
getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
}
return
}
@@ -314,10 +337,9 @@ function RegisterPageContent() {
setIsRegistered(true)
} catch (error) {
console.error('[register] unexpected exception', error instanceof Error ? error.message : String(error))
toast({
title: t('register_failed_title'),
description: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
variant: 'destructive',
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(false)
@@ -461,6 +483,24 @@ function RegisterPageContent() {
</div>
)}
{formError && (
<div className="mb-5">
<AuthFormError
message={formError.message}
action={
formError.kind === 'email_exists' ? (
<Link
href="/login"
className="font-medium underline underline-offset-2"
>
{t('sign_in')}
</Link>
) : undefined
}
/>
</div>
)}
{bankIdUser ? (
<form onSubmit={handleBankIdSignup} className="space-y-5">
<div className="rounded-lg border bg-muted/30 p-3">
@@ -539,34 +579,90 @@ function RegisterPageContent() {
<div className="space-y-2">
<Label htmlFor="password">{t('password_label')}</Label>
<Input
ref={passwordInputRef}
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder={t('password_placeholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => {
setPassword(e.target.value)
if (passwordError && isStrongPassword(e.target.value)) {
setPasswordError(null)
}
}}
required
minLength={8}
disabled={isLoading}
aria-invalid={passwordError ? true : undefined}
aria-describedby="password-requirements"
className="h-11"
/>
<ul
id="password-requirements"
className="grid grid-cols-2 gap-x-3 gap-y-1 pt-1"
>
{passwordChecks.map((check) => (
<li
key={check.key}
className={cn(
'flex items-center gap-2 text-xs transition-colors duration-150',
check.met ? 'text-foreground' : 'text-muted-foreground',
)}
>
<span
aria-hidden
className="flex h-3 w-3 items-center justify-center"
>
{check.met ? (
<Check className="h-3 w-3" />
) : (
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
)}
</span>
{t(check.key)}
</li>
))}
</ul>
{passwordError && (
<p role="alert" className="text-xs text-destructive">
{passwordError}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">{t('confirm_password_label')}</Label>
<Input
ref={confirmInputRef}
id="confirm_password"
name="confirm_password"
type="password"
autoComplete="new-password"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onChange={(e) => {
setConfirmPassword(e.target.value)
if (confirmError && e.target.value === password) {
setConfirmError(null)
}
}}
required
minLength={8}
disabled={isLoading}
aria-invalid={confirmError ? true : undefined}
aria-describedby={confirmError ? 'confirm-password-error' : undefined}
className="h-11"
/>
{confirmError && (
<p
id="confirm-password-error"
role="alert"
className="text-xs text-destructive"
>
{confirmError}
</p>
)}
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (