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
+1
View File
@@ -810,3 +810,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-06] Sandbox ledger history marked no_doc_required instead of seeding receipt documents: the history represents books kept before the company arrived in Accounted, so its underlag sits in the previous system. Same rationale and same sidecar table the SIE-import opt-in uses. Without it the demo's first screen read "Verifikat utan underlag: 39".
[2026-08-06] Sandbox payroll takes skatteavdrag from FALLBACK_TAX_TABLES_2026 rather than a flat schablon: the draft run ships calculated, so its live "Beräkna om" would have jumped ~4 600 kr away from the sibling booked run, and a wrong skatteavdrag would show unlabelled in the payslip, the 2710 line and the AGI figures.
[2026-08-06] Added guardSandbox to /api/salary/runs/[id]/payslips/send: it was the only send path without one, and seeding a booked salary run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it.
[2026-08-06] Login credentials error says "Fel e-postadress eller lösenord", not "Fel lösenord": GoTrue returns one invalid_credentials code for unknown-email and wrong-password alike (anti-enumeration), so a "wrong password" claim would be both unknowable and an account-existence leak. Clarity comes from inline placement + reset link instead.
+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 ? (
+31
View File
@@ -0,0 +1,31 @@
'use client'
import type { ReactNode } from 'react'
/**
* Inline error alert for the auth forms (login, register, reset).
*
* Auth failures render here, adjacent to the fields, instead of in a toast:
* a toast in the corner auto-dismisses, sits far from the locus of attention,
* and is easy to miss entirely. role="alert" makes screen readers announce
* the message when it appears.
*/
export function AuthFormError({
message,
action,
}: {
message: string
action?: ReactNode
}) {
return (
<div
role="alert"
className="animate-fade-in rounded-lg border border-destructive/30 bg-destructive/5 p-4"
>
<p className="text-sm text-destructive">
{message}
{action && <> {action}</>}
</p>
</div>
)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary/20 aria-invalid:border-destructive aria-invalid:focus-visible:border-destructive aria-invalid:focus-visible:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { classifyAuthError } from '../classify-auth-error'
describe('classifyAuthError', () => {
it('maps GoTrue error codes', () => {
expect(classifyAuthError({ code: 'invalid_credentials', message: 'Invalid login credentials', status: 400 }))
.toBe('invalid_credentials')
expect(classifyAuthError({ code: 'email_not_confirmed', message: 'Email not confirmed', status: 400 }))
.toBe('email_not_confirmed')
expect(classifyAuthError({ code: 'over_request_rate_limit', message: 'Request rate limit reached', status: 429 }))
.toBe('rate_limited')
expect(classifyAuthError({ code: 'over_email_send_rate_limit', message: '...', status: 429 }))
.toBe('rate_limited')
expect(classifyAuthError({ code: 'user_banned', message: 'User is banned', status: 403 }))
.toBe('user_banned')
expect(classifyAuthError({ code: 'user_already_exists', message: 'User already registered', status: 422 }))
.toBe('email_exists')
expect(classifyAuthError({ code: 'weak_password', message: 'Password is too weak', status: 422 }))
.toBe('weak_password')
expect(classifyAuthError({ code: 'email_address_invalid', message: 'Email address is invalid', status: 400 }))
.toBe('email_invalid')
expect(classifyAuthError({ code: 'signup_disabled', message: 'Signups not allowed', status: 400 }))
.toBe('signup_disabled')
expect(classifyAuthError({ code: 'email_provider_disabled', message: 'Email signups are disabled', status: 400 }))
.toBe('signup_disabled')
})
it('falls back on message strings when code is missing (older self-hosted GoTrue)', () => {
expect(classifyAuthError({ message: 'Invalid login credentials', status: 400 }))
.toBe('invalid_credentials')
expect(classifyAuthError({ message: 'Email not confirmed', status: 400 }))
.toBe('email_not_confirmed')
expect(classifyAuthError({ message: 'User already registered', status: 422 }))
.toBe('email_exists')
expect(classifyAuthError({ message: 'Signups not allowed for this instance', status: 400 }))
.toBe('signup_disabled')
expect(classifyAuthError({ message: 'Email signups are disabled', status: 400 }))
.toBe('signup_disabled')
expect(classifyAuthError({ message: 'Email rate limit exceeded', status: 429 }))
.toBe('rate_limited')
})
it('falls back on HTTP 429 when neither code nor message identifies the error', () => {
expect(classifyAuthError({ message: 'something opaque', status: 429 })).toBe('rate_limited')
})
it('returns unknown for unrecognized or malformed input', () => {
expect(classifyAuthError({ code: 'mfa_totp_verify_not_enabled', message: 'x', status: 400 })).toBe('unknown')
expect(classifyAuthError({ message: 'fetch failed' })).toBe('unknown')
expect(classifyAuthError(new Error('network down'))).toBe('unknown')
expect(classifyAuthError('a string')).toBe('unknown')
expect(classifyAuthError(null)).toBe('unknown')
expect(classifyAuthError(undefined)).toBe('unknown')
})
it('never leaks which credential part failed: unknown email and wrong password share a kind', () => {
const unknownEmail = { code: 'invalid_credentials', message: 'Invalid login credentials', status: 400 }
const wrongPassword = { code: 'invalid_credentials', message: 'Invalid login credentials', status: 400 }
expect(classifyAuthError(unknownEmail)).toBe(classifyAuthError(wrongPassword))
})
})
+62
View File
@@ -0,0 +1,62 @@
/**
* Classifies Supabase GoTrue auth errors into a small set of kinds the auth
* pages can map to specific, localized inline messages.
*
* Security note: GoTrue deliberately returns the same `invalid_credentials`
* code for "unknown email" and "wrong password" so the login form cannot be
* used to probe which addresses have accounts (anti-enumeration). The UI must
* keep that ambiguity: "wrong email or password", never "wrong password".
*
* Hosted runs a current GoTrue where `error.code` is always set; self-hosted
* installations may run older images without `code`, so the classifier falls
* back on the stable English message strings, then on HTTP status.
*/
export type AuthErrorKind =
| 'invalid_credentials'
| 'email_not_confirmed'
| 'rate_limited'
| 'user_banned'
| 'email_exists'
| 'weak_password'
| 'email_invalid'
| 'signup_disabled'
| 'unknown'
const CODE_MAP: Record<string, AuthErrorKind> = {
invalid_credentials: 'invalid_credentials',
email_not_confirmed: 'email_not_confirmed',
over_request_rate_limit: 'rate_limited',
over_email_send_rate_limit: 'rate_limited',
user_banned: 'user_banned',
user_already_exists: 'email_exists',
email_exists: 'email_exists',
weak_password: 'weak_password',
email_address_invalid: 'email_invalid',
signup_disabled: 'signup_disabled',
email_provider_disabled: 'signup_disabled',
}
export function classifyAuthError(error: unknown): AuthErrorKind {
if (typeof error !== 'object' || error === null) return 'unknown'
const { code, message, status } = error as {
code?: unknown
message?: unknown
status?: unknown
}
if (typeof code === 'string' && CODE_MAP[code]) return CODE_MAP[code]
if (typeof message === 'string') {
if (/invalid login credentials/i.test(message)) return 'invalid_credentials'
if (/email not confirmed/i.test(message)) return 'email_not_confirmed'
if (/already registered/i.test(message)) return 'email_exists'
if (/signups? not allowed/i.test(message)) return 'signup_disabled'
if (/signups? (are )?disabled/i.test(message)) return 'signup_disabled'
if (/rate limit/i.test(message)) return 'rate_limited'
}
if (status === 429) return 'rate_limited'
return 'unknown'
}
+14 -2
View File
@@ -269,7 +269,11 @@
"or_email_divider": "or sign in with email",
"login_failed_title": "Sign in failed",
"login_failed_bankid": "Could not complete BankID sign in.",
"login_invalid_credentials": "Wrong email or password.",
"login_invalid_credentials": "Wrong email address or password.",
"login_error_email_not_confirmed": "Your email address hasn't been confirmed yet. Click the link in the email you received when the account was created.",
"login_error_rate_limited": "Too many sign-in attempts. Wait a moment and try again.",
"login_error_user_banned": "This account has been suspended. Contact support if you think this is a mistake.",
"login_error_reset_link": "Reset your password",
"session_idle": "You were inactive. Sign in again.",
"session_absolute": "Your session expired for security reasons.",
"use_password_instead": "Sign in with email instead",
@@ -925,9 +929,17 @@
"bankid_email_hint": "Used for sign-in and notifications.",
"invite_email_hint": "The invitation was sent to this address.",
"password_label": "Password",
"password_placeholder": "Min 8 chars, Aa1!",
"password_placeholder": "Choose a strong password",
"confirm_password_label": "Confirm password",
"confirm_password_placeholder": "Repeat the password",
"password_req_length": "At least 8 characters",
"password_req_case": "Upper and lower case letters",
"password_req_number": "At least one number",
"password_req_special": "At least one special character",
"password_error_requirements": "The password doesn't meet all the requirements yet.",
"error_email_invalid": "That email address doesn't look valid. Check the spelling.",
"error_rate_limited": "Too many attempts. Wait a moment and try again.",
"error_signup_disabled": "Account registration is turned off on this installation. Contact the person who invited you or your administrator to get an account.",
"create_account": "Create account",
"creating": "Creating account...",
"back": "Back",
+14 -2
View File
@@ -269,7 +269,11 @@
"or_email_divider": "eller logga in med e-post",
"login_failed_title": "Inloggning misslyckades",
"login_failed_bankid": "Kunde inte slutföra BankID-inloggningen.",
"login_invalid_credentials": "Fel e-post eller lösenord.",
"login_invalid_credentials": "Fel e-postadress eller lösenord.",
"login_error_email_not_confirmed": "E-postadressen är inte bekräftad än. Klicka på länken i mejlet du fick när kontot skapades.",
"login_error_rate_limited": "För många inloggningsförsök. Vänta en stund och försök igen.",
"login_error_user_banned": "Kontot är avstängt. Kontakta supporten om du tror att det är fel.",
"login_error_reset_link": "Återställ lösenordet",
"session_idle": "Du har varit inaktiv. Logga in igen.",
"session_absolute": "Sessionen har upphört av säkerhetsskäl.",
"use_password_instead": "Logga in med e-post i stället",
@@ -925,9 +929,17 @@
"bankid_email_hint": "Används för inloggning och notifieringar.",
"invite_email_hint": "Inbjudan skickades till denna adress.",
"password_label": "Lösenord",
"password_placeholder": "Minst 8 tecken, Aa1!",
"password_placeholder": "Välj ett starkt lösenord",
"confirm_password_label": "Bekräfta lösenord",
"confirm_password_placeholder": "Upprepa lösenordet",
"password_req_length": "Minst 8 tecken",
"password_req_case": "Stora och små bokstäver",
"password_req_number": "Minst en siffra",
"password_req_special": "Minst ett specialtecken",
"password_error_requirements": "Lösenordet uppfyller inte alla krav än.",
"error_email_invalid": "E-postadressen verkar inte vara giltig. Kontrollera stavningen.",
"error_rate_limited": "För många försök. Vänta en stund och försök igen.",
"error_signup_disabled": "Kontoregistrering är avstängd på den här installationen. Kontakta den som bjöd in dig eller din administratör för att få ett konto.",
"create_account": "Skapa konto",
"creating": "Skapar konto...",
"back": "Tillbaka",