Skip to content

Commit ad5f414

Browse files
claudelongsizhuo
authored andcommitted
feat(admin): /admin 后台首页 + /admin/users 超管用户管理 + PR #299 CR
## PR #299 Copilot CR 修复 1) /admin/events/[id]/edit 的 id 校验改成严格正则 ^[1-9]\d*$。之前用 Number.parseInt 会把 "12ab" 宽松解析成 12,错误编辑到 id=12 的活动。 2) lib/events-fetch.ts 在后端返回 success=false / 缺 data 的分支补一条 console.warn,携带 message / success / hasData,让 server log 能定位。 3) FloatWindow 注释笔误 "/ events" 修成 "/events"。 ## /admin 后台首页 app/admin/layout.tsx Server Component 承载 Header / Footer(之前在 events/layout.tsx 里做的,现在提到 /admin 根 layout 避免重复)。 app/admin/page.tsx 是管理员登录后的入口卡片页: - admin 看到"活动管理"卡片 - superadmin 额外看到"用户管理"卡片(红色强调色,Superadmin only 徽章) 个人主页"管理员界面"按钮的 href 从 /admin/events 改成 /admin。 ## /admin/users 超管用户管理 app/admin/users/page.tsx 是 superadmin 专属页面: - 表格列出所有用户,含头像 / username / displayName / email / roles tag - 每行一个 admin checkbox,乐观更新 + 失败回滚 - superadmin 行禁用 checkbox(产品规则) - 自己的行禁用 checkbox(防止唯一超管自我锁死) - 搜索框前端即时过滤 + 后端同词支持 ?q= ## AdminGuard 支持 required 参数 原先只做 admin 判定。扩展成 required: "admin" | "superadmin": - /admin/events/* 依然 required="admin"(superadmin 也带 admin 角色自动通过) - /admin/users required="superadmin" 严格卡 ## next.config.mjs rewrites 新增 /api/admin/users 和 /api/admin/users/:path* 两条代理规则。 ## 配套后端 involutionhell-backend fix/events-cr-local 分支同步新增 /api/admin/users CRUD(@SaCheckRole("superadmin"))和 EventInterestRepositoryTests 集成测试(7 条 all green)。
1 parent 03480c1 commit ad5f414

11 files changed

Lines changed: 517 additions & 46 deletions

File tree

app/admin/events/AdminGuard.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,31 @@
33
/**
44
* 前端管理员页的权限包装器。
55
*
6-
* 做的事
6+
* 行为
77
* - 未登录:跳 /login
8-
* - 登录但不是 admin:渲染 403 提示
9-
* - 是 admin:渲染 children
8+
* - 登录但不满足 required 角色:渲染 403 提示
9+
* - 通过:渲染 children
1010
*
11-
* 注意这个只是 UX 层的保护——真正的安全由后端 @SaCheckRole("admin") 兜底。
12-
* 用户只要能绕过这里也拿不到数据,后端直接返回 401/403。
11+
* required 取值:
12+
* "admin" → roles 包含 admin(superadmin 也通过,因为他们 roles 里也会带 admin)
13+
* "superadmin" → roles 必须包含 superadmin
14+
*
15+
* 这个只是 UX 层保护——真正的安全由后端 @SaCheckRole(...) 兜底,绕过 UI
16+
* 也拿不到数据。
1317
*/
1418

1519
import { useEffect } from "react";
1620
import { useRouter } from "next/navigation";
1721
import { useAuth } from "@/lib/use-auth";
1822
import type { ReactNode } from "react";
1923

