From eaef213d221406cdf19188369a6a8f45cec3fb2f Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 22 Mar 2026 19:52:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20R27-1=20=E6=94=B6=E5=8F=A3=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=85=A5=E5=8F=A3=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/auth.ts | 8 +- ...19-06-31-r27-wecom-entry-and-ux-polish.csv | 8 + server/src/routes/wecomAuth.js | 104 ++++++-- server/tests/unit/routes/wecomAuth.test.js | 243 ++++++++++++++++++ 4 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv create mode 100644 server/tests/unit/routes/wecomAuth.test.js diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 93036816..9e20b644 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -17,15 +17,19 @@ export async function changePassword(payload: { oldPassword: string; newPassword }); } - export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string }; export type WecomEntryConfig = { enabled: boolean; + entryEnabled: boolean; + oauthEnabled: boolean; corpId: string; agentId: string; - oauthEnabled: boolean; loginUrl: string | null; + bindEnabled: boolean; + defaultReturnTo: "/portal"; + status: "ready" | "disabled" | "misconfigured"; + statusReasons: string[]; }; export async function fetchSsoProviders() { diff --git a/issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv b/issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv new file mode 100644 index 00000000..b33de724 --- /dev/null +++ b/issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv @@ -0,0 +1,8 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +R27-0,执行合同建档与范围冻结,为企业微信正式接入与首批交互优化建立执行合同并冻结本轮范围,Issue CSV 校验通过 且范围锁定为企微入口 登录绑定 Portal 导航与测试文档,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,manual,DONE,DONE,DONE,issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,[],none +R27-1,企业微信接入配置与入口链路收口,扩展 wecom config 返回状态与原因 将 WECOM_ENTRY_ENABLED 纳入正式入口判断并保持现有 login bind callback 主链路不变,GET /api/auth/wecom/config 返回 decision complete 状态结构 且未绑定回退与白名单 returnTo 逻辑零回退,cd server && npm test,manual,DONE,DONE,DONE,server/src/routes/wecomAuth.js|client/src/api/auth.ts|server/tests/unit/routes/wecomAuth.test.js,"[""R27-0""]",不新增新路由 仅扩展 config 返回 +R27-2,登录页与个人绑定体验优化,重构 LoginPage 与 ProfilePage 让企微入口 绑定状态 回退提示更产品化,普通浏览器与企微场景 CTA 分层明确 wecom query 提示可读 个人设置页可清晰展示接入与绑定状态,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/LoginPage.tsx|client/src/pages/ProfilePage.tsx|client/src/api/auth.ts,"[""R27-1""]",不新增页面 继续复用现有登录页与个人设置页 +R27-3,Portal 首屏信息架构重排,重排 Portal 首屏卡片与摘要层级 让员工与管理员优先看到今日动作与关键提醒,员工与管理员首屏主卡片顺序符合规划 观测摘要对普通员工弱化 公告降为第二阅读层,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx,"[""R27-1""]",仅做信息架构与文案层级优化 不改后端 portal payload 结构 +R27-4,导航 Topbar 与备用模块弱化,优化 NavLinks Sidebar Topbar 的分区展示与企微入口提示 弱化备用模块存在感,主导航稳定为 9 项 管理入口单独分组 备用模块默认折叠 顶栏在企微 WebView 下显示入口提示,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/components/navigation/NavLinks.tsx|client/src/components/navigation/Sidebar.tsx|client/src/components/navigation/Topbar.tsx,"[""R27-3""]",不改后端导航接口 以前端分组与展示优化为主 +R27-5,自动化测试与文档补强,补齐 wecom 后端单测 登录与 Portal 交互 E2E 并同步管理员与员工文档,新增或扩展测试覆盖 wecom config 登录提示 Portal 顺序与导航弱化 文档写清配置与使用方式,cd server && npm test && cd ../client && npm run test:e2e,manual,DOING,TODO,TODO,server/tests/unit/routes/wecomAuth.test.js|client/tests/e2e/wecom-entry.spec.ts|client/tests/e2e/login-portal-ux.spec.ts|docs/admin-handbook.md|docs/employee-quickstart.md,"[""R27-2"",""R27-3"",""R27-4""]",新增 wecom-entry/login-portal-ux 用例已通过;client 全量 test:e2e 被既有 assets-flow 与 confidential-boundary 失败阻塞 +R27-6,试点验收与上线清单收口,完成本地验证 部署抽检 文档清单同步与 git 提交 形成可进入小范围企微试点的版本,关键单测 类型检查 构建 E2E 通过 且接入清单可供管理员逐项配置实施,cd server && npm test && cd ../client && npm run typecheck && npm run build && npm run test:e2e,manual,DOING,TODO,TODO,deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|issues/2026-03-22_19-06-31-r27-wecom-entry-and-ux-polish.csv,"[""R27-5""]",本地 server 全量单测 250/250 通过;client typecheck/build 通过;全量 E2E 仍受既有失败阻塞 diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index 8419c7e9..d8f34860 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -9,6 +9,8 @@ const { sendData } = require("../utils/reply"); const BIND_COOKIE_NAME = "wecom_bind"; const DATE_WINDOW_MS = 10 * 60 * 1000; +const DEFAULT_RETURN_TO = "/portal"; +const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]); const QUERY_SCHEMA = z.object({ returnTo: z.string().optional(), mode: z.enum(["login", "bind"]).optional(), @@ -58,7 +60,6 @@ function parseAllowedReturnHosts(config, request) { try { hosts.add(new URL(item).host); } catch { - // ignore invalid origin } } for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) { @@ -68,7 +69,6 @@ function parseAllowedReturnHosts(config, request) { try { hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host); } catch { - // ignore } } return hosts; @@ -76,39 +76,102 @@ function parseAllowedReturnHosts(config, request) { function sanitizeReturnTo(rawValue, config, request) { if (typeof rawValue !== "string") { - return "/portal"; + return DEFAULT_RETURN_TO; } const trimmed = rawValue.trim(); if (!trimmed) { - return "/portal"; + return DEFAULT_RETURN_TO; } if (trimmed.startsWith("/")) { - return trimmed.startsWith("//") ? "/portal" : trimmed; + return trimmed.startsWith("//") ? DEFAULT_RETURN_TO : trimmed; } try { const url = new URL(trimmed); const allowedHosts = parseAllowedReturnHosts(config, request); if (!allowedHosts.has(url.host)) { - return "/portal"; + return DEFAULT_RETURN_TO; } - return `${url.pathname || "/portal"}${url.search || ""}${url.hash || ""}`; + return `${url.pathname || DEFAULT_RETURN_TO}${url.search || ""}${url.hash || ""}`; } catch { - return "/portal"; + return DEFAULT_RETURN_TO; } } -function assertWecomEnabled(config) { +function resolveWecomEntryConfig(config) { + const statusReasons = []; + if (!config.wecomEnabled) { + statusReasons.push("wecom_disabled"); + } + if (!config.wecomEntryEnabled) { + statusReasons.push("entry_disabled"); + } + if (!config.wecomOauthEnabled) { + statusReasons.push("oauth_disabled"); + } + if (!config.wecomCorpId) { + statusReasons.push("corp_id_missing"); + } + if (!config.wecomAgentId) { + statusReasons.push("agent_id_missing"); + } + if (!config.wecomRedirectUri) { + statusReasons.push("redirect_uri_missing"); + } + + const bindEnabled = Boolean( + config.wecomEnabled + && config.wecomOauthEnabled + && config.wecomCorpId + && config.wecomAgentId + && config.wecomRedirectUri, + ); + const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled); + const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason)); + + return { + enabled: entryEnabled, + entryEnabled, + oauthEnabled: Boolean(config.wecomOauthEnabled), + corpId: String(config.wecomCorpId ?? ""), + agentId: String(config.wecomAgentId ?? ""), + loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null, + bindEnabled, + defaultReturnTo: DEFAULT_RETURN_TO, + status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured", + statusReasons, + }; +} + +function assertWecomOauthReady(config) { + const entryConfig = resolveWecomEntryConfig(config); if (!config.wecomEnabled || !config.wecomOauthEnabled) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } - if (!config.wecomCorpId || !config.wecomAgentId || !config.wecomRedirectUri) { + if (!entryConfig.bindEnabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, - details: { wecom: "missing_required_config" }, + details: { wecom: "missing_required_config", statusReasons: entryConfig.statusReasons }, }); } + return entryConfig; +} + +function assertWecomEntryReady(config) { + const entryConfig = assertWecomOauthReady(config); + if (!config.wecomEntryEnabled) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + return entryConfig; +} + +function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") { + const params = new URLSearchParams({ returnTo }); + if (mode === "bind") { + params.set("mode", "bind"); + } + return `/api/auth/wecom/login?${params.toString()}`; } async function getWecomAccessToken(config) { @@ -218,30 +281,25 @@ async function createAuthenticatedSession({ prisma, request, config, employeeId, async function wecomAuthRoutes(fastify) { fastify.get("/wecom/config", async (request, reply) => { - const enabled = Boolean(fastify.config.wecomEnabled && fastify.config.wecomOauthEnabled && fastify.config.wecomCorpId && fastify.config.wecomAgentId && fastify.config.wecomRedirectUri); - return sendData(reply, request, { - enabled, - corpId: enabled ? fastify.config.wecomCorpId : "", - agentId: enabled ? fastify.config.wecomAgentId : "", - oauthEnabled: enabled, - loginUrl: enabled ? "/api/auth/wecom/login?returnTo=%2Fportal" : null, - }); + return sendData(reply, request, resolveWecomEntryConfig(fastify.config)); }); fastify.get("/wecom/login", async (request, reply) => { - assertWecomEnabled(fastify.config); const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() }); } const mode = parsed.data.mode ?? "login"; if (mode === "bind") { + assertWecomOauthReady(fastify.config); const bindCookie = request.cookies?.[BIND_COOKIE_NAME]; const bindState = verifyToken(bindCookie, fastify.config.jwtSecret); if (!bindState.ok) { reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 }); } + } else { + assertWecomEntryReady(fastify.config); } const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request); const state = signPayload({ @@ -254,7 +312,7 @@ async function wecomAuthRoutes(fastify) { }); fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => { - assertWecomEnabled(fastify.config); + assertWecomOauthReady(fastify.config); const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() }); @@ -267,7 +325,7 @@ async function wecomAuthRoutes(fastify) { }, fastify.config.jwtSecret); reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config)); return sendData(reply, request, { - loginUrl: `/api/auth/wecom/login?mode=bind&returnTo=${encodeURIComponent(returnTo)}`, + loginUrl: buildWecomLoginUrl(returnTo, "bind"), }); }); @@ -290,7 +348,7 @@ async function wecomAuthRoutes(fastify) { }); fastify.get("/wecom/callback", async (request, reply) => { - assertWecomEnabled(fastify.config); + assertWecomOauthReady(fastify.config); const stateToken = String(request.query?.state ?? "").trim(); const code = String(request.query?.code ?? "").trim(); const verified = verifyToken(stateToken, fastify.config.jwtSecret); diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js new file mode 100644 index 00000000..094898bd --- /dev/null +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -0,0 +1,243 @@ +const Fastify = require("fastify"); +const fastifyCookie = require("@fastify/cookie"); + +const { AppError } = require("../../../src/utils/AppError"); +const wecomAuthRoutes = require("../../../src/routes/wecomAuth"); +const { signPayload, verifyToken } = require("../../../src/utils/signedUrl"); + +const NOW = new Date("2026-03-22T11:00:00.000Z"); +const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; +const JWT_SECRET = "test-wecom-secret"; +const originalFetch = global.fetch; + +function buildPrisma(overrides = {}) { + return { + wecomIdentityBinding: { + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockResolvedValue({ id: "binding-1" }), + ...(overrides.wecomIdentityBinding ?? {}), + }, + employee: { + findUnique: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, status: "active" }), + ...(overrides.employee ?? {}), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit-1" }), + ...(overrides.auditLog ?? {}), + }, + ...overrides, + }; +} + +async function buildApp({ config = {}, prisma = buildPrisma() } = {}) { + const app = Fastify(); + await app.register(fastifyCookie); + app.decorate("config", { + appOrigin: "https://oa.hyinda.com", + jwtSecret: JWT_SECRET, + refreshCookieName: "refresh_token", + refreshTtlSeconds: 7 * 24 * 60 * 60, + cookieSecure: true, + wecomEnabled: true, + wecomEntryEnabled: true, + wecomOauthEnabled: true, + wecomCorpId: "wx-test-corp", + wecomAgentId: "1000001", + wecomAgentSecret: "wecom-secret", + wecomRedirectUri: "https://oa.hyinda.com/api/auth/wecom/callback", + wecomAllowedReturnHosts: "oa.hyinda.com", + ...config, + }); + app.decorate("prisma", prisma); + app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token")); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { id: EMPLOYEE_ID }; + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { code: error.code, message: error.message, details: error.details ?? {} }, + }); + } + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(wecomAuthRoutes, { prefix: "/api/auth" }); + return app; +} + +function mockWecomFetch({ userId = "wecom-user-1", openId = "wecom-open-1" } = {}) { + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 0, access_token: "access-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 0, UserId: userId, OpenId: openId, DeviceId: "device-1" }), + }); +} + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("wecom auth routes R27", () => { + it("returns entry-disabled config while keeping bind available", async () => { + const app = await buildApp({ config: { wecomEntryEnabled: false } }); + + const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ + enabled: false, + entryEnabled: false, + bindEnabled: true, + oauthEnabled: true, + status: "disabled", + defaultReturnTo: "/portal", + loginUrl: null, + statusReasons: expect.arrayContaining(["entry_disabled"]), + })); + + await app.close(); + }); + + it("returns misconfigured status when corp id is missing", async () => { + const app = await buildApp({ config: { wecomCorpId: "" } }); + + const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual(expect.objectContaining({ + enabled: false, + bindEnabled: false, + status: "misconfigured", + statusReasons: expect.arrayContaining(["corp_id_missing"]), + corpId: "", + agentId: "1000001", + })); + + await app.close(); + }); + + it("sanitizes login returnTo to allowed hosts or local paths", async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: "GET", + url: "/api/auth/wecom/login?returnTo=https%3A%2F%2Fevil.example.com%2Fsteal", + }); + + expect(response.statusCode).toBe(302); + const location = response.headers.location; + expect(location).toContain("open.weixin.qq.com/connect/oauth2/authorize"); + const state = new URL(location).searchParams.get("state"); + const verified = verifyToken(state, JWT_SECRET); + expect(verified.ok).toBe(true); + expect(verified.payload.returnTo).toBe("/portal"); + expect(verified.payload.mode).toBe("login"); + + await app.close(); + }); + + it("redirects unbound users back to login with wecom hint", async () => { + mockWecomFetch({ userId: "wecom-unbound" }); + const prisma = buildPrisma({ + wecomIdentityBinding: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }); + const app = await buildApp({ prisma }); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-login", + returnTo: "/portal", + mode: "login", + }, JWT_SECRET); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("https://oa.hyinda.com/login?redirect=%2Fportal&wecom=unbound"); + + await app.close(); + }); + + it("creates bind audit log on successful wecom bind callback", async () => { + mockWecomFetch({ userId: "wecom-bind-1" }); + const prisma = buildPrisma(); + const app = await buildApp({ prisma }); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-bind", + returnTo: "/settings/profile", + mode: "bind", + }, JWT_SECRET); + const bindCookie = signPayload({ + exp: Date.now() + 60_000, + employeeId: EMPLOYEE_ID, + returnTo: "/settings/profile", + }, JWT_SECRET); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, + headers: { + cookie: `wecom_bind=${bindCookie}`, + }, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("https://oa.hyinda.com/settings/profile?wecomBind=success"); + expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled(); + expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + actor_id: EMPLOYEE_ID, + action: "AUTH_WECOM_BIND", + target_type: "employee", + target_id: EMPLOYEE_ID, + }), + })); + + await app.close(); + }); + + it("writes audit log when unbinding wecom identity", async () => { + const prisma = buildPrisma(); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: "POST", + url: "/api/auth/wecom/unbind", + headers: { + authorization: "Bearer test-token", + }, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.wecomIdentityBinding.deleteMany).toHaveBeenCalledWith({ where: { employee_id: EMPLOYEE_ID } }); + expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + actor_id: EMPLOYEE_ID, + action: "AUTH_WECOM_UNBIND", + }), + })); + + await app.close(); + }); +});