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:
@@ -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-4(ARCHITECTURE.md §10):按角色静态定义内置默认仪表盘插件集,
|
||||
* 保证 config-service 不可用时仪表盘仍有内容(fail-safe 而非空壳)。
|
||||
*
|
||||
* 角色映射依据各 widget 的 manifest `requiredRoles` 字段(src/widgets 下各目录):
|
||||
* - topbar 4 件:全角色(notification-bell/global-search/locale-switcher/user-menu)
|
||||
* - sidebar:teacher → class-selector + term-switcher + quick-actions;
|
||||
* student → term-switcher + quick-actions;
|
||||
* parent → child-selector + term-switcher + quick-actions;
|
||||
* admin → 无 sidebar 上下文
|
||||
* - main:universal 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 slot(universal 按角色 + 角色专属) ──
|
||||
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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user