diff --git a/DECISIONS.md b/DECISIONS.md index dc8758c2..c36306e3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -840,3 +840,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-08] extractJsonObject upgraded from brace-slice to depth-aware balanced scan after PR 1460 review: prose containing braces around the JSON no longer poisons the slice; first parseable candidate wins. [2026-08-08] Journey branch question (PR 2 of the activation concept) renders only in mode='first' and persists initial_setup_path as a fire-and-forget PATCH: in mode='add' a silently-failed setActiveCompany (deliberately non-fatal in createCompanyFromOnboarding) would make the PATCH land on the PREVIOUS company's settings, and experienced multi-company users get the Hem checklist anyway; navigation never blocks on the PATCH because the checklist path is a nicety, not a prerequisite. Provider preselect at /import?mode=migration&provider=X auto-advances only for sieViaApi providers (fortnox/bjornlunden/briox): visma/bokio must land on the provider list where the "SIE krävs först" gate renders with its async connection status. [2026-08-08] SIE export always emits #FORMAT PC8 even when bytes are UTF-8: the record is compulsory in the spec and strict importers (Visma Spiris) reject files without it, while real encoding is detected from bytes (Fortnox ships the same shape). Default bytes stay UTF-8; encoding=cp437 remains opt-in. +[2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt. diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx new file mode 100644 index 00000000..f237980d --- /dev/null +++ b/app/(auth)/login/login-client.tsx @@ -0,0 +1,741 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import dynamic from 'next/dynamic' +import Image from 'next/image' +import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' +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 { AttnLine } from '@/components/ui/attn-line' +import { + Loader2, + Mail, + ArrowLeft, + KeyRound, + ExternalLink, + CircleAlert, + Eye, + EyeOff, +} 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' +import { getBranding } from '@/lib/branding/service' +import { detectWebmailHint } from '@/lib/auth/webmail-search' +import { safeReturnTo } from '@/lib/auth/safe-return-to' +import { + consumeInviteCookie, + INVITE_PROBLEM_MESSAGE_KEYS, +} from '@/lib/auth/consume-invite-cookie' +import { AuthFormError } from '@/components/auth/AuthFormError' +import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton' +import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth' +import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error' +import { resetAnalyticsIdentity } from '@/lib/analytics/reset' +import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method' +import { + isSessionAuthMethod, + setSessionAuthMethodHint, + type SessionTimeoutReason, +} from '@/lib/auth/session-timeout-shared' + +const branding = getBranding() +import type { BankIdResult } from '@/components/auth/BankIdAuth' + +const BankIdAuth = dynamic( + () => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth), + { ssr: false }, +) + +/** + * The login panel shows one method at a time (the pattern Swedish users know + * from banks, Kivra and Fortnox): a primary zone owned by the active method, + * and the remaining methods as quiet half-width chips under a single divider. + * `initialMethod` comes from the server page reading the method-hint cookie, + * so a returning password user lands straight on the form with no flash. + */ +export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | null }) { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [isEmailSent, setIsEmailSent] = useState(false) + const [showResetPassword, setShowResetPassword] = useState(false) + const [resetCooldownUntil, setResetCooldownUntil] = useState(null) + const [resetCooldownRemaining, setResetCooldownRemaining] = useState(0) + const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null) + const [bankIdUnavailable, setBankIdUnavailable] = useState(false) + // Auth failures render inline (see AuthFormError / the field error line), + // never as a toast: `kind` drives field highlighting and the recovery action. + const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null) + // Consecutive credential failures; from the second one on, the error line + // grows a reset-password action (extra help on repeated errors). + const [failedAttempts, setFailedAttempts] = useState(0) + const passwordInputRef = useRef(null) + const emailInputRef = useRef(null) + const { toast } = useToast() + const router = useRouter() + const searchParams = useSearchParams() + const callbackError = searchParams.get('error') + const callbackFlow = searchParams.get('flow') + const reasonParam = searchParams.get('reason') + const timeoutReason: SessionTimeoutReason | null = + reasonParam === 'idle' || reasonParam === 'absolute' ? reasonParam : null + const methodParam = searchParams.get('method') + const requestedMethod = isSessionAuthMethod(methodParam) ? methodParam : 'password' + // Post-login destination, set e.g. by the MCP OAuth authorize endpoint + // (/login?next=/api/mcp-oauth/authorize?...). Sanitized to a same-origin + // relative path; '/' means no explicit destination. + const nextPath = safeReturnTo(searchParams.get('next'), '/') + const supabase = createClient() + const bankIdEnabled = isBankIdEnabled() + const googleAuthEnabled = isGoogleAuthEnabled() + const tAuth = useTranslations('auth') + const tCommon = useTranslations('common') + const tInvite = useTranslations('invite') + const errorLocale = useLocale() as ErrorLocale + + // Which method owns the panel. A session-timeout re-login follows the method + // that timed out; otherwise the cookie hint wins; a fresh visitor starts on + // BankID (the Swedish default) when it is enabled. + const [method, setMethod] = useState(() => { + if (!bankIdEnabled) return 'email' + if (timeoutReason) return requestedMethod === 'bankid' ? 'bankid' : 'email' + if (initialMethod) return initialMethod + return 'bankid' + }) + const prevMethodRef = useRef(method) + + useEffect(() => { + 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]) + + // Switching to the email form should land the caret in the first field. + useEffect(() => { + if (prevMethodRef.current !== method) { + prevMethodRef.current = method + if (method === 'email') emailInputRef.current?.focus() + } + }, [method]) + + const switchMethod = (next: LoginMethod) => { + setFormError(null) + setMethod(next) + } + + 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 + // /onboarding and /select-company can retry acceptance server-side. + const acceptPendingInvite = async (): Promise => { + const invite = await consumeInviteCookie() + if (invite.accepted) return true + if (invite.problem) { + const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem] + toast({ + title: tInvite(keys.title), + description: tInvite(keys.body), + variant: 'destructive', + }) + } + return false + } + + // Reset cooldown timer + useEffect(() => { + if (!resetCooldownUntil) return + const tick = () => { + const remaining = Math.max(0, Math.ceil((resetCooldownUntil - Date.now()) / 1000)) + setResetCooldownRemaining(remaining) + if (remaining <= 0) setResetCooldownUntil(null) + } + tick() + const interval = setInterval(tick, 1000) + return () => clearInterval(interval) + }, [resetCooldownUntil]) + + const handleBankIdComplete = async (result: BankIdResult) => { + if (result.error === 'no_account') { + setBankIdNoAccount({ givenName: result.givenName, surname: result.surname }) + setMethod('email') + return + } + + if (result.error === 'service_unavailable') { + setBankIdUnavailable(true) + setMethod('email') + return + } + + if (result.error) { + setFormError({ kind: 'bankid', message: tAuth('login_failed_bankid') }) + return + } + + if (result.tokenHash && result.type) { + try { + const { error } = await supabase.auth.verifyOtp({ + token_hash: result.tokenHash, + type: result.type as 'magiclink', + }) + + if (error) { + console.error('[login] BankID verifyOtp failed', error) + setFormError({ kind: 'bankid', message: tAuth('login_failed_bankid') }) + return + } + + setSessionAuthMethodHint('bankid') + persistLoginMethodHint('bankid') + + // Check for pending invite token + if (await acceptPendingInvite()) { + window.location.href = '/' + return + } + + if (nextPath !== '/') { + // An explicit destination (e.g. the MCP OAuth consent page, raw + // HTML from a route handler) outranks the company picker. + window.location.assign(nextPath) + return + } + + // Always land on the picker after BankID login so the user sees + // fresh CompanyRoles fetched during this session's enrichment. + router.push('/select-company') + router.refresh() + } catch (error) { + console.error('[login] BankID complete error', error) + setFormError({ + kind: 'bankid', + message: getErrorMessage(error, { context: 'auth', locale: errorLocale }), + }) + } + } + } + + const handlePasswordLogin = async (e: React.FormEvent) => { + e.preventDefault() + setFormError(null) + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const emailValue = (formData.get('email') as string) || email + const passwordValue = (formData.get('password') as string) || password + + try { + const { error } = await supabase.auth.signInWithPassword({ + email: emailValue, + password: passwordValue, + }) + + if (error) { + 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'), + } + if (kind === 'invalid_credentials') { + setFailedAttempts((count) => count + 1) + } + setFormError({ + kind, + message: + messageByKind[kind] ?? + getErrorMessage(error, { context: 'auth', locale: errorLocale }), + }) + return + } + + setSessionAuthMethodHint('password') + persistLoginMethodHint('email') + + // Check MFA status + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + + if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { + router.push( + nextPath === '/' + ? '/mfa/verify' + : `/mfa/verify?returnTo=${encodeURIComponent(nextPath)}` + ) + return + } + + // Check for pending invite token + if (await acceptPendingInvite()) { + window.location.href = '/' + return + } + + if (nextPath !== '/') { + // Full navigation: the destination can be a route handler that + // returns raw HTML (the MCP OAuth consent page), which the client + // router cannot render. + window.location.assign(nextPath) + return + } + + router.push('/') + router.refresh() + } catch (error) { + setFormError({ + kind: 'unknown', + message: getErrorMessage(error, { context: 'auth', locale: errorLocale }), + }) + } finally { + setIsLoading(false) + } + } + + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault() + setFormError(null) + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const emailValue = (formData.get('email') as string) || email + + try { + const { error } = await supabase.auth.resetPasswordForEmail(emailValue, { + redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, + }) + + if (error) { + 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) + } catch (error) { + setFormError({ + kind: 'unknown', + message: getErrorMessage(error, { context: 'auth', locale: errorLocale }), + }) + } finally { + setIsLoading(false) + } + } + + // Credential/form failures attach to the form; BankID and Google failures + // belong to the panel (they originate outside the fields). + const panelError = formError && (formError.kind === 'bankid' || formError.kind === 'oauth') + ? formError + : null + const formLevelError = formError && !panelError ? formError : null + + // Email sent confirmation screen + if (isEmailSent) { + const webmailHint = detectWebmailHint(email, branding.authEmailFrom) + + return ( +
+
+
+
+ +
+
+ +
+

{tAuth('email_sent_title')}

+

+ {showResetPassword + ? tAuth.rich('email_sent_body_reset', { + email, + strong: (chunks) => {chunks}, + }) + : tAuth.rich('email_sent_body_login', { + email, + strong: (chunks) => {chunks}, + })} +

+
+ +
+

+ {showResetPassword ? tAuth('email_sent_hint_reset') : tAuth('email_sent_hint_login')} +

+
+ +
+ {webmailHint && ( + + )} + +
+
+
+ ) + } + + // Reset password form + if (showResetPassword) { + return ( +
+
+
+
+
+ +
+
+

{tAuth('reset_title')}

+

+ {tAuth('reset_subtitle')} +

+
+ +
+
+ {formError && } +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+ + +
+ + +
+
+ ) + } + + const showBankIdChip = method === 'email' && bankIdEnabled + const showEmailChip = method === 'bankid' + const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + (googleAuthEnabled ? 1 : 0) + + return ( +
+
+
+

{tAuth('login_title')}

+ +
+ +
+ {timeoutReason && ( +
+ + {timeoutReason === 'idle' ? tAuth('session_idle') : tAuth('session_absolute')} + +
+ )} + {callbackError === 'auth_error' && ( +
+ {callbackFlow === 'oauth' ? ( + + ) : callbackFlow === 'recovery' ? ( + + {tAuth('request_new_reset_link')} + + } + /> + ) : ( + + )} +
+ )} + {panelError && ( +
+ +
+ )} + {bankIdNoAccount && ( +
+

+ {tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })} +

