@oj-module/cli 0.2.50 → 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/wecom-internal-app.md +111 -4
- 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/shared-deps.ts +27 -9
- package/src/site-base.ts +1 -1
- package/templates/init.include.yaml +4 -7
- package/templates/openapi.json +2520 -2412
- package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
- package/templates/src/auth/api/_shared/wecom/admin.ts +4 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
- package/templates/src/auth/api/_shared/wecom/events.ts +3 -0
- package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
- 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/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 +14 -0
- package/templates/src/system/web/locales/zh-CN.json +14 -0
- 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 +130 -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
|
@@ -59,13 +59,30 @@ function assertNotSelf(actorId: number, targetUid: number): void {
|
|
|
59
59
|
fail(400, "cannot operate on yourself");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* AC1-8(已修订):成员资格提权守卫——按**被授予的角色**分层,不再一刀切。
|
|
64
|
+
*
|
|
65
|
+
* · `superadmin` —— 仍严格只许 superadmin 授。它是**平台级**角色:握着它就能进
|
|
66
|
+
* /system/wecom 那类跨租户页面(listApps 列出**所有企业**的档案),一旦租户内可授
|
|
67
|
+
* 即为跨租户越权,故这条不因下面的修订而放宽。
|
|
68
|
+
* · `admin` —— 允许本租户内握有 `admin` 或 `superadmin` 的人任命。
|
|
69
|
+
* 判定与写入两侧都被租户作用域约束:`rolesOf()` 读 sys_user_role 走请求租户注入,
|
|
70
|
+
* 下面的写入同样带 tenant_id,所以租户管理员只能影响**自己租户**,不会越到别家。
|
|
71
|
+
* 动机:企业租户是隐式具象化的,平台 superadmin 因租户隔离查不到企业租户的用户
|
|
72
|
+
* (实测 404),若 admin 任命也只许 superadmin,企业租户就永远配不出管理员。
|
|
73
|
+
* · 其余角色(editor/viewer 等)—— 不校验,保持原行为。
|
|
74
|
+
*/
|
|
63
75
|
async function assertMembershipAllowed(actorId: number, nextRoles: string[]): Promise<void> {
|
|
64
76
|
if (!nextRoles.includes("superadmin") && !nextRoles.includes("admin"))
|
|
65
77
|
return;
|
|
66
78
|
const actorRoles = await rolesOf(Number(actorId));
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
const isSuper = actorRoles.includes("superadmin");
|
|
80
|
+
// 提权到平台级:只许 superadmin
|
|
81
|
+
if (nextRoles.includes("superadmin") && !isSuper)
|
|
82
|
+
fail(403, "granting superadmin requires superadmin");
|
|
83
|
+
// 任命本租户管理员:本租户的 admin 或 superadmin 均可
|
|
84
|
+
if (nextRoles.includes("admin") && !isSuper && !actorRoles.includes("admin"))
|
|
85
|
+
fail(403, "membership management requires admin or superadmin");
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
export async function listUsers(): Promise<UserRow[]> {
|
|
@@ -206,11 +223,30 @@ export async function updateUserRoles(actorId: number, targetUid: number, roles:
|
|
|
206
223
|
await t.table("sys_user_role").insert({ user_id: targetUid, role_name: role }).run();
|
|
207
224
|
});
|
|
208
225
|
invalidateUser(Number(targetUid));
|
|
226
|
+
// 提权类变更(牵涉 admin / superadmin)单列严重级:任命管理员=把该租户的最高权限交出去,
|
|
227
|
+
// 不该和「勾了个 editor」落在同一噪声水平上。收回也算——撤销提权同样是安全事件。
|
|
228
|
+
const touchesPrivilege = (r: string) => r === "admin" || r === "superadmin";
|
|
229
|
+
const elevated = next.some(touchesPrivilege) || [...beforeSet].some(touchesPrivilege);
|
|
209
230
|
await recordOp({
|
|
210
231
|
action: "user.roles.update",
|
|
211
232
|
module: "framework",
|
|
212
233
|
target: await usernameOf(Number(targetUid)),
|
|
213
|
-
detail: {
|
|
234
|
+
detail: {
|
|
235
|
+
added: next.filter(r => !beforeSet.has(r)),
|
|
236
|
+
removed: [...beforeSet].filter(r => !next.includes(r)),
|
|
237
|
+
// —— 租户维度 ——
|
|
238
|
+
// 每个租户都有自己的管理员,且**用户名撞车**(都叫 admin):只记 username 分不清
|
|
239
|
+
// 「哪个租户的 admin 任命了谁」。sys_oplog.tenant_id 虽由请求租户注入,但那是隐式列,
|
|
240
|
+
// 显式落一份 + 双方 uid,审计才能直接按租户筛、也能定位到具体两个账号。
|
|
241
|
+
tenant: http.tenantId ?? "",
|
|
242
|
+
actorUid: Number(actorId),
|
|
243
|
+
targetUid: Number(targetUid),
|
|
244
|
+
// 便于直接筛出「任命/撤销管理员」这类高价值条目,不必去解析 added/removed
|
|
245
|
+
elevated,
|
|
246
|
+
},
|
|
247
|
+
// 4=warning(与「重设租户管理员口令」同级):提权变更按安全事件留痕;
|
|
248
|
+
// 普通角色变更(editor/viewer)保持默认 6=info,避免审计被噪声淹没。
|
|
249
|
+
level: elevated ? 4 : 6,
|
|
214
250
|
});
|
|
215
251
|
}
|
|
216
252
|
|
|
@@ -13,6 +13,7 @@ import type { WecomConfig, WecomCorpConfig, WecomKind } from "./config";
|
|
|
13
13
|
import type { WecomStoreDeps } from "./store";
|
|
14
14
|
import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
|
|
15
15
|
import { assertProviderConfig, eventUrlOf } from "./config";
|
|
16
|
+
import { provisionTenantAdmin } from "./tenant-admin";
|
|
16
17
|
import { corpScope, revokeTenantSessions, saveProfile, tenantOfCorp } from "./store";
|
|
17
18
|
|
|
18
19
|
export interface WecomAdminDeps {
|
|
@@ -194,6 +195,9 @@ export async function connectCorp(cfg: WecomCorpConfig, client: WecomClient, dep
|
|
|
194
195
|
}).run();
|
|
195
196
|
await client.invalidateCorpToken(cfg.corpId, old);
|
|
196
197
|
}
|
|
198
|
+
// 租户具象化:给这个企业配一个本租户的管理员(admin,非 superadmin),否则
|
|
199
|
+
// 该租户里只有 JIT 出来的员工号,没人能做组织架构同步、也没人能在租户内授权。
|
|
200
|
+
await provisionTenantAdmin(cfg.corpId);
|
|
197
201
|
invalidateAllUsers();
|
|
198
202
|
return { corpId: cfg.corpId, agentId: cfg.agentId, departments: depts.length, members };
|
|
199
203
|
}
|
|
@@ -518,11 +518,18 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
518
518
|
await invalidateCorpToken(corpId, agentId);
|
|
519
519
|
continue;
|
|
520
520
|
}
|
|
521
|
-
if (errcode !== 0)
|
|
521
|
+
if (errcode !== 0) {
|
|
522
|
+
// 部署侧错误(IP 白名单 / 限流)不是用户凭据问题:按 502 抛,避免被统一成 401 把排查带偏
|
|
523
|
+
log.warn("wecom getuserinfo rejected", "errcode", String(errcode), "errmsg", String(res.errmsg ?? ""));
|
|
524
|
+
if (errcode === 60020 || errcode === 45009)
|
|
525
|
+
throw upstream(`企微 auth/getuserinfo 失败:errcode=${errcode} ${String(res.errmsg ?? "")}`.trim());
|
|
522
526
|
rejectCode();
|
|
527
|
+
}
|
|
523
528
|
const userid = res.userid == null ? "" : String(res.userid);
|
|
524
|
-
if (!userid)
|
|
529
|
+
if (!userid) {
|
|
530
|
+
log.warn("wecom getuserinfo no userid", "keys", Object.keys(res).join(","));
|
|
525
531
|
rejectCode(); // 非企业成员(外部联系人)时企微只给 openid,不认作本应用用户
|
|
532
|
+
}
|
|
526
533
|
return userid;
|
|
527
534
|
}
|
|
528
535
|
throw upstream("企微 token 反复失效(40001/42001)");
|
|
@@ -21,6 +21,7 @@ import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
|
|
|
21
21
|
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
22
22
|
import { handleContactChange } from "./contacts";
|
|
23
23
|
import { callbackFields, field, requireField } from "./payload";
|
|
24
|
+
import { provisionTenantAdmin } from "./tenant-admin";
|
|
24
25
|
import { corpScope, ensureTenant, findCorpAuth, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setAuthMode, setProfileStatus, tenantOfCorp } from "./store";
|
|
25
26
|
|
|
26
27
|
/** suite_ticket 官方有效期 30 分钟(10 分钟推一次);本地按 30 分钟作废,宁可拒一次也不拿过期票打企微 */
|
|
@@ -117,6 +118,8 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
|
|
|
117
118
|
// secret 可能已轮换:旧 token 必须立刻作废,否则下一个登录会带着失效 token 打 userinfo
|
|
118
119
|
await client.invalidateCorpToken(granted.corpId, agentId);
|
|
119
120
|
}
|
|
121
|
+
// 租户具象化:代开发企业授权成功即配一个本租户管理员(尽力而为,绝不拖垮 1 秒回调窗口)
|
|
122
|
+
await provisionTenantAdmin(granted.corpId);
|
|
120
123
|
await auditAuth("wecom.auth.apply", granted.corpId, { agentIds: granted.agentIds, env });
|
|
121
124
|
return { corpId: granted.corpId, agentIds: granted.agentIds };
|
|
122
125
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// 租户管理员:给每个企业租户(tenant_id = corp_id)配一个**本租户内**的管理员账号。
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须有这一层:
|
|
4
|
+
// 企业租户是企微登录时才由 `ensureTenant()` 隐式具象化的,里面只有 JIT 建出来的
|
|
5
|
+
// `wecom:<userid>` 员工号,**一个管理员都没有**——于是没人能进 /system/departments
|
|
6
|
+
// 做组织架构同步、也没人能在该租户内授权。平台 superadmin 虽然在 /system/wecom
|
|
7
|
+
// 上看得到所有企业,但管不了租户内的组织/成员/角色(详见下面「越权」一段)。
|
|
8
|
+
//
|
|
9
|
+
// 为什么是 `admin` 而不是 `superadmin`:
|
|
10
|
+
// `superadmin` 在本系统是**跨租户**的框架级角色——/system/wecom 每个端点都过
|
|
11
|
+
// `assertSuperAdmin()`,而它列出的 listApps() 是**所有企业**的档案。只要某租户里
|
|
12
|
+
// 有人握着 superadmin,他带自己租户的头访问该页就能看到别家企业的档案,属跨租户
|
|
13
|
+
// 越权。所以:superadmin = 平台级、只留在 default 租户(运维纪律);
|
|
14
|
+
// admin = 租户级、每租户一个(本文件负责开的就是这个)。
|
|
15
|
+
//
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// 【db.asSystem 例外说明 —— 全仓唯一,不得复制】
|
|
18
|
+
//
|
|
19
|
+
// 本文件是全代码库**唯一**使用 `db.asSystem()` 的地方。
|
|
20
|
+
//
|
|
21
|
+
// 为什么必须用它:superadmin 的请求自带 `default` 租户头,而 `db.asTenant` 是
|
|
22
|
+
// 「请求级、只能设一次,且要求当时还没有租户」。也就是说平台侧**没有任何正常通道**
|
|
23
|
+
// 能往企业租户里写 users / sys_user_role。而「给新租户配管理员」是平台开通动作,
|
|
24
|
+
// 不是业务写——正是逃生通道的用途(global.d.ts 原文:"use db.asSystem() for system tasks")。
|
|
25
|
+
//
|
|
26
|
+
// 框架红线:同一段注释紧接着写「业务 handler 不得使用」。所以这里是**有意识且被审计
|
|
27
|
+
// 的单一例外**,代价与边界必须写清楚:
|
|
28
|
+
// · 范围:只在本文件,只服务「开通 / 重设租户管理员」这一件事;
|
|
29
|
+
// · 写操作必须显式带上 tenant_id 条件(asSystem 绕过自动注入,漏了就是跨租户脏写);
|
|
30
|
+
// · 每个入口都在 admin/api.ts 走 recordOp 留痕(开通 level 2、改密 level 4);
|
|
31
|
+
// · **不得在本文件之外任何地方复制 db.asSystem()**。
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** 租户管理员的固定账号名(每租户各一个,互不冲突——users 唯一键含 tenant_id) */
|
|
35
|
+
export const TENANT_ADMIN_USERNAME = "admin";
|
|
36
|
+
/** 授予的角色:租户级日常管理(组织/成员/角色/同步),**不是**跨租户的 superadmin */
|
|
37
|
+
const TENANT_ADMIN_ROLE = "admin";
|
|
38
|
+
|
|
39
|
+
export interface TenantAdminState {
|
|
40
|
+
/** 租户(= 企业 ID) */
|
|
41
|
+
corpId: string
|
|
42
|
+
/** 管理员账号名 */
|
|
43
|
+
username: string
|
|
44
|
+
/** 该租户是否已有一个握着 admin 角色的账号 */
|
|
45
|
+
exists: boolean
|
|
46
|
+
/** 是否处于「须改密」状态(开通后恒为 true,直到本人改过一次) */
|
|
47
|
+
mustChange: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 随机初始口令:**任何人都不知道**(bcrypt 单向,也不落库、不打日志)。
|
|
52
|
+
*
|
|
53
|
+
* 这不是缺陷而是设计:开通时不需要有人知道口令;平台侧要接管时走
|
|
54
|
+
* `setTenantAdminPassword()` **自己设一个已知的**——他输入的那个他本来就知道,
|
|
55
|
+
* 于是「口令需要被传递」这个问题被消掉了,全系统没有任何口令以可逆形式静态存储。
|
|
56
|
+
*/
|
|
57
|
+
function randomPassword(): string {
|
|
58
|
+
// randomHex 产出 [0-9a-f],长度 20 > 口令最小长度 6,且数字字母混合
|
|
59
|
+
return crypto.randomHex(10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 跨租户读:指定租户下的管理员行(asSystem 例外,见文件头) */
|
|
63
|
+
async function adminRow(tenantId: string): Promise<{ id: number, must_change: number } | null> {
|
|
64
|
+
const rows = await db.asSystem().table("users")
|
|
65
|
+
.select(["id", "must_change"])
|
|
66
|
+
.where({
|
|
67
|
+
and: [
|
|
68
|
+
{ field: "tenant_id", op: "eq", value: tenantId },
|
|
69
|
+
{ field: "username", op: "eq", value: TENANT_ADMIN_USERNAME },
|
|
70
|
+
],
|
|
71
|
+
})
|
|
72
|
+
.all() as Array<{ id: number, must_change: number | null }>;
|
|
73
|
+
if (!rows.length)
|
|
74
|
+
return null;
|
|
75
|
+
return { id: Number(rows[0]!.id), must_change: Number(rows[0]!.must_change ?? 0) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 跨租户读:该用户在本租户是否握着 admin 角色(账号存在 ≠ 有角色,可能被收回过) */
|
|
79
|
+
async function hasAdminRole(tenantId: string, uid: number): Promise<boolean> {
|
|
80
|
+
const roles = await db.asSystem().table("sys_user_role")
|
|
81
|
+
.select(["role_name"])
|
|
82
|
+
.where({
|
|
83
|
+
and: [
|
|
84
|
+
{ field: "tenant_id", op: "eq", value: tenantId },
|
|
85
|
+
{ field: "user_id", op: "eq", value: uid },
|
|
86
|
+
],
|
|
87
|
+
})
|
|
88
|
+
.all() as Array<{ role_name: string }>;
|
|
89
|
+
return roles.some(r => String(r.role_name) === TENANT_ADMIN_ROLE);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 查看某租户的管理员状态(幂等、只读;不存在不建) */
|
|
93
|
+
export async function tenantAdminState(tenantId: string): Promise<TenantAdminState> {
|
|
94
|
+
const row = await adminRow(tenantId);
|
|
95
|
+
if (!row)
|
|
96
|
+
return { corpId: tenantId, username: TENANT_ADMIN_USERNAME, exists: false, mustChange: false };
|
|
97
|
+
return {
|
|
98
|
+
corpId: tenantId,
|
|
99
|
+
username: TENANT_ADMIN_USERNAME,
|
|
100
|
+
exists: await hasAdminRole(tenantId, row.id),
|
|
101
|
+
mustChange: row.must_change === 1,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 开通租户管理员(幂等):没有就建,有就只确认角色还在。
|
|
107
|
+
*
|
|
108
|
+
* 只做两件事:建 `admin` 账号(随机口令 + 强制改密)、授予本租户的 `admin` 角色。
|
|
109
|
+
* 重复调用安全——这正是「验证并接入可以反复点」的前提。
|
|
110
|
+
*/
|
|
111
|
+
export async function ensureTenantAdmin(tenantId: string): Promise<{ created: boolean }> {
|
|
112
|
+
let row = await adminRow(tenantId);
|
|
113
|
+
|
|
114
|
+
if (!row) {
|
|
115
|
+
// 与 ensureWecomUser 同形状:只填业务关心的列,其余走列默认值
|
|
116
|
+
await db.asSystem().table("users").insert({
|
|
117
|
+
tenant_id: tenantId,
|
|
118
|
+
username: TENANT_ADMIN_USERNAME,
|
|
119
|
+
display_name: "租户管理员",
|
|
120
|
+
password_hash: await bcrypt.hash(randomPassword()),
|
|
121
|
+
avatar_base64: "",
|
|
122
|
+
source: "local",
|
|
123
|
+
must_change: 1,
|
|
124
|
+
is_forbidden: 0,
|
|
125
|
+
activated: 1,
|
|
126
|
+
}).run();
|
|
127
|
+
// 回查拿 id:预查与插入之间有竞态(并发开通),回查是唯一可靠的取 id 方式
|
|
128
|
+
row = await adminRow(tenantId);
|
|
129
|
+
}
|
|
130
|
+
if (!row)
|
|
131
|
+
throw Object.assign(new Error("租户管理员建档失败"), { status: 500 });
|
|
132
|
+
|
|
133
|
+
if (!await hasAdminRole(tenantId, row.id)) {
|
|
134
|
+
await db.asSystem().table("sys_user_role").insert({
|
|
135
|
+
user_id: row.id,
|
|
136
|
+
role_name: TENANT_ADMIN_ROLE,
|
|
137
|
+
tenant_id: tenantId,
|
|
138
|
+
}).run();
|
|
139
|
+
return { created: true };
|
|
140
|
+
}
|
|
141
|
+
return { created: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 开通租户管理员(**尽力而为**):开不通只告警,绝不打断调用方。
|
|
146
|
+
*
|
|
147
|
+
* 为什么调用方不能因为它失败:
|
|
148
|
+
* · create_auth 有约 1 秒响应窗口(超时企业管理员看到「应用安装失败」),而 bcrypt
|
|
149
|
+
* 是 CPU 密集操作,不该让它有机会拖垮回调;
|
|
150
|
+
* · connectCorp 走到这里时档案**已经落库**,抛错只会让界面显示「接入失败」而实际已接入。
|
|
151
|
+
*
|
|
152
|
+
* 补开通道:superadmin 随时可走「重设租户管理员口令」入口,该入口内部会先
|
|
153
|
+
* `ensureTenantAdmin()`,所以一次开通失败不会留下没有管理员的死租户。
|
|
154
|
+
*/
|
|
155
|
+
export async function provisionTenantAdmin(tenantId: string): Promise<void> {
|
|
156
|
+
try {
|
|
157
|
+
const r = await ensureTenantAdmin(tenantId);
|
|
158
|
+
log.info("wecom tenant admin provisioned", "corpId", tenantId, "created", r.created);
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
log.warn("wecom tenant admin provision skipped", "corpId", tenantId, "reason", (e as Error).message);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 重设租户管理员口令(平台 superadmin 专用入口)。
|
|
167
|
+
*
|
|
168
|
+
* 口令**不存储**:superadmin 自己输入新口令 → 他知道 → 告知租户管理员。
|
|
169
|
+
* 落库的只有 bcrypt,且置 must_change=1(首次登录必须改),并级联吊销该用户的
|
|
170
|
+
* refresh token,避免旧会话继续有效。
|
|
171
|
+
*/
|
|
172
|
+
export async function setTenantAdminPassword(tenantId: string, password: string): Promise<TenantAdminState> {
|
|
173
|
+
// 先确保账号与角色都在:允许「还没人登录过」的租户被平台直接接管
|
|
174
|
+
await ensureTenantAdmin(tenantId);
|
|
175
|
+
const row = await adminRow(tenantId);
|
|
176
|
+
if (!row)
|
|
177
|
+
throw Object.assign(new Error("租户管理员建档失败"), { status: 500 });
|
|
178
|
+
|
|
179
|
+
await db.asSystem().table("users")
|
|
180
|
+
.update({ password_hash: await bcrypt.hash(password), must_change: 1 })
|
|
181
|
+
.where({
|
|
182
|
+
and: [
|
|
183
|
+
{ field: "tenant_id", op: "eq", value: tenantId },
|
|
184
|
+
{ field: "username", op: "eq", value: TENANT_ADMIN_USERNAME },
|
|
185
|
+
],
|
|
186
|
+
})
|
|
187
|
+
.run();
|
|
188
|
+
|
|
189
|
+
// 改密后旧会话必须失效。跨租户删 refresh 走带 tenant_id 条件的裸 SQL
|
|
190
|
+
// (先例见 store.ts::revokeTenantSessions:守卫是 check-on-miss,带了租户条件即放行)
|
|
191
|
+
await db.exec("DELETE FROM sys_refresh_token WHERE tenant_id = ? AND user_id = ?", [tenantId, row.id]);
|
|
192
|
+
|
|
193
|
+
return await tenantAdminState(tenantId);
|
|
194
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// GET /api/auth/wecom/admin/tenant-admin?corpId= —— 查某企业的租户管理员状态。
|
|
2
|
+
//
|
|
3
|
+
// 权限:仅 superadmin。这是**跨租户**的平台级视图(查的是别家企业的管理员有没有开通),
|
|
4
|
+
// 与同目录其它端点一致。只读,不存在也不建——建号由开通流程与 password 端点负责。
|
|
5
|
+
import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
|
|
6
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
7
|
+
import { tenantAdminState } from "../../../_shared/wecom/tenant-admin";
|
|
8
|
+
|
|
9
|
+
async function get() {
|
|
10
|
+
await respond(async () => {
|
|
11
|
+
await assertSuperAdmin();
|
|
12
|
+
const corpId = String(((http.query ?? {}) as Record<string, unknown>).corpId ?? "").trim();
|
|
13
|
+
if (!corpId)
|
|
14
|
+
throw Object.assign(new Error("缺少企业代码"), { status: 400 });
|
|
15
|
+
return tenantAdminState(corpId);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get.schema = {
|
|
20
|
+
query: {
|
|
21
|
+
type: "object",
|
|
22
|
+
required: ["corpId"],
|
|
23
|
+
properties: { corpId: { type: "string", minLength: 1 } },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
get.response = {
|
|
28
|
+
type: "object",
|
|
29
|
+
required: ["corpId", "username", "exists", "mustChange"],
|
|
30
|
+
properties: {
|
|
31
|
+
corpId: { type: "string" },
|
|
32
|
+
// 固定账号名:每租户各一个(users 唯一键含 tenant_id,互不影响)
|
|
33
|
+
username: { type: "string" },
|
|
34
|
+
// 该租户是否已有一个握着 admin 角色的账号
|
|
35
|
+
exists: { type: "boolean" },
|
|
36
|
+
// 是否须改密(开通后恒 true,直到本人改过一次)
|
|
37
|
+
mustChange: { type: "boolean" },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default { get };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// POST /api/auth/wecom/admin/tenant-admin/password —— 重设某企业的租户管理员口令。
|
|
2
|
+
//
|
|
3
|
+
// 权限:仅 superadmin(跨租户的平台级动作)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么做成「superadmin 自己输入新口令」而不是「系统生成后回显」:
|
|
6
|
+
// 口令只以 bcrypt 落库,**没有任何可逆存储**,所以系统无法回显一个已存在的口令。
|
|
7
|
+
// 让 superadmin 自己输入,他知道这个值 → 直接告知租户管理员即可。代价为零,
|
|
8
|
+
// 收益是全系统不存在静态可读的口令副本。
|
|
9
|
+
//
|
|
10
|
+
// 副作用(都是必需的):
|
|
11
|
+
// · must_change = 1 —— 管理员首次登录必须改掉这个临时口令;
|
|
12
|
+
// · 级联删 refresh —— 改密后旧会话立即失效,避免旧 token 继续可用。
|
|
13
|
+
import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
|
|
14
|
+
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
15
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
16
|
+
import { setTenantAdminPassword } from "../../../../_shared/wecom/tenant-admin";
|
|
17
|
+
|
|
18
|
+
async function post() {
|
|
19
|
+
await respond(async () => {
|
|
20
|
+
await assertSuperAdmin();
|
|
21
|
+
const body = (http.body ?? {}) as { corpId?: unknown, password?: unknown };
|
|
22
|
+
const corpId = String(body.corpId ?? "").trim();
|
|
23
|
+
const password = String(body.password ?? "");
|
|
24
|
+
if (!corpId)
|
|
25
|
+
throw Object.assign(new Error("缺少企业代码"), { status: 400 });
|
|
26
|
+
// 与 users/password 同下限(6 位):太短的口令在交接环节泄露风险更高
|
|
27
|
+
if (password.length < 6)
|
|
28
|
+
throw Object.assign(new Error("口令至少 6 位"), { status: 400 });
|
|
29
|
+
|
|
30
|
+
const state = await setTenantAdminPassword(corpId, password);
|
|
31
|
+
await recordOp({
|
|
32
|
+
action: "wecom.admin.tenant_admin_password",
|
|
33
|
+
module: "auth",
|
|
34
|
+
target: corpId,
|
|
35
|
+
// 不记口令本身(连脱敏都不记):审计只要能回答「谁在何时给哪个企业重设过」
|
|
36
|
+
detail: { username: state.username },
|
|
37
|
+
// 重设他人管理员口令=接管该租户的最高权限,按安全事件留痕
|
|
38
|
+
level: 4,
|
|
39
|
+
});
|
|
40
|
+
return state;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
post.schema = {
|
|
45
|
+
body: {
|
|
46
|
+
type: "object",
|
|
47
|
+
required: ["corpId", "password"],
|
|
48
|
+
properties: {
|
|
49
|
+
corpId: { type: "string", minLength: 1 },
|
|
50
|
+
password: { type: "string", minLength: 6 },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
post.response = {
|
|
56
|
+
type: "object",
|
|
57
|
+
required: ["corpId", "username", "exists", "mustChange"],
|
|
58
|
+
properties: {
|
|
59
|
+
corpId: { type: "string" },
|
|
60
|
+
username: { type: "string" },
|
|
61
|
+
exists: { type: "boolean" },
|
|
62
|
+
mustChange: { type: "boolean" },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export default { post };
|
|
@@ -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
|
},
|
|
@@ -228,6 +228,20 @@
|
|
|
228
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
229
|
"connectOk": "Connected: {{depts}} departments visible, {{members}} members in the sample department",
|
|
230
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"
|
|
231
245
|
}
|
|
232
246
|
},
|
|
233
247
|
"err": {
|