Secure admin login flow
This commit is contained in:
@@ -1104,3 +1104,194 @@ html[data-theme] .pill {
|
||||
html[data-theme] .banner {
|
||||
background: rgba(241, 233, 220, 0.78);
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-logo {
|
||||
display: inline-grid;
|
||||
min-width: 160px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #2f241b, #54402f);
|
||||
color: #fff9f1;
|
||||
padding: 16px 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-card .login__brand {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-card .login__brand::after {
|
||||
margin-top: 0;
|
||||
letter-spacing: 0.24em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form__fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form .field-type {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form .field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(47, 36, 27, 0.72);
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form .text-input {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(84, 60, 34, 0.16);
|
||||
border-radius: 10px;
|
||||
background: #fffdf9;
|
||||
padding: 11px 12px;
|
||||
color: #171412;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form .text-input:focus {
|
||||
border-color: rgba(47, 36, 27, 0.52);
|
||||
outline: 2px solid rgba(183, 129, 72, 0.24);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-captcha-field__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-captcha-field__code {
|
||||
min-width: 110px;
|
||||
border: 1px solid rgba(84, 60, 34, 0.16);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.72), rgba(241, 233, 220, 0.92)),
|
||||
repeating-linear-gradient(45deg, transparent 0 7px, rgba(84, 60, 34, 0.08) 7px 9px);
|
||||
color: #2f241b;
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form__error {
|
||||
margin: 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(184, 59, 49, 0.1);
|
||||
color: #8f2d25;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-login-form__forgot {
|
||||
justify-self: start;
|
||||
color: rgba(47, 36, 27, 0.72);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu__initial {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #2f241b, #816244);
|
||||
color: #fff9f1;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 2147483200;
|
||||
display: grid;
|
||||
min-width: 148px;
|
||||
gap: 7px;
|
||||
border: 1px solid rgba(84, 60, 34, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 253, 249, 0.98);
|
||||
box-shadow: 0 16px 32px rgba(52, 34, 18, 0.14);
|
||||
opacity: 0;
|
||||
padding: 10px;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu:hover .eversolo-admin-avatar-menu__panel,
|
||||
html[data-theme] .eversolo-admin-avatar-menu:focus-within .eversolo-admin-avatar-menu__panel {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu__label {
|
||||
color: rgba(47, 36, 27, 0.58);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu__button,
|
||||
html[data-theme] .eversolo-admin-avatar-menu__link {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu__button {
|
||||
background: #2f241b;
|
||||
color: #fff9f1;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-admin-avatar-menu__link {
|
||||
background: rgba(84, 60, 34, 0.07);
|
||||
color: #2f241b;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
html[data-theme] .eversolo-captcha-field__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
html[data-theme] .eversolo-captcha-field__code {
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { headers as getHeaders } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { executeAuthStrategies, getPayload } from 'payload'
|
||||
// @ts-expect-error generated by Payload admin import map generator
|
||||
import { importMap } from '../importMap.js'
|
||||
import { AdminHome } from '../_components/admin-home'
|
||||
import { AdminLoginForm } from '../_components/admin-login-form'
|
||||
|
||||
type AdminPageProps = {
|
||||
params: Promise<{ segments?: string[] }>
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}
|
||||
|
||||
async function getAdminUser() {
|
||||
const [config, headers] = await Promise.all([configPromise, getHeaders()])
|
||||
const payload = await getPayload({
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
const { user } = await executeAuthStrategies({
|
||||
headers,
|
||||
payload,
|
||||
})
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: AdminPageProps) {
|
||||
return generatePageMetadata({
|
||||
config: configPromise,
|
||||
@@ -27,7 +45,15 @@ export default async function PayloadAdminPage({ params, searchParams }: AdminPa
|
||||
const { segments } = await normalizedParams
|
||||
const resolvedSearchParams = await searchParams
|
||||
|
||||
if (segments.length === 1 && segments[0] === 'login') {
|
||||
if (await getAdminUser()) redirect('/admin')
|
||||
|
||||
return <AdminLoginForm />
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
if (!(await getAdminUser())) redirect('/admin/login?redirect=/admin')
|
||||
|
||||
return <AdminHome cacheCleared={resolvedSearchParams.cache === 'cleared'} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HtmlPreviewTextareaField as HtmlPreviewTextareaField_753f6c383200cc3519583a44e0773d61 } from '@/payload/admin/HtmlPreviewTextareaField'
|
||||
import { AdminAvatarMenu as AdminAvatarMenu_709e36c272d83f8e640d7dc687c3a445 } from '@/app/(payload)/admin/_components/admin-avatar-menu'
|
||||
import { EversoloPasteURLFetchProvider as EversoloPasteURLFetchProvider_a0494b42366ba3cb335226070f16d4b1 } from '@/app/(payload)/admin/_components/eversolo-paste-url-fetch-provider'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
@@ -57,6 +58,7 @@ export const importMap = {
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@/payload/admin/HtmlPreviewTextareaField#HtmlPreviewTextareaField": HtmlPreviewTextareaField_753f6c383200cc3519583a44e0773d61,
|
||||
"@/app/(payload)/admin/_components/admin-avatar-menu#AdminAvatarMenu": AdminAvatarMenu_709e36c272d83f8e640d7dc687c3a445,
|
||||
"@/app/(payload)/admin/_components/eversolo-paste-url-fetch-provider#EversoloPasteURLFetchProvider": EversoloPasteURLFetchProvider_a0494b42366ba3cb335226070f16d4b1,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createAdminCaptcha } from '@/lib/admin-captcha'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export function GET() {
|
||||
const captcha = createAdminCaptcha()
|
||||
|
||||
return NextResponse.json(captcha, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user