Secure admin login flow

This commit is contained in:
Codex
2026-04-28 09:16:15 +08:00
parent 21cb5c9c15
commit 98e32a4385
15 changed files with 646 additions and 0 deletions
@@ -0,0 +1,51 @@
'use client'
import { useConfig } from '@payloadcms/ui'
import { formatAdminURL } from 'payload/shared'
export function AdminAvatarMenu() {
const {
config: {
admin: {
routes: { account: accountRoute, logout: logoutRoute },
},
routes: { admin: adminRoute },
},
} = useConfig()
const logoutHref = formatAdminURL({ adminRoute, path: logoutRoute })
const accountHref = formatAdminURL({ adminRoute, path: accountRoute })
return (
<span className="eversolo-admin-avatar-menu">
<span className="eversolo-admin-avatar-menu__initial" aria-hidden="true">
E
</span>
<span className="eversolo-admin-avatar-menu__panel">
<span className="eversolo-admin-avatar-menu__label">Account</span>
<button
className="eversolo-admin-avatar-menu__button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
window.location.assign(logoutHref)
}}
type="button"
>
Log out
</button>
<button
className="eversolo-admin-avatar-menu__link"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
window.location.assign(accountHref)
}}
type="button"
>
Profile
</button>
</span>
</span>
)
}
@@ -9,6 +9,8 @@ export function AdminCacheButton() {
const cacheCleared = searchParams.get('cache') === 'cleared'
const returnTo = pathname || '/admin'
if (pathname === '/admin/login') return null
return (
<div className="admin-cache-header-slot" aria-live="polite">
<form action={clearFrontendCache}>
@@ -0,0 +1,197 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react'
import Link from 'next/link'
type CaptchaResponse = {
expiresAt: number
prompt: string
token: string
}
function getSafeRedirect() {
if (typeof window === 'undefined') return '/admin'
const redirectTo = new URLSearchParams(window.location.search).get('redirect')
if (!redirectTo || !redirectTo.startsWith('/') || redirectTo.startsWith('//')) return '/admin'
return redirectTo
}
async function readErrorMessage(response: Response) {
try {
const data = await response.json()
if (typeof data?.message === 'string') return data.message
if (Array.isArray(data?.errors) && typeof data.errors[0]?.message === 'string') {
return data.errors[0].message
}
} catch {
return null
}
return null
}
export function AdminLoginForm() {
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [captchaAnswer, setCaptchaAnswer] = useState('')
const [error, setError] = useState('')
const [loadingCaptcha, setLoadingCaptcha] = useState(true)
const [submitting, setSubmitting] = useState(false)
const redirectTo = useMemo(getSafeRedirect, [])
const refreshCaptcha = useCallback(async () => {
setLoadingCaptcha(true)
setCaptchaAnswer('')
try {
const response = await fetch('/api/admin/captcha', {
cache: 'no-store',
credentials: 'same-origin',
})
if (!response.ok) throw new Error('Failed to load captcha')
setCaptcha(await response.json())
} catch {
setError('验证码加载失败,请刷新页面后重试。')
} finally {
setLoadingCaptcha(false)
}
}, [])
useEffect(() => {
refreshCaptcha()
}, [refreshCaptcha])
useEffect(() => {
fetch('/api/users/me', {
cache: 'no-store',
credentials: 'same-origin',
})
.then(async (response) => {
if (!response.ok) return
const data = await response.json()
if (data?.user) window.location.assign(redirectTo)
})
.catch(() => undefined)
}, [redirectTo])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!captcha) {
setError('验证码还没有加载完成。')
return
}
setError('')
setSubmitting(true)
const response = await fetch('/api/users/login', {
body: JSON.stringify({
captchaAnswer,
captchaToken: captcha.token,
email,
password,
}),
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (response.ok) {
window.location.assign(redirectTo)
return
}
const message = await readErrorMessage(response)
setError(message || '登录失败,请检查账号、密码和验证码。')
setSubmitting(false)
refreshCaptcha()
}
return (
<main className="template-minimal eversolo-login-shell">
<div className="template-minimal__wrap eversolo-login-card">
<div className="login__brand" aria-hidden="true">
<div className="eversolo-login-logo">Eversolo</div>
</div>
<form className="login__form eversolo-login-form" onSubmit={handleSubmit}>
<div className="login__form__inputWrap eversolo-login-form__fields">
<label className="field-type text">
<span className="field-label">Email</span>
<input
autoComplete="email"
autoFocus
className="text-input"
name="email"
onChange={(event) => setEmail(event.target.value)}
required
type="email"
value={email}
/>
</label>
<label className="field-type text">
<span className="field-label">Password</span>
<input
autoComplete="current-password"
className="text-input"
name="password"
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<label className="field-type text eversolo-captcha-field">
<span className="field-label">Verification code</span>
<span className="eversolo-captcha-field__row">
<input
autoComplete="off"
className="text-input"
disabled={loadingCaptcha}
inputMode="text"
name="captchaAnswer"
onChange={(event) => setCaptchaAnswer(event.target.value.toUpperCase())}
required
type="text"
value={captchaAnswer}
/>
<button
className="eversolo-captcha-field__code"
disabled={loadingCaptcha}
onClick={refreshCaptcha}
type="button"
>
{loadingCaptcha ? 'Loading' : captcha?.prompt}
</button>
</span>
</label>
</div>
{error ? (
<p className="eversolo-login-form__error" role="alert">
{error}
</p>
) : null}
<Link className="eversolo-login-form__forgot" href="/admin/forgot" prefetch={false}>
Forgot password?
</Link>
<button className="btn btn--style-primary btn--size-large" disabled={submitting || loadingCaptcha} type="submit">
{submitting ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</main>
)
}