fix(cms): resolve admin dogfood findings on auth, content editing, and UX

修复 CMS / Admin 后台 dogfood 专项测试发现的阻塞性与体验性问题:
- 登录后无限重定向(Secure Cookie + Edge Runtime JWT 兼容)
- 内容新增/编辑保存 400/500(默认不加密请求体、自动生成唯一 slug)
- 编辑时提交 status 导致验证错误
- 原生 confirm 阻塞自动化测试,改为 AlertDialog
- 表单字段可访问性(id/htmlFor/fieldset)
- JSON 数组标签字段新增 TagInput 组件
- 操作反馈统一使用 Sonner Toast
- 后台隐藏营销 Cookie 横幅
- 媒体库空状态优化

添加 dogfood-cms-regression 与 dogfood-cms-output 报告、截图及工作流路由测试。
This commit is contained in:
张翔
2026-07-25 08:03:06 +08:00
parent f6f5766bb9
commit d9c0e5f2c1
48 changed files with 1445 additions and 106 deletions
+17 -1
View File
@@ -13,7 +13,8 @@ class AdminApiClient {
options: RequestInit & { encrypt?: boolean } = {},
): Promise<T> {
const token = this.getToken();
const shouldEncrypt = options.encrypt !== false && token;
// 当前服务端 API 未实现请求体解密,默认不加密;如需加密,调用方显式传入 encrypt: true。
const shouldEncrypt = options.encrypt === true && token;
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
@@ -173,6 +174,21 @@ class AdminApiClient {
zones: Array.isArray(zones) ? zones.length : 0,
};
}
async getRoles() {
return this.request<{
roles: Array<{ code: string; name: string; builtin: boolean }>;
permissions: Array<{ roleCode: string; modelCode: string; action: string }>;
models: Array<{ code: string; name: string }>;
}>('/api/admin/roles');
}
async updateRolePermissions(roleCode: string, permissions: Array<{ modelCode: string; action: string }>) {
return this.request<{ roleCode: string; permissions: string[] }>('/api/admin/roles', {
method: 'PUT',
body: JSON.stringify({ roleCode, permissions }),
});
}
}
export const adminApi = new AdminApiClient();
+35 -24
View File
@@ -2,11 +2,19 @@ import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { type NextRequest } from 'next/server';
const JWT_SECRET = process.env.JWT_SECRET || 'novalon-cms-secret-key-2024';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'novalon-cms-refresh-secret-2024';
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const TOKEN_EXPIRY = '24h';
const REFRESH_TOKEN_EXPIRY = '7d';
if (!JWT_SECRET || !JWT_REFRESH_SECRET) {
throw new Error(
'JWT_SECRET and JWT_REFRESH_SECRET must be set in environment variables. ' +
'Do not run the application with default or empty secrets.'
);
}
export interface JwtPayload {
userId: string;
username: string;
@@ -26,17 +34,17 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
// ============ Token 工具 ============
export function generateTokens(payload: JwtPayload): { accessToken: string; refreshToken: string } {
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
const refreshToken = jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
const accessToken = jwt.sign(payload, JWT_SECRET!, { expiresIn: TOKEN_EXPIRY });
const refreshToken = jwt.sign(payload, JWT_REFRESH_SECRET!, { expiresIn: REFRESH_TOKEN_EXPIRY });
return { accessToken, refreshToken };
}
export function verifyAccessToken(token: string): JwtPayload {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
return jwt.verify(token, JWT_SECRET!) as JwtPayload;
}
export function verifyRefreshToken(token: string): JwtPayload {
return jwt.verify(token, JWT_REFRESH_SECRET) as JwtPayload;
return jwt.verify(token, JWT_REFRESH_SECRET!) as JwtPayload;
}
// ============ 请求认证 ============
@@ -61,24 +69,27 @@ export function authenticateRequest(request: NextRequest): JwtPayload | null {
// ============ Cookie 工具 ============
export function setTokenCookie(response: Response, accessToken: string, refreshToken: string): void {
response.headers.set(
'Set-Cookie',
`novalon_token=${accessToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
);
response.headers.append(
'Set-Cookie',
`novalon_refresh=${refreshToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`
);
function buildCookie(name: string, value: string, maxAge: number, secure = false): string {
const secureFlag = secure ? '; Secure' : '';
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax${secureFlag}; Max-Age=${maxAge}`;
}
export function clearTokenCookie(response: Response): void {
response.headers.set(
'Set-Cookie',
`novalon_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
);
response.headers.append(
'Set-Cookie',
`novalon_refresh=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`
);
/**
* 设置认证 cookie。
* @param secure 是否添加 Secure 标志。应根据当前请求协议决定(https 为 true),
* 避免在本地 http 预览/开发环境设置 Secure 导致浏览器拒绝保存 cookie。
*/
export function setTokenCookie(
response: Response,
accessToken: string,
refreshToken: string,
secure = false,
): void {
response.headers.set('Set-Cookie', buildCookie('novalon_token', accessToken, 86400, secure));
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', refreshToken, 604800, secure));
}
export function clearTokenCookie(response: Response, secure = false): void {
response.headers.set('Set-Cookie', buildCookie('novalon_token', '', 0, secure));
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', '', 0, secure));
}