diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md
index 47cdcb3..4bf69e7 100644
--- a/docs/deployment-runbook.md
+++ b/docs/deployment-runbook.md
@@ -47,6 +47,7 @@ Optional variables:
12. Run `npm run payload:migrate:status` and confirm every migration is `Yes`.
13. Start the app with `npm run start`.
14. Run `npm run smoke -- "$NEXT_PUBLIC_SITE_URL"`.
+15. Log out of the admin, open `/admin/login`, and confirm the verification code is required before password validation.
## Data Rules
@@ -56,6 +57,7 @@ Optional variables:
- Do not edit migrations after they have shipped to production. Add a new forward migration instead.
- Keep schema migrations and data backfills separate. Backfill scripts in `src/scripts/` are manual operational tools, not automatic boot steps.
- The deployed database and upload directories must come from the same local export, otherwise media relationships can point at missing files.
+- Admin login captcha is stateless and signed with `PAYLOAD_SECRET`; changing that secret invalidates existing login sessions and outstanding captcha tokens.
## Rollback
diff --git a/docs/local-db-to-production-deployment.md b/docs/local-db-to-production-deployment.md
index d88aa51..54237dc 100644
--- a/docs/local-db-to-production-deployment.md
+++ b/docs/local-db-to-production-deployment.md
@@ -190,6 +190,7 @@ npm run start
```bash
curl -f "$NEXT_PUBLIC_SITE_URL/api/health"
+curl -f "$NEXT_PUBLIC_SITE_URL/api/admin/captcha"
npm run smoke -- "$NEXT_PUBLIC_SITE_URL"
```
@@ -205,8 +206,11 @@ npm run smoke -- "$NEXT_PUBLIC_SITE_URL"
/en/support/tutorial
/en/dealers
/admin
+/admin/login
```
+后台上线后再手工确认一次:退出登录,打开 `/admin/login`,验证码错误时应该直接失败,验证码正确但密码错误时才进入账号密码校验。
+
## 8. 回滚
回滚要成套回滚:
diff --git a/docs/production-release-checklist.md b/docs/production-release-checklist.md
index 488964e..5b8407f 100644
--- a/docs/production-release-checklist.md
+++ b/docs/production-release-checklist.md
@@ -12,6 +12,7 @@ Use this checklist before every production deployment.
- [ ] `npm run build` passes.
- [ ] `npm run payload:migrate:status` shows all migrations as `Yes` on the release database.
- [ ] `payload_migrations` has no `batch = -1` dev marker before exporting/importing the database.
+- [ ] Admin login shows a verification code, invalid captcha fails before password validation, and `/admin` redirects to `/admin/login` when logged out.
- [ ] No new user-facing fallback copy was introduced without a CMS field.
## Environment
@@ -51,7 +52,9 @@ Required manual spot checks:
- `/en/support`
- `/en/support/tutorial`
- `/admin`
+- `/admin/login`
- `/api/health`
+- `/api/admin/captcha`
## Launch Notes
diff --git a/src/app/(payload)/admin-theme.css b/src/app/(payload)/admin-theme.css
index 1f0b801..6d38115 100644
--- a/src/app/(payload)/admin-theme.css
+++ b/src/app/(payload)/admin-theme.css
@@ -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;
+ }
+}
diff --git a/src/app/(payload)/admin/[[...segments]]/page.tsx b/src/app/(payload)/admin/[[...segments]]/page.tsx
index bc7ec1f..34e4755 100644
--- a/src/app/(payload)/admin/[[...segments]]/page.tsx
+++ b/src/app/(payload)/admin/[[...segments]]/page.tsx
@@ -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