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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
a0ca692fed
commit
0bb0b89353
@@ -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
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user