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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user