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

@@ -1,12 +1,9 @@
/**
* Apollo Clientv2.1 M8 验收点
*
* 所有 portal-shell 查询走 apollo-routerGraphQL 联邦入口):
* portal-shell → apollo-router :3000/graphql → 各子图iam/core-edu/content/msg/data-ana/ai/config-service
* Apollo Clientv3.0 P0-3ARCHITECTURE.md §3.4 V3-A2 / §5.1 / §5.2
*
* 双端使用:
* - 服务端RSCcreateApolloClient() 每次请求新建实例ssrMode=true
* - 客户端getApolloClient() 单例,复用 InMemoryCache
* - 客户端HttpLink 指向同域 `/api/graphql`JWT 由 httpOnly cookie 经代理注入
* - 服务端RSC直连 `APOLLO_ROUTER_URL`(内网,由 middleware 注入身份头)
*
* APQAutomatic Persisted Queriesv2.1 M3 安全加固):
* - 生产环境前端只发 query hashsha256不发明文 query
@@ -14,17 +11,29 @@
* - 防止攻击者通过 DevTools 构造任意查询探测 schema
* - 开发模式可设 NEXT_PUBLIC_APOLLO_APQ=false 关闭 APQ 便于调试
*
* 关联portal-shell spec §4.1 APQ、§5.5 RSC 预取、§5.6 统一 Hook、M8 验收标准
* 安全V3-A2
* - 客户端不再注入 Authorization 头token 全程不出 httpOnly cookie
* - 客户端不再读 localStorage.edu_token方案已废止
* - 客户端 credentials:"include" 让 cookie 流向同域 /api/graphql
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§5.2
*/
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
const APOLLO_ROUTER_URL =
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
process.env.APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
/**
* 服务端 RSC 直连 apollo-router URL仅服务端可用浏览器走同域代理
* 不挂 NEXT_PUBLIC_ 前缀 → 不打包进客户端 bundle。
*/
const SERVER_APOLLO_ROUTER_URL =
process.env.APOLLO_ROUTER_URL || "http://localhost:3000/graphql";
/**
* 客户端同域代理路径V3-A2浏览器只发同域请求
* 由 /api/graphql Route Handler 取 httpOnly cookie 中的 JWT 并转发。
*/
const CLIENT_PROXY_URL = "/api/graphql";
// 开发模式可关闭 APQ 便于调试NEXT_PUBLIC_APOLLO_APQ=false
// 生产环境默认启用(未设置或设置为 true 均启用)
@@ -33,42 +42,39 @@ const APQ_ENABLED = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false";
/**
* 创建 Apollo Client 实例。
*
* @param getAuthToken 可选,返回 JWT 用于注入 Authorization 头(客户端从 cookie/localStorage 读取)
* @param options 可选:
* - serverSide: true 表示服务端 RSC 模式(直连 routerfalse/省略表示客户端(走 /api/graphql
*
* 客户端不再接受 getAuthToken 参数JWT 已迁至 httpOnly cookie
* JS 永远拿不到 tokenARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2
*/
export function createApolloClient(
getAuthToken?: () => string | null,
options: { serverSide?: boolean } = {},
): ApolloClient<unknown> {
const isServer = options.serverSide ?? typeof window === "undefined";
const httpLink = new HttpLink({
uri: APOLLO_ROUTER_URL,
credentials: "include",
uri: isServer ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL,
// 客户端同域请求cookie 自动随行;服务端:直连 router 不需要 cookie
credentials: isServer ? "omit" : "include",
});
const authLink = setContext((_, { headers }) => {
const token = getAuthToken?.() ?? null;
return {
headers: {
...headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
};
});
// Link 链顺序authLink → pqLink → httpLink
// - authLink 注入 Authorization 头
// Link 链顺序pqLink → httpLink
// - pqLink 将 query 替换为 hash启用时
// - httpLink 发送请求
// - httpLink 发送请求(含 cookie
// 客户端不再需要 authLinktoken 注入由 /api/graphql 代理负责)
const link = APQ_ENABLED
? from([authLink, createPersistedQueryLink({ sha256 }), httpLink])
: from([authLink, httpLink]);
? from([createPersistedQueryLink({ sha256 }), httpLink])
: from([httpLink]);
return new ApolloClient({
link,
cache: new InMemoryCache(),
ssrMode: typeof window === "undefined",
ssrMode: isServer,
defaultOptions: {
query: {
errorPolicy: "all",
fetchPolicy: typeof window === "undefined" ? "no-cache" : "cache-first",
fetchPolicy: isServer ? "no-cache" : "cache-first",
},
watchQuery: {
errorPolicy: "all",
@@ -81,15 +87,15 @@ let clientSingleton: ApolloClient<unknown> | null = null;
/**
* 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。
*
* 注意:不再接受 getAuthToken 参数V3-A2 移除 localStorage 方案)。
*/
export function getApolloClient(
getAuthToken?: () => string | null,
): ApolloClient<unknown> {
export function getApolloClient(): ApolloClient<unknown> {
if (typeof window === "undefined") {
return createApolloClient(getAuthToken);
return createApolloClient({ serverSide: true });
}
if (!clientSingleton) {
clientSingleton = createApolloClient(getAuthToken);
clientSingleton = createApolloClient({ serverSide: false });
}
return clientSingleton;
}

View File

@@ -18,7 +18,14 @@
*/
import { gql } from "@apollo/client";
import { createApolloClient } from "./apollo-client";
import type { PluginConfigResponse, Role } from "./types";
import type {
LayoutTemplateInfo,
PluginConfigResponse,
PluginPlacement,
PluginRegistryItem,
Role,
SlotConfig,
} from "./types";
/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */
export const GET_PLUGIN_CONFIG = gql`
@@ -124,9 +131,11 @@ export async function fetchPluginConfig(
}
}
// 3. 最终兜底:默认配置
console.warn(`[portal-shell] fetchPluginConfig returning empty default`);
return getDefaultConfig();
// 3. 最终兜底:内置默认配置按角色静态定义P0-4
console.warn(
`[portal-shell] fetchPluginConfig falling back to built-in default config (role=${role})`,
);
return getDefaultConfig(role);
}
/**
@@ -188,25 +197,196 @@ async function fetchPluginConfigDirect(
/**
* 默认 classic 布局配置Router 未就绪 / 查询失败时降级)。
* 保证 Shell 始终可渲染,不因下游不可用而白屏。
*
* P0-4ARCHITECTURE.md §10按角色静态定义内置默认仪表盘插件集
* 保证 config-service 不可用时仪表盘仍有内容fail-safe 而非空壳)。
*
* 角色映射依据各 widget 的 manifest `requiredRoles` 字段src/widgets 下各目录):
* - topbar 4 件全角色notification-bell/global-search/locale-switcher/user-menu
* - sidebarteacher → class-selector + term-switcher + quick-actions
* student → term-switcher + quick-actions
* parent → child-selector + term-switcher + quick-actions
* admin → 无 sidebar 上下文
* - mainuniversal 7 件按角色过滤 + 角色专属 widget
*
* @param role 用户角色;未提供时按 teacher 兜底(与现有 ShellPage 默认行为一致)
*/
export function getDefaultConfig(): PluginConfigResponse {
export function getDefaultConfig(role?: Role): PluginConfigResponse {
const effectiveRole: Role = role ?? "teacher";
const layout: LayoutTemplateInfo = {
layoutId: "classic",
displayName: "经典三栏",
description: "TopBar + SideNav + Main",
availableSlots: ["top", "side", "main"],
layoutSchemaJson: JSON.stringify({
grid: { rows: 1, cols: 1, areas: [["main"]] },
}),
};
const slots: SlotConfig[] = [
{ slotName: "top", navItems: [] },
{ slotName: "side", navItems: [] },
{ slotName: "main", navItems: [] },
];
// ── top slot全角色共享通知铃 / 全局搜索 / 语言切换 / 用户菜单) ──
const topPlugins: PluginPlacement[] = [
placement("notification-bell", "top", 0, { colSpan: 1, rowSpan: 1 }),
placement("global-search", "top", 1, { colSpan: 1, rowSpan: 1 }),
placement("locale-switcher", "top", 2, { colSpan: 1, rowSpan: 1 }),
placement("user-menu", "top", 3, { colSpan: 1, rowSpan: 1 }),
];
// ── side slot按角色裁剪 ──
const sidePlugins: PluginPlacement[] = SIDE_DEFAULTS[effectiveRole].map(
(id, idx) => placement(id, "side", idx, { colSpan: 1, rowSpan: 1 }),
);
// ── main slotuniversal 按角色 + 角色专属) ──
const mainPlugins: PluginPlacement[] = MAIN_DEFAULTS[effectiveRole].map(
(id, idx) => placement(id, "main", idx, { colSpan: 2, rowSpan: 1 }),
);
// ── registry按角色聚合所有可见插件的元信息 ──
const registry: PluginRegistryItem[] = [
...topPlugins,
...sidePlugins,
...mainPlugins,
].map((p) => registryItem(p.pluginId, effectiveRole));
return {
activeLayout: {
layoutId: "classic",
displayName: "经典三栏",
description: "TopBar + SideNav + Main",
availableSlots: ["top", "side", "main"],
layoutSchemaJson: JSON.stringify({
grid: { rows: 1, cols: 1, areas: [["main"]] },
}),
},
slots: [
{ slotName: "top", navItems: [] },
{ slotName: "side", navItems: [] },
{ slotName: "main", navItems: [] },
],
plugins: [],
registry: [],
activeLayout: layout,
slots,
plugins: [...topPlugins, ...sidePlugins, ...mainPlugins],
registry,
};
}
/** side slot 角色默认(按渲染顺序) */
const SIDE_DEFAULTS: Record<Role, string[]> = {
teacher: ["class-selector", "term-switcher", "quick-actions"],
student: ["term-switcher", "quick-actions"],
parent: ["child-selector", "term-switcher", "quick-actions"],
admin: [],
};
/** main slot 角色默认universal + 角色专属,按渲染顺序) */
const MAIN_DEFAULTS: Record<Role, string[]> = {
teacher: [
"schedule-widget",
"grades-widget",
"homework-widget",
"exams-widget",
"attendance-widget",
"announcements-widget",
"notifications-widget",
"lesson-plan-editor",
"question-bank",
"textbook-manager",
"scheduling-rules",
],
student: [
"schedule-widget",
"grades-widget",
"homework-widget",
"exams-widget",
"attendance-widget",
"announcements-widget",
"notifications-widget",
"error-book",
"learning-path",
"elective-selector",
"ai-tutor",
],
parent: [
"schedule-widget",
"grades-widget",
"homework-widget",
"exams-widget",
"attendance-widget",
"announcements-widget",
"notifications-widget",
"child-overview",
"leave-approval",
],
admin: [
"announcements-widget",
"notifications-widget",
"user-management",
"rbac-manager",
"plugin-manager",
"school-settings",
"audit-logs",
"invitation-codes",
],
};
/** 简易 PluginPlacement 构造器 */
function placement(
pluginId: string,
slot: string,
sortOrder: number,
size: { colSpan: number; rowSpan: number },
): PluginPlacement {
return {
pluginId,
slot,
sortOrder,
sizeJson: JSON.stringify(size),
propsJson: "{}",
isVisible: true,
};
}
/** 简易 PluginRegistryItem 构造器(基于内置 manifest 元数据) */
function registryItem(pluginId: string, role: Role): PluginRegistryItem {
// category 由 pluginId 前缀目录决定,与 src/widgets/<category>/ 对齐
const category = inferCategory(pluginId);
return {
pluginId,
category,
version: "0.1.0",
displayName: pluginId,
description: `Built-in ${category} plugin (default config fallback)`,
requiredRoles: [role],
isBuiltin: true,
isActive: true,
};
}
/** 由 pluginId 推断 category与目录结构 src/widgets/<category>/ 对齐) */
function inferCategory(pluginId: string): string {
if (pluginId.endsWith("-widget")) return "universal";
if (
pluginId === "notification-bell" ||
pluginId === "global-search" ||
pluginId === "locale-switcher" ||
pluginId === "user-menu"
)
return "topbar";
if (
pluginId === "class-selector" ||
pluginId === "child-selector" ||
pluginId === "term-switcher" ||
pluginId === "quick-actions"
)
return "sidebar";
if (
pluginId === "lesson-plan-editor" ||
pluginId === "question-bank" ||
pluginId === "textbook-manager" ||
pluginId === "scheduling-rules"
)
return "teacher";
if (
pluginId === "error-book" ||
pluginId === "learning-path" ||
pluginId === "elective-selector" ||
pluginId === "ai-tutor"
)
return "student";
if (pluginId === "child-overview" || pluginId === "leave-approval")
return "parent";
return "admin";
}