@oj-module/cli 0.2.48 → 0.2.50
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 +9 -7
- package/devkit/SKILL.md +53 -14
- package/devkit/manual.md +203 -3
- package/devkit/wecom-daikaifa.md +100 -1
- package/devkit/wecom-internal-app.md +643 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +80 -0
- package/src/build.ts +50 -0
- package/src/init.ts +5 -4
- package/templates/config.yaml +28 -6
- package/templates/openapi.json +174 -2
- package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
- package/templates/src/auth/api/_shared/wecom/client.ts +42 -6
- 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 +25 -6
- package/templates/src/auth/api/_shared/wecom/login.ts +44 -4
- package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
- package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
- package/templates/src/auth/api/_shared/wecom/sync.ts +25 -1
- 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/api.ts +151 -59
- 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/locales/en-US.json +2 -0
- package/templates/src/login/web/locales/zh-CN.json +2 -0
- package/templates/src/login/web/pages/login.tsx +39 -3
- package/templates/src/system/web/locales/en-US.json +24 -1
- package/templates/src/system/web/locales/zh-CN.json +24 -1
- package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
- package/templates/src/system/web/pages/wecom/index.tsx +164 -46
- package/templates/tests/wecom-admin.spec.ts +7 -7
- package/templates/tests/wecom-connector.spec.ts +38 -8
- package/templates/tests/wecom-corp.spec.ts +523 -0
- package/templates/tests/wecom-endpoints.spec.ts +73 -7
- package/templates/tests/wecom-org-sync.spec.ts +7 -1
- package/vendor/host-versions.json +3 -3
|
@@ -66,8 +66,13 @@ export default function LoginPage() {
|
|
|
66
66
|
const current = useTenantStore.getState().tenant;
|
|
67
67
|
return current === DEFAULT_TENANT ? "" : current;
|
|
68
68
|
});
|
|
69
|
-
//
|
|
70
|
-
|
|
69
|
+
// 企微深链入口:应用「主页」配成 /login?corp=<corpId>&agent=<agentId>,
|
|
70
|
+
// 成员从企微内点开即带参到达——据此预填企业代码/应用,并由下方 effect 自动发起授权。
|
|
71
|
+
const urlCorp = new URLSearchParams(search).get("corp")?.trim() ?? "";
|
|
72
|
+
const urlAgent = new URLSearchParams(search).get("agent")?.trim() ?? "";
|
|
73
|
+
// 企微 tab 的企业代码(优先用 URL 深链带的,其次已持久化的值,单租户的 default 不预填)
|
|
74
|
+
const [corp, setCorp] = useState(() => urlCorp || tenant);
|
|
75
|
+
const [agent, setAgent] = useState(() => urlAgent);
|
|
71
76
|
|
|
72
77
|
// 企微 302 落地参数:只在首次渲染解析一次(ticket 一次性,解析结果必须稳定)
|
|
73
78
|
const [landing] = useState(() => parseWecomLanding(search));
|
|
@@ -110,6 +115,23 @@ export default function LoginPage() {
|
|
|
110
115
|
})();
|
|
111
116
|
}, [landing, login, navigate, search]);
|
|
112
117
|
|
|
118
|
+
// 企微深链自动入口:带 ?corp= 到达(且非回跳落地 / 非出错)且企微方式可用时,
|
|
119
|
+
// 直接整页发起授权——「点开即登录」,无需手动点「企业微信」tab。
|
|
120
|
+
const autoFiredRef = useRef(false);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const c = new URLSearchParams(search).get("corp")?.trim() ?? "";
|
|
123
|
+
// 企微内(wxwork UA)从「应用主页」点开即自动发起授权;带 ?corp= 的深链同样自动发起。
|
|
124
|
+
const inWecom = typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent);
|
|
125
|
+
if ((!c && !inWecom) || landing || autoFiredRef.current)
|
|
126
|
+
return;
|
|
127
|
+
if (new URLSearchParams(search).get("error"))
|
|
128
|
+
return;
|
|
129
|
+
if (!methods.includes("wecom"))
|
|
130
|
+
return;
|
|
131
|
+
autoFiredRef.current = true;
|
|
132
|
+
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
|
|
133
|
+
}, [landing, methods, search, corp, agent]);
|
|
134
|
+
|
|
113
135
|
async function onFinish(values: LoginFormValues, method: LoginMethod) {
|
|
114
136
|
setLoading(true);
|
|
115
137
|
setError(undefined);
|
|
@@ -144,7 +166,7 @@ export default function LoginPage() {
|
|
|
144
166
|
// 必须拼 API base(部署期可为 /api 或网关前缀);拼站点 base 只会落到
|
|
145
167
|
// SPA 兜底页——点击后页面原地重载、一个 auth 请求都不发。
|
|
146
168
|
// 企业代码不在这里写 store——只有服务端回跳带的 tenant 才是权威值。
|
|
147
|
-
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
169
|
+
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
|
|
148
170
|
}
|
|
149
171
|
|
|
150
172
|
const showTabs = methods.length > 1;
|
|
@@ -180,6 +202,7 @@ export default function LoginPage() {
|
|
|
180
202
|
// 受控值不走 antd 表单(Form.Item 无 name),故显式给 id/htmlFor:
|
|
181
203
|
// 少了这层关联,标签只是视觉文本,读屏与 getByLabel 都取不到输入框。
|
|
182
204
|
const wecomCorpId = "wecom-corp";
|
|
205
|
+
const wecomAgentId = "wecom-agent";
|
|
183
206
|
const wecomPanel = (
|
|
184
207
|
<Form layout="vertical" onFinish={wecomLogin}>
|
|
185
208
|
<Form.Item
|
|
@@ -195,6 +218,19 @@ export default function LoginPage() {
|
|
|
195
218
|
onChange={e => setCorp(e.target.value)}
|
|
196
219
|
/>
|
|
197
220
|
</Form.Item>
|
|
221
|
+
<Form.Item
|
|
222
|
+
label={t("login:method.wecom.agent")}
|
|
223
|
+
htmlFor={wecomAgentId}
|
|
224
|
+
extra={t("login:method.wecom.agentHint")}
|
|
225
|
+
>
|
|
226
|
+
<Input
|
|
227
|
+
id={wecomAgentId}
|
|
228
|
+
autoComplete="off"
|
|
229
|
+
placeholder="1000..."
|
|
230
|
+
value={agent}
|
|
231
|
+
onChange={e => setAgent(e.target.value)}
|
|
232
|
+
/>
|
|
233
|
+
</Form.Item>
|
|
198
234
|
<Button type="primary" htmlType="submit" block>
|
|
199
235
|
{t("login:method.wecom.action")}
|
|
200
236
|
</Button>
|
|
@@ -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,28 @@
|
|
|
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."
|
|
208
231
|
}
|
|
209
232
|
},
|
|
210
233
|
"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,28 @@
|
|
|
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;同步到多少人取决于「应用可见范围」,要全量请设为全公司。"
|
|
208
231
|
}
|
|
209
232
|
},
|
|
210
233
|
"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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { request, useUserStore } from "@oj-module/runtime";
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
-
import { App, Button, Card, Descriptions, Popconfirm, QRCode, Space, Table, Tag, Typography } from "antd";
|
|
3
|
+
import { Alert, App, Button, Card, Descriptions, Popconfirm, QRCode, Space, Table, Tag, Typography } from "antd";
|
|
4
4
|
import { useEffect, useState } from "react";
|
|
5
5
|
import { useTranslation } from "react-i18next";
|
|
6
6
|
|
|
@@ -16,7 +16,9 @@ interface AppRow {
|
|
|
16
16
|
env: string
|
|
17
17
|
corp_name: string | null
|
|
18
18
|
status: "active" | "disabled"
|
|
19
|
-
/**
|
|
19
|
+
/** 应用来源:suite=代开发 / corp=企业内部应用(自建) */
|
|
20
|
+
credential_kind: "suite" | "corp"
|
|
21
|
+
/** 0=管理员授权 / 1=成员授权:成员授权下这家企业的应用做不了组织架构同步;自建行恒 0 */
|
|
20
22
|
auth_mode: number
|
|
21
23
|
installed_at: number
|
|
22
24
|
update_time: number
|
|
@@ -28,6 +30,23 @@ interface InstallInfo {
|
|
|
28
30
|
expiresIn: number
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/** GET /auth/wecom/admin/overview:本部署配了哪几套凭据、回调地址填什么 */
|
|
34
|
+
interface Overview {
|
|
35
|
+
kinds: Array<"suite" | "corp">
|
|
36
|
+
configError: string | null
|
|
37
|
+
callbackUrl: string | null
|
|
38
|
+
corpId: string | null
|
|
39
|
+
agentId: string | null
|
|
40
|
+
corpCallbackConfigured: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ConnectResult {
|
|
44
|
+
corpId: string
|
|
45
|
+
agentId: string
|
|
46
|
+
departments: number
|
|
47
|
+
members: number
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
/**
|
|
32
51
|
* 端点不在本模块的生成 client 里(`__client` 按模块切分),故直接用 runtime 的 ky 出口。
|
|
33
52
|
* 路径不带前导 `/`:与 request 的 baseURL 拼接口径一致(fetchLogin 同款)。
|
|
@@ -37,6 +56,11 @@ async function fetchApps(): Promise<AppRow[]> {
|
|
|
37
56
|
return env.data ?? [];
|
|
38
57
|
}
|
|
39
58
|
|
|
59
|
+
async function fetchOverview(): Promise<Overview> {
|
|
60
|
+
const env = await request.get("auth/wecom/admin/overview").json<{ code: number, data?: Overview }>();
|
|
61
|
+
return env.data ?? { kinds: [], configError: null, callbackUrl: null, corpId: null, agentId: null, corpCallbackConfigured: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
async function setStatus(input: { corpId: string, agentId: string, status: "active" | "disabled" }): Promise<{ updated: number, revokedSessions: number }> {
|
|
41
65
|
const env = await request.post("auth/wecom/admin/status", { json: input }).json<{ code: number, data?: { updated: number, revokedSessions: number } }>();
|
|
42
66
|
return env.data ?? { updated: 0, revokedSessions: 0 };
|
|
@@ -49,12 +73,19 @@ async function createInstall(): Promise<InstallInfo> {
|
|
|
49
73
|
return env.data;
|
|
50
74
|
}
|
|
51
75
|
|
|
76
|
+
async function connectCorp(): Promise<ConnectResult> {
|
|
77
|
+
const env = await request.post("auth/wecom/admin/connect", { json: {} }).json<{ code: number, data?: ConnectResult }>();
|
|
78
|
+
if (!env.data)
|
|
79
|
+
throw new Error("企业微信未返回接入结果");
|
|
80
|
+
return env.data;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
/**
|
|
53
|
-
*
|
|
54
|
-
* ·
|
|
55
|
-
*
|
|
56
|
-
* ·
|
|
57
|
-
*
|
|
84
|
+
* 企微后台(superadmin 独占),两块卡片按「本部署配了哪套凭据」决定显不显示:
|
|
85
|
+
* · 代开发:已授权企业 × 应用清单、启停、开通入口(点「生成开通链接」→ 后端调
|
|
86
|
+
* get_customized_auth_url 换 qrcode_url,企业管理员扫/开即授权,由 create_auth 回调落库);
|
|
87
|
+
* · 企业内部应用(自建):本企业的那一份凭据 + 回调地址 + 「验证并接入」。
|
|
88
|
+
* 两套可以同时启用(`wecom_allow_coexist: 'true'`),此时两张卡片并列,清单里按「来源」区分。
|
|
58
89
|
*/
|
|
59
90
|
export default function WecomAdminPage() {
|
|
60
91
|
const { t } = useTranslation();
|
|
@@ -83,6 +114,14 @@ export default function WecomAdminPage() {
|
|
|
83
114
|
retry: false,
|
|
84
115
|
});
|
|
85
116
|
|
|
117
|
+
// 概览读的是**部署常量**而不是库:配置错的时候清单可能是空的,而这里必须还能说清「缺什么」
|
|
118
|
+
const overviewQuery = useQuery({
|
|
119
|
+
queryKey: ["system", "wecom-overview"],
|
|
120
|
+
queryFn: fetchOverview,
|
|
121
|
+
enabled: isSuper,
|
|
122
|
+
retry: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
86
125
|
const statusMut = useMutation({
|
|
87
126
|
mutationFn: setStatus,
|
|
88
127
|
onSuccess: (r) => {
|
|
@@ -106,9 +145,24 @@ export default function WecomAdminPage() {
|
|
|
106
145
|
onError: e => message.error(explainError(e, t)),
|
|
107
146
|
});
|
|
108
147
|
|
|
148
|
+
const connectMut = useMutation({
|
|
149
|
+
mutationFn: connectCorp,
|
|
150
|
+
onSuccess: (r) => {
|
|
151
|
+
message.success(t("system:wecom.corp.connectOk", { depts: r.departments, members: r.members }));
|
|
152
|
+
queryClient.invalidateQueries({ queryKey: ["system", "wecom-apps"] });
|
|
153
|
+
queryClient.invalidateQueries({ queryKey: ["system", "wecom-overview"] });
|
|
154
|
+
},
|
|
155
|
+
onError: e => message.error(explainError(e, t)),
|
|
156
|
+
});
|
|
157
|
+
|
|
109
158
|
if (!isSuper)
|
|
110
159
|
return <ForbiddenPanel t={t} />;
|
|
111
160
|
|
|
161
|
+
const overview = overviewQuery.data;
|
|
162
|
+
const kinds = overview?.kinds ?? [];
|
|
163
|
+
const hasSuite = kinds.includes("suite");
|
|
164
|
+
const hasCorp = kinds.includes("corp");
|
|
165
|
+
|
|
112
166
|
const columns = [
|
|
113
167
|
{
|
|
114
168
|
title: t("system:wecom.col.corp"),
|
|
@@ -120,16 +174,26 @@ export default function WecomAdminPage() {
|
|
|
120
174
|
</div>
|
|
121
175
|
),
|
|
122
176
|
},
|
|
177
|
+
{
|
|
178
|
+
// 来源列:共存部署里清单会同时出现两类行,不标出来就分不清「这行是谁装进来的」
|
|
179
|
+
title: t("system:wecom.col.source"),
|
|
180
|
+
dataIndex: "credential_kind",
|
|
181
|
+
render: (v: AppRow["credential_kind"]) => (v === "corp"
|
|
182
|
+
? <Tag color="blue">{t("system:wecom.source.corp")}</Tag>
|
|
183
|
+
: <Tag>{t("system:wecom.source.suite")}</Tag>),
|
|
184
|
+
},
|
|
123
185
|
{ title: t("system:wecom.col.agent"), dataIndex: "agent_id" },
|
|
124
186
|
{ title: t("system:wecom.col.env"), dataIndex: "env", render: (v: string) => <Tag>{v}</Tag> },
|
|
125
187
|
{
|
|
126
188
|
// 权限三态:成员授权是「装了也同步不了」的硬约束(企微侧通讯录接口一律 60011),
|
|
127
|
-
//
|
|
189
|
+
// 摆在清单上,服务商不用点进去试一遍才知道。自建应用没有这个概念,显示空值。
|
|
128
190
|
title: t("system:wecom.col.authMode"),
|
|
129
191
|
dataIndex: "auth_mode",
|
|
130
|
-
render: (v: number) => (
|
|
131
|
-
? <
|
|
132
|
-
:
|
|
192
|
+
render: (v: number, row: AppRow) => (row.credential_kind === "corp"
|
|
193
|
+
? <Typography.Text type="secondary">{t("system:wecom.authModeNone")}</Typography.Text>
|
|
194
|
+
: v === 0
|
|
195
|
+
? <Tag color="green">{t("system:wecom.authModeAdmin")}</Tag>
|
|
196
|
+
: <Tag color="orange">{t("system:wecom.authModeMember")}</Tag>),
|
|
133
197
|
},
|
|
134
198
|
{
|
|
135
199
|
title: t("system:wecom.col.status"),
|
|
@@ -166,41 +230,95 @@ export default function WecomAdminPage() {
|
|
|
166
230
|
|
|
167
231
|
return (
|
|
168
232
|
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
233
|
+
{overview?.configError && (
|
|
234
|
+
<Alert
|
|
235
|
+
type="error"
|
|
236
|
+
showIcon
|
|
237
|
+
message={t("system:wecom.configErrorTitle")}
|
|
238
|
+
description={overview.configError}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
241
|
+
{hasSuite && hasCorp && (
|
|
242
|
+
<Alert type="info" showIcon message={t("system:wecom.coexistHint")} />
|
|
243
|
+
)}
|
|
244
|
+
{hasSuite && (
|
|
245
|
+
<Card
|
|
246
|
+
title={t("system:wecom.title")}
|
|
247
|
+
extra={(
|
|
248
|
+
<Button type="primary" loading={installMut.isPending} onClick={() => installMut.mutate()}>
|
|
249
|
+
{t("system:wecom.genInstall")}
|
|
250
|
+
</Button>
|
|
251
|
+
)}
|
|
252
|
+
>
|
|
253
|
+
<Typography.Paragraph type="secondary">{t("system:wecom.hint")}</Typography.Paragraph>
|
|
254
|
+
{install && (
|
|
255
|
+
<div style={{ display: "flex", gap: 24, alignItems: "flex-start", flexWrap: "wrap" }}>
|
|
256
|
+
{/* 二维码用 antd 内置 QRCode(同一份共享 antd,不额外引二维码依赖):
|
|
257
|
+
企业管理员多半在手机上收通知,扫码比复制链接快 */}
|
|
258
|
+
<QRCode
|
|
259
|
+
value={install.installUrl}
|
|
260
|
+
size={160}
|
|
261
|
+
type="svg"
|
|
262
|
+
bordered
|
|
263
|
+
status={expired ? "expired" : "active"}
|
|
264
|
+
onRefresh={() => installMut.mutate()}
|
|
265
|
+
/>
|
|
266
|
+
<Descriptions column={1} size="small" bordered style={{ flex: 1, minWidth: 320 }}>
|
|
267
|
+
<Descriptions.Item label={t("system:wecom.installUrl")}>
|
|
268
|
+
<Typography.Text copyable={{ text: install.installUrl }}>{install.installUrl}</Typography.Text>
|
|
269
|
+
</Descriptions.Item>
|
|
270
|
+
<Descriptions.Item label={t("system:wecom.callbackUrl")}>
|
|
271
|
+
<Typography.Text copyable={{ text: install.callbackUrl }}>{install.callbackUrl}</Typography.Text>
|
|
272
|
+
</Descriptions.Item>
|
|
273
|
+
<Descriptions.Item label={t("system:wecom.expiresIn")}>
|
|
274
|
+
{t("system:wecom.expiresValue", { count: Math.round(install.expiresIn / 60) })}
|
|
275
|
+
</Descriptions.Item>
|
|
276
|
+
</Descriptions>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</Card>
|
|
280
|
+
)}
|
|
281
|
+
{hasCorp && (
|
|
282
|
+
<Card
|
|
283
|
+
title={t("system:wecom.corp.title")}
|
|
284
|
+
extra={(
|
|
285
|
+
<Popconfirm
|
|
286
|
+
title={t("system:wecom.corp.connectConfirm")}
|
|
287
|
+
onConfirm={() => connectMut.mutate()}
|
|
288
|
+
>
|
|
289
|
+
<Button type="primary" loading={connectMut.isPending}>
|
|
290
|
+
{t("system:wecom.corp.connect")}
|
|
291
|
+
</Button>
|
|
292
|
+
</Popconfirm>
|
|
293
|
+
)}
|
|
294
|
+
>
|
|
295
|
+
<Typography.Paragraph type="secondary">{t("system:wecom.corp.hint")}</Typography.Paragraph>
|
|
296
|
+
<Descriptions column={1} size="small" bordered>
|
|
297
|
+
<Descriptions.Item label={t("system:wecom.corp.corpId")}>
|
|
298
|
+
<Typography.Text>{overview?.corpId ?? "-"}</Typography.Text>
|
|
299
|
+
</Descriptions.Item>
|
|
300
|
+
<Descriptions.Item label={t("system:wecom.corp.agentId")}>
|
|
301
|
+
<Typography.Text>{overview?.agentId ?? "-"}</Typography.Text>
|
|
302
|
+
</Descriptions.Item>
|
|
303
|
+
<Descriptions.Item label={t("system:wecom.corp.callbackUrl")}>
|
|
304
|
+
{overview?.callbackUrl
|
|
305
|
+
? <Typography.Text copyable={{ text: overview.callbackUrl }}>{overview.callbackUrl}</Typography.Text>
|
|
306
|
+
: "-"}
|
|
307
|
+
</Descriptions.Item>
|
|
308
|
+
<Descriptions.Item label={t("system:wecom.corp.callbackConfigured")}>
|
|
309
|
+
{overview?.corpCallbackConfigured
|
|
310
|
+
? <Tag color="green">{t("system:wecom.corp.callbackYes")}</Tag>
|
|
311
|
+
: <Tag color="orange">{t("system:wecom.corp.callbackNo")}</Tag>}
|
|
312
|
+
</Descriptions.Item>
|
|
313
|
+
</Descriptions>
|
|
314
|
+
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
|
315
|
+
{t("system:wecom.corp.callbackHint")}
|
|
316
|
+
</Typography.Paragraph>
|
|
317
|
+
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
|
|
318
|
+
{t("system:wecom.corp.connectHint")}
|
|
319
|
+
</Typography.Paragraph>
|
|
320
|
+
</Card>
|
|
321
|
+
)}
|
|
204
322
|
<Card title={t("system:wecom.appsTitle")}>
|
|
205
323
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
206
324
|
{t("system:wecom.authModeHint")}
|
|
@@ -15,7 +15,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
|
15
15
|
import { assertSuperAdmin, invalidateAllUsers } from "../src/_platform/api/_shared/service/infra/access";
|
|
16
16
|
import { listApps, setAppStatus } from "../src/auth/api/_shared/wecom/admin";
|
|
17
17
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../src/auth/api/_shared/wecom/client";
|
|
18
|
-
import {
|
|
18
|
+
import { readSuiteConfig } from "../src/auth/api/_shared/wecom/config";
|
|
19
19
|
import { installDb } from "./fixture";
|
|
20
20
|
|
|
21
21
|
const g = globalThis as any;
|
|
@@ -123,7 +123,7 @@ function fakeClient(): { client: any, invalidated: string[] } {
|
|
|
123
123
|
const deps = { store: { encrypt: (p: string) => `gcm(${p})`, decrypt: (b: string) => b, now: () => NOW } };
|
|
124
124
|
|
|
125
125
|
/** createInstall 的薄包装:省掉动态 import 的样板 */
|
|
126
|
-
async function createInstallForTest(cfg: ReturnType<typeof
|
|
126
|
+
async function createInstallForTest(cfg: ReturnType<typeof readSuiteConfig>, client: ReturnType<typeof createWecomClient>, state: string) {
|
|
127
127
|
const { createInstall } = await import("../src/auth/api/_shared/wecom/admin");
|
|
128
128
|
return createInstall(cfg, client, state);
|
|
129
129
|
}
|
|
@@ -274,7 +274,7 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
|
|
|
274
274
|
|
|
275
275
|
it("provider_token → get_customized_auth_url → qrcode_url(即开通链接),且绝不调 get_pre_auth_code", async () => {
|
|
276
276
|
const { calls, bodies } = stubUpstream();
|
|
277
|
-
const cfg =
|
|
277
|
+
const cfg = readSuiteConfig();
|
|
278
278
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
279
279
|
|
|
280
280
|
const r = await createInstallForTest(cfg, client, "st_1");
|
|
@@ -297,7 +297,7 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
|
|
|
297
297
|
|
|
298
298
|
it("调用方没传 state 时仍发默认 state(避免 40058 state missing)", async () => {
|
|
299
299
|
const { bodies } = stubUpstream();
|
|
300
|
-
const cfg =
|
|
300
|
+
const cfg = readSuiteConfig();
|
|
301
301
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
302
302
|
await createInstallForTest(cfg, client, "");
|
|
303
303
|
const body = bodies.find(b => b && b.templateid_list) as any;
|
|
@@ -307,14 +307,14 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
|
|
|
307
307
|
|
|
308
308
|
it("缺 provider 凭证(wecom_provider_id / wecom_provider_secret)→ 400 并点名缺哪两个 vars", async () => {
|
|
309
309
|
installVars({ wecom_provider_id: "", wecom_provider_secret: "" });
|
|
310
|
-
const cfg =
|
|
310
|
+
const cfg = readSuiteConfig();
|
|
311
311
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
312
312
|
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 400 });
|
|
313
313
|
});
|
|
314
314
|
|
|
315
315
|
it("上游 errcode(如 provider_secret 错)→ 502(可重试语义,不是 500)", async () => {
|
|
316
316
|
g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid provider secret" }) });
|
|
317
|
-
const cfg =
|
|
317
|
+
const cfg = readSuiteConfig();
|
|
318
318
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
319
319
|
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 502 });
|
|
320
320
|
});
|
|
@@ -330,7 +330,7 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
|
|
|
330
330
|
throw new Error(`stub fetch 未预期:${url}`);
|
|
331
331
|
},
|
|
332
332
|
});
|
|
333
|
-
const cfg =
|
|
333
|
+
const cfg = readSuiteConfig();
|
|
334
334
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
335
335
|
await expect(createInstallForTest(cfg, client, "st_1")).rejects.toMatchObject({
|
|
336
336
|
status: 502,
|