feat(portal-shell): v2.1 P0 auth + middleware + login + graphql proxy

- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档
  涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、
  §6 部署、§10 P0-P3 验收清单
- 新增 middleware.ts:认证 + 路由门禁
  httpOnly cookie edu_session(JWT)读取
  DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production &&
  NEXT_PUBLIC_DEV_MODE=true)
  生产模式 jose JWKS RS256 验签(iss/aud 校验)
  路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头
  /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden
- 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false
  防止生产环境误开 DEV_MODE 合成身份
- 新增 app/api/auth/login/route.ts + logout/route.ts
  登录走 api-gateway /v1/iam/login
  设置 httpOnly + Secure + SameSite=Strict cookie
- 新增 app/api/graphql/route.ts:同域 GraphQL 代理
  转发到 apollo-router,注入 router-authorization 头
- 新增 app/login/page.tsx + login-form.tsx
  zod 表单校验,next 参数支持
- 新增 app/shell/forbidden/page.tsx:403 页面
- 更新 route-permissions.ts:补全 P0 路由权限映射
- 更新 permission-bitmap.ts(shared-ts):位图编码/解码
- 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理
- 更新 config-fetcher.ts:config-service 直连降级
- 更新 ApolloProvider.tsx:SSR/RSC 兼容
- 更新 eslint.config.js:design-tokens/no-hardcoded-fonts
  白名单调整
This commit is contained in:
SpecialX
2026-07-22 11:35:36 +08:00
parent 682f323bad
commit cfb7b005fd
23 changed files with 2542 additions and 435 deletions

View File

