diff --git a/app/admin/events/AdminGuard.tsx b/app/admin/events/AdminGuard.tsx index 1f558eb5..e2a805fa 100644 --- a/app/admin/events/AdminGuard.tsx +++ b/app/admin/events/AdminGuard.tsx @@ -3,13 +3,17 @@ /** * 前端管理员页的权限包装器。 * - * 做的事: + * 行为: * - 未登录:跳 /login - * - 登录但不是 admin:渲染 403 提示 - * - 是 admin:渲染 children + * - 登录但不满足 required 角色:渲染 403 提示 + * - 通过:渲染 children * - * 注意这个只是 UX 层的保护——真正的安全由后端 @SaCheckRole("admin") 兜底。 - * 用户只要能绕过这里也拿不到数据,后端直接返回 401/403。 + * required 取值: + * "admin" → roles 包含 admin(superadmin 也通过,因为他们 roles 里也会带 admin) + * "superadmin" → roles 必须包含 superadmin + * + * 这个只是 UX 层保护——真正的安全由后端 @SaCheckRole(...) 兜底,绕过 UI + * 也拿不到数据。 */ import { useEffect } from "react"; @@ -17,13 +21,23 @@ import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/use-auth"; import type { ReactNode } from "react"; -export function AdminGuard({ children }: { children: ReactNode }) { +interface Props { + children: ReactNode; + /** 默认 "admin"(事件管理等通用后台页);用户管理页传 "superadmin" */ + required?: "admin" | "superadmin"; +} + +export function AdminGuard({ children, required = "admin" }: Props) { const { user, status } = useAuth(); const router = useRouter(); useEffect(() => { if (status === "unauthenticated") { - router.replace("/login?next=/admin/events"); + // 注意:登录页 / SignInButton 当前没实现 next 参数透传(走 GitHub OAuth + // 走 /oauth/render/github,回调后固定落在首页 #token=xxx)。这里就不带 + // ?next=,避免"看起来支持其实不生效"的迷惑。登录成功后用户需自己再点 + // 个人主页里的"管理员界面"按钮返回这里。 + router.replace("/login"); } }, [status, router]); @@ -39,18 +53,24 @@ export function AdminGuard({ children }: { children: ReactNode }) { if (status === "unauthenticated") return null; - const isAdmin = user?.roles?.includes("admin") ?? false; - if (!isAdmin) { + const roles = user?.roles ?? []; + const passes = required === "superadmin" + ? roles.includes("superadmin") + : roles.includes("admin"); // superadmin 在 seed 里也会带 admin,所以这里一起通过 + if (!passes) { return (
403 · Forbidden
-

你不是管理员

+

+ {required === "superadmin" ? "需要超级管理员权限" : "你不是管理员"} +

- 管理员界面仅对 roles 包含 admin{" "} - 的账号开放。 如果你认为这是误报,联系站点维护者。 + {required === "superadmin" + ? "此页面仅对 superadmin 开放。如果你认为这是误报,联系站点维护者。" + : "管理员界面仅对 roles 包含 admin 的账号开放。如果你认为这是误报,联系站点维护者。"}

diff --git a/app/admin/events/[id]/edit/page.tsx b/app/admin/events/[id]/edit/page.tsx index 8a367af4..cd09da75 100644 --- a/app/admin/events/[id]/edit/page.tsx +++ b/app/admin/events/[id]/edit/page.tsx @@ -21,21 +21,34 @@ interface Param { export default function EditEventPage({ params }: Param) { // React 19 的 new-style async params:用 use() 同步解包 Promise const { id } = use(params); - const eventId = Number(id); + // 路由参数不受控,可能是 "abc"、"12ab" 之类非法字符串。 + // 必须"整串都是正整数"严格校验——不能用 parseInt,因为 "12ab" 会被宽松解析成 + // 12 从而错误编辑到 id=12 的活动。用正则 ^[1-9]\d*$ 拒绝前导零 / 非数字字符 / + // 负号 / 小数点,非法时传 null 让下游渲染错误态,不打请求。 + const eventId = /^[1-9]\d*$/.test(id) ? Number(id) : null; return ( - + ); } -function EditEventInner({ eventId }: { eventId: number }) { +function EditEventInner({ + eventId, + rawId, +}: { + eventId: number | null; + rawId: string; +}) { const [event, setEvent] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [loading, setLoading] = useState(eventId !== null); + const [error, setError] = useState( + eventId === null ? `非法的活动 id: ${rawId}` : null, + ); useEffect(() => { + if (eventId === null) return; let cancelled = false; (async () => { try { @@ -63,7 +76,7 @@ function EditEventInner({ eventId }: { eventId: number }) {
- Admin · Events · Edit #{eventId} + Admin · Events · Edit #{eventId ?? rawId}

编辑活动 diff --git a/app/admin/events/layout.tsx b/app/admin/events/layout.tsx index 5ab7c002..30c8f8b2 100644 --- a/app/admin/events/layout.tsx +++ b/app/admin/events/layout.tsx @@ -1,25 +1,11 @@ import type { ReactNode } from "react"; -import { Header } from "@/app/components/Header"; -import { Footer } from "@/app/components/Footer"; /** - * Admin 活动后台的共享 layout: - * 因为 Header / Footer 是 Server Component(用 next-intl 的 getTranslations), - * 不能直接在 "use client" 的 page.tsx 里渲染(会触发 - * "getTranslations is not supported in Client Components" 500)。 - * 所以把 Header / Footer 提到这个 Server Component layout 里, - * 让 admin page 自己是纯 client 组件只管渲染内容区。 + * /admin/events/* 子树的 layout。 + * + * 之前这里单独挂 Header / Footer 是因为当时还没有 /admin/layout.tsx。现在根 admin + * 已经有共享 layout,这层只是透传,保留文件是为了 Next 路由分段还能命中。 */ -export default function AdminEventsLayout({ - children, -}: { - children: ReactNode; -}) { - return ( - <> -
- {children} -