Files
accounted/app/(auth)/login/login-client.tsx
T
Mattsson d29a5bda14 fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS

Password reset, invite, email change and signup links now resolve the
request host against brands.domain server-side. The env var was a second
copy of that registry compiled into the browser; every new brand needed
the row, the env var, the GoTrue allowlist and a redeploy, and two
partners shipped with the env var stale, so their reset mails went out
canonical-branded to the canonical host.

- New POST /api/auth/password-reset: the login page no longer calls
  GoTrue directly, so the browser carries no domain list.
- lib/domains/trusted-app-origin.ts is async and registry-backed; it
  also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so
  previews keep sending links to themselves.
- Signup shares the same resolver instead of following the raw host.
- Docs and .env.example describe the single registry; GoTrue keeps the
  redirect allowlist as backstop (hosted: *.accounted.se wildcard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): await the async origin resolver in the billing routes merged from main

PR #2370 added resolveRequestAppOrigin callers in billing/checkout and
billing/portal after this branch made the resolver async. Await them and
move their tests from the removed env var to the brands mock; update the
login source-assert test to the server-routed reset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs

Skeptic and CI findings on #2376, one pass:

- A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR,
  503, retryable) instead of falling back to the canonical origin: a
  canonical link is the wrong-brand mail this PR removes. Password reset
  and email change answer 503 themselves; withRouteContext routes map the
  code.
- A local canonical (dev) trusts other local hosts and ports on the same
  scheme, so lane servers on 3001-3003 confirm signups on themselves.
- GoTrue matches the full redirect_to including the query and `*` stops
  at `.` and `/`: docs and decision line now prescribe
  https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**.
- The Turnstile contract test asserts the server-routed reset forwards
  the captcha token (it still asserted the removed browser call).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:12:22 +02:00

965 lines
37 KiB
TypeScript

