Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions app/admin/events/AdminGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,41 @@
/**
* 前端管理员页的权限包装器。
*
* 做的事
* 行为
* - 未登录:跳 /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";
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]);

Expand All @@ -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 (
<main className="pt-32 pb-16 min-h-screen flex items-center justify-center px-6">
<div className="max-w-lg border border-[#CC0000] p-8 text-center">
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#CC0000] mb-3">
403 · Forbidden
</div>
<h1 className="font-serif text-2xl font-black mb-3">你不是管理员</h1>
<h1 className="font-serif text-2xl font-black mb-3">
{required === "superadmin" ? "需要超级管理员权限" : "你不是管理员"}
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
管理员界面仅对 <code>roles</code> 包含 <code>admin</code>{" "}
的账号开放。 如果你认为这是误报,联系站点维护者。
{required === "superadmin"
? "此页面仅对 superadmin 开放。如果你认为这是误报,联系站点维护者。"
: "管理员界面仅对 roles 包含 admin 的账号开放。如果你认为这是误报,联系站点维护者。"}
</p>
</div>
</main>
Expand Down
25 changes: 19 additions & 6 deletions app/admin/events/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AdminGuard>
<EditEventInner eventId={eventId} />
<EditEventInner eventId={eventId} rawId={id} />
</AdminGuard>
);
}

function EditEventInner({ eventId }: { eventId: number }) {
function EditEventInner({
eventId,
rawId,
}: {
eventId: number | null;
rawId: string;
}) {
const [event, setEvent] = useState<EventView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(eventId !== null);
const [error, setError] = useState<string | null>(
eventId === null ? `非法的活动 id: ${rawId}` : null,
);

useEffect(() => {
if (eventId === null) return;
let cancelled = false;
(async () => {
try {
Expand Down Expand Up @@ -63,7 +76,7 @@ function EditEventInner({ eventId }: { eventId: number }) {
</Link>
<header className="mt-4 border-t-4 border-[var(--foreground)] pt-6 mb-8">
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
Admin · Events · Edit #{eventId}
Admin · Events · Edit #{eventId ?? rawId}
</div>
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase mt-2 tracking-tight">
编辑活动
Expand Down
26 changes: 6 additions & 20 deletions app/admin/events/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Header />
{children}
<Footer />
</>
);
export default function AdminEventsLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
25 changes: 25 additions & 0 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { Header } from "@/app/components/Header";
import { Footer } from "@/app/components/Footer";

/**
* /admin/* 的共享 layout。
*
* 和 app/admin/events/layout.tsx 同样的问题:Header / Footer 是 Server Component
* (用 next-intl/server.getTranslations),不能嵌在 "use client" 的页面里,否则
* 报 "getTranslations is not supported in Client Components" 500。
* 提到这一层 Server Component layout,让各个 admin 页面本身保持 client 组件。
*
* 注意:之前 app/admin/events/layout.tsx 是为 events 子树单独做的。现在引入
* /admin 根 layout 之后,二者嵌套 Header / Footer 会重复。events 那层 layout
* 相应精简为空透传(见该文件)。
*/
export default function AdminLayout({ children }: { children: ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}
102 changes: 102 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";

/**
* /admin — 管理员后台首页。
*
* 根据登录用户的 role 决定卡片显隐:
* admin → 看到"活动管理"
* superadmin → 额外看到"用户管理"
*
* 权限由 <AdminGuard required="admin"> 统一把守——superadmin 必然有 admin 角色,
* 所以不会被挡;非 admin 直接 403。
*/

import Link from "next/link";
import { useAuth } from "@/lib/use-auth";
import { AdminGuard } from "./events/AdminGuard";

export default function AdminHomePage() {
return (
<AdminGuard>
<AdminHomeInner />
</AdminGuard>
);
}

function AdminHomeInner() {
const { user } = useAuth();
const isSuperadmin = user?.roles?.includes("superadmin") ?? false;

return (
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<header className="border-t-4 border-[var(--foreground)] pt-6 mb-10">
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
Admin · Home
</div>
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase mt-2 tracking-tight">
管理员后台
</h1>
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
选择要管理的模块。真正的权限控制在后端,前端按钮只是入口。
</p>
</header>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<AdminCard
title="活动管理"
description="创建、编辑、归档社群活动(Coffee Chat / Mock / Career Journey 等)。"
href="/admin/events"
badge="Admin+"
/>
{isSuperadmin && (
<AdminCard
title="用户管理"
description="给其他登录用户授予 / 撤销 admin 角色。"
href="/admin/users"
badge="Superadmin only"
accent
/>
)}
</div>
</div>
</main>
);
}

function AdminCard({
title,
description,
href,
badge,
accent,
}: {
title: string;
description: string;
href: string;
badge: string;
accent?: boolean;
}) {
return (
<Link
href={href}
className={`block border p-6 transition-colors group ${
accent
? "border-[#CC0000] hover:bg-[#CC0000] hover:text-white"
: "border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)]"
}`}
>
<div
className={`font-mono text-[10px] uppercase tracking-[0.3em] mb-2 ${
accent ? "text-[#CC0000] group-hover:text-white" : "text-neutral-500 group-hover:text-[var(--background)]"
}`}
>
{badge}
</div>
<h2 className="font-serif text-xl font-black uppercase tracking-tight mb-2">
{title}
</h2>
<p className="text-sm leading-relaxed opacity-80">{description}</p>
</Link>
);
}
63 changes: 63 additions & 0 deletions app/admin/users/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

/**
* Admin Users API client(client-only,需要 satoken header)。
*/

export interface AdminUserView {
id: number;
username: string;
displayName: string | null;
email: string | null;
avatarUrl: string | null;
githubId: number | null;
enabled: boolean;
roles: string[];
}

interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}

function token(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("satoken");
}

async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const t = token();
const res = await fetch(url, {
...init,
headers: {
"content-type": "application/json",
accept: "application/json",
...(t ? { satoken: t } : {}),
...(init.headers ?? {}),
},
});
const json = (await res.json()) as ApiResponse<T>;
if (!res.ok || !json.success) {
throw new Error(json.message ?? `请求失败 ${res.status}`);
}
if (json.data === undefined) {
throw new Error("后端返回 success 但没有 data");
}
return json.data;
}

export function listAdminUsers(q?: string): Promise<AdminUserView[]> {
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
return request<AdminUserView[]>(`/api/admin/users${qs}`);
}

export function setUserAdminRole(
userId: number,
admin: boolean,
): Promise<AdminUserView> {
return request<AdminUserView>(`/api/admin/users/${userId}/admin`, {
method: "PUT",
body: JSON.stringify({ admin }),
});
}
Loading
Loading