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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
b4b7549004
commit
645ed0a53e
@@ -840,3 +840,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
<p
|
||||
role="alert"
|
||||
className="animate-fade-in rounded-lg border border-destructive/30 bg-destructive/5 p-4"
|
||||
className="animate-fade-in flex items-start gap-2 text-[13px] leading-5 text-destructive"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{message}
|
||||
{action && <> {action}</>}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,13 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function AuthPageSkeleton() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background p-4" aria-busy="true">
|
||||
<div className="w-full max-w-sm space-y-6 rounded-lg border bg-card p-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="mx-auto h-8 w-40" />
|
||||
<Skeleton className="mx-auto h-4 w-56" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<main className="flex min-h-screen items-center justify-center bg-frame p-4" aria-busy="true">
|
||||
<div className="w-full max-w-sm">
|
||||
<Skeleton className="mx-auto mb-8 h-12 w-48" />
|
||||
<div className="space-y-4 rounded-xl border border-border bg-background p-6">
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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<BankIdStatus>('idle')
|
||||
const [session, setSession] = useState<BankIdSession | null>(null)
|
||||
const [hintMessage, setHintMessage] = useState<string>('')
|
||||
@@ -446,10 +452,12 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
return (
|
||||
<Button
|
||||
onClick={startSession}
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-[1.5px] py-6 text-base"
|
||||
variant={hero ? 'default' : 'outline'}
|
||||
className={hero ? 'h-11 w-full gap-2' : 'w-full gap-2 border-[1.5px] py-6 text-base'}
|
||||
>
|
||||
<BankIdIcon />
|
||||
{/* On the filled pill the logo must counter-invert: primary is dark in
|
||||
light mode (white logo) and light in dark mode (black logo). */}
|
||||
<BankIdIcon className={hero ? 'invert dark:invert-0' : undefined} />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
@@ -546,14 +554,15 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function BankIdIcon() {
|
||||
function BankIdIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<Image
|
||||
src="/logos/bankid-seeklogo.svg"
|
||||
alt="BankID"
|
||||
width={20}
|
||||
height={20}
|
||||
className="dark:invert"
|
||||
loading="eager"
|
||||
className={className ?? 'dark:invert'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
className={compact ? 'h-10 w-full gap-2' : 'w-full h-11'}
|
||||
onClick={handleClick}
|
||||
disabled={isRedirecting}
|
||||
aria-label={tAuth('continue_with_google')}
|
||||
>
|
||||
{isRedirecting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className={compact ? 'h-4 w-4 animate-spin' : 'mr-2 h-4 w-4 animate-spin'} />
|
||||
) : (
|
||||
<span className="mr-2 flex items-center"><GoogleMark /></span>
|
||||
<span className={compact ? 'flex items-center' : 'mr-2 flex items-center'}>
|
||||
<GoogleMark />
|
||||
</span>
|
||||
)}
|
||||
{tAuth('continue_with_google')}
|
||||
{compact ? 'Google' : tAuth('continue_with_google')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user