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:
parent
613dda4ad1
commit
ae5e09be76
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
407
dashboard-fresh/islands/LoginForm.tsx
Normal file
407
dashboard-fresh/islands/LoginForm.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
131
dashboard-fresh/routes/login.tsx
Normal file
131
dashboard-fresh/routes/login.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user