feat(auth): migrate login page to Fresh with AuthLayout and MFA support

- Migrate /components/auth/AuthLayout.tsx to Preact
 - Create /routes/login.tsx using Fresh handlers and SSR
 - Create /islands/LoginForm.tsx with MFA (TOTP) support
This commit is contained in:
Haitao Pan 2025-11-04 22:26:05 +08:00
parent 613dda4ad1
commit ae5e09be76
5 changed files with 610 additions and 54 deletions

View File

@ -1,8 +1,13 @@
'use client'
/** @jsxImportSource preact */
/**
* AuthLayout Component - Fresh + Preact
*
* Layout wrapper for authentication pages (login/register)
* Migrated from Next.js to preserve original design
*/
import clsx from 'clsx'
import Link from 'next/link'
import type { MouseEvent, ReactNode } from 'react'
import { ComponentChildren } from 'preact'
import { clsx } from 'clsx'
type SwitchAction = {
text: string
@ -13,9 +18,9 @@ type SwitchAction = {
export type AuthLayoutSocialButton = {
label: string
href: string
icon: ReactNode
icon: ComponentChildren
disabled?: boolean
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void
onClick?: (event: MouseEvent) => void
}
type AlertType = 'error' | 'success' | 'info'
@ -28,32 +33,40 @@ type AuthLayoutProps = {
alert?: { type: AlertType; message: string } | null
socialHeading?: string
socialButtons?: AuthLayoutSocialButton[]
aboveForm?: ReactNode
children: ReactNode
footnote?: ReactNode
aboveForm?: ComponentChildren
children: ComponentChildren
footnote?: ComponentChildren
bottomNote?: string
switchAction: SwitchAction
}
function AuthLayoutTab({ href, active, children }: { href: string; active: boolean; children: ReactNode }) {
function AuthLayoutTab({
href,
active,
children,
}: {
href: string
active: boolean
children: ComponentChildren
}) {
return (
<Link
<a
href={href}
className={clsx(
class={clsx(
'flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold transition',
active
? 'bg-white text-slate-900 shadow-sm shadow-slate-900/5'
: 'text-slate-500 hover:text-slate-700 focus-visible:text-slate-700',
: 'text-slate-500 hover:text-slate-700 focus-visible:text-slate-700'
)}
aria-current={active ? 'page' : undefined}
>
{children}
</Link>
</a>
)
}
function AuthSocialButton({ label, href, icon, disabled, onClick }: AuthLayoutSocialButton) {
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
const handleClick = (event: MouseEvent) => {
if (disabled) {
event.preventDefault()
event.stopPropagation()
@ -65,11 +78,11 @@ function AuthSocialButton({ label, href, icon, disabled, onClick }: AuthLayoutSo
<a
href={href}
onClick={handleClick}
className={clsx(
class={clsx(
'flex items-center justify-center gap-3 rounded-2xl px-4 py-2.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
disabled
? 'cursor-not-allowed bg-slate-100 text-slate-400 focus-visible:outline-slate-200'
: 'bg-slate-900 text-white shadow-lg shadow-slate-900/10 hover:bg-slate-800 focus-visible:outline-slate-900',
: 'bg-slate-900 text-white shadow-lg shadow-slate-900/10 hover:bg-slate-800 focus-visible:outline-slate-900'
)}
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
@ -95,24 +108,24 @@ export function AuthLayout({
switchAction,
}: AuthLayoutProps) {
return (
<div className="relative flex min-h-screen flex-col overflow-hidden bg-slate-50">
<div class="relative flex min-h-screen flex-col overflow-hidden bg-slate-50">
<div
className="pointer-events-none absolute inset-x-0 -top-1/3 h-1/2 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-100 via-transparent to-transparent"
aria-hidden
class="pointer-events-none absolute inset-x-0 -top-1/3 h-1/2 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-100 via-transparent to-transparent"
aria-hidden="true"
/>
<main
className="relative flex flex-1 items-center justify-center px-4 py-12 sm:px-6 lg:px-8"
class="relative flex flex-1 items-center justify-center px-4 py-12 sm:px-6 lg:px-8"
data-testid="auth-layout"
>
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="text-3xl font-semibold tracking-tight text-slate-900">
Svc.Plus
</Link>
<p className="mt-1 text-sm text-slate-500">Cloud-Neutral · </p>
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<a href="/" class="text-3xl font-semibold tracking-tight text-slate-900">
CloudNative Suite
</a>
<p class="mt-1 text-sm text-slate-500"> · Cloud-Neutral</p>
</div>
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white/90 p-8 shadow-xl shadow-slate-900/5 backdrop-blur">
<div className="grid grid-cols-2 gap-2 rounded-full bg-slate-100 p-1">
<div class="overflow-hidden rounded-3xl border border-slate-200 bg-white/90 p-8 shadow-xl shadow-slate-900/5 backdrop-blur">
<div class="grid grid-cols-2 gap-2 rounded-full bg-slate-100 p-1">
<AuthLayoutTab href="/login" active={mode === 'login'}>
Sign In
</AuthLayoutTab>
@ -120,58 +133,58 @@ export function AuthLayout({
Sign Up
</AuthLayoutTab>
</div>
<div className="mt-6 space-y-6">
{badge ? (
<span className="inline-flex items-center rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-sky-700">
<div class="mt-6 space-y-6">
{badge && (
<span class="inline-flex items-center rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-sky-700">
{badge}
</span>
) : null}
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-slate-900 sm:text-3xl">{title}</h1>
{description ? <p className="text-sm text-slate-600">{description}</p> : null}
)}
<div class="space-y-2">
<h1 class="text-2xl font-semibold text-slate-900 sm:text-3xl">{title}</h1>
{description && <p class="text-sm text-slate-600">{description}</p>}
</div>
{alert ? (
{alert && (
<div
className={clsx(
class={clsx(
'rounded-2xl border px-4 py-3 text-sm font-medium',
alert.type === 'error'
? 'border-red-200 bg-red-50 text-red-700'
: alert.type === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-sky-200 bg-sky-50 text-sky-700',
: 'border-sky-200 bg-sky-50 text-sky-700'
)}
role="status"
aria-live="polite"
>
{alert.message}
</div>
) : null}
)}
{aboveForm}
<div className="space-y-5">{children}</div>
{socialButtons.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center gap-4 text-xs uppercase tracking-[0.2em] text-slate-400">
<span className="h-px flex-1 bg-slate-200" aria-hidden />
<div class="space-y-5">{children}</div>
{socialButtons.length > 0 && (
<div class="space-y-4">
<div class="flex items-center gap-4 text-xs uppercase tracking-[0.2em] text-slate-400">
<span class="h-px flex-1 bg-slate-200" aria-hidden="true" />
{socialHeading ?? 'Or continue with'}
<span className="h-px flex-1 bg-slate-200" aria-hidden />
<span class="h-px flex-1 bg-slate-200" aria-hidden="true" />
</div>
<div className="grid grid-cols-2 gap-3">
{socialButtons.map(button => (
<div class="grid grid-cols-2 gap-3">
{socialButtons.map((button) => (
<AuthSocialButton key={button.label} {...button} />
))}
</div>
</div>
) : null}
<p className="text-sm text-slate-600">
)}
<p class="text-sm text-slate-600">
{switchAction.text}{' '}
<Link href={switchAction.href} className="font-semibold text-sky-600 hover:text-sky-500">
<a href={switchAction.href} class="font-semibold text-sky-600 hover:text-sky-500">
{switchAction.linkLabel}
</Link>
</a>
</p>
{footnote ? <div className="text-xs text-slate-400">{footnote}</div> : null}
{footnote && <div class="text-xs text-slate-400">{footnote}</div>}
</div>
</div>
{bottomNote ? <p className="mt-6 text-center text-xs text-slate-500">{bottomNote}</p> : null}
{bottomNote && <p class="mt-6 text-center text-xs text-slate-500">{bottomNote}</p>}
</div>
</main>
</div>

View File

@ -73,6 +73,7 @@
// UI & Icons
"lucide-preact": "https://esm.sh/lucide-preact@0.319.0",
"clsx": "https://esm.sh/clsx@2.1.0",
// Security
"dompurify": "https://esm.sh/dompurify@3.0.9",

View File

@ -14,10 +14,12 @@ import * as $api_ping from './routes/api/ping.ts'
import * as $api_render_markdown from './routes/api/render-markdown.ts'
import * as $api_templates from './routes/api/templates.ts'
import * as $index from './routes/index.tsx'
import * as $login from './routes/login.tsx'
import * as $navbar_demo from './routes/navbar-demo.tsx'
import * as $AccountDropdown from './islands/AccountDropdown.tsx'
import * as $AskAIButton from './islands/AskAIButton.tsx'
import * as $Counter from './islands/Counter.tsx'
import * as $LoginForm from './islands/LoginForm.tsx'
import * as $MobileMenu from './islands/MobileMenu.tsx'
import * as $Navbar from './islands/Navbar.tsx'
import * as $SearchDialog from './islands/SearchDialog.tsx'
@ -37,12 +39,14 @@ const manifest = {
'./routes/api/render-markdown.ts': $api_render_markdown,
'./routes/api/templates.ts': $api_templates,
'./routes/index.tsx': $index,
'./routes/login.tsx': $login,
'./routes/navbar-demo.tsx': $navbar_demo,
},
islands: {
'./islands/AccountDropdown.tsx': $AccountDropdown,
'./islands/AskAIButton.tsx': $AskAIButton,
'./islands/Counter.tsx': $Counter,
'./islands/LoginForm.tsx': $LoginForm,
'./islands/MobileMenu.tsx': $MobileMenu,
'./islands/Navbar.tsx': $Navbar,
'./islands/SearchDialog.tsx': $SearchDialog,

View File

@ -0,0 +1,407 @@
/**
* LoginForm Island - Fresh + Preact
*
* Client-side interactive login form with MFA support
*/
import { useSignal, useComputed } from '@preact/signals'
import { useEffect } from 'preact/hooks'
interface LoginFormProps {
language: 'zh' | 'en'
initialEmail?: string
onSuccess?: () => void
}
// Translation keys
const translations = {
zh: {
email: '用户名 / 邮箱',
emailPlaceholder: '请输入用户名或邮箱',
password: '密码',
passwordPlaceholder: '请输入密码',
totpLabel: '双因素认证码TOTP',
totpPlaceholder: '请输入 6 位验证码',
mfaMode: '登录模式',
passwordOnly: '仅密码验证',
passwordAndTotp: '密码 + 双因素认证',
remember: '保持登录 30 天',
forgotPassword: '忘记密码?',
submit: '登录',
submitting: '登录中',
missingEmail: '请输入用户名或邮箱',
missingPassword: '请输入密码',
missingTotp: '请输入双因素认证码',
invalidTotp: '双因素认证码格式不正确(需要 6 位数字)',
invalidCredentials: '用户名或密码错误',
userNotFound: '用户不存在',
mfaRequired: '需要双因素认证',
genericError: '登录失败,请稍后重试',
serviceUnavailable: '服务暂时不可用',
goHome: '返回首页',
logout: '退出登录',
success: '欢迎回来,{username}',
},
en: {
email: 'Username / Email',
emailPlaceholder: 'Enter username or email',
password: 'Password',
passwordPlaceholder: 'Enter password',
totpLabel: 'Two-Factor Code (TOTP)',
totpPlaceholder: 'Enter 6-digit code',
mfaMode: 'Login Mode',
passwordOnly: 'Password Only',
passwordAndTotp: 'Password + Two-Factor',
remember: 'Keep me logged in for 30 days',
forgotPassword: 'Forgot password?',
submit: 'Sign In',
submitting: 'Signing in',
missingEmail: 'Please enter username or email',
missingPassword: 'Please enter password',
missingTotp: 'Please enter two-factor code',
invalidTotp: 'Invalid two-factor code format (6 digits required)',
invalidCredentials: 'Invalid username or password',
userNotFound: 'User not found',
mfaRequired: 'Two-factor authentication required',
genericError: 'Login failed. Please try again',
serviceUnavailable: 'Service temporarily unavailable',
goHome: 'Go Home',
logout: 'Logout',
success: 'Welcome back, {username}!',
},
}
export default function LoginForm({ language, initialEmail = '', onSuccess }: LoginFormProps) {
const t = translations[language]
const identifier = useSignal(initialEmail)
const password = useSignal('')
const totpCode = useSignal('')
const remember = useSignal(false)
const error = useSignal<string | null>(null)
const isSubmitting = useSignal(false)
const mfaRequirement = useSignal<'optional' | 'required'>('optional')
const user = useSignal<{ username: string } | null>(null)
// Check MFA status when identifier changes
useEffect(() => {
const trimmedIdentifier = identifier.value.trim()
if (!trimmedIdentifier) {
mfaRequirement.value = 'optional'
return
}
const normalizedIdentifier = trimmedIdentifier.toLowerCase()
const controller = new AbortController()
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(
`/api/auth/mfa/status?identifier=${encodeURIComponent(normalizedIdentifier)}`,
{
method: 'GET',
cache: 'no-store',
signal: controller.signal,
}
)
if (!response.ok) {
mfaRequirement.value = 'optional'
return
}
const payload = (await response.json().catch(() => ({}))) as {
mfa?: { totpEnabled?: boolean }
}
mfaRequirement.value = payload?.mfa?.totpEnabled ? 'required' : 'optional'
} catch (lookupError) {
if ((lookupError as Error)?.name !== 'AbortError') {
mfaRequirement.value = 'optional'
}
}
}, 300)
return () => {
controller.abort()
clearTimeout(timeoutId)
}
}, [identifier.value])
// Clear TOTP code when identifier changes
useEffect(() => {
totpCode.value = ''
}, [identifier.value])
// Clear TOTP code if MFA not required
useEffect(() => {
if (mfaRequirement.value !== 'required' && totpCode.value !== '') {
totpCode.value = ''
}
}, [mfaRequirement.value])
const handleSubmit = async (event: Event) => {
event.preventDefault()
const trimmedIdentifier = identifier.value.trim()
if (!trimmedIdentifier) {
error.value = t.missingEmail
return
}
if (!password.value) {
error.value = t.missingPassword
return
}
const requiresTotp = mfaRequirement.value === 'required'
const sanitizedTotp = totpCode.value.replace(/\D/g, '')
if (requiresTotp) {
if (!sanitizedTotp) {
error.value = t.missingTotp
return
}
if (sanitizedTotp.length !== 6) {
error.value = t.invalidTotp
return
}
} else if (sanitizedTotp && sanitizedTotp.length !== 6) {
error.value = t.invalidTotp
return
}
error.value = null
isSubmitting.value = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
email: trimmedIdentifier,
password: password.value,
totp: sanitizedTotp.length === 6 ? sanitizedTotp : undefined,
remember: remember.value,
}),
credentials: 'include',
})
const payload = (await response.json().catch(() => ({}))) as {
success?: boolean
error?: string | null
needMfa?: boolean
}
if (payload.needMfa) {
mfaRequirement.value = 'required'
globalThis.location.href = '/panel/account?setupMfa=1'
return
}
const isSuccessful = response.ok && (payload.success ?? true)
if (!isSuccessful) {
const messageKey = payload.error ?? 'generic_error'
if (
messageKey === 'mfa_code_required' ||
messageKey === 'invalid_mfa_code' ||
messageKey === 'mfa_required' ||
messageKey === 'mfa_setup_required' ||
messageKey === 'mfa_challenge_failed'
) {
mfaRequirement.value = 'required'
}
switch (messageKey) {
case 'missing_credentials':
error.value = t.missingEmail
break
case 'invalid_credentials':
error.value = t.invalidCredentials
break
case 'user_not_found':
error.value = t.userNotFound
break
case 'mfa_code_required':
error.value = t.missingTotp
break
case 'invalid_mfa_code':
error.value = t.invalidTotp
break
case 'mfa_challenge_failed':
error.value = t.mfaRequired
break
case 'account_service_unreachable':
error.value = t.serviceUnavailable
break
default:
error.value = t.genericError
break
}
return
}
// Fetch session to get user info
const sessionResponse = await fetch('/api/auth/session', {
credentials: 'include',
})
if (sessionResponse.ok) {
const sessionData = (await sessionResponse.json()) as {
user?: { username?: string; email?: string }
}
if (sessionData.user?.username) {
user.value = { username: sessionData.user.username }
}
}
if (onSuccess) {
onSuccess()
} else {
// Redirect to home after short delay
setTimeout(() => {
globalThis.location.href = '/'
}, 1000)
}
} catch (submitError) {
console.warn('Login failed', submitError)
error.value = t.genericError
} finally {
isSubmitting.value = false
}
}
const handleGoHome = () => {
globalThis.location.href = '/'
}
const handleLogout = () => {
globalThis.location.href = '/logout'
}
const requiresTotpInput = useComputed(() => mfaRequirement.value === 'required')
const mfaModeLabel = useComputed(() =>
requiresTotpInput.value ? t.passwordAndTotp : t.passwordOnly
)
return (
<>
{user.value ? (
<div class="space-y-4 rounded-2xl border border-sky-200 bg-sky-50/80 p-5 text-sm text-sky-700">
<p class="text-base font-semibold">
{t.success.replace('{username}', user.value.username)}
</p>
<div class="flex flex-wrap gap-3">
<button
type="button"
onClick={handleGoHome}
class="inline-flex items-center justify-center rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500"
>
{t.goHome}
</button>
<button
type="button"
onClick={handleLogout}
class="inline-flex items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300"
>
{t.logout}
</button>
</div>
</div>
) : (
<form method="post" onSubmit={handleSubmit} class="space-y-5" noValidate>
<div class="space-y-2">
<label htmlFor="login-identifier" class="text-sm font-medium text-slate-600">
{t.email}
</label>
<input
id="login-identifier"
name="identifier"
type="text"
autoComplete="username"
value={identifier.value}
onInput={(event) => (identifier.value = (event.target as HTMLInputElement).value)}
placeholder={t.emailPlaceholder}
class="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-slate-600">{t.mfaMode}</p>
<div class="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
{mfaModeLabel}
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<label htmlFor="login-password" class="font-medium text-slate-600">
{t.password}
</label>
<a href="#" class="font-medium text-sky-600 hover:text-sky-500">
{t.forgotPassword}
</a>
</div>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
value={password.value}
onInput={(event) => (password.value = (event.target as HTMLInputElement).value)}
placeholder={t.passwordPlaceholder}
class="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
{requiresTotpInput.value && (
<div class="space-y-2">
<label htmlFor="login-totp" class="text-sm font-medium text-slate-600">
{t.totpLabel}
</label>
<input
id="login-totp"
name="totpCode"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={totpCode.value}
onInput={(event) => {
const digits = (event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 6)
totpCode.value = digits
}}
placeholder={t.totpPlaceholder}
class="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
)}
<label class="flex items-center gap-3 text-sm text-slate-600">
<input
type="checkbox"
name="remember"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
checked={remember.value}
onChange={(event) => (remember.value = (event.target as HTMLInputElement).checked)}
/>
{t.remember}
</label>
{error.value && <p class="text-sm text-red-600">{error.value}</p>}
<button
type="submit"
disabled={isSubmitting.value}
class="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting.value ? `${t.submitting}` : t.submit}
</button>
</form>
)}
</>
)
}

View File

@ -0,0 +1,131 @@
/**
* Login Page - Fresh + Deno
*
* User authentication page with MFA support
*/
import { Head } from '$fresh/runtime.ts'
import { Handlers, PageProps } from '$fresh/server.ts'
import { FreshState } from '@/middleware.ts'
import LoginForm from '@/islands/LoginForm.tsx'
import { AuthLayout } from '@/components/auth/AuthLayout.tsx'
type Language = 'zh' | 'en'
interface LoginPageData {
language: Language
user: { username?: string; email?: string } | null
errorParam: string | null
registeredParam: string | null
setupMfaParam: string | null
}
export const handler: Handlers<LoginPageData, FreshState> = {
async GET(req, ctx) {
// Get language from query param
const url = new URL(req.url)
const langParam = url.searchParams.get('lang')
const language: Language = (langParam === 'en' || langParam === 'zh') ? langParam : 'zh'
// Get query params for alerts
const errorParam = url.searchParams.get('error')
const registeredParam = url.searchParams.get('registered')
const setupMfaParam = url.searchParams.get('setupMfa')
return ctx.render({
language,
user: ctx.state.user || null,
errorParam,
registeredParam,
setupMfaParam,
})
},
}
export default function LoginPage({ data }: PageProps<LoginPageData>) {
const { language, user, errorParam, registeredParam, setupMfaParam } = data
const t = {
zh: {
title: '登录到您的账户',
pageTitle: '登录 - CloudNative Suite',
badge: '欢迎回来',
description: '输入您的邮箱和密码继续',
registerPrompt: '还没有账户?',
registerLink: '立即注册',
registered: '注册成功!请登录以继续',
setupMfaRequired: '需要设置双因素认证后才能继续',
bottomNote: '登录即表示您同意我们的服务条款和隐私政策',
errorMessages: {
invalid_credentials: '用户名或密码错误',
user_not_found: '用户不存在',
missing_credentials: '请输入完整的登录信息',
credentials_in_query: '请不要在 URL 中传递敏感信息',
invalid_request: '无效的请求',
generic_error: '登录失败,请稍后重试',
},
},
en: {
title: 'Sign in to your account',
pageTitle: 'Login - CloudNative Suite',
badge: 'Welcome Back',
description: 'Enter your email and password to continue',
registerPrompt: "Don't have an account?",
registerLink: 'Sign up',
registered: 'Registration successful! Please sign in to continue',
setupMfaRequired: 'Two-factor authentication setup required before you can continue',
bottomNote: 'By signing in, you agree to our Terms of Service and Privacy Policy',
errorMessages: {
invalid_credentials: 'Invalid username or password',
user_not_found: 'User not found',
missing_credentials: 'Please enter your credentials',
credentials_in_query: 'Please do not pass credentials in URL',
invalid_request: 'Invalid request',
generic_error: 'Login failed. Please try again',
},
},
}
const copy = t[language]
// Derive alert message
let alert: { type: 'error' | 'success' | 'info'; message: string } | null = null
if (registeredParam === '1') {
alert = { type: 'success', message: copy.registered }
} else if (setupMfaParam === '1') {
alert = { type: 'info', message: copy.setupMfaRequired }
} else if (errorParam) {
const normalizedError = errorParam.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_')
const errorMessage =
copy.errorMessages[normalizedError as keyof typeof copy.errorMessages] ||
copy.errorMessages.generic_error
alert = { type: 'error', message: errorMessage }
}
return (
<>
<Head>
<title>{copy.pageTitle}</title>
<meta name="description" content={copy.description} />
<link rel="stylesheet" href="/styles/globals.css" />
</Head>
<AuthLayout
mode="login"
badge={copy.badge}
title={copy.title}
description={copy.description}
alert={alert}
switchAction={{
text: copy.registerPrompt,
linkLabel: copy.registerLink,
href: `/register${language === 'en' ? '?lang=en' : ''}`,
}}
bottomNote={copy.bottomNote}
>
<LoginForm language={language} initialEmail={user?.email || ''} />
</AuthLayout>
</>
)
}