+

{tAuth('bankid_no_account_body')}

+

+ + {tAuth('bankid_no_account_create')} + +

+
+ )} + {bankIdUnavailable && ( +
+

{tAuth('bankid_unavailable_title')}

+

{tAuth('bankid_unavailable_body')}

+
+ )} + +
+ {method === 'bankid' ? ( + + ) : ( +
+
+ + setEmail(e.target.value)} + required + disabled={isLoading} + aria-invalid={formError?.kind === 'invalid_credentials' || undefined} + className="h-11" + /> +
+
+
+ + +
+
+ setPassword(e.target.value)} + required + disabled={isLoading} + aria-invalid={formError?.kind === 'invalid_credentials' || undefined} + className="h-11 pr-10" + /> + +
+ {formLevelError && ( +

+

+ )} +
+ +
+ )} +
+ + {chipCount > 0 && ( + <> +
+
+
+
+
+ + {tAuth('or_divider')} + +
+
+
+ {showBankIdChip && ( + + )} + {googleAuthEnabled && ( + setFormError({ kind: 'oauth', message })} + /> + )} + {showEmailChip && ( + + )} +
+ + )} +
+ +

+ {tAuth('login_new_here')}{' '} + + {tAuth('no_account')} + +

+ +

+ {tAuth('terms_prefix')}{' '} + + {tAuth('terms_link')} + {' '} + {tAuth('terms_and')}{' '} + + {tAuth('privacy_link')} + + . +