20-
export function AdminGuard({ children }: { children: ReactNode }) {
24+
interface Props {
25+
children: ReactNode;
26+
/** 默认 "admin"(事件管理等通用后台页);用户管理页传 "superadmin" */
27+
required?: "admin" | "superadmin";
28+
}
29+
30+
export function AdminGuard({ children, required = "admin" }: Props) {
2131
const { user, status } = useAuth();
2232
const router = useRouter();
2333

@@ -43,18 +53,24 @@ export function AdminGuard({ children }: { children: ReactNode }) {
4353

4454
if (status === "unauthenticated") return null;
4555

46-
const isAdmin = user?.roles?.includes("admin") ?? false;
47-
if (!isAdmin) {
56+
const roles = user?.roles ?? [];
57+
const passes = required === "superadmin"
58+
? roles.includes("superadmin")
59+
: roles.includes("admin"); // superadmin 在 seed 里也会带 admin,所以这里一起通过
60+
if (!passes) {
4861
return (
4962
<main className="pt-32 pb-16 min-h-screen flex items-center justify-center px-6">
5063
<div className="max-w-lg border border-[#CC0000] p-8 text-center">
5164
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#CC0000] mb-3">
5265
403 · Forbidden
5366
</div>
54-
<h1 className="font-serif text-2xl font-black mb-3">你不是管理员</h1>
67+
<h1 className="font-serif text-2xl font-black mb-3">
68+
{required === "superadmin" ? "需要超级管理员权限" : "你不是管理员"}
69+
</h1>
5570
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
56-
管理员界面仅对 <code>roles</code> 包含 <code>admin</code>{" "}
57-
的账号开放。 如果你认为这是误报,联系站点维护者。
71+
{required === "superadmin"
72+
? "此页面仅对 superadmin 开放。如果你认为这是误报,联系站点维护者。"
73+
: "管理员界面仅对 roles 包含 admin 的账号开放。如果你认为这是误报,联系站点维护者。"}
5874
</p>
5975
</div>
6076
</main>

app/admin/events/[id]/edit/page.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ interface Param {
2121
export default function EditEventPage({ params }: Param) {
2222
// React 19 的 new-style async params:用 use() 同步解包 Promise
2323
const { id } = use(params);
24-
// 路由参数不受控,可能是 "abc"、"12ab" 之类非法字符串;直接 Number() 得到 NaN
25-
// 会让后续请求变成 /api/admin/events/NaN 并报一条迷惑错误("活动不存在")。
26-
// 用 parseInt 严格解析 + Number.isFinite 双重校验,非法 id 直接传 null
27-
// 让下游渲染一个"id 非法"的错误态,不打请求。
28-
const parsed = Number.parseInt(id, 10);
29-
const eventId = Number.isFinite(parsed) && parsed > 0 ? parsed : null;
24+
// 路由参数不受控,可能是 "abc"、"12ab" 之类非法字符串。
25+
// 必须"整串都是正整数"严格校验——不能用 parseInt,因为 "12ab" 会被宽松解析成
26+
// 12 从而错误编辑到 id=12 的活动。用正则 ^[1-9]\d*$ 拒绝前导零 / 非数字字符 /
27+
// 负号 / 小数点,非法时传 null 让下游渲染错误态,不打请求。
28+
const eventId = /^[1-9]\d*$/.test(id) ? Number(id) : null;
3029

3130
return (
3231
<AdminGuard>

app/admin/events/layout.tsx

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,11 @@
11
import type { ReactNode } from "react";
2-
import { Header } from "@/app/components/Header";
3-
import { Footer } from "@/app/components/Footer";
42

53
/**
6-
* Admin 活动后台的共享 layout:
7-
* 因为 Header / Footer 是 Server Component(用 next-intl 的 getTranslations),
8-
* 不能直接在 "use client" 的 page.tsx 里渲染(会触发
9-
* "getTranslations is not supported in Client Components" 500)。
10-
* 所以把 Header / Footer 提到这个 Server Component layout 里,
11-
* 让 admin page 自己是纯 client 组件只管渲染内容区。
4+
* /admin/events/* 子树的 layout。
5+
*
6+
* 之前这里单独挂 Header / Footer 是因为当时还没有 /admin/layout.tsx。现在根 admin
7+
* 已经有共享 layout,这层只是透传,保留文件是为了 Next 路由分段还能命中。
128
*/
13-
export default function AdminEventsLayout({
14-
children,
15-
}: {
16-
children: ReactNode;
17-
}) {
18-
return (
19-
<>
20-
<Header />
21-
{children}
22-
<Footer />
23-
</>
24-
);
9+
export default function AdminEventsLayout({ children }: { children: ReactNode }) {
10+
return <>{children}</>;
2511
}

app/admin/layout.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ReactNode } from "react";
2+
import { Header } from "@/app/components/Header";
3+
import { Footer } from "@/app/components/Footer";
4+
5+
/**
6+
* /admin/* 的共享 layout。
7+
*
8+
* 和 app/admin/events/layout.tsx 同样的问题:Header / Footer 是 Server Component
9+
* (用 next-intl/server.getTranslations),不能嵌在 "use client" 的页面里,否则
10+
* 报 "getTranslations is not supported in Client Components" 500。
11+
* 提到这一层 Server Component layout,让各个 admin 页面本身保持 client 组件。
12+
*
13+
* 注意:之前 app/admin/events/layout.tsx 是为 events 子树单独做的。现在引入
14+
* /admin 根 layout 之后,二者嵌套 Header / Footer 会重复。events 那层 layout
15+
* 相应精简为空透传(见该文件)。
16+
*/
17+
export default function AdminLayout({ children }: { children: ReactNode }) {
18+
return (
19+
<>
20+
<Header />
21+
{children}
22+
<Footer />
23+
</>
24+
);
25+
}

app/admin/page.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"use client";
2+
3+
/**
4+
* /admin — 管理员后台首页。
5+
*
6+
* 根据登录用户的 role 决定卡片显隐:
7+
* admin → 看到"活动管理"
8+
* superadmin → 额外看到"用户管理"
9+
*
10+
* 权限由 <AdminGuard required="admin"> 统一把守——superadmin 必然有 admin 角色,
11+
* 所以不会被挡;非 admin 直接 403。
12+
*/
13+
14+
import Link from "next/link";
15+
import { useAuth } from "@/lib/use-auth";
16+
import { AdminGuard } from "./events/AdminGuard";
17+
18+
export default function AdminHomePage() {
19+
return (
20+
<AdminGuard>
21+
<AdminHomeInner />
22+
</AdminGuard>
23+
);
24+
}
25+
26+
function AdminHomeInner() {
27+
const { user } = useAuth();
28+
const isSuperadmin = user?.roles?.includes("superadmin") ?? false;
29+
30+
return (
31+
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
32+
<div className="max-w-4xl mx-auto px-6 lg:px-8">
33+
<header className="border-t-4 border-[var(--foreground)] pt-6 mb-10">
34+
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
35+
Admin · Home
36+
</div>
37+
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase mt-2 tracking-tight">
38+
管理员后台
39+
</h1>
40+
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
41+
选择要管理的模块。真正的权限控制在后端,前端按钮只是入口。
42+
</p>
43+
</header>
44+
45+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
46+
<AdminCard
47+
title="活动管理"
48+
description="创建、编辑、归档社群活动(Coffee Chat / Mock / Career Journey 等)。"
49+
href="/admin/events"
50+
badge="Admin+"
51+
/>
52+
{isSuperadmin && (
53+
<AdminCard
54+
title="用户管理"
55+
description="给其他登录用户授予 / 撤销 admin 角色。"
56+
href="/admin/users"
57+
badge="Superadmin only"
58+
accent
59+
/>
60+
)}
61+
</div>
62+
</div>
63+
</main>
64+
);
65+
}
66+
67+
function AdminCard({
68+
title,
69+
description,
70+
href,
71+
badge,
72+
accent,
73+
}: {
74+
title: string;
75+
description: string;
76+
href: string;
77+
badge: string;
78+
accent?: boolean;
79+
}) {
80+
return (
81+
<Link
82+
href={href}
83+
className={`block border p-6 transition-colors group ${
84+
accent
85+
? "border-[#CC0000] hover:bg-[#CC0000] hover:text-white"
86+
: "border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)]"
87+
}`}
88+
>
89+
<div
90+
className={`font-mono text-[10px] uppercase tracking-[0.3em] mb-2 ${
91+
accent ? "text-[#CC0000] group-hover:text-white" : "text-neutral-500 group-hover:text-[var(--background)]"
92+
}`}
93+
>
94+
{badge}
95+
</div>
96+
<h2 className="font-serif text-xl font-black uppercase tracking-tight mb-2">
97+
{title}
98+
</h2>
99+
<p className="text-sm leading-relaxed opacity-80">{description}</p>
100+
</Link>
101+
);
102+
}

app/admin/users/lib.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
/**
4+
* Admin Users API client(client-only,需要 satoken header)。
5+
*/
6+
7+
export interface AdminUserView {
8+
id: number;
9+
username: string;
10+
displayName: string | null;
11+
email: string | null;
12+
avatarUrl: string | null;
13+
githubId: number | null;
14+
enabled: boolean;
15+
roles: string[];
16+
}
17+
18+
interface ApiResponse<T> {
19+
success: boolean;
20+
data?: T;
21+
message?: string;
22+
}
23+
24+
function token(): string | null {
25+
if (typeof window === "undefined") return null;
26+
return localStorage.getItem("satoken");
27+
}
28+
29+
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
30+
const t = token();
31+
const res = await fetch(url, {
32+
...init,
33+
headers: {
34+
"content-type": "application/json",
35+
accept: "application/json",
36+
...(t ? { satoken: t } : {}),
37+
...(init.headers ?? {}),
38+
},
39+
});
40+
const json = (await res.json()) as ApiResponse<T>;
41+
if (!res.ok || !json.success) {
42+
throw new Error(json.message ?? `请求失败 ${res.status}`);
43+
}
44+
if (json.data === undefined) {
45+
throw new Error("后端返回 success 但没有 data");
46+
}
47+
return json.data;
48+
}
49+
50+
export function listAdminUsers(q?: string): Promise<AdminUserView[]> {
51+
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
52+
return request<AdminUserView[]>(`/api/admin/users${qs}`);
53+
}
54+
55+
export function setUserAdminRole(
56+
userId: number,
57+
admin: boolean,
58+
): Promise<AdminUserView> {
59+
return request<AdminUserView>(`/api/admin/users/${userId}/admin`, {
60+
method: "PUT",
61+
body: JSON.stringify({ admin }),
62+
});
63+
}

0 commit comments

Comments
 (0)