feat(login): method-state login panel with quiet inline errors (#1469)

* feat(login): method-state login panel with quiet inline errors

The login panel now shows one method at a time (the pattern Swedish
users know from banks, Kivra and Fortnox): BankID as the hero state,
the email form as a peer state, and the remaining methods as two quiet
half-width chips under a single divider. The last successful method is
remembered in an accounted-login-method cookie, read server-side so a
returning password user gets the form on the first paint with no flash.

Error display drops the boxed banner everywhere: credential failures
render as one destructive sentence directly under the password field
(fields keep aria-invalid), and the reset-password action surfaces from
the second consecutive failure. BankID/Google failures, callback errors
and the session-timeout notice are single quiet lines at the top of the
panel (AttnLine for the informational one).

Also: password visibility toggle, webkit autofill repaint to the theme
surface, auth pages move from the gradient background to the app frame
tone, register/MFA/reset get the same backdrop for cross-page
coherence, and Skapa konto moves out of the panel into a footer line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(register): mirror the method-state panel on signup

Same treatment as the login page: BankID signup as the hero state, the
email form as a peer state (live password checklist kept), alternatives
as half-width chips under one divider, quiet-line notices instead of
the blue box, subtitle dropped, footer harmonized. Successful signup
persists the method hint so the user's first login opens correctly.
The BankID-verified email-collection step keeps its panel takeover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-08 16:33:50 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent b4b7549004
commit 645ed0a53e
16 changed files with 1036 additions and 781 deletions
+741
View File
@@ -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<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 passwordInputRef = useRef<HTMLInputElement>(null)
const emailInputRef = useRef<HTMLInputElement>(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<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)
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<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) {
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<HTMLFormElement>) => {
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<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
}
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<HTMLFormElement>) => {
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 (
<div className="min-h-screen 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-2xl 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 font-medium 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-screen 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-2xl bg-primary/8 flex items-center justify-center">
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium 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>
<Button type="submit" className="w-full h-11" disabled={isLoading || !!resetCooldownUntil}>
{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'
const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + (googleAuthEnabled ? 1 : 0)
return (
<div className="min-h-screen 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>
<p className="mt-1">
<Link
href="/register"
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' ? (
<BankIdAuth mode="login" hero onComplete={handleBankIdComplete} />
) : (
<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>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{tAuth('logging_in')}
</>
) : (
tAuth('login_button')
)}
</Button>
</form>
)}
</div>
{chipCount > 0 && (
<>
<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>
)}
{googleAuthEnabled && (
<GoogleAuthButton
compact
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>
)}
</div>
</>
)}
</div>
<p className="mt-6 text-center text-[13px] text-muted-foreground">
{tAuth('login_new_here')}{' '}
<Link
href="/register"
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')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
{tAuth('terms_link')}
</a>{' '}
{tAuth('terms_and')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
{tAuth('privacy_link')}
</a>
.
</p>
</div>
</div>
)
}
+12 -679
View File
@@ -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 (
<Suspense fallback={<AuthPageSkeleton />}>
<LoginPageContent />
<LoginClient initialMethod={isLoginMethod(stored) ? stored : null} />
</Suspense>
)
}
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<number | null>(null)
const [resetCooldownRemaining, setResetCooldownRemaining] = useState(0)
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
// Auth failures render inline next to the form (see AuthFormError), never
// as a toast: `kind` drives field highlighting and the recovery action.
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(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<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 [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<HTMLFormElement>) => {
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<Record<AuthErrorKind, string>> = {
invalid_credentials: tAuth('login_invalid_credentials'),
email_not_confirmed: tAuth('login_error_email_not_confirmed'),
rate_limited: tAuth('login_error_rate_limited'),
user_banned: tAuth('login_error_user_banned'),
}
setFormError({
kind,
message:
messageByKind[kind] ??
getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
return
}
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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] 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-2xl 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 font-medium 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-lg border bg-card 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-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] 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-2xl bg-primary/8 flex items-center justify-center">
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">{tAuth('reset_title')}</h1>
<p className="text-muted-foreground text-sm mt-2">
{tAuth('reset_subtitle')}
</p>
</div>
<div className="rounded-lg border bg-card p-6">
<form onSubmit={handleResetPassword} className="space-y-5">
{formError && <AuthFormError message={formError.message} />}
<div className="space-y-2">
<Label htmlFor="email">{tAuth('email_label')}</Label>
<Input
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>
<Button type="submit" className="w-full h-11" disabled={isLoading || !!resetCooldownUntil}>
{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>
)
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<BrandWordmark size="hero" className="mb-2" />
<p className="text-muted-foreground text-sm mt-3">
{tAuth('login_subtitle')}
</p>
</div>
<div className="rounded-lg border bg-card p-6">
{timeoutReason && (
<div
className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30"
role="alert"
>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
{timeoutReason === 'idle'
? tAuth('session_idle')
: tAuth('session_absolute')}
</p>
</div>
)}
{callbackError === 'auth_error' && (
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
{callbackFlow === 'oauth' ? (
<>
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title_oauth')}
</p>
<p className="mt-1 text-sm text-destructive/90">
{tAuth('callback_error_body_oauth')}
</p>
</>
) : callbackFlow === 'recovery' ? (
<>
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title')}
</p>
<p className="mt-1 text-sm text-destructive/90">
{tAuth('callback_error_body')}{' '}
<button
type="button"
onClick={openResetForm}
className="font-medium underline underline-offset-2"
>
{tAuth('request_new_reset_link')}
</button>
.
</p>
</>
) : (
<>
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title_signup')}
</p>
<p className="mt-1 text-sm text-destructive/90">
{tAuth('callback_error_body_signup')}
</p>
</>
)}
</div>
)}
{(bankIdEnabled || googleAuthEnabled) && (
<>
{bankIdEnabled && (bankIdNoAccount ? (
<div className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}
</p>
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
{tAuth('bankid_no_account_body')}
</p>
<p className="mt-2">
<Link
href="/register"
className="text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 dark:text-amber-400"
>
{tAuth('bankid_no_account_create')}
</Link>
</p>
</div>
) : (
<div className="mb-5">
<BankIdAuth mode="login" onComplete={handleBankIdComplete} />
</div>
))}
{googleAuthEnabled && !(isBankIdReauth && !showPasswordLogin) && (
<div className="mb-5">
<GoogleAuthButton
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
</div>
)}
{isBankIdReauth && !showPasswordLogin ? (
<Button
type="button"
variant="ghost"
className="mb-5 w-full text-muted-foreground"
onClick={() => setShowPasswordFallback(true)}
>
{tAuth('use_password_instead')}
</Button>
) : (
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{tAuth('or_email_divider')}</span>
</div>
</div>
)}
</>
)}
{bankIdUnavailable && (
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
{tAuth('bankid_unavailable_title')}
</p>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
{tAuth('bankid_unavailable_body')}
</p>
</div>
)}
{formError && (
<div className="mb-5">
<AuthFormError
message={formError.message}
action={
formError.kind === 'invalid_credentials' ? (
<button
type="button"
onClick={openResetForm}
className="font-medium underline underline-offset-2"
>
{tAuth('login_error_reset_link')}
</button>
) : undefined
}
/>
</div>
)}
{showPasswordLogin && (
<>
<form onSubmit={handlePasswordLogin} className="space-y-5">
<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}
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>
<Input
ref={passwordInputRef}
id="password"
name="password"
type="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"
/>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{tAuth('logging_in')}
</>
) : (
tAuth('login_button')
)}
</Button>
</form>
<div className="relative my-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{tAuth('or_divider')}</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
asChild
>
<Link href="/register">
{tAuth('no_account')}
</Link>
</Button>
</>
)}
</div>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
{tAuth('terms_prefix')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
{tAuth('terms_link')}
</a>{' '}
{tAuth('terms_and')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
{tAuth('privacy_link')}
</a>
.
</p>
</div>
</div>
)
}
+2 -2
View File
@@ -175,7 +175,7 @@ function MfaEnrollContent() {
// Step 1: Show enroll button
if (!qrCode) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-screen 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">
@@ -229,7 +229,7 @@ function MfaEnrollContent() {
// Step 2: Show QR code and verification
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-screen 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-8">
<div className="flex justify-center mb-4">
+1 -1
View File
@@ -170,7 +170,7 @@ function MfaVerifyContent() {
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-screen 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">
+147 -70
View File
@@ -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<string | null>(null)
const [confirmError, setConfirmError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const passwordInputRef = useRef<HTMLInputElement>(null)
const confirmInputRef = useRef<HTMLInputElement>(null)
const emailInputRef = useRef<HTMLInputElement>(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<LoginMethod>(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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-screen 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-2xl bg-primary/8 flex items-center justify-center">
@@ -367,7 +400,7 @@ function RegisterPageContent() {
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="rounded-xl border border-border bg-background p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
{t('duplicate_hint')}
</p>
@@ -404,7 +437,7 @@ function RegisterPageContent() {
const webmailHint = detectWebmailHint(email, branding.authEmailFrom)
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-screen 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-2xl bg-primary/8 flex items-center justify-center">
@@ -422,7 +455,7 @@ function RegisterPageContent() {
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="rounded-xl border border-border bg-background p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
{t('confirm_email_hint')}
</p>
@@ -452,51 +485,22 @@ function RegisterPageContent() {
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-screen 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">
<BrandWordmark size="hero" className="mb-2" />
<p className="text-muted-foreground text-sm mt-3">
{t('subtitle')}
</p>
</div>
<div className="rounded-lg border bg-card p-6">
{(bankIdEnabled || googleAuthEnabled) && !bankIdUser && (
<>
{bankIdEnabled && (
<div className="mb-5">
<BankIdAuth mode="signup" onComplete={handleBankIdComplete} />
</div>
)}
{googleAuthEnabled && (
<div className="mb-5">
<GoogleAuthButton
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
</div>
)}
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{t('or_email_divider')}</span>
</div>
</div>
</>
)}
<header className="text-center mb-8">
<h1 className="sr-only">{t('create_account')}</h1>
<BrandWordmark size="hero" />
</header>
<div className="rounded-xl border border-border bg-background p-6">
{bankIdUnavailable && !bankIdUser && (
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
{t('bankid_unavailable_body')}
</p>
</div>
<p className="mb-4 text-[13px] leading-5 text-muted-foreground">
{t('bankid_unavailable_body')}
</p>
)}
{formError && (
<div className="mb-5">
<div className="mb-4">
<AuthFormError
message={formError.message}
action={
@@ -514,7 +518,7 @@ function RegisterPageContent() {
)}
{bankIdUser ? (
<form onSubmit={handleBankIdSignup} className="space-y-5">
<form onSubmit={handleBankIdSignup} className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">
{bankIdUser.givenName} {bankIdUser.surname}
@@ -566,10 +570,15 @@ function RegisterPageContent() {
</Button>
</form>
) : (
<form onSubmit={handleRegister} className="space-y-5">
<div key={method} className="animate-fade-in">
{method === 'bankid' && bankIdEnabled ? (
<BankIdAuth mode="signup" hero onComplete={handleBankIdComplete} />
) : (
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Input
ref={emailInputRef}
id="email"
name="email"
type="email"
@@ -590,27 +599,42 @@ function RegisterPageContent() {
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('password_label')}</Label>
<Input
ref={passwordInputRef}
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder={t('password_placeholder')}
value={password}
onChange={(e) => {
setPassword(e.target.value)
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"
/>
<div className="relative">
<Input
ref={passwordInputRef}
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('password_placeholder')}
value={password}
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 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>
<ul
id="password-requirements"
className="grid grid-cols-2 gap-x-3 gap-y-1 pt-1"
@@ -688,19 +712,72 @@ function RegisterPageContent() {
</Button>
</form>
)}
</div>
)}
{!bankIdUser && chipCount > 0 && (
<>
<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>
)}
{googleAuthEnabled && (
<GoogleAuthButton
compact
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>
)}
</div>
</>
)}
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
<p className="mt-6 text-center text-[13px] text-muted-foreground">
{t('already_have_account')}{' '}
<Link
href="/login"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary transition-colors"
className="font-medium text-foreground underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('sign_in')}
</Link>
</p>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
<p className="mt-3 text-center text-xs text-muted-foreground/80 leading-relaxed">
{t('terms_prefix')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
{t('terms_link')}
+1 -1
View File
@@ -208,7 +208,7 @@ function ResetPasswordInner() {
: t('subtitle')
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="min-h-screen 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">
+12
View File
@@ -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;