'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-flags'
import { useBranding } from '@/lib/branding/brand-context'
import { SourceCodeFooter } from '@/components/branding/SourceCodeFooter'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { resolvePostLoginDestination } from '@/lib/company/post-login-landing'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { OAuthButton } from '@/components/auth/OAuthButton'
import {
TurnstileChallenge,
type TurnstileChallengeHandle,
} from '@/components/auth/TurnstileChallenge'
import {
captchaTokenOptions,
isTurnstileSubmissionBlocked,
} from '@/lib/auth/turnstile'
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'
import type { GoTrueAuthSettings } from '@/lib/auth/gotrue-providers'
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,
authSettings,
canUseSaml
}: {
initialMethod: LoginMethod | null
authSettings: GoTrueAuthSettings
canUseSaml?: boolean
}) {
const { providers, passwordLoginEnabled, registrationEnabled, samlEnabled } = authSettings
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<number | null>(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 [passwordCaptchaToken, setPasswordCaptchaToken] = useState<string | null>(null)
const [resetCaptchaToken, setResetCaptchaToken] = useState<string | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordTurnstileRef = useRef<TurnstileChallengeHandle>(null)
const resetTurnstileRef = useRef<TurnstileChallengeHandle>(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'), '/')
// A visitor who arrives here from the MCP consent page and has no account
// yet must be able to sign up without losing that destination (issue
// #1814). The register page re-sanitises it through safeReturnTo.
const registerHref = nextPath === '/' ? '/register' : `/register?next=${encodeURIComponent(nextPath)}`
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
// Per-request brand merged over getBranding() defaults (WL-12): identical
// values on default hosts, brand values on branded hosts.
const branding = useBranding()
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<LoginMethod>(() => {
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)
setResetCaptchaToken(null)
setShowResetPassword(true)
}
const closeResetForm = () => {
setFormError(null)
setResetCaptchaToken(null)
setShowResetPassword(false)
}
const handleSamlLogin = async () => {
setFormError(null)
setIsLoading(true)
try {
const ssoDomain = process.env.NEXT_PUBLIC_SSO_DOMAIN
const ssoProviderId = process.env.NEXT_PUBLIC_SSO_PROVIDER_ID
const params = ssoProviderId
? { providerId: ssoProviderId }
: ssoDomain
? { domain: ssoDomain }
: null
if (!params) {
setFormError({ kind: 'oauth', message: tAuth('saml_no_domain') })
return
}
const { error } = await supabase.auth.signInWithSSO({
...params,
options: { redirectTo: `${window.location.origin}/auth/callback?flow=oauth&next=${encodeURIComponent(nextPath)}` },
})
if (error) {
setFormError({ kind: 'oauth', message: getErrorMessage(error, { context: 'auth', locale: errorLocale }) })
}
} catch (error) {
setFormError({
kind: 'oauth',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(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<boolean> => {
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 === 'email_unconfirmed') {
// The BankID identity exists but its e-mail was never confirmed: the
// server re-sent the confirmation mail and explains what to do.
setFormError({ kind: 'bankid', message: result.message ?? tAuth('bankid_email_unconfirmed') })
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
}
// Byrå staff on their byrå's home domain land in the cockpit
// (WL-14). Everyone else keeps the picker: landing on
// /select-company after BankID is deliberate, so the user sees
// fresh CompanyRoles fetched during this session's enrichment
// (and any failure inside the helper degrades to it).
const dest = await resolvePostLoginDestination()
router.push(dest === '/clients' ? '/clients' : '/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<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
if (isTurnstileSubmissionBlocked(passwordCaptchaToken)) {
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
return
}
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,
options: captchaTokenOptions(passwordCaptchaToken),
})
if (error) {
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'),
}
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
}
// Byrå staff land in the cockpit on their byrå's home domain (WL-14);
// everyone else resolves to '/' and keeps today's flow byte-identically
// (any failure inside the helper also degrades to '/').
router.push(await resolvePostLoginDestination())
router.refresh()
} catch (error) {
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
passwordTurnstileRef.current?.reset()
setIsLoading(false)
}
}
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
if (isTurnstileSubmissionBlocked(resetCaptchaToken)) {
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
return
}
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('email') as string) || email
try {
// Server-side reset (POST /api/auth/password-reset): the route resolves
// the recovery callback against the brands table from the request
// host, so the browser carries no domain list and a new brand needs no
// redeploy. GoTrue call, captcha and rate limits are unchanged.
const res = await fetch('/api/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: emailValue,
captchaToken: captchaTokenOptions(resetCaptchaToken).captchaToken ?? null,
}),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
const error = {
code: json?.error?.code,
message:
(errorLocale === 'en' ? json?.error?.message_en : json?.error?.message) ??
json?.error?.message,
status: res.status,
}
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 {
resetTurnstileRef.current?.reset()
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 (
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl tracking-tight">{tAuth('email_sent_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{showResetPassword
? tAuth.rich('email_sent_body_reset', {
email,
strong: (chunks) => <span className="font-medium text-foreground">{chunks}</span>,
})
: tAuth.rich('email_sent_body_login', {
email,
strong: (chunks) => <span className="font-medium text-foreground">{chunks}</span>,
})}
</p>
</div>
<div className="rounded-xl border border-border bg-background p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
{showResetPassword ? tAuth('email_sent_hint_reset') : tAuth('email_sent_hint_login')}
</p>
</div>
<div className="space-y-2">
{webmailHint && (
<Button className="w-full" asChild>
<a href={webmailHint.url} target="_blank" rel="noopener noreferrer">
{tAuth(webmailHint.hasSearch ? 'open_webmail_search' : 'open_webmail_inbox', {
provider: webmailHint.name,
})}
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
)}
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => {
setIsEmailSent(false)
setShowResetPassword(false)
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{tCommon('back')}
</Button>
</div>
</div>
</div>
)
}
// Reset password form
if (showResetPassword) {
return (
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl tracking-tight">{tAuth('reset_title')}</h1>
<p className="text-muted-foreground text-sm mt-2">
{tAuth('reset_subtitle')}
</p>
</div>
<div className="rounded-xl border border-border bg-background p-6">
<form onSubmit={handleResetPassword} className="space-y-4">
{formError && <AuthFormError message={formError.message} />}
<div className="space-y-2">
<Label htmlFor="email">{tAuth('email_label')}</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder={tAuth('email_placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<TurnstileChallenge
ref={resetTurnstileRef}
action="accounted_password_reset"
onTokenChange={setResetCaptchaToken}
/>
<Button
type="submit"
className="w-full h-11"
disabled={
isLoading ||
!!resetCooldownUntil ||
isTurnstileSubmissionBlocked(resetCaptchaToken)
}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{tAuth('reset_sending')}
</>
) : resetCooldownUntil ? (
tAuth('reset_cooldown', { seconds: resetCooldownRemaining })
) : (
tAuth('reset_button')
)}
</Button>
</form>
</div>
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
onClick={closeResetForm}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{tAuth('back_to_login')}
</Button>
</div>
</div>
)
}
const showBankIdChip = method === 'email' && bankIdEnabled
const showEmailChip = method === 'bankid' && passwordLoginEnabled
const samlAvailable = samlEnabled && canUseSaml
const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + providers.length + (samlAvailable ? 1 : 0)
const hasPrimaryMethod =
(method === 'bankid' && bankIdEnabled) ||
(method === 'email' && passwordLoginEnabled)
const hasSecondaryMethods = chipCount > 0
return (
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
<div className="w-full max-w-sm animate-slide-up">
<header className="text-center mb-8">
<h1 className="sr-only">{tAuth('login_title')}</h1>
<BrandWordmark size="hero" />
</header>
<div className="rounded-xl border border-border bg-background p-6">
{timeoutReason && (
<div role="alert" className="mb-4">
<AttnLine>
{timeoutReason === 'idle' ? tAuth('session_idle') : tAuth('session_absolute')}
</AttnLine>
</div>
)}
{callbackError === 'auth_error' && (
<div className="mb-4">
{callbackFlow === 'oauth' ? (
<AuthFormError
message={`${tAuth('callback_error_title_oauth')}. ${tAuth('callback_error_body_oauth')}`}
/>
) : callbackFlow === 'recovery' ? (
<AuthFormError
message={`${tAuth('callback_error_title')}. ${tAuth('callback_error_body')}`}
action={
<button
type="button"
onClick={openResetForm}
className="font-medium underline underline-offset-2"
>
{tAuth('request_new_reset_link')}
</button>
}
/>
) : (
<AuthFormError
message={`${tAuth('callback_error_title_signup')}. ${tAuth('callback_error_body_signup')}`}
/>
)}
</div>
)}
{panelError && (
<div className="mb-4">
<AuthFormError message={panelError.message} />
</div>
)}
{bankIdNoAccount && (
<div className="mb-4 text-[13px] leading-5">
<p className="font-medium">
{tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}
</p>
<p className="mt-1 text-muted-foreground">{tAuth('bankid_no_account_body')}</p>
{passwordLoginEnabled && registrationEnabled && (
<p className="mt-1">
<Link
href={registerHref}
className="text-muted-foreground underline underline-offset-2 hover:text-foreground transition-colors"
>
{tAuth('bankid_no_account_create')}
</Link>
</p>
)}
</div>
)}
{bankIdUnavailable && (
<div className="mb-4 text-[13px] leading-5">
<p className="font-medium">{tAuth('bankid_unavailable_title')}</p>
<p className="mt-1 text-muted-foreground">{tAuth('bankid_unavailable_body')}</p>
</div>
)}
<div key={method} className="animate-fade-in">
{method === 'bankid' && bankIdEnabled ? (
<BankIdAuth mode="login" hero onComplete={handleBankIdComplete} />
) : method === 'email' && passwordLoginEnabled ? (
<form onSubmit={handlePasswordLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{tAuth('email_label')}</Label>
<Input
ref={emailInputRef}
id="email"
name="email"
type="email"
autoComplete="email"
placeholder={tAuth('email_placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
aria-invalid={formError?.kind === 'invalid_credentials' || undefined}
className="h-11"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">{tAuth('password_label')}</Label>
<button
type="button"
onClick={openResetForm}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
>
{tAuth('forgot_password')}
</button>
</div>
<div className="relative">
<Input
ref={passwordInputRef}
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder={tAuth('password_placeholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
aria-invalid={formError?.kind === 'invalid_credentials' || undefined}
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((visible) => !visible)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? tAuth('hide_password') : tAuth('show_password')}
aria-pressed={showPassword}
>
{showPassword ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
{formLevelError && (
<p
role="alert"
className="animate-fade-in flex items-start gap-2 pt-1 text-[13px] leading-5 text-destructive"
>
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>
{formLevelError.message}
{formLevelError.kind === 'invalid_credentials' && failedAttempts >= 2 && (
<>
{' '}
<button
type="button"
onClick={openResetForm}
className="font-medium underline underline-offset-2"
>
{tAuth('login_error_reset_link')}
</button>
</>
)}
</span>
</p>
)}
</div>
<TurnstileChallenge
ref={passwordTurnstileRef}
action="accounted_login"
onTokenChange={setPasswordCaptchaToken}
/>
<Button
type="submit"
className="w-full h-11"
disabled={isLoading || isTurnstileSubmissionBlocked(passwordCaptchaToken)}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{tAuth('logging_in')}
</>
) : (
tAuth('login_button')
)}
</Button>
</form>
) : (
providers.length > 0 ? (
<div className="space-y-3">
{providers.map((provider) => (
<OAuthButton
key={provider.id}
provider={provider}
next={nextPath}
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
))}
{samlAvailable && !hasPrimaryMethod && (
<Button
type="button"
variant="outline"
className="w-full h-11 gap-2"
onClick={handleSamlLogin}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{tAuth('continue_with_provider', { provider: 'SAML' })}
</Button>
)}
</div>
) : samlAvailable ? (
<Button
type="button"
variant="outline"
className="w-full h-11 gap-2"
onClick={handleSamlLogin}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{tAuth('continue_with_provider', { provider: 'SAML' })}
</Button>
) : (
<p className="text-center text-sm text-muted-foreground">
{tAuth('no_login_methods')}
</p>
)
)}
</div>
{hasPrimaryMethod && hasSecondaryMethods && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="bg-background px-3 text-xs text-muted-foreground">
{tAuth('or_divider')}
</span>
</div>
</div>
<div className={chipCount === 2 ? 'grid grid-cols-2 gap-3' : 'grid grid-cols-1 gap-3'}>
{showBankIdChip && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={() => switchMethod('bankid')}
>
<Image
src="/logos/bankid-seeklogo.svg"
alt=""
width={18}
height={18}
className="dark:invert"
/>
BankID
</Button>
)}
{providers.map((provider) => (
<OAuthButton
compact
key={provider.id}
provider={provider}
next={nextPath}
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
))}
{showEmailChip && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={() => switchMethod('email')}
>
<Mail className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{tAuth('method_email_chip')}
</Button>
)}
{samlAvailable && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={handleSamlLogin}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{tAuth('continue_with_provider', { provider: 'SAML' })}
</Button>
)}
</div>
</>
)}
</div>
{passwordLoginEnabled && registrationEnabled && (
<p className="mt-6 text-center text-[13px] text-muted-foreground">
{tAuth('login_new_here')}{' '}
<Link
href={registerHref}
className="font-medium text-foreground underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{tAuth('no_account')}
</Link>
</p>
)}
<p className="mt-3 text-center text-xs text-muted-foreground/80 leading-relaxed">
{tAuth('terms_prefix')}{' '}
{/* The platform's terms live on the marketing site; the privacy
policy is the in-app /privacy page (host-relative, so it works
on branded byrå domains too). New tab: don't lose login state. */}
<a
href="https://accounted.se/terms"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{tAuth('terms_link')}
</a>{' '}
{tAuth('terms_and')}{' '}
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{tAuth('privacy_link')}
</a>
.
</p>
{/* AGPL section 13 source offer (WL-06): renders on both default and
branded hosts; never gate this on a brand. */}
<SourceCodeFooter className="mt-4" />
</div>
</div>
)
}