From 98e32a4385e7ed11345d31571f0ea2d3f318ac66 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Apr 2026 09:16:15 +0800 Subject: [PATCH] Secure admin login flow --- docs/deployment-runbook.md | 2 + docs/local-db-to-production-deployment.md | 4 + docs/production-release-checklist.md | 3 + src/app/(payload)/admin-theme.css | 191 +++++++++++++++++ .../(payload)/admin/[[...segments]]/page.tsx | 26 +++ .../admin/_components/admin-avatar-menu.tsx | 51 +++++ .../admin/_components/admin-cache-button.tsx | 2 + .../admin/_components/admin-login-form.tsx | 197 ++++++++++++++++++ src/app/(payload)/admin/importMap.js | 2 + src/app/api/admin/captcha/route.ts | 14 ++ src/lib/admin-captcha.ts | 120 +++++++++++ src/payload.config.ts | 6 + .../admin/require-admin-login-captcha.ts | 21 ++ src/payload/collections/Users.ts | 4 + src/scripts/smoke-check.mjs | 3 + 15 files changed, 646 insertions(+) create mode 100644 src/app/(payload)/admin/_components/admin-avatar-menu.tsx create mode 100644 src/app/(payload)/admin/_components/admin-login-form.tsx create mode 100644 src/app/api/admin/captcha/route.ts create mode 100644 src/lib/admin-captcha.ts create mode 100644 src/payload/admin/require-admin-login-captcha.ts 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 + } + if (segments.length === 0) { + if (!(await getAdminUser())) redirect('/admin/login?redirect=/admin') + return } diff --git a/src/app/(payload)/admin/_components/admin-avatar-menu.tsx b/src/app/(payload)/admin/_components/admin-avatar-menu.tsx new file mode 100644 index 0000000..a882456 --- /dev/null +++ b/src/app/(payload)/admin/_components/admin-avatar-menu.tsx @@ -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 ( + + + + Account + + + + + ) +} diff --git a/src/app/(payload)/admin/_components/admin-cache-button.tsx b/src/app/(payload)/admin/_components/admin-cache-button.tsx index 671817c..a6d104c 100644 --- a/src/app/(payload)/admin/_components/admin-cache-button.tsx +++ b/src/app/(payload)/admin/_components/admin-cache-button.tsx @@ -9,6 +9,8 @@ export function AdminCacheButton() { const cacheCleared = searchParams.get('cache') === 'cleared' const returnTo = pathname || '/admin' + if (pathname === '/admin/login') return null + return (
diff --git a/src/app/(payload)/admin/_components/admin-login-form.tsx b/src/app/(payload)/admin/_components/admin-login-form.tsx new file mode 100644 index 0000000..bd3294f --- /dev/null +++ b/src/app/(payload)/admin/_components/admin-login-form.tsx @@ -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(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) { + 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 ( +
+
+ + +
+ + + +
+ {error ? ( +

+ {error} +

+ ) : null} + + Forgot password? + + + +
+
+ ) +} diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index f32713e..cce63b0 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -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 } diff --git a/src/app/api/admin/captcha/route.ts b/src/app/api/admin/captcha/route.ts new file mode 100644 index 0000000..41cf449 --- /dev/null +++ b/src/app/api/admin/captcha/route.ts @@ -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', + }, + }) +} diff --git a/src/lib/admin-captcha.ts b/src/lib/admin-captcha.ts new file mode 100644 index 0000000..377c7a9 --- /dev/null +++ b/src/lib/admin-captcha.ts @@ -0,0 +1,120 @@ +import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto' + +const CAPTCHA_TTL_MS = 5 * 60 * 1000 +const CAPTCHA_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ' + +type CaptchaPayload = { + answerHash: string + expiresAt: number + nonce: string + prompt: string + version: 1 +} + +type CaptchaVerification = + | { ok: true } + | { message: string; ok: false } + +function getSecret() { + return process.env.PAYLOAD_SECRET || 'development-payload-secret' +} + +function base64url(value: string | Buffer) { + return Buffer.from(value).toString('base64url') +} + +function hmac(value: string) { + return createHmac('sha256', getSecret()).update(value).digest('base64url') +} + +function safeEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} + +function signPayload(payload: CaptchaPayload) { + const encodedPayload = base64url(JSON.stringify(payload)) + const signature = hmac(encodedPayload) + + return `${encodedPayload}.${signature}` +} + +function parseToken(token: string): CaptchaPayload | null { + const [encodedPayload, signature] = token.split('.') + + if (!encodedPayload || !signature || !safeEqual(hmac(encodedPayload), signature)) return null + + try { + const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) as CaptchaPayload + + if ( + payload.version !== 1 || + typeof payload.answerHash !== 'string' || + typeof payload.expiresAt !== 'number' || + typeof payload.nonce !== 'string' || + typeof payload.prompt !== 'string' + ) { + return null + } + + return payload + } catch { + return null + } +} + +function createCode(length = 5) { + return Array.from({ length }, () => CAPTCHA_ALPHABET[randomInt(CAPTCHA_ALPHABET.length)]).join('') +} + +export function createAdminCaptcha() { + const answer = createCode() + const expiresAt = Date.now() + CAPTCHA_TTL_MS + const nonce = randomBytes(16).toString('base64url') + const prompt = answer + const answerHash = hmac(`${nonce}:${answer.toUpperCase()}:${expiresAt}`) + + return { + expiresAt, + prompt, + token: signPayload({ + answerHash, + expiresAt, + nonce, + prompt, + version: 1, + }), + } +} + +export function verifyAdminCaptcha(token: unknown, answer: unknown): CaptchaVerification { + if (typeof token !== 'string' || typeof answer !== 'string') { + return { ok: false, message: '请输入验证码。' } + } + + const normalizedAnswer = answer.trim().toUpperCase() + + if (!/^[A-Z0-9]{4,8}$/.test(normalizedAnswer)) { + return { ok: false, message: '验证码不正确,请重新输入。' } + } + + const payload = parseToken(token) + + if (!payload) { + return { ok: false, message: '验证码已失效,请刷新后重试。' } + } + + if (Date.now() > payload.expiresAt) { + return { ok: false, message: '验证码已过期,请刷新后重试。' } + } + + const expectedHash = hmac(`${payload.nonce}:${normalizedAnswer}:${payload.expiresAt}`) + + if (!safeEqual(expectedHash, payload.answerHash)) { + return { ok: false, message: '验证码不正确,请重新输入。' } + } + + return { ok: true } +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 8445a7a..361d62b 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -56,6 +56,12 @@ if ( export default buildConfig({ admin: { + avatar: { + Component: { + path: '@/app/(payload)/admin/_components/admin-avatar-menu', + exportName: 'AdminAvatarMenu', + }, + }, components: { providers: [ { diff --git a/src/payload/admin/require-admin-login-captcha.ts b/src/payload/admin/require-admin-login-captcha.ts new file mode 100644 index 0000000..f656c08 --- /dev/null +++ b/src/payload/admin/require-admin-login-captcha.ts @@ -0,0 +1,21 @@ +import type { CollectionBeforeOperationHook } from 'payload' +import { APIError } from 'payload' +import { verifyAdminCaptcha } from '@/lib/admin-captcha' + +type LoginRequestData = { + captchaAnswer?: unknown + captchaToken?: unknown +} + +export const requireAdminLoginCaptcha: CollectionBeforeOperationHook<'users'> = ({ args, operation }) => { + if (operation !== 'login') return args + + const requestData = args.req.data as LoginRequestData | undefined + const result = verifyAdminCaptcha(requestData?.captchaToken, requestData?.captchaAnswer) + + if (!result.ok) { + throw new APIError(result.message, 401) + } + + return args +} diff --git a/src/payload/collections/Users.ts b/src/payload/collections/Users.ts index 4a51659..31f4113 100644 --- a/src/payload/collections/Users.ts +++ b/src/payload/collections/Users.ts @@ -1,4 +1,5 @@ import type { CollectionConfig } from 'payload' +import { requireAdminLoginCaptcha } from '@/payload/admin/require-admin-login-captcha' export const Users: CollectionConfig = { slug: 'users', @@ -13,6 +14,9 @@ export const Users: CollectionConfig = { singular: { en: 'User', zh: '用户' }, }, auth: true, + hooks: { + beforeOperation: [requireAdminLoginCaptcha], + }, fields: [ { name: 'name', type: 'text' }, { diff --git a/src/scripts/smoke-check.mjs b/src/scripts/smoke-check.mjs index 537ac63..e579935 100644 --- a/src/scripts/smoke-check.mjs +++ b/src/scripts/smoke-check.mjs @@ -2,6 +2,7 @@ const base = process.argv[2] || process.env.SMOKE_BASE_URL || 'http://localhost: const routes = [ '/admin', + '/admin/login', '/en', '/zh', '/en/products', @@ -28,6 +29,7 @@ const routes = [ '/en/support/tutorial', '/zh/support/tutorial', '/api/health', + '/api/admin/captcha', '/favicon.ico', '/Product/index/model/DAC-Z10/target/X4C68nRijzjeq7k9e%5Bld%5D3ulg%3D%3D.html', ] @@ -43,6 +45,7 @@ async function check(route) { location, ok: response.status === 200 || + (route === '/admin' && [307, 308].includes(response.status) && Boolean(location?.includes('/admin/login'))) || (route.includes('/Product/index/model/') && response.status === 301 && Boolean(location)) || (route === '/' && response.status === 307 && Boolean(location)), }