From 0bb0b89353025089de4c94067bc8211bf7dd8e44 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 6 Aug 2026 21:24:36 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(auth)/login/page.tsx | 126 +++++++---- app/(auth)/register/page.tsx | 204 +++++++++++++----- components/auth/AuthFormError.tsx | 31 +++ components/ui/input.tsx | 2 +- .../__tests__/classify-auth-error.test.ts | 61 ++++++ lib/auth/classify-auth-error.ts | 62 ++++++ messages/en.json | 16 +- messages/sv.json | 16 +- 9 files changed, 420 insertions(+), 99 deletions(-) create mode 100644 components/auth/AuthFormError.tsx create mode 100644 lib/auth/__tests__/classify-auth-error.test.ts create mode 100644 lib/auth/classify-auth-error.ts diff --git a/DECISIONS.md b/DECISIONS.md index 35d08610..71dae89b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -810,3 +810,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index abda8772..6a0476b0 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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(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(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) => { 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> = { + 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) => { 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() {
+ {formError && }
setShowResetPassword(false)} + onClick={closeResetForm} > {tAuth('back_to_login')} @@ -454,7 +479,7 @@ function LoginPageContent() { {tAuth('callback_error_body')}{' '}
)} + {formError && ( +
+ + {tAuth('login_error_reset_link')} + + ) : undefined + } + /> +
+ )} {showPasswordLogin && ( <> @@ -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" />
@@ -552,13 +596,14 @@ function LoginPageContent() { setPassword(e.target.value)} required disabled={isLoading} + aria-invalid={formError?.kind === 'invalid_credentials' || undefined} className="h-11" /> diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 21446180..1b1a391c 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -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(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(null) + const [confirmError, setConfirmError] = useState(null) + const passwordInputRef = useRef(null) + const confirmInputRef = useRef(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) => { 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) => { 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> = { + 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() { )} + {formError && ( +
+ + {t('sign_in')} + + ) : undefined + } + /> +
+ )} + {bankIdUser ? (
@@ -539,34 +579,90 @@ function RegisterPageContent() {
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" /> +
    + {passwordChecks.map((check) => ( +
  • + + {check.met ? ( + + ) : ( + + )} + + {t(check.key)} +
  • + ))} +
+ {passwordError && ( +

+ {passwordError} +

+ )}
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 && ( + + )}