@oj-module/cli 0.2.43 → 0.2.45
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 +7 -5
- package/devkit/SKILL.md +22 -2
- package/devkit/manual.md +5 -0
- package/devkit/wecom-daikaifa.md +392 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +102 -39
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +22 -0
- package/src/__tests__/site-tenant-header.test.ts +54 -0
- package/src/__tests__/templates-tenant-scope.test.ts +139 -0
- package/src/build.ts +9 -0
- package/src/init.ts +4 -1
- package/src/install.ts +2 -2
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +72 -2
- package/templates/openapi.json +319 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
- package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
- package/templates/src/_platform/api/_shared/respond.ts +11 -6
- package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
- package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
- package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
- package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
- package/templates/src/_platform/api/contacts/api.ts +4 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
- package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
- package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
- package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
- package/templates/src/_platform/api/schema.yaml +28 -0
- package/templates/src/_platform/api/seed.sql +3 -0
- package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
- package/templates/src/auth/api/_shared/wecom/config.ts +133 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -0
- package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
- package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
- package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
- package/templates/src/auth/api/manifest.yaml +6 -0
- package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
- package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
- package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
- package/templates/src/auth/api/schema.yaml +48 -0
- package/templates/src/auth/api/wecom/admin/api.ts +121 -0
- package/templates/src/auth/api/wecom/api.ts +226 -0
- package/templates/src/auth/web/__client/api.schemas.ts +36 -0
- package/templates/src/auth/web/__client/api.ts +148 -1
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
- package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
- package/templates/src/demo/api/schema.yaml +1 -0
- package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
- package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
- package/templates/src/home/api/schema.yaml +2 -0
- package/templates/src/login/web/auth-provider.ts +48 -0
- package/templates/src/login/web/entry.ts +21 -1
- package/templates/src/login/web/locales/en-US.json +11 -1
- package/templates/src/login/web/locales/zh-CN.json +11 -1
- package/templates/src/login/web/pages/login.tsx +154 -25
- package/templates/src/login/web/wecom.ts +51 -0
- package/templates/src/notification/api/_shared/notify.ts +9 -5
- package/templates/src/notification/api/api.ts +25 -0
- package/templates/src/notification/api/del/api.ts +5 -0
- package/templates/src/notification/api/manifest.yaml +3 -0
- package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
- package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
- package/templates/src/notification/api/schema.yaml +4 -0
- package/templates/src/personal-center/api/profile/api.ts +13 -0
- package/templates/src/personal-center/api/upload/api.ts +8 -1
- package/templates/src/system/web/entry.ts +8 -0
- package/templates/src/system/web/locales/en-US.json +33 -2
- package/templates/src/system/web/locales/zh-CN.json +33 -2
- package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
- package/templates/src/system/web/pages/users/index.tsx +1 -1
- package/templates/src/system/web/pages/wecom/index.tsx +205 -0
- package/templates/tests/fixture.ts +91 -11
- package/templates/tests/guard.spec.ts +9 -3
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
- package/templates/tests/lifecycle/installer.spec.ts +76 -0
- package/templates/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/tests/query-utils.spec.ts +12 -9
- package/templates/tests/rbac-guard.spec.ts +18 -1
- package/templates/tests/tenant-access-cache.spec.ts +99 -0
- package/templates/tests/tenant-forgery.spec.ts +152 -0
- package/templates/tests/tenant-isolation.spec.ts +133 -0
- package/templates/tests/username-namespace.spec.ts +118 -0
- package/templates/tests/wecom-admin.spec.ts +386 -0
- package/templates/tests/wecom-connector.spec.ts +671 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +457 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// 出参挂平级 `.response`(动词级优先于组级,组级沿嵌套继承)——oj 运行期据此校验入参、
|
|
8
8
|
// 并供 `oj openapi` 生成 openapi.json → 前端 `ojm api` 生成 __client。
|
|
9
9
|
// `.schema` 是 JSON Schema 严格子集(关键字白名单),union 用类型数组表达(见 userId)。
|
|
10
|
+
import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
|
|
10
11
|
import { clearNotices, markAllRead, markRead, noticesFor, pushBroadcastNotice, pushNotice } from "./_shared/notify";
|
|
11
12
|
|
|
12
13
|
const notifications = {
|
|
@@ -19,6 +20,10 @@ const notifications = {
|
|
|
19
20
|
json.fail(401, "unauthorized");
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
23
|
+
if (!(await isSelfTenant())) {
|
|
24
|
+
json.fail(403, "forbidden");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
22
27
|
try {
|
|
23
28
|
json.ok(await noticesFor(uid));
|
|
24
29
|
}
|
|
@@ -35,6 +40,10 @@ const notifications = {
|
|
|
35
40
|
json.fail(401, "unauthorized");
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
43
|
+
if (!(await isSelfTenant())) {
|
|
44
|
+
json.fail(403, "forbidden");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
38
47
|
const body = (http.body ?? {}) as { id?: unknown };
|
|
39
48
|
const id = String(body.id ?? "");
|
|
40
49
|
if (!id) {
|
|
@@ -62,6 +71,10 @@ const notifications = {
|
|
|
62
71
|
json.fail(401, "unauthorized");
|
|
63
72
|
return;
|
|
64
73
|
}
|
|
74
|
+
if (!(await isSelfTenant())) {
|
|
75
|
+
json.fail(403, "forbidden");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
65
78
|
try {
|
|
66
79
|
await markAllRead(uid);
|
|
67
80
|
json.ok(null);
|
|
@@ -80,6 +93,10 @@ const notifications = {
|
|
|
80
93
|
json.fail(401, "unauthorized");
|
|
81
94
|
return;
|
|
82
95
|
}
|
|
96
|
+
if (!(await isSelfTenant())) {
|
|
97
|
+
json.fail(403, "forbidden");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
83
100
|
try {
|
|
84
101
|
await clearNotices(uid);
|
|
85
102
|
json.ok(null);
|
|
@@ -133,6 +150,10 @@ const push = {
|
|
|
133
150
|
json.fail(401, "unauthorized");
|
|
134
151
|
return;
|
|
135
152
|
}
|
|
153
|
+
if (!(await isSelfTenant())) {
|
|
154
|
+
json.fail(403, "forbidden");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
136
157
|
const b = (http.body ?? {}) as {
|
|
137
158
|
userId?: unknown
|
|
138
159
|
title?: unknown
|
|
@@ -187,6 +208,10 @@ const broadcast = {
|
|
|
187
208
|
json.fail(401, "unauthorized");
|
|
188
209
|
return;
|
|
189
210
|
}
|
|
211
|
+
if (!(await isSelfTenant())) {
|
|
212
|
+
json.fail(403, "forbidden");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
190
215
|
const b = (http.body ?? {}) as {
|
|
191
216
|
title?: unknown
|
|
192
217
|
message?: unknown
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// POST /api/notification/del —— 删除一条通知(发射原语 HTTP 入口)。
|
|
2
2
|
// 按人项校验归属,非本人返回 403;广播项仅删本人回执。Bearer 守卫保护。
|
|
3
|
+
import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
|
|
3
4
|
import { delNotice } from "../_shared/notify";
|
|
4
5
|
|
|
5
6
|
// POST /api/notification/del —— 删除一条通知(发射原语 HTTP 入口)。
|
|
@@ -12,6 +13,10 @@ const handler = {
|
|
|
12
13
|
json.fail(401, "unauthorized");
|
|
13
14
|
return;
|
|
14
15
|
}
|
|
16
|
+
if (!(await isSelfTenant())) {
|
|
17
|
+
json.fail(403, "forbidden");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
15
20
|
const b = (http.body ?? {}) as { id?: unknown, userId?: unknown };
|
|
16
21
|
const id = String(b.id ?? "");
|
|
17
22
|
if (!id) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
name: notification
|
|
2
2
|
desc: 通知模块(契约化通知读写:读 + 单条已读 + 全部已读 + 清空 + 广播;表与种子归属本模块)
|
|
3
3
|
version: 0.1.0
|
|
4
|
+
deps: # 租户自洽守卫(isSelfTenant)读 _platform 的 users 判定「token 的 sub 是否在本租户」;
|
|
5
|
+
# 跨模块别名 `#/_platform/…` 的依赖门禁(oj build S008)
|
|
6
|
+
_platform: ^0.1.0
|
|
4
7
|
tables:
|
|
5
8
|
- notifications
|
|
6
9
|
- notifications_broadcast
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- 0005__tenant(mysql 方言):通知四表租户化。
|
|
2
|
+
-- 差异:TEXT 不能带字面默认值 → 租户列用 VARCHAR(255)。
|
|
3
|
+
ALTER TABLE notifications ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
4
|
+
ALTER TABLE notifications_broadcast ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
5
|
+
ALTER TABLE notifications_broadcast_read ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
6
|
+
ALTER TABLE notifications_broadcast_hidden ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- 0005__tenant:通知四表租户化(企微代开发 v3 Phase 0 的 A 组)。
|
|
2
|
+
-- 广播/按人通知都是租户内数据:不同 corp 的公告与已读回执必须互不可见。
|
|
3
|
+
ALTER TABLE notifications ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
4
|
+
ALTER TABLE notifications_broadcast ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
5
|
+
ALTER TABLE notifications_broadcast_read ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
6
|
+
ALTER TABLE notifications_broadcast_hidden ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
@@ -5,6 +5,7 @@ tables:
|
|
|
5
5
|
pk: id
|
|
6
6
|
columns:
|
|
7
7
|
id: {type: integer, autoincrement: true}
|
|
8
|
+
tenant_id: {type: text, null: false}
|
|
8
9
|
user_id: {type: integer, null: true}
|
|
9
10
|
avatar: {type: text, null: false}
|
|
10
11
|
date: {type: text, null: false}
|
|
@@ -17,6 +18,7 @@ tables:
|
|
|
17
18
|
pk: id
|
|
18
19
|
columns:
|
|
19
20
|
id: {type: integer, autoincrement: true}
|
|
21
|
+
tenant_id: {type: text, null: false}
|
|
20
22
|
avatar: {type: text, null: false}
|
|
21
23
|
date: {type: text, null: false}
|
|
22
24
|
message: {type: text, null: false}
|
|
@@ -28,6 +30,7 @@ tables:
|
|
|
28
30
|
pk: id
|
|
29
31
|
columns:
|
|
30
32
|
id: {type: integer, autoincrement: true}
|
|
33
|
+
tenant_id: {type: text, null: false}
|
|
31
34
|
broadcast_id: {type: integer, null: false}
|
|
32
35
|
user_id: {type: integer, null: false}
|
|
33
36
|
read_time: {type: text, null: false}
|
|
@@ -35,6 +38,7 @@ tables:
|
|
|
35
38
|
pk: id
|
|
36
39
|
columns:
|
|
37
40
|
id: {type: integer, autoincrement: true}
|
|
41
|
+
tenant_id: {type: text, null: false}
|
|
38
42
|
broadcast_id: {type: integer, null: false}
|
|
39
43
|
user_id: {type: integer, null: false}
|
|
40
44
|
create_time: {type: text, null: false}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { RESERVED_USERNAME_PREFIX } from "#/_platform/_shared/service/domain/users";
|
|
1
2
|
// POST /api/personal-center/profile —— 保存「我的资料」(个人中心表单入库端点)。
|
|
2
3
|
// 只允许改本人行(http.user.id 权威,不接受 body 传 id);username 全局唯一,
|
|
3
4
|
// 重名 400;avatar 走 upload 端点(multipart),不在此通道。
|
|
5
|
+
import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
|
|
6
|
+
|
|
4
7
|
const handler = {
|
|
5
8
|
async post() {
|
|
6
9
|
const uid = http.user?.id;
|
|
@@ -8,6 +11,10 @@ const handler = {
|
|
|
8
11
|
json.fail(401, "unauthorized");
|
|
9
12
|
return;
|
|
10
13
|
}
|
|
14
|
+
if (!(await isSelfTenant())) {
|
|
15
|
+
json.fail(403, "forbidden");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
11
18
|
const b = (http.body ?? {}) as {
|
|
12
19
|
username?: unknown
|
|
13
20
|
email?: unknown
|
|
@@ -17,6 +24,12 @@ const handler = {
|
|
|
17
24
|
const patch: Record<string, string> = {};
|
|
18
25
|
if (b.username != null && String(b.username).trim() !== "") {
|
|
19
26
|
const username = String(b.username).trim();
|
|
27
|
+
// 与建号同红线(B6):自助改名不能抢占 ldap:/sso:/wecom: 命名空间——
|
|
28
|
+
// 否则该外部成员首次登录时 ensureXXXUser 按用户名查到的是**别人那一行**,直接签发别人的 uid。
|
|
29
|
+
if (RESERVED_USERNAME_PREFIX.test(username)) {
|
|
30
|
+
json.fail(400, "username prefix reserved for external identity sources");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
20
33
|
const dup = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: username }).all();
|
|
21
34
|
if (dup.some(r => Number(r.id) !== Number(uid))) {
|
|
22
35
|
json.fail(400, "username already taken");
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// · 大文件(> max_upload):服务端已流式直落 blob、字节不进 handler,此时
|
|
7
7
|
// http.file(0) 会**报错**,必须改走 files[0].key + blob.get(key)。
|
|
8
8
|
// 头像通常远小于阈值,但别写成「只会一种」——换了上传组件/放开体积就会 500。
|
|
9
|
+
import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
|
|
10
|
+
|
|
9
11
|
function toBase64(bytes: Uint8Array): string {
|
|
10
12
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
11
13
|
let out = "";
|
|
@@ -23,7 +25,6 @@ function toBase64(bytes: Uint8Array): string {
|
|
|
23
25
|
return out;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
// POST /api/personal-center/upload —— 头像上传(multipart→base64,P4-3,D7/F4)。
|
|
27
28
|
const handler = {
|
|
28
29
|
async post() {
|
|
29
30
|
const uid = http.user?.id;
|
|
@@ -31,6 +32,12 @@ const handler = {
|
|
|
31
32
|
json.fail(401, "unauthorized");
|
|
32
33
|
return;
|
|
33
34
|
}
|
|
35
|
+
// 写入目标按 uid 定位 users 行:伪造 X-TENANT-ID 时该行在本租户不存在,
|
|
36
|
+
// 不拦就会「更新 0 行却回 200」,还顺带在 blob 里留下垃圾文件
|
|
37
|
+
if (!(await isSelfTenant())) {
|
|
38
|
+
json.fail(403, "forbidden");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
34
41
|
if (!http.files || http.files.length === 0) {
|
|
35
42
|
json.fail(400, "no file");
|
|
36
43
|
return;
|
|
@@ -13,6 +13,7 @@ const Roles = lazy(() => import("./pages/roles"));
|
|
|
13
13
|
const Oplog = lazy(() => import("./pages/oplog"));
|
|
14
14
|
const Settings = lazy(() => import("./pages/settings"));
|
|
15
15
|
const LoginMethods = lazy(() => import("./pages/login-methods"));
|
|
16
|
+
const WecomAdmin = lazy(() => import("./pages/wecom"));
|
|
16
17
|
|
|
17
18
|
const routes: AppRouteRecordRaw[] = [
|
|
18
19
|
{
|
|
@@ -56,6 +57,13 @@ const routes: AppRouteRecordRaw[] = [
|
|
|
56
57
|
Component: LoginMethods,
|
|
57
58
|
handle: { title: "system:menu.loginMethods" },
|
|
58
59
|
},
|
|
60
|
+
{
|
|
61
|
+
// 企微代开发后台(服务商运营面):页面内再判一次 superadmin(路由级不做角色门禁,
|
|
62
|
+
// 与 users/roles 页同策略——403 面板比路由消失更容易解释)
|
|
63
|
+
path: "/system/wecom",
|
|
64
|
+
Component: WecomAdmin,
|
|
65
|
+
handle: { title: "system:menu.wecom" },
|
|
66
|
+
},
|
|
59
67
|
],
|
|
60
68
|
},
|
|
61
69
|
];
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"roles": "Roles & Permissions",
|
|
7
7
|
"oplog": "Audit Log",
|
|
8
8
|
"settings": "Site Settings",
|
|
9
|
-
"loginMethods": "Login Methods"
|
|
9
|
+
"loginMethods": "Login Methods",
|
|
10
|
+
"wecom": "WeCom Integration"
|
|
10
11
|
},
|
|
11
12
|
"common": {
|
|
12
13
|
"confirm": "Confirm",
|
|
@@ -73,7 +74,36 @@
|
|
|
73
74
|
"title": "Enabled login methods",
|
|
74
75
|
"methodLocal": "Local password",
|
|
75
76
|
"methodLdap": "LDAP",
|
|
76
|
-
"methodSso": "SSO"
|
|
77
|
+
"methodSso": "SSO",
|
|
78
|
+
"methodWecom": "WeCom"
|
|
79
|
+
},
|
|
80
|
+
"wecom": {
|
|
81
|
+
"title": "Onboard a corporation",
|
|
82
|
+
"hint": "Click \"Generate install link\" and send the link or QR code to the corporation's admin for approval. The callback on the corporation side writes the row into this table after approval. The link expires in 10 minutes and works once; the QR code greys out when it does, so regenerate it.",
|
|
83
|
+
"appsTitle": "Onboarded corporations",
|
|
84
|
+
"genInstall": "Generate install link",
|
|
85
|
+
"installOk": "Generated. Send the link to the corporation's admin.",
|
|
86
|
+
"installUrl": "Install link",
|
|
87
|
+
"callbackUrl": "Callback URL",
|
|
88
|
+
"expiresIn": "Valid for",
|
|
89
|
+
"expiresValue": "{{count}} minutes",
|
|
90
|
+
"col": {
|
|
91
|
+
"corp": "Corporation",
|
|
92
|
+
"agent": "App",
|
|
93
|
+
"env": "Env",
|
|
94
|
+
"status": "Status",
|
|
95
|
+
"installedAt": "Onboarded at",
|
|
96
|
+
"action": "Actions"
|
|
97
|
+
},
|
|
98
|
+
"statusActive": "Active",
|
|
99
|
+
"statusDisabled": "Disabled",
|
|
100
|
+
"statusOk": "Status updated",
|
|
101
|
+
"statusOkRevoked": "Status updated; {{count}} login sessions of this corporation were revoked",
|
|
102
|
+
"disable": "Disable",
|
|
103
|
+
"enable": "Enable",
|
|
104
|
+
"disableConfirm": "Disable this app? Members of this corporation will no longer be able to sign in with it.",
|
|
105
|
+
"enableConfirm": "Enable this app?",
|
|
106
|
+
"empty": "No corporations onboarded yet"
|
|
77
107
|
},
|
|
78
108
|
"err": {
|
|
79
109
|
"forbidden": "Forbidden",
|
|
@@ -91,6 +121,7 @@
|
|
|
91
121
|
"site_title": "Site Title",
|
|
92
122
|
"ldap_enabled": "Enable LDAP login",
|
|
93
123
|
"sso_enabled": "Enable SSO login",
|
|
124
|
+
"wecom_enabled": "Enable WeCom login",
|
|
94
125
|
"ldap_base_dn": "LDAP Base DN",
|
|
95
126
|
"ldap_uid_attr": "LDAP UID Attribute",
|
|
96
127
|
"ldap_email_attr": "LDAP Email Attribute"
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"roles": "角色权限",
|
|
7
7
|
"oplog": "操作日志",
|
|
8
8
|
"settings": "站点设置",
|
|
9
|
-
"loginMethods": "登录方式"
|
|
9
|
+
"loginMethods": "登录方式",
|
|
10
|
+
"wecom": "企业微信代开发"
|
|
10
11
|
},
|
|
11
12
|
"common": {
|
|
12
13
|
"confirm": "确认",
|
|
@@ -73,7 +74,36 @@
|
|
|
73
74
|
"title": "当前启用的登录方式",
|
|
74
75
|
"methodLocal": "本地口令",
|
|
75
76
|
"methodLdap": "LDAP",
|
|
76
|
-
"methodSso": "SSO 单点登录"
|
|
77
|
+
"methodSso": "SSO 单点登录",
|
|
78
|
+
"methodWecom": "企业微信"
|
|
79
|
+
},
|
|
80
|
+
"wecom": {
|
|
81
|
+
"title": "开通企业",
|
|
82
|
+
"hint": "点「生成开通链接」后把链接或二维码发给企业管理员,由其确认授权;确认后企业侧的回调会写入本表。链接 10 分钟内有效且只能用一次,过期后二维码会置灰,需重新生成。",
|
|
83
|
+
"appsTitle": "已开通企业",
|
|
84
|
+
"genInstall": "生成开通链接",
|
|
85
|
+
"installOk": "已生成,请把链接发给企业管理员",
|
|
86
|
+
"installUrl": "开通链接",
|
|
87
|
+
"callbackUrl": "回调地址",
|
|
88
|
+
"expiresIn": "有效期",
|
|
89
|
+
"expiresValue": "{{count}} 分钟",
|
|
90
|
+
"col": {
|
|
91
|
+
"corp": "企业",
|
|
92
|
+
"agent": "应用",
|
|
93
|
+
"env": "环境",
|
|
94
|
+
"status": "状态",
|
|
95
|
+
"installedAt": "开通时间",
|
|
96
|
+
"action": "操作"
|
|
97
|
+
},
|
|
98
|
+
"statusActive": "启用",
|
|
99
|
+
"statusDisabled": "停用",
|
|
100
|
+
"statusOk": "状态已更新",
|
|
101
|
+
"statusOkRevoked": "状态已更新,并吊销了该企业 {{count}} 个登录会话",
|
|
102
|
+
"disable": "停用",
|
|
103
|
+
"enable": "启用",
|
|
104
|
+
"disableConfirm": "停用该应用?企业成员将无法用它登录。",
|
|
105
|
+
"enableConfirm": "启用该应用?",
|
|
106
|
+
"empty": "暂无已开通企业"
|
|
77
107
|
},
|
|
78
108
|
"err": {
|
|
79
109
|
"forbidden": "无权限",
|
|
@@ -91,6 +121,7 @@
|
|
|
91
121
|
"site_title": "站点标题",
|
|
92
122
|
"ldap_enabled": "启用 LDAP 登录",
|
|
93
123
|
"sso_enabled": "启用 SSO 登录",
|
|
124
|
+
"wecom_enabled": "启用企业微信登录",
|
|
94
125
|
"ldap_base_dn": "LDAP Base DN",
|
|
95
126
|
"ldap_uid_attr": "LDAP UID 属性",
|
|
96
127
|
"ldap_email_attr": "LDAP 邮箱属性"
|
|
@@ -8,9 +8,10 @@ const METHOD_KEY: Record<string, string> = {
|
|
|
8
8
|
local: "system:loginMethodsPage.methodLocal",
|
|
9
9
|
ldap: "system:loginMethodsPage.methodLdap",
|
|
10
10
|
sso: "system:loginMethodsPage.methodSso",
|
|
11
|
+
wecom: "system:loginMethodsPage.methodWecom",
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
/** 登录方式发现(C5/D2,只读):local 恒在,ldap/sso 由设置项开关。 */
|
|
14
|
+
/** 登录方式发现(C5/D2,只读):local 恒在,ldap/sso/wecom 由设置项开关。 */
|
|
14
15
|
export default function LoginMethodsPage() {
|
|
15
16
|
const { t } = useTranslation();
|
|
16
17
|
const query = useQuery({ queryKey: ["system", "login-methods"], queryFn: systemLoginMethodsGet });
|
|
@@ -135,7 +135,7 @@ export default function UsersPage() {
|
|
|
135
135
|
onOk={() => createForm.submit()}
|
|
136
136
|
>
|
|
137
137
|
<Form form={createForm} layout="vertical" onFinish={values => createMut.mutate(values)}>
|
|
138
|
-
<Form.Item name="username" label={t("system:users.columns.username")} rules={[{ required: true }]} extra="ldap:/ sso: 为外部身份源保留">
|
|
138
|
+
<Form.Item name="username" label={t("system:users.columns.username")} rules={[{ required: true }]} extra="ldap:/ sso:/ wecom: 为外部身份源保留">
|
|
139
139
|
<Input />
|
|
140
140
|
</Form.Item>
|
|
141
141
|
<Form.Item name="password" label="Password" rules={[{ required: true }, { min: 6 }]} extra={t("system:users.pwdPlaceholder")}>
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { request, useUserStore } from "@oj-module/runtime";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { App, Button, Card, Descriptions, Popconfirm, QRCode, Space, Table, Tag, Typography } from "antd";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
|
|
7
|
+
import { explainError } from "../errors";
|
|
8
|
+
import { ForbiddenPanel } from "../forbidden";
|
|
9
|
+
|
|
10
|
+
/** GET /auth/wecom/admin/apps 的行(后端不返回 permanent_code,secret 不出库) */
|
|
11
|
+
interface AppRow {
|
|
12
|
+
suite_id: string
|
|
13
|
+
corp_id: string
|
|
14
|
+
agent_id: string
|
|
15
|
+
auth_corp_id: string
|
|
16
|
+
env: string
|
|
17
|
+
corp_name: string | null
|
|
18
|
+
status: "active" | "disabled"
|
|
19
|
+
installed_at: number
|
|
20
|
+
update_time: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface InstallInfo {
|
|
24
|
+
installUrl: string
|
|
25
|
+
callbackUrl: string
|
|
26
|
+
expiresIn: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 端点不在本模块的生成 client 里(`__client` 按模块切分),故直接用 runtime 的 ky 出口。
|
|
31
|
+
* 路径不带前导 `/`:与 request 的 baseURL 拼接口径一致(fetchLogin 同款)。
|
|
32
|
+
*/
|
|
33
|
+
async function fetchApps(): Promise<AppRow[]> {
|
|
34
|
+
const env = await request.get("auth/wecom/admin/apps").json<{ code: number, data?: AppRow[] }>();
|
|
35
|
+
return env.data ?? [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function setStatus(input: { corpId: string, agentId: string, status: "active" | "disabled" }): Promise<{ updated: number, revokedSessions: number }> {
|
|
39
|
+
const env = await request.post("auth/wecom/admin/status", { json: input }).json<{ code: number, data?: { updated: number, revokedSessions: number } }>();
|
|
40
|
+
return env.data ?? { updated: 0, revokedSessions: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createInstall(): Promise<InstallInfo> {
|
|
44
|
+
const env = await request.get("auth/wecom/admin/install").json<{ code: number, data?: InstallInfo }>();
|
|
45
|
+
if (!env.data)
|
|
46
|
+
throw new Error("企业微信未返回开通链接");
|
|
47
|
+
return env.data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 企微代开发后台(服务商运营面,superadmin 独占):
|
|
52
|
+
* · 已授权企业 × 应用清单(同一企业测试/正式两个应用各一行);
|
|
53
|
+
* · 启停(停用企业最后一个应用时后端同时吊销该企业会话);
|
|
54
|
+
* · 开通入口(预授权码 10 分钟有效,每次点都重新换)。
|
|
55
|
+
*/
|
|
56
|
+
export default function WecomAdminPage() {
|
|
57
|
+
const { t } = useTranslation();
|
|
58
|
+
const { message } = App.useApp();
|
|
59
|
+
const queryClient = useQueryClient();
|
|
60
|
+
const roles = useUserStore(s => s.roles);
|
|
61
|
+
const isSuper = roles.includes("superadmin");
|
|
62
|
+
|
|
63
|
+
const [install, setInstall] = useState<InstallInfo | null>(null);
|
|
64
|
+
const [expired, setExpired] = useState(false);
|
|
65
|
+
|
|
66
|
+
// 预授权码只有 10 分钟:到点把码置灰(antd QRCode 的 expired 态自带「刷新」入口),
|
|
67
|
+
// 免得操作员把一张已失效的码发出去、企业管理员点开只看到企微的「链接无效」。
|
|
68
|
+
// 计时器只负责「置灰」,复位放在生成成功那一刻(effect 里同步 setState 会多一次渲染)。
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!install)
|
|
71
|
+
return undefined;
|
|
72
|
+
const timer = setTimeout(setExpired, install.expiresIn * 1000, true);
|
|
73
|
+
return () => clearTimeout(timer);
|
|
74
|
+
}, [install]);
|
|
75
|
+
|
|
76
|
+
const appsQuery = useQuery({
|
|
77
|
+
queryKey: ["system", "wecom-apps"],
|
|
78
|
+
queryFn: fetchApps,
|
|
79
|
+
enabled: isSuper,
|
|
80
|
+
retry: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const statusMut = useMutation({
|
|
84
|
+
mutationFn: setStatus,
|
|
85
|
+
onSuccess: (r) => {
|
|
86
|
+
// 吊销了会话就明确说出来:这是「有人被踢下线」的事实,不该只显示「已保存」
|
|
87
|
+
message.success(r.revokedSessions > 0
|
|
88
|
+
? t("system:wecom.statusOkRevoked", { count: r.revokedSessions })
|
|
89
|
+
: t("system:wecom.statusOk"));
|
|
90
|
+
queryClient.invalidateQueries({ queryKey: ["system", "wecom-apps"] });
|
|
91
|
+
},
|
|
92
|
+
onError: e => message.error(explainError(e, t)),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const installMut = useMutation({
|
|
96
|
+
mutationFn: createInstall,
|
|
97
|
+
onSuccess: (r) => {
|
|
98
|
+
setInstall(r);
|
|
99
|
+
setExpired(false);
|
|
100
|
+
// 预授权码一次有效:链接只在该页面的内存里,不写 localStorage
|
|
101
|
+
message.success(t("system:wecom.installOk"));
|
|
102
|
+
},
|
|
103
|
+
onError: e => message.error(explainError(e, t)),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!isSuper)
|
|
107
|
+
return <ForbiddenPanel t={t} />;
|
|
108
|
+
|
|
109
|
+
const columns = [
|
|
110
|
+
{
|
|
111
|
+
title: t("system:wecom.col.corp"),
|
|
112
|
+
dataIndex: "corp_id",
|
|
113
|
+
render: (_: string, row: AppRow) => (
|
|
114
|
+
<div>
|
|
115
|
+
<div>{row.corp_id}</div>
|
|
116
|
+
{row.corp_name && <Typography.Text type="secondary">{row.corp_name}</Typography.Text>}
|
|
117
|
+
</div>
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
{ title: t("system:wecom.col.agent"), dataIndex: "agent_id" },
|
|
121
|
+
{ title: t("system:wecom.col.env"), dataIndex: "env", render: (v: string) => <Tag>{v}</Tag> },
|
|
122
|
+
{
|
|
123
|
+
title: t("system:wecom.col.status"),
|
|
124
|
+
dataIndex: "status",
|
|
125
|
+
render: (v: string) => (
|
|
126
|
+
<Tag color={v === "active" ? "green" : "red"}>
|
|
127
|
+
{t(v === "active" ? "system:wecom.statusActive" : "system:wecom.statusDisabled")}
|
|
128
|
+
</Tag>
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
title: t("system:wecom.col.installedAt"),
|
|
133
|
+
dataIndex: "installed_at",
|
|
134
|
+
render: (v: number) => (v ? new Date(v * 1000).toLocaleString() : "-"),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
title: t("system:wecom.col.action"),
|
|
138
|
+
key: "action",
|
|
139
|
+
render: (_: unknown, row: AppRow) => {
|
|
140
|
+
const next = row.status === "active" ? "disabled" : "active";
|
|
141
|
+
return (
|
|
142
|
+
<Popconfirm
|
|
143
|
+
title={t(next === "disabled" ? "system:wecom.disableConfirm" : "system:wecom.enableConfirm")}
|
|
144
|
+
onConfirm={() => statusMut.mutate({ corpId: row.corp_id, agentId: row.agent_id, status: next })}
|
|
145
|
+
>
|
|
146
|
+
<Button danger={next === "disabled"} size="small" loading={statusMut.isPending}>
|
|
147
|
+
{t(next === "disabled" ? "system:wecom.disable" : "system:wecom.enable")}
|
|
148
|
+
</Button>
|
|
149
|
+
</Popconfirm>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
|
157
|
+
<Card
|
|
158
|
+
title={t("system:wecom.title")}
|
|
159
|
+
extra={(
|
|
160
|
+
<Button type="primary" loading={installMut.isPending} onClick={() => installMut.mutate()}>
|
|
161
|
+
{t("system:wecom.genInstall")}
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<Typography.Paragraph type="secondary">{t("system:wecom.hint")}</Typography.Paragraph>
|
|
166
|
+
{install && (
|
|
167
|
+
<div style={{ display: "flex", gap: 24, alignItems: "flex-start", flexWrap: "wrap" }}>
|
|
168
|
+
{/* 二维码用 antd 内置 QRCode(同一份共享 antd,不额外引二维码依赖):
|
|
169
|
+
企业管理员多半在手机上收通知,扫码比复制链接快 */}
|
|
170
|
+
<QRCode
|
|
171
|
+
value={install.installUrl}
|
|
172
|
+
size={160}
|
|
173
|
+
type="svg"
|
|
174
|
+
bordered
|
|
175
|
+
status={expired ? "expired" : "active"}
|
|
176
|
+
onRefresh={() => installMut.mutate()}
|
|
177
|
+
/>
|
|
178
|
+
<Descriptions column={1} size="small" bordered style={{ flex: 1, minWidth: 320 }}>
|
|
179
|
+
<Descriptions.Item label={t("system:wecom.installUrl")}>
|
|
180
|
+
<Typography.Text copyable={{ text: install.installUrl }}>{install.installUrl}</Typography.Text>
|
|
181
|
+
</Descriptions.Item>
|
|
182
|
+
<Descriptions.Item label={t("system:wecom.callbackUrl")}>
|
|
183
|
+
<Typography.Text copyable={{ text: install.callbackUrl }}>{install.callbackUrl}</Typography.Text>
|
|
184
|
+
</Descriptions.Item>
|
|
185
|
+
<Descriptions.Item label={t("system:wecom.expiresIn")}>
|
|
186
|
+
{t("system:wecom.expiresValue", { count: Math.round(install.expiresIn / 60) })}
|
|
187
|
+
</Descriptions.Item>
|
|
188
|
+
</Descriptions>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</Card>
|
|
192
|
+
<Card title={t("system:wecom.appsTitle")}>
|
|
193
|
+
<Table<AppRow>
|
|
194
|
+
rowKey={row => `${row.suite_id}:${row.corp_id}:${row.agent_id}`}
|
|
195
|
+
size="small"
|
|
196
|
+
loading={appsQuery.isLoading}
|
|
197
|
+
dataSource={appsQuery.data ?? []}
|
|
198
|
+
columns={columns}
|
|
199
|
+
pagination={false}
|
|
200
|
+
locale={{ emptyText: t("system:wecom.empty") }}
|
|
201
|
+
/>
|
|
202
|
+
</Card>
|
|
203
|
+
</Space>
|
|
204
|
+
);
|
|
205
|
+
}
|