+
+
+ ) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 21da9636..81acc17b 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,687 +1,20 @@ -'use client' - -import { Suspense, useState, useEffect, useRef } from 'react' -import dynamic from 'next/dynamic' -import { useRouter, useSearchParams } from 'next/navigation' -import { useLocale, useTranslations } from 'next-intl' -import Link from 'next/link' -import { createClient } from '@/lib/supabase/client' -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, KeyRound, 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' -import { getBranding } from '@/lib/branding/service' -import { detectWebmailHint } from '@/lib/auth/webmail-search' -import { safeReturnTo } from '@/lib/auth/safe-return-to' -import { - consumeInviteCookie, - INVITE_PROBLEM_MESSAGE_KEYS, -} from '@/lib/auth/consume-invite-cookie' +import { Suspense } from 'react' +import { cookies } from 'next/headers' import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton' -import { AuthFormError } from '@/components/auth/AuthFormError' -import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton' -import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth' -import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error' -import { resetAnalyticsIdentity } from '@/lib/analytics/reset' -import { - isSessionAuthMethod, - setSessionAuthMethodHint, - type SessionTimeoutReason, -} from '@/lib/auth/session-timeout-shared' +import { LOGIN_METHOD_COOKIE, isLoginMethod } from '@/lib/auth/login-method' +import { LoginClient } from './login-client' -const branding = getBranding() -import type { BankIdResult } from '@/components/auth/BankIdAuth' +// Server component: reads the method-hint cookie so the panel opens in the +// state the user last logged in with, rendered correctly on the first paint. +// The Suspense wrapper is still required because the client component uses +// useSearchParams(), which forces dynamic rendering in Next.js 16. +export default async function LoginPage() { + const cookieStore = await cookies() + const stored = cookieStore.get(LOGIN_METHOD_COOKIE)?.value -const BankIdAuth = dynamic( - () => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth), - { ssr: false }, -) - -// Wrapping in Suspense is required because useSearchParams() forces -// dynamic rendering in Next.js 16; static prerender bails out otherwise. -export default function LoginPage() { return ( }> - + ) } - -function LoginPageContent() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [isEmailSent, setIsEmailSent] = useState(false) - const [showResetPassword, setShowResetPassword] = useState(false) - const [showPasswordFallback, setShowPasswordFallback] = useState(false) - 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' | 'oauth'; message: string } | null>(null) - const passwordInputRef = useRef(null) - const { toast } = useToast() - const router = useRouter() - const searchParams = useSearchParams() - const callbackError = searchParams.get('error') - const callbackFlow = searchParams.get('flow') - const reasonParam = searchParams.get('reason') - const timeoutReason: SessionTimeoutReason | null = - reasonParam === 'idle' || reasonParam === 'absolute' ? reasonParam : null - const methodParam = searchParams.get('method') - const requestedMethod = isSessionAuthMethod(methodParam) ? methodParam : 'password' - // Post-login destination, set e.g. by the MCP OAuth authorize endpoint - // (/login?next=/api/mcp-oauth/authorize?...). Sanitized to a same-origin - // relative path; '/' means no explicit destination. - const nextPath = safeReturnTo(searchParams.get('next'), '/') - const supabase = createClient() - const bankIdEnabled = isBankIdEnabled() - const googleAuthEnabled = isGoogleAuthEnabled() - const tAuth = useTranslations('auth') - const tCommon = useTranslations('common') - const tInvite = useTranslations('invite') - const errorLocale = useLocale() as ErrorLocale - - useEffect(() => { - 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 - // /onboarding and /select-company can retry acceptance server-side. - const acceptPendingInvite = async (): Promise => { - const invite = await consumeInviteCookie() - if (invite.accepted) return true - if (invite.problem) { - const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem] - toast({ - title: tInvite(keys.title), - description: tInvite(keys.body), - variant: 'destructive', - }) - } - return false - } - - // Reset cooldown timer - useEffect(() => { - if (!resetCooldownUntil) return - const tick = () => { - const remaining = Math.max(0, Math.ceil((resetCooldownUntil - Date.now()) / 1000)) - setResetCooldownRemaining(remaining) - if (remaining <= 0) setResetCooldownUntil(null) - } - tick() - const interval = setInterval(tick, 1000) - return () => clearInterval(interval) - }, [resetCooldownUntil]) - - const [bankIdUnavailable, setBankIdUnavailable] = useState(false) - - const handleBankIdComplete = async (result: BankIdResult) => { - if (result.error === 'no_account') { - setBankIdNoAccount({ givenName: result.givenName, surname: result.surname }) - return - } - - if (result.error === 'service_unavailable') { - setBankIdUnavailable(true) - setShowPasswordFallback(true) - return - } - - if (result.error) { - setFormError({ kind: 'bankid', message: tAuth('login_failed_bankid') }) - return - } - - if (result.tokenHash && result.type) { - try { - const { error } = await supabase.auth.verifyOtp({ - token_hash: result.tokenHash, - type: result.type as 'magiclink', - }) - - if (error) { - console.error('[login] BankID verifyOtp failed', error) - setFormError({ kind: 'bankid', message: tAuth('login_failed_bankid') }) - return - } - - setSessionAuthMethodHint('bankid') - - // Check for pending invite token - if (await acceptPendingInvite()) { - window.location.href = '/' - return - } - - if (nextPath !== '/') { - // An explicit destination (e.g. the MCP OAuth consent page, raw - // HTML from a route handler) outranks the company picker. - window.location.assign(nextPath) - return - } - - // Always land on the picker after BankID login so the user sees - // fresh CompanyRoles fetched during this session's enrichment. - router.push('/select-company') - router.refresh() - } catch (error) { - console.error('[login] BankID complete error', error) - setFormError({ - kind: 'bankid', - message: getErrorMessage(error, { context: 'auth', locale: errorLocale }), - }) - } - } - } - - const handlePasswordLogin = async (e: React.FormEvent) => { - e.preventDefault() - setFormError(null) - setIsLoading(true) - - const formData = new FormData(e.currentTarget) - const emailValue = (formData.get('email') as string) || email - const passwordValue = (formData.get('password') as string) || password - - try { - const { error } = await supabase.auth.signInWithPassword({ - email: emailValue, - password: passwordValue, - }) - - if (error) { - 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 - } - - setSessionAuthMethodHint('password') - - // Check MFA status - const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() - - if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { - router.push( - nextPath === '/' - ? '/mfa/verify' - : `/mfa/verify?returnTo=${encodeURIComponent(nextPath)}` - ) - return - } - - // Check for pending invite token - if (await acceptPendingInvite()) { - window.location.href = '/' - return - } - - if (nextPath !== '/') { - // Full navigation: the destination can be a route handler that - // returns raw HTML (the MCP OAuth consent page), which the client - // router cannot render. - window.location.assign(nextPath) - return - } - - router.push('/') - router.refresh() - } catch (error) { - setFormError({ - kind: 'unknown', - message: getErrorMessage(error, { context: 'auth', locale: errorLocale }), - }) - } finally { - setIsLoading(false) - } - } - - const handleResetPassword = async (e: React.FormEvent) => { - e.preventDefault() - setFormError(null) - setIsLoading(true) - - const formData = new FormData(e.currentTarget) - const emailValue = (formData.get('email') as string) || email - - try { - const { error } = await supabase.auth.resetPasswordForEmail(emailValue, { - redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, - }) - - if (error) { - 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) - } catch (error) { - setFormError({ - kind: 'unknown', - message: getErrorMessage(error, { context: 'auth', locale: errorLocale }), - }) - } finally { - setIsLoading(false) - } - } - - const isBankIdReauth = timeoutReason !== null && - requestedMethod === 'bankid' && - bankIdEnabled - const showPasswordLogin = !isBankIdReauth || - showPasswordFallback || - bankIdUnavailable || - bankIdNoAccount !== null - - // Email sent confirmation screen - if (isEmailSent) { - const webmailHint = detectWebmailHint(email, branding.authEmailFrom) - - return ( -
-
-
-
- -
-
- -
-

