@oj-module/cli 0.2.49 → 0.2.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +8 -6
- package/devkit/SKILL.md +32 -12
- package/devkit/manual.md +6 -3
- package/devkit/wecom-daikaifa.md +95 -1
- package/devkit/wecom-internal-app.md +750 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index-FYt9CjFd.js → index-e_Mp2mb5.js} +5 -1
- package/shell-dist/assets/runtime.js +24 -3
- package/shell-dist/index.html +9 -12
- package/shell-dist/versions.json +3 -3
- package/src/build.ts +19 -2
- package/src/init.ts +5 -4
- package/src/shared-deps.ts +27 -9
- package/src/site-base.ts +1 -1
- package/templates/config.yaml +28 -6
- package/templates/init.include.yaml +4 -7
- package/templates/openapi.json +2636 -2356
- package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
- package/templates/src/auth/api/_shared/wecom/admin.ts +91 -7
- package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
- package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
- package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
- package/templates/src/auth/api/_shared/wecom/events.ts +28 -6
- package/templates/src/auth/api/_shared/wecom/login.ts +21 -13
- package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
- package/templates/src/auth/api/_shared/wecom/sync.ts +11 -1
- package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
- package/templates/src/auth/api/schema.yaml +2 -0
- package/templates/src/auth/api/wecom/admin/api.ts +112 -11
- package/templates/src/auth/api/wecom/admin/tenant-admin/api.ts +41 -0
- package/templates/src/auth/api/wecom/admin/tenant-admin/password/api.ts +66 -0
- package/templates/src/auth/api/wecom/api.ts +102 -48
- package/templates/src/auth/api/wecom/sync/api.ts +21 -5
- package/templates/src/auth/web/__client/api.schemas.ts +22 -1
- package/templates/src/auth/web/__client/api.ts +47 -1
- package/templates/src/login/web/pages/login.tsx +54 -24
- package/templates/src/system/api/settings/api.ts +3 -3
- package/templates/src/system/web/entry.ts +4 -4
- package/templates/src/system/web/locales/en-US.json +38 -1
- package/templates/src/system/web/locales/zh-CN.json +38 -1
- package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
- package/templates/src/system/web/pages/users/index.tsx +7 -4
- package/templates/src/system/web/pages/users/roles.tsx +14 -3
- package/templates/src/system/web/pages/wecom/index.tsx +292 -45
- package/templates/tests/wecom-admin.spec.ts +7 -7
- package/templates/tests/wecom-connector.spec.ts +7 -6
- package/templates/tests/wecom-corp.spec.ts +523 -0
- package/templates/tests/wecom-endpoints.spec.ts +10 -4
- package/templates/tests/wecom-org-sync.spec.ts +7 -1
- package/templates/tsconfig.paths.json +0 -1
- package/templates/web.config.ts +1 -2
- package/vendor/host-versions.json +3 -3
- package/shell-dist/assets/echarts-charts.js +0 -58571
- package/shell-dist/assets/echarts-features.js +0 -24057
- package/shell-dist/assets/echarts-for-react.js +0 -704
- package/shell-dist/assets/echarts.js +0 -82916
- package/shell-dist/assets/editor.js +0 -161457
- package/shell-dist/assets/vditor-index-css.js +0 -7
- package/shell-dist/assets/vditor.js +0 -15223
- package/templates/src/demo/api/manifest.yaml +0 -5
- package/templates/src/demo/api/migrations/0001__create_todos.sql +0 -9
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +0 -2
- package/templates/src/demo/api/migrations/0002__tenant.sql +0 -2
- package/templates/src/demo/api/schema.yaml +0 -12
- package/templates/src/demo/api/seed.sql +0 -5
- package/templates/src/demo/api/todos/api.ts +0 -59
- package/templates/src/demo/web/__client/api.schemas.ts +0 -20
- package/templates/src/demo/web/__client/api.ts +0 -89
- package/templates/src/demo/web/entry.ts +0 -42
- package/templates/src/demo/web/locales/en-US.json +0 -10
- package/templates/src/demo/web/locales/zh-CN.json +0 -10
- package/templates/src/demo/web/pages/index.tsx +0 -45
|
@@ -18,7 +18,7 @@ import { assertPermission, assertTenantSelf } from "#/_platform/_shared/service/
|
|
|
18
18
|
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
19
19
|
import { rateLimit } from "#/_platform/_shared/service/infra/rate";
|
|
20
20
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
|
|
21
|
-
import {
|
|
21
|
+
import { corpIdentity, readBase } from "../../_shared/wecom/config";
|
|
22
22
|
import { findProfile, probeProfile, runtimeStoreDeps } from "../../_shared/wecom/store";
|
|
23
23
|
import { attachProfile, cancelSync, emptyView, startSync, syncStatus } from "../../_shared/wecom/sync";
|
|
24
24
|
|
|
@@ -44,12 +44,15 @@ const STATS = {
|
|
|
44
44
|
|
|
45
45
|
const VIEW = {
|
|
46
46
|
type: "object",
|
|
47
|
-
required: ["connected", "authMode", "profileStatus", "status", "phase", "stats", "progress", "errCode", "errDetail", "startedAt", "finishedAt", "updateTime"],
|
|
47
|
+
required: ["connected", "authMode", "profileStatus", "agentId", "credentialKind", "status", "phase", "stats", "progress", "errCode", "errDetail", "startedAt", "finishedAt", "updateTime"],
|
|
48
48
|
properties: {
|
|
49
49
|
// 授权事实(页面据此决定「整条隐藏 / 成员授权卡片 / 冻结黄条 / 正常同步栏」)
|
|
50
50
|
connected: { type: "boolean" },
|
|
51
51
|
authMode: { type: "number" },
|
|
52
52
|
profileStatus: { type: ["string", "null"] },
|
|
53
|
+
// 分辨锚点:多应用/共存企业下前端必须原样回传(C6)
|
|
54
|
+
agentId: { type: ["string", "null"] },
|
|
55
|
+
credentialKind: { type: ["string", "null"], enum: ["suite", "corp", null] },
|
|
53
56
|
status: { type: "string", enum: ["idle", "running", "done", "failed", "cancelled"] },
|
|
54
57
|
phase: { type: "string", enum: ["departments", "members", "reconcile"] },
|
|
55
58
|
stats: STATS,
|
|
@@ -80,6 +83,18 @@ function agentOf(raw: unknown): string | null {
|
|
|
80
83
|
return s.length > 0 ? s : null;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/**
|
|
87
|
+
* 未带 agent 时的兜底:**只有当本租户就是本部署自建的那家企业**才用配置里的 agent。
|
|
88
|
+
*
|
|
89
|
+
* 共存下的双应用租户(同一 corp 同时有代开发行与自建行)不带 agent 时探针只能 400,
|
|
90
|
+
* 而「点进自己组织架构页」不该要求管理员手填应用 id。别家企业没有这个兜底——
|
|
91
|
+
* 它们的 agent 只能来自登录链接/状态条回传,不由本站配置决定(那正是代开发的意义)。
|
|
92
|
+
*/
|
|
93
|
+
function selfAgentOf(tenantId: string): string | null {
|
|
94
|
+
const self = corpIdentity();
|
|
95
|
+
return self && self.corpId === tenantId ? self.agentId : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
/**
|
|
84
99
|
* 解析当前租户的授权档案(企业代码 = 租户;多应用时必须带 agent)。
|
|
85
100
|
*
|
|
@@ -88,14 +103,15 @@ function agentOf(raw: unknown): string | null {
|
|
|
88
103
|
* 是自己填错了),findProfile 负责解密出 permanent_code。
|
|
89
104
|
*/
|
|
90
105
|
async function profileOf(tenantId: string, agent: unknown) {
|
|
91
|
-
const probe = await probeProfile(tenantId, agentOf(agent));
|
|
106
|
+
const probe = await probeProfile(tenantId, agentOf(agent) ?? selfAgentOf(tenantId));
|
|
92
107
|
if (!probe) {
|
|
93
108
|
throw Object.assign(new Error("本站尚未开通企业微信:没有可同步的授权记录"), { status: 400 });
|
|
94
109
|
}
|
|
95
110
|
if (probe.status !== "active") {
|
|
96
111
|
throw Object.assign(new Error("该企业已取消授权或应用已停用:组织架构同步已冻结,请联系服务商重新开通"), { status: 400 });
|
|
97
112
|
}
|
|
98
|
-
|
|
113
|
+
// 同步只用与来源无关的两项(落库密钥 + 基址):读整套 suite 配置会把自建部署打成 400
|
|
114
|
+
const cfg = readBase();
|
|
99
115
|
const profile = await findProfile(tenantId, probe.agentId, runtimeStoreDeps(cfg));
|
|
100
116
|
return { profile, probe, cfg };
|
|
101
117
|
}
|
|
@@ -149,7 +165,7 @@ const status = {
|
|
|
149
165
|
const tenantId = currentTenant();
|
|
150
166
|
if (!tenantId)
|
|
151
167
|
throw Object.assign(new Error("缺少租户上下文"), { status: 400 });
|
|
152
|
-
const probe = await probeProfile(tenantId, agentOf((http.query as Record<string, unknown>).agent));
|
|
168
|
+
const probe = await probeProfile(tenantId, agentOf((http.query as Record<string, unknown>).agent) ?? selfAgentOf(tenantId));
|
|
153
169
|
if (!probe)
|
|
154
170
|
return attachProfile(emptyView("idle"), null);
|
|
155
171
|
return attachProfile(await syncStatus(tenantId, probe.agentId), probe);
|
|
@@ -19,6 +19,7 @@ export const schemas = {
|
|
|
19
19
|
"env": z.string(),
|
|
20
20
|
"corp_name": z.string().nullable().optional(),
|
|
21
21
|
"status": z.enum(["active", "disabled"]),
|
|
22
|
+
"credential_kind": z.enum(["suite", "corp"]),
|
|
22
23
|
"auth_mode": z.number(),
|
|
23
24
|
"installed_at": z.number(),
|
|
24
25
|
"update_time": z.number()
|
|
@@ -40,6 +41,24 @@ export const schemas = {
|
|
|
40
41
|
"installUrl": z.string(),
|
|
41
42
|
"callbackUrl": z.string(),
|
|
42
43
|
"expiresIn": z.number()
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
"authWecomAdminOverviewGet": {
|
|
47
|
+
data: z.object({
|
|
48
|
+
"kinds": z.array(z.enum(["suite", "corp"])),
|
|
49
|
+
"configError": z.string().nullable(),
|
|
50
|
+
"callbackUrl": z.string().nullable(),
|
|
51
|
+
"corpId": z.string().nullable(),
|
|
52
|
+
"agentId": z.string().nullable(),
|
|
53
|
+
"corpCallbackConfigured": z.boolean()
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
"authWecomAdminConnectPost": {
|
|
57
|
+
data: z.object({
|
|
58
|
+
"corpId": z.string(),
|
|
59
|
+
"agentId": z.string(),
|
|
60
|
+
"departments": z.number(),
|
|
61
|
+
"members": z.number()
|
|
43
62
|
}),
|
|
44
63
|
},
|
|
45
64
|
"authWecomTicketPost": {
|
|
@@ -108,7 +127,9 @@ export const schemas = {
|
|
|
108
127
|
}).nullable(),
|
|
109
128
|
"startedAt": z.number(),
|
|
110
129
|
"finishedAt": z.number(),
|
|
111
|
-
"updateTime": z.number()
|
|
130
|
+
"updateTime": z.number(),
|
|
131
|
+
"agentId": z.string().nullable(),
|
|
132
|
+
"credentialKind": z.union([z.literal("suite"), z.literal("corp"), z.literal(null)]).nullable()
|
|
112
133
|
}),
|
|
113
134
|
},
|
|
114
135
|
"authWecomSyncCancelPost": {
|
|
@@ -33,7 +33,7 @@ export function createAuthClient(ctx: ModuleContext) {
|
|
|
33
33
|
}
|
|
34
34
|
ctx.register.apiPrefix(API_PREFIX);
|
|
35
35
|
bindRequest(ctx.utils.request);
|
|
36
|
-
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost, authWecomSyncStartPost, authWecomSyncStatusGet, authWecomSyncCancelPost };
|
|
36
|
+
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomAdminOverviewGet, authWecomAdminConnectPost, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost, authWecomSyncStartPost, authWecomSyncStatusGet, authWecomSyncCancelPost };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -211,6 +211,52 @@ export async function authWecomAdminInstallGet(): Promise<AuthWecomAdminInstallG
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
export type AuthWecomAdminOverviewGetData = z.infer<(typeof schemas)["authWecomAdminOverviewGet"]["data"]>;
|
|
215
|
+
|
|
216
|
+
export async function authWecomAdminOverviewGet(): Promise<AuthWecomAdminOverviewGetData> {
|
|
217
|
+
const client = ensureReq();
|
|
218
|
+
try {
|
|
219
|
+
const env = await client.get(`auth/wecom/admin/overview`).json<OjEnvelope<AuthWecomAdminOverviewGetData>>();
|
|
220
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
221
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
222
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
223
|
+
const data = env.data as AuthWecomAdminOverviewGetData;
|
|
224
|
+
if (import.meta.env.DEV) {
|
|
225
|
+
const { schemas } = await import("./api.schemas");
|
|
226
|
+
const r = schemas["authWecomAdminOverviewGet"].data.safeParse(data);
|
|
227
|
+
if (!r.success)
|
|
228
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminOverviewGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
229
|
+
}
|
|
230
|
+
return data;
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
throw await toApiError(e);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type AuthWecomAdminConnectPostData = z.infer<(typeof schemas)["authWecomAdminConnectPost"]["data"]>;
|
|
238
|
+
|
|
239
|
+
export async function authWecomAdminConnectPost(): Promise<AuthWecomAdminConnectPostData> {
|
|
240
|
+
const client = ensureReq();
|
|
241
|
+
try {
|
|
242
|
+
const env = await client.post(`auth/wecom/admin/connect`).json<OjEnvelope<AuthWecomAdminConnectPostData>>();
|
|
243
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
244
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
245
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
246
|
+
const data = env.data as AuthWecomAdminConnectPostData;
|
|
247
|
+
if (import.meta.env.DEV) {
|
|
248
|
+
const { schemas } = await import("./api.schemas");
|
|
249
|
+
const r = schemas["authWecomAdminConnectPost"].data.safeParse(data);
|
|
250
|
+
if (!r.success)
|
|
251
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminConnectPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
252
|
+
}
|
|
253
|
+
return data;
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
throw await toApiError(e);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
214
260
|
export async function authWecomLoginGet(): Promise<unknown> {
|
|
215
261
|
const client = ensureReq();
|
|
216
262
|
try {
|
|
@@ -54,12 +54,19 @@ export default function LoginPage() {
|
|
|
54
54
|
const login = useAuthStore(s => s.login);
|
|
55
55
|
const [error, setError] = useState<string>();
|
|
56
56
|
const [loading, setLoading] = useState(false);
|
|
57
|
+
const [known, setKnown] = useState(false);
|
|
58
|
+
const inWecom = typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent);
|
|
57
59
|
const [methods, setMethods] = useState<LoginMethod[]>(["local"]);
|
|
58
60
|
const [active, setActive] = useState<LoginMethod>("local");
|
|
59
61
|
// 企业代码入口默认收起:单租户部署(绝大多数)保持今天的登录页外观,
|
|
60
62
|
// 只有服务商/客户带 `?tenant=` 或手工展开时才需要填。
|
|
61
|
-
//
|
|
63
|
+
// 例外一:持久化租户不是部署默认值时必须展开——租户头是**隐式**跟着请求走的
|
|
62
64
|
// (runtime 每请求注入),藏起来等于让上一次的租户无声地把这次登录带走。
|
|
65
|
+
// 例外二:**开了企业微信登录就默认展开**(见下方 effect)——企微租户的键是 corp_id,
|
|
66
|
+
// 一旦有企业接入,「登录哪个租户」就是真问题:平台管理员在 default,而各企业的
|
|
67
|
+
// 管理员在各自的 corp 租户里,收起等于让运维找不到切租户的入口。
|
|
68
|
+
// 判据用「wecom 是否在登录方式里」而不是去数租户:匿名请求没有租户上下文,
|
|
69
|
+
// 统计租户要动用 db.asSystem(全仓仅 tenant-admin.ts 一处,不为此破例)。
|
|
63
70
|
const [showTenant, setShowTenant] = useState(() => useTenantStore.getState().tenant !== DEFAULT_TENANT);
|
|
64
71
|
// 表单里的企业代码:带上 persisted 值(URL `?tenant=` 已先落到 store),用户看得见才改得动
|
|
65
72
|
const [tenant] = useState(() => {
|
|
@@ -77,18 +84,31 @@ export default function LoginPage() {
|
|
|
77
84
|
// 企微 302 落地参数:只在首次渲染解析一次(ticket 一次性,解析结果必须稳定)
|
|
78
85
|
const [landing] = useState(() => parseWecomLanding(search));
|
|
79
86
|
const exchangedRef = useRef(false);
|
|
87
|
+
// 仅「认证中 / 已失败」两态:企微内、回跳落地、或 ?corp 深链即进入认证态,
|
|
88
|
+
// 直接展示「认证中」,不渲染登录表单;只有认证失败(换票失败 / 企微方式不可用)才回落表单。
|
|
89
|
+
const [phase, setPhase] = useState<"auth" | "form">(
|
|
90
|
+
inWecom || landing || urlCorp ? "auth" : "form",
|
|
91
|
+
);
|
|
80
92
|
|
|
81
93
|
useEffect(() => {
|
|
82
94
|
fetchLoginMethods()
|
|
83
95
|
.then((r) => {
|
|
84
|
-
|
|
96
|
+
setKnown(true);
|
|
97
|
+
// 先按 string[] 看一眼:runtime 的返回类型把 wecom 排除在外,而这里恰恰要认它
|
|
98
|
+
const raw: string[] = r.methods;
|
|
99
|
+
const m = raw.filter(isKnownMethod);
|
|
85
100
|
if (!m.length)
|
|
86
101
|
return;
|
|
87
102
|
setMethods(m);
|
|
103
|
+
// 有企微登录 ⇒ 有企业租户(租户键 = corp_id)⇒ 默认展开企业代码,
|
|
104
|
+
// 否则平台/企业两类管理员没法切换登录目标(用户仍可点「收起」手动折叠)。
|
|
105
|
+
if (raw.includes("wecom"))
|
|
106
|
+
setShowTenant(true);
|
|
88
107
|
if (!m.includes("local"))
|
|
89
108
|
setActive(m[0]);
|
|
90
109
|
})
|
|
91
110
|
.catch(() => {
|
|
111
|
+
setKnown(true);
|
|
92
112
|
// 发现失败时回落单表单(local),不阻断登录。
|
|
93
113
|
});
|
|
94
114
|
}, []);
|
|
@@ -110,6 +130,7 @@ export default function LoginPage() {
|
|
|
110
130
|
}
|
|
111
131
|
catch (e) {
|
|
112
132
|
setError(e instanceof Error ? e.message : String(e));
|
|
133
|
+
setPhase("form");
|
|
113
134
|
setLoading(false);
|
|
114
135
|
}
|
|
115
136
|
})();
|
|
@@ -121,13 +142,16 @@ export default function LoginPage() {
|
|
|
121
142
|
useEffect(() => {
|
|
122
143
|
const c = new URLSearchParams(search).get("corp")?.trim() ?? "";
|
|
123
144
|
// 企微内(wxwork UA)从「应用主页」点开即自动发起授权;带 ?corp= 的深链同样自动发起。
|
|
124
|
-
const inWecom = typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent);
|
|
125
145
|
if ((!c && !inWecom) || landing || autoFiredRef.current)
|
|
126
146
|
return;
|
|
127
|
-
if (new URLSearchParams(search).get("error"))
|
|
147
|
+
if (new URLSearchParams(search).get("error")) {
|
|
148
|
+
setPhase("form");
|
|
128
149
|
return;
|
|
129
|
-
|
|
150
|
+
}
|
|
151
|
+
if (known && !methods.includes("wecom")) {
|
|
152
|
+
setPhase("form");
|
|
130
153
|
return;
|
|
154
|
+
}
|
|
131
155
|
autoFiredRef.current = true;
|
|
132
156
|
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
|
|
133
157
|
}, [landing, methods, search, corp, agent]);
|
|
@@ -245,6 +269,18 @@ export default function LoginPage() {
|
|
|
245
269
|
return form(m);
|
|
246
270
|
}
|
|
247
271
|
|
|
272
|
+
// 认证态:只展示「认证中」,不渲染任何登录表单(满足「只有登录失败才出现 login」)
|
|
273
|
+
if (phase === "auth") {
|
|
274
|
+
return (
|
|
275
|
+
<>
|
|
276
|
+
<Typography.Title level={3}>
|
|
277
|
+
{t("login:page.title")}
|
|
278
|
+
</Typography.Title>
|
|
279
|
+
<Alert style={{ marginBottom: 16 }} type="info" message={t("login:method.wecom.exchanging")} showIcon />
|
|
280
|
+
</>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
248
284
|
return (
|
|
249
285
|
<>
|
|
250
286
|
<Typography.Title level={3}>
|
|
@@ -253,26 +289,20 @@ export default function LoginPage() {
|
|
|
253
289
|
<Typography.Paragraph type="secondary">
|
|
254
290
|
{t("login:page.subtitle")}
|
|
255
291
|
</Typography.Paragraph>
|
|
256
|
-
{landing && loading && (
|
|
257
|
-
<Alert style={{ marginBottom: 16 }} type="info" message={t("login:method.wecom.exchanging")} showIcon />
|
|
258
|
-
)}
|
|
259
292
|
{error && <Alert style={{ marginBottom: 16 }} type="error" message={error} showIcon />}
|
|
260
|
-
{
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
/>
|
|
274
|
-
)
|
|
275
|
-
: panelOf(methods[0] ?? "local")}
|
|
293
|
+
{showTabs
|
|
294
|
+
? (
|
|
295
|
+
<Tabs
|
|
296
|
+
activeKey={active}
|
|
297
|
+
onChange={key => setActive(key as LoginMethod)}
|
|
298
|
+
items={methods.map(m => ({
|
|
299
|
+
key: m,
|
|
300
|
+
label: t(`login:method.${m}`),
|
|
301
|
+
children: panelOf(m),
|
|
302
|
+
}))}
|
|
303
|
+
/>
|
|
304
|
+
)
|
|
305
|
+
: panelOf(methods[0] ?? "local")}
|
|
276
306
|
</>
|
|
277
307
|
);
|
|
278
308
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { respond } from "#/_platform/_shared/respond";
|
|
2
2
|
import { listSettings, updateSetting } from "#/_platform/_shared/service/domain/settings";
|
|
3
3
|
// /api/system/settings —— 系统设置(PRD §4.3):GET 按组列出(secret 掩码),PUT 写值(写审计)。
|
|
4
|
-
import {
|
|
4
|
+
import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
|
|
5
5
|
|
|
6
6
|
const settingItemSchema = {
|
|
7
7
|
type: "object",
|
|
@@ -23,7 +23,7 @@ const settingItemSchema = {
|
|
|
23
23
|
|
|
24
24
|
async function get() {
|
|
25
25
|
await respond(async () => {
|
|
26
|
-
await
|
|
26
|
+
await assertSuperAdmin();
|
|
27
27
|
return listSettings();
|
|
28
28
|
});
|
|
29
29
|
}
|
|
@@ -41,7 +41,7 @@ get.response = {
|
|
|
41
41
|
|
|
42
42
|
async function put() {
|
|
43
43
|
await respond(async () => {
|
|
44
|
-
await
|
|
44
|
+
await assertSuperAdmin();
|
|
45
45
|
const body = http.body || {};
|
|
46
46
|
return updateSetting(Number(http.user?.id), String(body.key ?? ""), String(body.value ?? ""));
|
|
47
47
|
});
|
|
@@ -54,9 +54,10 @@ const routes: AppRouteRecordRaw[] = [
|
|
|
54
54
|
handle: { title: "system:menu.oplog" },
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
|
+
// 站点设置(证书/站点级开关):仅超管可见可访问
|
|
57
58
|
path: "/system/settings",
|
|
58
59
|
Component: Settings,
|
|
59
|
-
handle: { title: "system:menu.settings" },
|
|
60
|
+
handle: { title: "system:menu.settings", roles: ["superadmin"] },
|
|
60
61
|
},
|
|
61
62
|
{
|
|
62
63
|
path: "/system/login-methods",
|
|
@@ -64,11 +65,10 @@ const routes: AppRouteRecordRaw[] = [
|
|
|
64
65
|
handle: { title: "system:menu.loginMethods" },
|
|
65
66
|
},
|
|
66
67
|
{
|
|
67
|
-
//
|
|
68
|
-
// 与 users/roles 页同策略——403 面板比路由消失更容易解释)
|
|
68
|
+
// 企微代开发后台(服务商运营面):仅超管可见(路由级 roles + 页面内二次判定)
|
|
69
69
|
path: "/system/wecom",
|
|
70
70
|
Component: WecomAdmin,
|
|
71
|
-
handle: { title: "system:menu.wecom" },
|
|
71
|
+
handle: { title: "system:menu.wecom", roles: ["superadmin"] },
|
|
72
72
|
},
|
|
73
73
|
],
|
|
74
74
|
},
|
|
@@ -141,7 +141,8 @@
|
|
|
141
141
|
"status": "Status",
|
|
142
142
|
"installedAt": "Onboarded at",
|
|
143
143
|
"action": "Actions",
|
|
144
|
-
"authMode": "Auth mode"
|
|
144
|
+
"authMode": "Auth mode",
|
|
145
|
+
"source": "Source"
|
|
145
146
|
},
|
|
146
147
|
"statusActive": "Active",
|
|
147
148
|
"statusDisabled": "Disabled",
|
|
@@ -205,6 +206,42 @@
|
|
|
205
206
|
"hint": "Contact the service provider to investigate, then retry."
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
},
|
|
210
|
+
"source": {
|
|
211
|
+
"suite": "Delegated",
|
|
212
|
+
"corp": "Internal app"
|
|
213
|
+
},
|
|
214
|
+
"authModeNone": "—",
|
|
215
|
+
"configErrorTitle": "Deployment config check failed",
|
|
216
|
+
"coexistHint": "This deployment runs both delegated (suite) and internal (corp) apps (wecom_allow_coexist: 'true').",
|
|
217
|
+
"corp": {
|
|
218
|
+
"title": "Internal app (self-built)",
|
|
219
|
+
"hint": "This deployment connects as an internal app: credentials come from config.yaml (wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id). Fill them in, then click \"Verify and connect\"; the corporation is registered only after all three checks pass.",
|
|
220
|
+
"corpId": "Corporation ID",
|
|
221
|
+
"agentId": "AgentId",
|
|
222
|
+
"callbackUrl": "Contacts event callback URL",
|
|
223
|
+
"callbackHint": "Fill this URL into WeCom → Tools → Contacts sync → Set event receiver URL. Leave the app's own message callback empty: the two use different tokens, and pointing both at one URL makes half the payloads fail signature verification.",
|
|
224
|
+
"callbackConfigured": "Contacts event callback credentials",
|
|
225
|
+
"callbackYes": "Configured (member changes sync in real time)",
|
|
226
|
+
"callbackNo": "Not configured (full sync only; to get increments, fill Token / EncodingAESKey for this URL in the WeCom console)",
|
|
227
|
+
"connect": "Verify and connect",
|
|
228
|
+
"connectConfirm": "Connect checks: whether the app Secret can fetch a token, whether contacts are readable, and whether any member is visible. Re-run it whenever the Corporation ID / AgentId changed or the Secret was rotated.",
|
|
229
|
+
"connectOk": "Connected: {{depts}} departments visible, {{members}} members in the sample department",
|
|
230
|
+
"connectHint": "After rotating the app Secret you must click \"Verify and connect\" again, otherwise every call fails with 40001. How many people get synced depends on the app's visible scope — set it to the whole company for a full sync."
|
|
231
|
+
},
|
|
232
|
+
"tenantAdmin": {
|
|
233
|
+
"title": "Tenant administrators",
|
|
234
|
+
"hint": "Each enterprise tenant (tenant key = corp ID) gets an admin account automatically on provisioning, used to manage that tenant's org chart, members and roles. It is a tenant-level admin and cannot see other enterprises. The initial password is random and known to no one — to take over, reset a password you know here, hand it to the tenant admin, and they will be forced to change it on first login.",
|
|
235
|
+
"username": "Admin account",
|
|
236
|
+
"state": "State",
|
|
237
|
+
"exists": "Provisioned",
|
|
238
|
+
"missing": "Not provisioned",
|
|
239
|
+
"action": "Action",
|
|
240
|
+
"reset": "Reset password",
|
|
241
|
+
"resetTitle": "Reset tenant admin password",
|
|
242
|
+
"resetHint": "This resets the tenant admin password of enterprise {{corpId}}. Choose the new password yourself and deliver it out-of-band — only a bcrypt hash is stored, so no existing password can ever be shown. The admin must change it on first login, and their existing sessions are revoked immediately.",
|
|
243
|
+
"resetPlaceholder": "New password (at least 6 chars)",
|
|
244
|
+
"resetOk": "Tenant admin password reset"
|
|
208
245
|
}
|
|
209
246
|
},
|
|
210
247
|
"err": {
|
|
@@ -141,7 +141,8 @@
|
|
|
141
141
|
"status": "状态",
|
|
142
142
|
"installedAt": "开通时间",
|
|
143
143
|
"action": "操作",
|
|
144
|
-
"authMode": "授权模式"
|
|
144
|
+
"authMode": "授权模式",
|
|
145
|
+
"source": "来源"
|
|
145
146
|
},
|
|
146
147
|
"statusActive": "启用",
|
|
147
148
|
"statusDisabled": "停用",
|
|
@@ -205,6 +206,42 @@
|
|
|
205
206
|
"hint": "请联系服务商排查后重试。"
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
},
|
|
210
|
+
"source": {
|
|
211
|
+
"suite": "代开发",
|
|
212
|
+
"corp": "企业内部应用"
|
|
213
|
+
},
|
|
214
|
+
"authModeNone": "—",
|
|
215
|
+
"configErrorTitle": "部署配置自检未通过",
|
|
216
|
+
"coexistHint": "本部署同时启用了代开发与企业内部应用(wecom_allow_coexist: 'true')。",
|
|
217
|
+
"corp": {
|
|
218
|
+
"title": "企业内部应用(自建)",
|
|
219
|
+
"hint": "本部署作为企业内部应用接入:凭据来自 config.yaml 的 wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id。填好后点「验证并接入」,三步校验全过才会登记企业。",
|
|
220
|
+
"corpId": "企业ID",
|
|
221
|
+
"agentId": "AgentId",
|
|
222
|
+
"callbackUrl": "通讯录事件回调地址",
|
|
223
|
+
"callbackHint": "把上面这个地址填到企微「管理工具 → 通讯录同步 → 设置接收事件服务器」;应用自身的「接收消息」回调请留空——两处 Token 不同,填同一个 URL 会让一半报文验签失败。",
|
|
224
|
+
"callbackConfigured": "通讯录事件回调凭据",
|
|
225
|
+
"callbackYes": "已配置(成员变更会实时同步)",
|
|
226
|
+
"callbackNo": "未配置(只做全量同步;要增量请在企微后台填 Token / EncodingAESKey 到同一地址)",
|
|
227
|
+
"connect": "验证并接入",
|
|
228
|
+
"connectConfirm": "接入会依次校验:应用 Secret 能否换 token、能否读通讯录、可见范围内能否读到人。改过企业ID / AgentId 或换过 Secret 都必须重新点一次。",
|
|
229
|
+
"connectOk": "接入成功:可见范围内 {{depts}} 个部门,示例部门 {{members}} 人",
|
|
230
|
+
"connectHint": "换过应用 Secret 后必须重新「验证并接入」,否则所有调用都会 40001;同步到多少人取决于「应用可见范围」,要全量请设为全公司。"
|
|
231
|
+
},
|
|
232
|
+
"tenantAdmin": {
|
|
233
|
+
"title": "租户管理员",
|
|
234
|
+
"hint": "每个企业租户(租户键=企业ID)在开通时会自动建一个 admin 账号,用于管理本租户的组织架构、成员与角色。它是**租户级**管理员,看不到别家企业。初始口令随机且无人知晓——需要接管时由你在这里重设一个已知口令,交给对方后对方首次登录会被强制改密。",
|
|
235
|
+
"username": "管理员账号",
|
|
236
|
+
"state": "状态",
|
|
237
|
+
"exists": "已开通",
|
|
238
|
+
"missing": "未开通",
|
|
239
|
+
"action": "操作",
|
|
240
|
+
"reset": "重设口令",
|
|
241
|
+
"resetTitle": "重设租户管理员口令",
|
|
242
|
+
"resetHint": "将重设企业 {{corpId}} 的租户管理员口令。新口令请自行设定并线下告知对方——系统只存 bcrypt,不会也无法回显任何已有口令。重设后对方首次登录必须改密,其旧会话立即失效。",
|
|
243
|
+
"resetPlaceholder": "新口令(至少 6 位)",
|
|
244
|
+
"resetOk": "租户管理员口令已重设"
|
|
208
245
|
}
|
|
209
246
|
},
|
|
210
247
|
"err": {
|
|
@@ -18,6 +18,10 @@ interface SyncView {
|
|
|
18
18
|
connected: boolean
|
|
19
19
|
authMode: number
|
|
20
20
|
profileStatus: "active" | "disabled" | null
|
|
21
|
+
/** 已解析到的应用 id:多应用/共存企业的分辨锚点,start / cancel 要原样回传 */
|
|
22
|
+
agentId: string | null
|
|
23
|
+
/** suite=代开发 / corp=企业内部应用(自建):自建应用没有「授权模式」这回事 */
|
|
24
|
+
credentialKind: "suite" | "corp" | null
|
|
21
25
|
status: "idle" | "running" | "done" | "failed" | "cancelled"
|
|
22
26
|
phase: "departments" | "members" | "reconcile"
|
|
23
27
|
stats: { departments: number, members: number, skipped: number, reconciled: boolean }
|
|
@@ -36,25 +40,45 @@ const MIN_GAP_MS = 6000;
|
|
|
36
40
|
/** 一轮点击最多推多少片(12 秒/片 ≈ 12 分钟):兜底防呆,正常企业两三次就到底 */
|
|
37
41
|
const MAX_SLICES = 60;
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 已解析到的应用 id。同一家企业有两行档案(代开发 + 自建,迁移期常见)时后端要求带 agent,
|
|
45
|
+
* 而这个值只有后端知道:首次 status 不带(后端按「本站自建应用优先」解析),拿到后记住,
|
|
46
|
+
* 后续 start / cancel 原样回传。放模块级是为了跨组件实例复用,不参与渲染。
|
|
47
|
+
*/
|
|
48
|
+
let agentParam: string | null = null;
|
|
49
|
+
|
|
50
|
+
function withAgent(url: string): string {
|
|
51
|
+
return agentParam ? `${url}?agent=${encodeURIComponent(agentParam)}` : url;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function agentBody(): { agent?: string } {
|
|
55
|
+
return agentParam ? { agent: agentParam } : {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function remember(view: SyncView): SyncView {
|
|
59
|
+
agentParam = view.agentId ?? agentParam;
|
|
60
|
+
return view;
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
async function fetchStatus(): Promise<SyncView> {
|
|
40
|
-
const env = await request.get("auth/wecom/sync/status").json<{ code: number, data?: SyncView }>();
|
|
64
|
+
const env = await request.get(withAgent("auth/wecom/sync/status")).json<{ code: number, data?: SyncView }>();
|
|
41
65
|
if (!env.data)
|
|
42
66
|
throw new Error("企业微信同步状态读取失败");
|
|
43
|
-
return env.data;
|
|
67
|
+
return remember(env.data);
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
async function pushSlice(): Promise<SyncView> {
|
|
47
|
-
const env = await request.post("auth/wecom/sync/start", { json:
|
|
71
|
+
const env = await request.post("auth/wecom/sync/start", { json: agentBody() }).json<{ code: number, data?: SyncView }>();
|
|
48
72
|
if (!env.data)
|
|
49
73
|
throw new Error("企业微信同步未能启动");
|
|
50
|
-
return env.data;
|
|
74
|
+
return remember(env.data);
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
async function cancelSync(): Promise<SyncView> {
|
|
54
|
-
const env = await request.post("auth/wecom/sync/cancel", { json:
|
|
78
|
+
const env = await request.post("auth/wecom/sync/cancel", { json: agentBody() }).json<{ code: number, data?: SyncView }>();
|
|
55
79
|
if (!env.data)
|
|
56
80
|
throw new Error("取消同步失败");
|
|
57
|
-
return env.data;
|
|
81
|
+
return remember(env.data);
|
|
58
82
|
}
|
|
59
83
|
|
|
60
84
|
function sleep(ms: number): Promise<void> {
|
|
@@ -180,7 +204,8 @@ export function WecomSyncBar({ onFinished }: { onFinished: () => void }) {
|
|
|
180
204
|
if (!view || !view.connected)
|
|
181
205
|
return null;
|
|
182
206
|
|
|
183
|
-
|
|
207
|
+
// 成员授权卡片只对代开发成立:自建应用没有「授权模式」,authMode 恒 0(双保险,防上游写脏数据)
|
|
208
|
+
if (view.credentialKind !== "corp" && view.authMode !== 0) {
|
|
184
209
|
return (
|
|
185
210
|
<Alert
|
|
186
211
|
type="warning"
|
|
@@ -19,7 +19,8 @@ type UserRow = SystemUsersGetData[number];
|
|
|
19
19
|
/**
|
|
20
20
|
* 用户管理(C1):GET /system/users 列表 + 建号 / 禁用切换 / 重置口令。
|
|
21
21
|
* 本人行禁用一切自操作(后端 assertNotSelf 400 兜底,前端不给会失败的按钮,R3);
|
|
22
|
-
*
|
|
22
|
+
* 成员资格入口:superadmin 与 **本租户 admin** 均可见(授予 admin 已下放到租户内,
|
|
23
|
+
* 但授予 superadmin 仍只许 superadmin——见 domain/users.ts::assertMembershipAllowed)。
|
|
23
24
|
* 部门列标出主部门,顶部可按部门筛选(含子部门)——筛选在前端做,名单本来就是一页全量。
|
|
24
25
|
*/
|
|
25
26
|
export default function UsersPage() {
|
|
@@ -30,6 +31,8 @@ export default function UsersPage() {
|
|
|
30
31
|
const me = useUserStore(s => s.id);
|
|
31
32
|
const myRoles = useUserStore(s => s.roles);
|
|
32
33
|
const isSuper = myRoles.includes("superadmin");
|
|
34
|
+
// 任命本租户管理员已下放到租户内的 admin(提权到 superadmin 仍只许 superadmin)
|
|
35
|
+
const canManageMembership = isSuper || myRoles.includes("admin");
|
|
33
36
|
|
|
34
37
|
const [createOpen, setCreateOpen] = useState(false);
|
|
35
38
|
const [createForm] = Form.useForm<{ username: string, password: string, displayName?: string, email?: string }>();
|
|
@@ -129,9 +132,9 @@ export default function UsersPage() {
|
|
|
129
132
|
<Button size="small" disabled={String(row.id) === me} onClick={() => setPwdTarget(row)}>
|
|
130
133
|
{t("system:users.resetPwd")}
|
|
131
134
|
</Button>
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
{canManageMembership
|
|
136
|
+
? (
|
|
137
|
+
<Button size="small" type="link" onClick={() => navigate(`/system/users/${row.id}/roles`)}>
|
|
135
138
|
{t("system:users.membership")}
|
|
136
139
|
</Button>
|
|
137
140
|
)
|
|
@@ -11,9 +11,13 @@ import { explainError, isForbidden } from "../errors";
|
|
|
11
11
|
import { ForbiddenPanel } from "../forbidden";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* 成员资格(AC1-7
|
|
14
|
+
* 成员资格(AC1-7):整集替换某用户角色。后端无 GET 单用户端点,
|
|
15
15
|
* 当前角色从 GET /system/users 列表按 uid 回显;写走 PUT /system/users/roles?uid=。
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* 可见性已从「superadmin-only」放宽到「本租户 admin 也可以」(见 users/index.tsx 的入口):
|
|
18
|
+
* 授予 admin 已下放到租户内,否则企业租户永远配不出管理员(平台 superadmin 因租户
|
|
19
|
+
* 隔离查不到企业租户的用户)。但 **superadmin 仍只许 superadmin 授**——它握着跨租户
|
|
20
|
+
* 权限,故这里对非 superadmin 直接把该选项置灰,比让他提交后吃 403 清楚。
|
|
17
21
|
*/
|
|
18
22
|
export default function UserRolesPage() {
|
|
19
23
|
const { t } = useTranslation();
|
|
@@ -22,6 +26,8 @@ export default function UserRolesPage() {
|
|
|
22
26
|
const { uid } = useParams();
|
|
23
27
|
const queryClient = useQueryClient();
|
|
24
28
|
const me = useUserStore(s => s.id);
|
|
29
|
+
const myRoles = useUserStore(s => s.roles);
|
|
30
|
+
const isSuper = myRoles.includes("superadmin");
|
|
25
31
|
|
|
26
32
|
// null = 尚未改动,回显服务端值;一旦勾选即持本地整集
|
|
27
33
|
const [selected, setSelected] = useState<string[] | null>(null);
|
|
@@ -51,7 +57,12 @@ export default function UserRolesPage() {
|
|
|
51
57
|
<Card title={`${t("system:userRoles.title")}: ${user?.username ?? `#${uid}`}`}>
|
|
52
58
|
<Space direction="vertical">
|
|
53
59
|
<Checkbox.Group
|
|
54
|
-
options={(rolesQuery.data ?? []).map(r => ({
|
|
60
|
+
options={(rolesQuery.data ?? []).map(r => ({
|
|
61
|
+
label: `${r.name}${r.deprecated ? "(已废弃)" : ""}`,
|
|
62
|
+
value: r.name,
|
|
63
|
+
// 非 superadmin 授不了平台级角色:置灰,提交后 403 太晚也太含糊
|
|
64
|
+
disabled: r.name === "superadmin" && !isSuper,
|
|
65
|
+
}))}
|
|
55
66
|
value={value}
|
|
56
67
|
onChange={v => setSelected(v)}
|
|
57
68
|
/>
|