From ae5e09be768ef905014f60d323715808eba69367 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 4 Nov 2025 22:26:05 +0800 Subject: [PATCH] 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 --- .../components/auth/AuthLayout.tsx | 121 +++--- dashboard-fresh/deno.jsonc | 1 + dashboard-fresh/fresh.gen.ts | 4 + dashboard-fresh/islands/LoginForm.tsx | 407 ++++++++++++++++++ dashboard-fresh/routes/login.tsx | 131 ++++++ 5 files changed, 610 insertions(+), 54 deletions(-) create mode 100644 dashboard-fresh/islands/LoginForm.tsx create mode 100644 dashboard-fresh/routes/login.tsx diff --git a/dashboard-fresh/components/auth/AuthLayout.tsx b/dashboard-fresh/components/auth/AuthLayout.tsx index 9442caa..6933a09 100644 --- a/dashboard-fresh/components/auth/AuthLayout.tsx +++ b/dashboard-fresh/components/auth/AuthLayout.tsx @@ -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) => 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 ( - {children} - + ) } function AuthSocialButton({ label, href, icon, disabled, onClick }: AuthLayoutSocialButton) { - const handleClick = (event: MouseEvent) => { + const handleClick = (event: MouseEvent) => { if (disabled) { event.preventDefault() event.stopPropagation() @@ -65,11 +78,11 @@ function AuthSocialButton({ label, href, icon, disabled, onClick }: AuthLayoutSo +
-
-
- - Svc.Plus - -

Cloud-Neutral · 自由中立

+
+
+ + CloudNative Suite + +

云原生套件 · Cloud-Neutral

-
-
+
+
Sign In @@ -120,58 +133,58 @@ export function AuthLayout({ Sign Up
-
- {badge ? ( - +
+ {badge && ( + {badge} - ) : null} -
-

{title}

- {description ?

{description}

: null} + )} +
+

{title}

+ {description &&

{description}

}
- {alert ? ( + {alert && (
{alert.message}
- ) : null} + )} {aboveForm} -
{children}
- {socialButtons.length > 0 ? ( -
-
- +
{children}
+ {socialButtons.length > 0 && ( +
+
+
-
- {socialButtons.map(button => ( +
+ {socialButtons.map((button) => ( ))}
- ) : null} -

+ )} +

{switchAction.text}{' '} - + {switchAction.linkLabel} - +

- {footnote ?
{footnote}
: null} + {footnote &&
{footnote}
}
- {bottomNote ?

{bottomNote}

: null} + {bottomNote &&

{bottomNote}

}
diff --git a/dashboard-fresh/deno.jsonc b/dashboard-fresh/deno.jsonc index 6c7af02..623f7d7 100644 --- a/dashboard-fresh/deno.jsonc +++ b/dashboard-fresh/deno.jsonc @@ -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", diff --git a/dashboard-fresh/fresh.gen.ts b/dashboard-fresh/fresh.gen.ts index c5baf18..9026890 100644 --- a/dashboard-fresh/fresh.gen.ts +++ b/dashboard-fresh/fresh.gen.ts @@ -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, diff --git a/dashboard-fresh/islands/LoginForm.tsx b/dashboard-fresh/islands/LoginForm.tsx new file mode 100644 index 0000000..19f414c --- /dev/null +++ b/dashboard-fresh/islands/LoginForm.tsx @@ -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(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 ? ( +
+

+ {t.success.replace('{username}', user.value.username)} +

+
+ + +
+
+ ) : ( +
+
+ + (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" + /> +
+ +
+

{t.mfaMode}

+
+ {mfaModeLabel} +
+
+ +
+
+ + + {t.forgotPassword} + +
+ (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" + /> +
+ + {requiresTotpInput.value && ( +
+ + { + 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" + /> +
+ )} + + + + {error.value &&

{error.value}

} + + +
+ )} + + ) +} diff --git a/dashboard-fresh/routes/login.tsx b/dashboard-fresh/routes/login.tsx new file mode 100644 index 0000000..6a2ffd3 --- /dev/null +++ b/dashboard-fresh/routes/login.tsx @@ -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 = { + 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) { + 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 ( + <> + + {copy.pageTitle} + + + + + + + + + ) +}