{tAuth('email_sent_title')}

-

- {showResetPassword - ? tAuth.rich('email_sent_body_reset', { - email, - strong: (chunks) => {chunks}, - }) - : tAuth.rich('email_sent_body_login', { - email, - strong: (chunks) => {chunks}, - })} -

-
- -
-

- {showResetPassword ? tAuth('email_sent_hint_reset') : tAuth('email_sent_hint_login')} -

-
- -
- {webmailHint && ( - - )} - -
-
-
- ) - } - - // Reset password form - if (showResetPassword) { - return ( -
-
-
-
-
- -
-
-

{tAuth('reset_title')}

-

- {tAuth('reset_subtitle')} -

-
- -
-
- {formError && } -
- - setEmail(e.target.value)} - required - disabled={isLoading} - className="h-11" - /> -
- - -
- - -
-
- ) - } - - return ( -
-
-
- -

- {tAuth('login_subtitle')} -

-
- -
- {timeoutReason && ( -
-

- {timeoutReason === 'idle' - ? tAuth('session_idle') - : tAuth('session_absolute')} -

-
- )} - {callbackError === 'auth_error' && ( -
- {callbackFlow === 'oauth' ? ( - <> -

- {tAuth('callback_error_title_oauth')} -

-

- {tAuth('callback_error_body_oauth')} -

- - ) : callbackFlow === 'recovery' ? ( - <> -

- {tAuth('callback_error_title')} -

-

- {tAuth('callback_error_body')}{' '} - - . -

- - ) : ( - <> -

- {tAuth('callback_error_title_signup')} -

-

- {tAuth('callback_error_body_signup')} -

- - )} -
- )} - {(bankIdEnabled || googleAuthEnabled) && ( - <> - {bankIdEnabled && (bankIdNoAccount ? ( -
-

- {tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })} -

-

- {tAuth('bankid_no_account_body')} -

-

- - {tAuth('bankid_no_account_create')} - -

-
- ) : ( -
- -
- ))} - {googleAuthEnabled && !(isBankIdReauth && !showPasswordLogin) && ( -
- setFormError({ kind: 'oauth', message })} - /> -
- )} - {isBankIdReauth && !showPasswordLogin ? ( - - ) : ( -
-
-
-
-
- {tAuth('or_email_divider')} -
-
- )} - - )} - {bankIdUnavailable && ( -
-

- {tAuth('bankid_unavailable_title')} -

-

- {tAuth('bankid_unavailable_body')} -

-
- )} - {formError && ( -
- - {tAuth('login_error_reset_link')} - - ) : undefined - } - /> -
- )} - {showPasswordLogin && ( - <> -
-
- - setEmail(e.target.value)} - required - disabled={isLoading} - aria-invalid={formError?.kind === 'invalid_credentials' || undefined} - className="h-11" - /> -
-
-
- - -
- setPassword(e.target.value)} - required - disabled={isLoading} - aria-invalid={formError?.kind === 'invalid_credentials' || undefined} - className="h-11" - /> -
- -
- -
-
-
-
-
- {tAuth('or_divider')} -
-
- - - - )} -
- -