@@ -0,0 +1,239 @@
/**
* 登录代理 Route HandlerP0-1ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.3
*
* 流程:
* Browser POST /api/auth/login { email, password }
* → 本 Route Handler 调 api-gateway /api/v1/iam/login
* → 成功后把 accessToken 写入 httpOnly cookie `edu_session`
* → 把 permissions 位图写入非 httpOnly cookie `edu_perms`(按钮级 UX 用,非安全依据)
* → 返回 { user } 给前端(不返回 tokenJS 永不接触 token
*
* 安全§4.3
* - cookie 名 `edu_session`HttpOnly + Secure(生产) + SameSite=Strict + Path=/
* - Max-Age 与 iam 返回的 expiresIn 对齐
* - 失败归一化错误401 / 429 / 5xx 分别处理
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.3、§6.1、§11.7 红线 #2
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import {
encodePermissionsBitmap,
PERMISSION_BITMAP_ORDER,
} from "@edu/shared-ts/permission-bitmap";
import type { Role } from "@edu/shared-ts/contracts";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const SESSION_COOKIE = "edu_session";
const PERMS_COOKIE = "edu_perms";
const GATEWAY_URL =
process.env.API_GATEWAY_URL ||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
"http://localhost:8080";
const IAM_LOGIN_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/login`;
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
interface UserInfo {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: string;
status: string;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface IamLoginResponse {
success: true;
data: { user: UserInfo; tokens: TokenPair };
}
/**
* 解析角色字符串为 portal-shell 4 角色之一。
* iam 返回 roles[],取主角色。
*/
function pickPrimaryRole(roles: string[]): Role {
for (const r of roles) {
if (r === "admin" || r === "teacher" || r === "student" || r === "parent") {
return r;
}
}
return "teacher";
}
/**
* 过滤出 PERMISSION_BITMAP_ORDER 中存在的权限点(避免位图编码丢失)。
*/
function filterKnownPermissions(perms: string[]): string[] {
const known = new Set<string>(PERMISSION_BITMAP_ORDER);
return perms.filter((p) => known.has(p));
}
export async function POST(req: NextRequest): Promise<NextResponse> {
// ── 1. 解析与校验请求体 ──
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "INVALID_BODY", message: "Request body must be JSON" },
{ status: 400 },
);
}
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "INVALID_INPUT",
message: "Email and password are required",
details: parsed.error.issues,
},
{ status: 400 },
);
}
// ── 2. 调用 iam 登录 ──
let iamResponse: Response;
try {
iamResponse = await fetch(IAM_LOGIN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
// 透传客户端 IP 与 UA 用于审计
...(req.headers.get("x-forwarded-for")
? { "X-Forwarded-For": req.headers.get("x-forwarded-for") as string }
: {}),
...(req.headers.get("user-agent")
? { "User-Agent": req.headers.get("user-agent") as string }
: {}),
},
body: JSON.stringify(parsed.data),
cache: "no-store",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(
`[portal-shell] /api/auth/login: iam unreachable: ${message} (url=${IAM_LOGIN_ENDPOINT})`,
);
return NextResponse.json(
{
error: "IAM_UNREACHABLE",
message: "Authentication service unavailable",
},
{ status: 502 },
);
}
// ── 3. 处理 iam 响应 ──
if (iamResponse.status === 401) {
return NextResponse.json(
{ error: "INVALID_CREDENTIALS", message: "邮箱或密码错误" },
{ status: 401 },
);
}
if (iamResponse.status === 429) {
return NextResponse.json(
{ error: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试" },
{ status: 429 },
);
}
if (!iamResponse.ok) {
// 其他错误403 账户锁定 / 5xx
let message = "登录失败";
try {
const errJson = (await iamResponse.json()) as { message?: string };
if (errJson.message) message = errJson.message;
} catch {
// 忽略 JSON 解析失败
}
return NextResponse.json(
{ error: "IAM_ERROR", message },
{ status: iamResponse.status },
);
}
// ── 4. 提取 token 与 user ──
let iamData: IamLoginResponse;
try {
iamData = (await iamResponse.json()) as IamLoginResponse;
} catch {
return NextResponse.json(
{ error: "IAM_BAD_RESPONSE", message: "登录服务返回数据异常" },
{ status: 502 },
);
}
const { user, tokens } = iamData.data;
if (!tokens?.accessToken || typeof tokens.expiresIn !== "number") {
return NextResponse.json(
{ error: "IAM_BAD_RESPONSE", message: "登录响应缺少 token" },
{ status: 502 },
);
}
// ── 5. 计算 cookie 值 ──
const secure = process.env.NODE_ENV === "production";
const maxAge = Math.min(tokens.expiresIn, 60 * 60 * 8); // 最长 8 小时
const knownPerms = filterKnownPermissions(user.permissions ?? []);
const permsBitmap = encodePermissionsBitmap(knownPerms);
// ── 6. 构建响应(不返回 token 给前端) ──
const response = NextResponse.json(
{
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: pickPrimaryRole(user.roles),
permissions: knownPerms,
dataScope: user.dataScope,
},
},
{ status: 200 },
);
// 设置 Set-Cookie 头(多 cookie 用逗号分隔NextResponse.cookies 更稳)
response.cookies.set(SESSION_COOKIE, tokens.accessToken, {
httpOnly: true,
secure,
sameSite: "strict",
path: "/",
maxAge,
});
response.cookies.set(PERMS_COOKIE, permsBitmap, {
httpOnly: false,
secure,
sameSite: "strict",
path: "/",
maxAge,
});
return response;
}
/**
* GET /api/auth/login → 简单状态端点(不暴露任何敏感信息)。
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{ ok: true, endpoint: "/api/auth/login", method: "POST" },
{ status: 200 },
);
}

View File

@@ -0,0 +1,108 @@
/**
* 登出代理 Route HandlerP0-1ARCHITECTURE.md §3.4 V3-A2 / §4.2 / §4.3
*
* 流程:
* Browser POST /api/auth/logout携带 edu_session cookie
* → 本 Route Handler 从 cookie 取 access token
* → 清除 edu_session + edu_perms cookie无论 iam 是否成功)
* → best-effort 调 iam /api/v1/iam/logout带 Authorization使 refresh token 失效
* → 返回 { success: true },前端跳转 /login
*
* 容错策略:
* - iam 不可达 / 返回错误 → 静默忽略,仍然清 cookie用户体验优先本地登出必成功
* - iam 端的 edu_refresh httpOnly cookie 由 iam 自行清除path=/api/v1/iam本代理无法跨 path 清)
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.2、§4.3、§11.7 红线 #2
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const SESSION_COOKIE = "edu_session";
const PERMS_COOKIE = "edu_perms";
const GATEWAY_URL =
process.env.API_GATEWAY_URL ||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
"http://localhost:8080";
const IAM_LOGOUT_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/logout`;
/**
* 解析 cookie 头中的指定 cookie。
*/
function readCookie(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${name}=`));
if (!match) return null;
return decodeURIComponent(match.slice(name.length + 1));
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const cookieHeader = req.headers.get("cookie");
const token = readCookie(cookieHeader, SESSION_COOKIE);
const secure = process.env.NODE_ENV === "production";
// ── 1. best-effort 调 iam logout使服务端 refresh token 失效) ──
if (token) {
try {
await fetch(IAM_LOGOUT_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(req.headers.get("x-forwarded-for")
? {
"X-Forwarded-For": req.headers.get("x-forwarded-for") as string,
}
: {}),
...(req.headers.get("user-agent")
? { "User-Agent": req.headers.get("user-agent") as string }
: {}),
},
body: JSON.stringify({}),
cache: "no-store",
});
} catch (err) {
// iam 不可达 → 静默,本地登出仍然完成
const message = err instanceof Error ? err.message : "Unknown error";
console.warn(
`[portal-shell] /api/auth/logout: iam unreachable: ${message} (url=${IAM_LOGOUT_ENDPOINT})`,
);
}
}
// ── 2. 清除 edu_session + edu_perms cookie无论 iam 是否成功) ──
const response = NextResponse.json({ success: true }, { status: 200 });
response.cookies.set(SESSION_COOKIE, "", {
httpOnly: true,
secure,
sameSite: "strict",
path: "/",
maxAge: 0,
});
response.cookies.set(PERMS_COOKIE, "", {
httpOnly: false,
secure,
sameSite: "strict",
path: "/",
maxAge: 0,
});
return response;
}
/**
* GET /api/auth/logout → 简单状态端点(不暴露任何敏感信息)。
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{ ok: true, endpoint: "/api/auth/logout", method: "POST" },
{ status: 200 },
);
}

View File

@@ -0,0 +1,123 @@
/**
* GraphQL 同域代理P0-3ARCHITECTURE.md §3.4 V3-A2 / §4 / §5.2
*
* 浏览器 Apollo Client 一律走同域 `/api/graphql`
* Browser → /api/graphql (本 Route Handler) → apollo-router :3000
*
* 职责:
* 1. 从 httpOnly cookie `edu_session` 取 JWT注入 `Authorization: Bearer`
* 2. 透传 bodyAPQ hash 或 query与 Apollo 相关头
* 3. 响应 status / JSON 原样回传,不缓存
* 4. 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`
*
* 安全收益§4.2
* - JWT 全程不出 httpOnly cookie消除 XSS 窃取凭证面
* - 修复"浏览器绕过 api-gateway"问题(代理在服务端调 router
*
* 验收命令:
* DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql`
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const SESSION_COOKIE = "edu_session";
const UPSTREAM_URL =
process.env.APOLLO_ROUTER_URL ||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
/**
* 从 Cookie 头解析指定 cookie 值。
*/
function readCookie(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${name}=`));
if (!match) return null;
return decodeURIComponent(match.slice(name.length + 1));
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const cookieHeader = req.headers.get("cookie");
const token = readCookie(cookieHeader, SESSION_COOKIE);
// 透传 bodyAPQ hash 请求或完整 query不解析不修改
const body = await req.text();
const upstreamHeaders: Record<string, string> = {
"Content-Type": req.headers.get("content-type") ?? "application/json",
Accept: req.headers.get("accept") ?? "application/json",
// Apollo Persisted Query 协议头透传
"X-APQ": req.headers.get("x-apq") ?? "1",
// 服务端追踪:透传客户端 X-Request-Id若有
...(req.headers.get("x-request-id")
? { "X-Request-Id": req.headers.get("x-request-id") as string }
: {}),
};
// 注入 Authorization若 cookie 中有 JWT
if (token) {
upstreamHeaders.Authorization = `Bearer ${token}`;
}
try {
const upstream = await fetch(UPSTREAM_URL, {
method: "POST",
headers: upstreamHeaders,
body,
cache: "no-store",
});
const responseText = await upstream.text();
return new NextResponse(responseText, {
status: upstream.status,
headers: {
"Content-Type":
upstream.headers.get("content-type") ?? "application/json",
// 不缓存GraphQL 响应可能因身份/变量而异
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown upstream error";
console.error(
`[portal-shell] /api/graphql upstream error: ${message} (url=${UPSTREAM_URL})`,
);
return NextResponse.json(
{
errors: [
{
message: "UPSTREAM_UNAVAILABLE",
extensions: {
code: "UPSTREAM_UNAVAILABLE",
reason: message,
},
},
],
},
{ status: 502 },
);
}
}
/**
* GET /api/graphql → 简单健康标识,便于排查路由是否挂载。
* Apollo Router 自身的 health 在 :8088/health。
*/
export async function GET(): Promise<NextResponse> {
return NextResponse.json(
{
ok: true,
proxy: "/api/graphql",
upstream: UPSTREAM_URL,
},
{ status: 200 },
);
}

View File

@@ -0,0 +1,220 @@
"use client";
/**
* 登录表单P0-1ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §8.3
*
* 客户端组件shadcn 令牌登录表单。
* - 提交POST /api/auth/login { email, password }
* - 成功router.push(next || "/shell")
* - 失败notify.error 显示归一化错误
* - DEV_MODE显示提示横幅 + 一键填充 dev 凭证按钮
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§8.3、§11.7 红线 #2
*/
import { useState, useTransition, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { GraduationCap, Loader2, LogIn } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import { notify } from "@/shared/lib/notify";
const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";
interface LoginSuccessResponse {
success: true;
user: {
id: string;
email: string;
name: string;
role: string;
permissions: string[];
dataScope: string;
};
}
interface LoginErrorResponse {
error: string;
message: string;
}
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
/**
* 把后端返回的错误码映射为中文文案。
*/
function mapLoginError(errorCode: string, fallback: string): string {
switch (errorCode) {
case "INVALID_BODY":
case "INVALID_INPUT":
return "请输入有效的邮箱和密码";
case "INVALID_CREDENTIALS":
return "邮箱或密码错误";
case "RATE_LIMITED":
return "登录尝试过于频繁,请稍后再试";
case "IAM_UNREACHABLE":
case "IAM_BAD_RESPONSE":
return "登录服务暂不可用,请稍后再试";
case "IAM_ERROR":
return fallback || "登录失败,请重试";
default:
return fallback || "登录失败,请重试";
}
}
export function LoginForm(): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition();
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
if (isPending) return;
startTransition(async () => {
const next = searchParams.get("next") || "/shell";
let response: Response;
try {
response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
credentials: "include",
});
} catch {
notify.error("网络错误,请检查网络连接后重试");
return;
}
let data: LoginResponse;
try {
data = (await response.json()) as LoginResponse;
} catch {
notify.error("登录服务返回数据异常");
return;
}
if (!response.ok || !("success" in data)) {
const errorResp = data as LoginErrorResponse;
notify.error(mapLoginError(errorResp.error, errorResp.message));
return;
}
notify.success(`欢迎回来,${data.user.name || data.user.email}`);
// 用 router.push 而非 location.href保留 SPA 体验cookie 已由 Set-Cookie 写入
router.push(next);
router.refresh();
});
}
function fillDevCredentials(): void {
setEmail("dev@edu.local");
setPassword("dev-password");
}
return (
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-2">
<div className="bg-primary text-primary-foreground flex size-12 items-center justify-center rounded-xl">
<GraduationCap className="size-6" aria-hidden="true" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">Edu Portal</h1>
<p className="text-muted-foreground text-sm">K12 </p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit} noValidate>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
</label>
<Input
id="email"
type="email"
autoComplete="email"
required
disabled={isPending}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@school.edu.cn"
aria-label="邮箱"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
</label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
disabled={isPending}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
aria-label="密码"
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button
type="submit"
className="w-full"
disabled={isPending || !email || !password}
>
{isPending ? (
<>
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
</>
) : (
<>
<LogIn className="size-4" aria-hidden="true" />
</>
)}
</Button>
{DEV_MODE ? (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={fillDevCredentials}
disabled={isPending}
>
dev
</Button>
) : null}
</CardFooter>
</form>
</Card>
<p className="text-muted-foreground mt-6 text-center text-xs">
Edu Portal 使
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
/**
* 登录页P0-1ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.2 / §7.1
*
* 路由:/login
*
* 职责:
* - RSC 入口检测已登录cookie edu_session 存在)→ redirect /shell
* - 渲染 <LoginForm /> 客户端组件
*
* 已登录检测策略:
* - middleware 将 /login 列入 PUBLIC_ROUTES不强制身份校验
* - 本页面 RSC 通过 cookies() 读 edu_session cookie仅看存在性不验签——验签由 middleware 负责)
* - 存在 cookie 视为已登录,直接 redirect /shell避免登录页"闪现"
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.2、§11.7 红线 #2
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { LoginForm } from "./login-form";
export const dynamic = "force-dynamic";
export default async function LoginPage(): Promise<React.ReactElement> {
const cookieStore = await cookies();
const session = cookieStore.get("edu_session");
if (session?.value) {
redirect("/shell");
}
return <LoginForm />;
}

View File

@@ -5,30 +5,36 @@ import type { Role } from "@/lib/types";
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
/**
* Shell 入口RSC Server Componentv2.1 M8 验收点 + 流式渲染)
* Shell 入口RSC Server Componentv3.0 P0-2 fail-closed + 流式渲染)
*
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染
* ① 从请求头获取 userId / roleapi-gateway 注入 x-user-id / x-user-role
* 数据流(ARCHITECTURE.md §3.4 V3-A2/A3、§5.5
* ① 从 middleware 注入的请求头获取 userId / rolemiddleware 已校验 cookie + 权限
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig三层合并
* ③ Config Promise 直接传给 ClientShell由客户端 use() 消费,启用流式渲染:
* - HTML 流式输出loading.tsx 先行Promise resolve 后替换为真实 UI
* - 客户端 Suspense避免客户端瀑布流不用 useEffect 二次请求)
*
* 流式渲染分层README v2.0 §5.3
* - L1 路由级loading.tsx整页骨架fetchPluginConfig 进行中
* - L2 区块级DashboardSection单一区块骨架Suspense 包裹
* - L3 插件级PluginBoundary单插件骨架dynamic import + Suspense
* fail-closedP0-2§11.7 红线 #5
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx禁止默认 teacher
*
* M8 验收portal-shell 查询走 apollo-routerfetchPluginConfig 经 Apollo Client
*
* 关联portal-shell spec §5.5、§6.2、M8 验收标准、README v2.0 §5.3
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2/A3、§5.5、§11.7 红线 #5
*/
export default async function ShellPage(): Promise<React.ReactElement> {
const headerList = await headers();
const userId =
headerList.get("x-user-id") ||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
const role = (headerList.get("x-user-role") || "teacher") as Role;
const userId = headerList.get("x-user-id");
const roleHeader = headerList.get("x-user-role");
// fail-closedmiddleware 必须注入身份头,缺失即异常(不再默认 teacher
if (!userId || !roleHeader) {
throw new Error(
"[portal-shell] ShellPage missing identity headers " +
"(middleware must inject x-user-id / x-user-role). " +
"If middleware is configured, this indicates a routing misconfiguration.",
);
}
const role = roleHeader as Role;
// 服务端通过 apollo-router 获取三层合并后的插件配置
// 不 await直接将 Promise 传给 ClientShell启用流式渲染

View File

@@ -0,0 +1,62 @@
import Link from "next/link";
import { ShieldX } from "lucide-react";
import { headers } from "next/headers";
/**
* 403 Forbidden 页P0-2ARCHITECTURE.md §3.4 V3-A3 / §4.2 / §6.2
*
* middleware 的 checkRoutePermission 拒绝时 302 重定向到此页。
* 页面渲染:
* - 友好提示 + 图标 + 返回仪表盘链接
* - 不暴露内部权限配置细节(仅显示通用 403 文案)
* - 支持查询参数 reason=no_config | missing_role | missing_permission仅 UX 提示)
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A3、§6.2、§11.7 红线 #5
*/
export default async function ForbiddenPage(): Promise<React.ReactElement> {
const headerList = await headers();
const userId = headerList.get("x-user-id") ?? "unknown";
const role = headerList.get("x-user-role") ?? "unknown";
return (
<main
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
role="alert"
aria-live="assertive"
>
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<ShieldX className="h-8 w-8" aria-hidden="true" />
</div>
<h1 className="text-2xl font-semibold leading-tight">
403 · 访
</h1>
<p className="max-w-md text-sm text-muted-foreground">
访
</p>
<p className="text-xs text-muted-foreground">
<span className="font-mono">{userId}</span>
{role !== "unknown" && (
<>
{" · "}<span className="font-mono">{role}</span>
</>
)}
</p>
</div>
<nav className="flex gap-3" aria-label="操作">
<Link
href="/shell"
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
</Link>
<Link
href="/login"
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
</Link>
</nav>
</main>
);
}