- {tAuth('terms_prefix')}{' '} - - {tAuth('terms_link')} - {' '} - {tAuth('terms_and')}{' '} - - {tAuth('privacy_link')} - - . -

-
-
- ) -} diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx index b4c15ff4..e46367f6 100644 --- a/app/(auth)/mfa/enroll/page.tsx +++ b/app/(auth)/mfa/enroll/page.tsx @@ -175,7 +175,7 @@ function MfaEnrollContent() { // Step 1: Show enroll button if (!qrCode) { return ( -
+
@@ -229,7 +229,7 @@ function MfaEnrollContent() { // Step 2: Show QR code and verification return ( -
+
diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index 2e338d4e..b099403b 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -170,7 +170,7 @@ function MfaVerifyContent() { } return ( -
+
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 7ca50da7..ed9b3a06 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, Suspense } from 'react' import dynamic from 'next/dynamic' +import Image from 'next/image' import { useSearchParams, useRouter } from 'next/navigation' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' @@ -10,7 +11,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 { Check, Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react' +import { Check, Loader2, Mail, ArrowLeft, ExternalLink, Eye, EyeOff } 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' @@ -26,6 +27,7 @@ import { AuthFormError } from '@/components/auth/AuthFormError' import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton' import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth' import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error' +import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method' import { cn } from '@/lib/utils' const branding = getBranding() @@ -72,17 +74,43 @@ function RegisterPageContent() { const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null) const [passwordError, setPasswordError] = useState(null) const [confirmError, setConfirmError] = useState(null) + const [showPassword, setShowPassword] = useState(false) const passwordInputRef = useRef(null) const confirmInputRef = useRef(null) + const emailInputRef = useRef(null) const { toast } = useToast() const router = useRouter() const supabase = createClient() const bankIdEnabled = isBankIdEnabled() const googleAuthEnabled = isGoogleAuthEnabled() const t = useTranslations('register') + const tAuth = useTranslations('auth') const tInvite = useTranslations('invite') const errorLocale = useLocale() as ErrorLocale + // Which method owns the panel (mirrors the login page): BankID is the + // Swedish default for a fresh signup; the email form is one chip away. + const [method, setMethod] = useState(bankIdEnabled ? 'bankid' : 'email') + const prevMethodRef = useRef(method) + + // Switching to the email form should land the caret in the first field, + // except when an invite pre-filled and locked it. + useEffect(() => { + if (prevMethodRef.current !== method) { + prevMethodRef.current = method + if (method === 'email' && !inviteEmail) emailInputRef.current?.focus() + } + }, [method, inviteEmail]) + + const switchMethod = (next: LoginMethod) => { + setFormError(null) + setMethod(next) + } + + const showBankIdChip = method === 'email' && bankIdEnabled + const showEmailChip = method === 'bankid' + const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + (googleAuthEnabled ? 1 : 0) + // 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 @@ -130,6 +158,7 @@ function RegisterPageContent() { const handleBankIdComplete = (result: BankIdResult) => { if (result.error === 'service_unavailable') { setBankIdUnavailable(true) + setMethod('email') return } @@ -202,6 +231,8 @@ function RegisterPageContent() { // invitee who registers with BankID lands on /select-company with no // membership and gets funneled into creating a company instead of // joining the one they were invited to. + persistLoginMethodHint('bankid') + if (await acceptPendingInvite()) { window.location.href = '/' return @@ -298,6 +329,8 @@ function RegisterPageContent() { return } + persistLoginMethodHint('email') + // If auto-confirmed (local dev), process invite immediately and redirect if (data.session) { const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) @@ -351,7 +384,7 @@ function RegisterPageContent() { if (duplicateEmail) { return ( -
+
@@ -367,7 +400,7 @@ function RegisterPageContent() {

-
+

{t('duplicate_hint')}

@@ -404,7 +437,7 @@ function RegisterPageContent() { const webmailHint = detectWebmailHint(email, branding.authEmailFrom) return ( -
+
@@ -422,7 +455,7 @@ function RegisterPageContent() {

-
+

{t('confirm_email_hint')}

@@ -452,51 +485,22 @@ function RegisterPageContent() { } return ( -
+
-
- -

- {t('subtitle')} -

-
- -
- {(bankIdEnabled || googleAuthEnabled) && !bankIdUser && ( - <> - {bankIdEnabled && ( -
- -
- )} - {googleAuthEnabled && ( -
- setFormError({ kind: 'oauth', message })} - /> -
- )} -
-
-
-
-
- {t('or_email_divider')} -
-
- - )} +
+

{t('create_account')}

+ +
+
{bankIdUnavailable && !bankIdUser && ( -
-

- {t('bankid_unavailable_body')} -

-
+

+ {t('bankid_unavailable_body')} +

)} {formError && ( -
+
+

{bankIdUser.givenName} {bankIdUser.surname} @@ -566,10 +570,15 @@ function RegisterPageContent() {

) : ( -
+
+ {method === 'bankid' && bankIdEnabled ? ( + + ) : ( +
- { - 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" - /> +
+ { + 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 pr-10" + /> + +
    )} +
+ )} + + {!bankIdUser && chipCount > 0 && ( + <> +
+
+
+
+
+ + {tAuth('or_divider')} + +
+
+
+ {showBankIdChip && ( + + )} + {googleAuthEnabled && ( + setFormError({ kind: 'oauth', message })} + /> + )} + {showEmailChip && ( + + )} +
+ + )}
-

+

{t('already_have_account')}{' '} {t('sign_in')}

-

+

{t('terms_prefix')}{' '} {t('terms_link')} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 43770027..6ad60d1d 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -208,7 +208,7 @@ function ResetPasswordInner() { : t('subtitle') return ( -

+
diff --git a/app/globals.css b/app/globals.css index 0bb1dde5..d61eb094 100644 --- a/app/globals.css +++ b/app/globals.css @@ -579,6 +579,18 @@ body { animation: scaleIn var(--duration-base) var(--ease-out); } +/* Chrome/Safari autofill repaints the field in a browser-chosen blue/yellow + that ignores the theme. Painting an inset shadow in the input's own surface + color over it keeps autofilled fields inside the achromatic palette; the + text and caret colors follow the foreground token. */ +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 1000px hsl(var(--card)) inset; + -webkit-text-fill-color: hsl(var(--foreground)); + caret-color: hsl(var(--foreground)); +} + /* Soft focus states */ .focus-ring:focus-visible { outline: none; diff --git a/components/auth/AuthFormError.tsx b/components/auth/AuthFormError.tsx index d171264e..21ba9da6 100644 --- a/components/auth/AuthFormError.tsx +++ b/components/auth/AuthFormError.tsx @@ -1,14 +1,17 @@ 'use client' import type { ReactNode } from 'react' +import { CircleAlert } from 'lucide-react' /** - * Inline error alert for the auth forms (login, register, reset). + * Inline error line for the auth forms (login, register, reset). * - * Auth failures render here, adjacent to the fields, instead of in a toast: + * Auth failures render here, adjacent to the form, 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. + * and is easy to miss entirely. Styled as one quiet destructive sentence with + * an icon, mirroring the AttnLine pattern, never as a boxed banner: the box + * reads louder than the message and breaks the panel's rhythm. role="alert" + * makes screen readers announce the message when it appears. */ export function AuthFormError({ message, @@ -18,14 +21,15 @@ export function AuthFormError({ action?: ReactNode }) { return ( -
-

+

-
+ +

) } diff --git a/components/auth/AuthPageSkeleton.tsx b/components/auth/AuthPageSkeleton.tsx index 96906ff8..8fc0b166 100644 --- a/components/auth/AuthPageSkeleton.tsx +++ b/components/auth/AuthPageSkeleton.tsx @@ -2,16 +2,13 @@ import { Skeleton } from '@/components/ui/skeleton' export function AuthPageSkeleton() { return ( -
-
-
- - -
-
- +
+
+ +
+
diff --git a/components/auth/BankIdAuth.tsx b/components/auth/BankIdAuth.tsx index 867cdacf..336c5501 100644 --- a/components/auth/BankIdAuth.tsx +++ b/components/auth/BankIdAuth.tsx @@ -41,6 +41,12 @@ export interface BankIdResult { interface BankIdAuthProps { mode: 'login' | 'signup' | 'link' onComplete: (result: BankIdResult) => void + /** + * Render the idle button as the panel's primary action (filled pill) instead + * of the default outline. Used by the login page, where BankID owns the + * primary zone; register and settings keep the quieter outline. + */ + hero?: boolean } const API_BASE = '/api/extensions/ext/tic/bankid' @@ -127,7 +133,7 @@ function launchBankIdApp(autoStartToken: string): void { * Handles QR code display (desktop) or app deep link (mobile), * polling, and result handling. */ -export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { +export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps) { const [status, setStatus] = useState('idle') const [session, setSession] = useState(null) const [hintMessage, setHintMessage] = useState('') @@ -446,10 +452,12 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { return ( ) @@ -546,14 +554,15 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { ) } -function BankIdIcon() { +function BankIdIcon({ className }: { className?: string }) { return ( BankID ) } diff --git a/components/auth/GoogleAuthButton.tsx b/components/auth/GoogleAuthButton.tsx index 1e64be1f..544d5c49 100644 --- a/components/auth/GoogleAuthButton.tsx +++ b/components/auth/GoogleAuthButton.tsx @@ -40,7 +40,18 @@ function GoogleMark() { * The flow=oauth marker lets the callback tag failures so the login page * shows Google-specific copy instead of the email-confirmation framing. */ -export function GoogleAuthButton({ onError }: { onError: (message: string) => void }) { +export function GoogleAuthButton({ + onError, + compact = false, +}: { + onError: (message: string) => void + /** + * Half-width alternative-method chip on the login panel: shows just the + * mark and "Google" (a brand name, never translated), with the full label + * kept as the accessible name. + */ + compact?: boolean +}) { const [isRedirecting, setIsRedirecting] = useState(false) const supabase = createClient() const tAuth = useTranslations('auth') @@ -70,16 +81,19 @@ export function GoogleAuthButton({ onError }: { onError: (message: string) => vo ) } diff --git a/lib/auth/__tests__/login-method.test.ts b/lib/auth/__tests__/login-method.test.ts new file mode 100644 index 00000000..dd47a941 --- /dev/null +++ b/lib/auth/__tests__/login-method.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + LOGIN_METHOD_COOKIE, + isLoginMethod, + persistLoginMethodHint, +} from '@/lib/auth/login-method' + +describe('isLoginMethod', () => { + it('accepts the two supported methods', () => { + expect(isLoginMethod('bankid')).toBe(true) + expect(isLoginMethod('email')).toBe(true) + }) + + it('rejects anything else', () => { + expect(isLoginMethod('google')).toBe(false) + expect(isLoginMethod('password')).toBe(false) + expect(isLoginMethod('')).toBe(false) + expect(isLoginMethod(undefined)).toBe(false) + expect(isLoginMethod(null)).toBe(false) + }) +}) + +describe('persistLoginMethodHint', () => { + it('is a no-op outside the browser', () => { + expect(() => persistLoginMethodHint('email')).not.toThrow() + }) +}) + +describe('LOGIN_METHOD_COOKIE', () => { + // New wire identifiers use the accounted name; only pre-rebrand ones keep + // the gnubok prefix. Locks the name so a rename cannot silently strand the + // stored hints of every returning user. + it('stays on the accounted name', () => { + expect(LOGIN_METHOD_COOKIE).toBe('accounted-login-method') + }) +}) diff --git a/lib/auth/login-method.ts b/lib/auth/login-method.ts new file mode 100644 index 00000000..fc1e749d --- /dev/null +++ b/lib/auth/login-method.ts @@ -0,0 +1,23 @@ +export const LOGIN_METHOD_COOKIE = 'accounted-login-method' + +/** + * Which login method the panel opens in. 'email' covers password login; + * Google is a one-click redirect and never owns the panel state. + */ +export type LoginMethod = 'bankid' | 'email' + +export function isLoginMethod(value: unknown): value is LoginMethod { + return value === 'bankid' || value === 'email' +} + +/** + * Remember the method that just succeeded so the next visit opens the login + * panel directly in that state. Read server-side by app/(auth)/login/page.tsx, + * which is why this is a cookie and not localStorage: the server can render + * the right state on the first paint, with no client-side flash. + */ +export function persistLoginMethodHint(method: LoginMethod): void { + if (typeof document === 'undefined') return + const secure = window.location.protocol === 'https:' ? '; Secure' : '' + document.cookie = `${LOGIN_METHOD_COOKIE}=${method}; Path=/; Max-Age=31536000; SameSite=Lax${secure}` +} diff --git a/messages/en.json b/messages/en.json index ab5f6e2b..b6c03032 100644 --- a/messages/en.json +++ b/messages/en.json @@ -268,6 +268,10 @@ "no_account": "Create account", "or_divider": "or", "or_email_divider": "or sign in with email", + "login_new_here": "New here?", + "method_email_chip": "Email", + "show_password": "Show password", + "hide_password": "Hide password", "login_failed_title": "Sign in failed", "login_failed_bankid": "Could not complete BankID sign in.", "login_invalid_credentials": "Wrong email address or password.", diff --git a/messages/sv.json b/messages/sv.json index 6057f4cd..e69bd4a9 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -268,6 +268,10 @@ "no_account": "Skapa konto", "or_divider": "eller", "or_email_divider": "eller logga in med e-post", + "login_new_here": "Ny här?", + "method_email_chip": "E-post", + "show_password": "Visa lösenord", + "hide_password": "Dölj lösenord", "login_failed_title": "Inloggning misslyckades", "login_failed_bankid": "Kunde inte slutföra BankID-inloggningen.", "login_invalid_credentials": "Fel e-postadress eller lösenord.",