@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
|
@@ -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 };
|
|
36
|
+
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -140,3 +140,150 @@ export async function authLdapLoginPost(): Promise<unknown> {
|
|
|
140
140
|
throw await toApiError(e);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
export type AuthWecomAdminAppsGetData = z.infer<(typeof schemas)["authWecomAdminAppsGet"]["data"]>;
|
|
145
|
+
|
|
146
|
+
export async function authWecomAdminAppsGet(): Promise<AuthWecomAdminAppsGetData> {
|
|
147
|
+
const client = ensureReq();
|
|
148
|
+
try {
|
|
149
|
+
const env = await client.get(`auth/wecom/admin/apps`).json<OjEnvelope<AuthWecomAdminAppsGetData>>();
|
|
150
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
151
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
152
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
153
|
+
const data = env.data as AuthWecomAdminAppsGetData;
|
|
154
|
+
if (import.meta.env.DEV) {
|
|
155
|
+
const { schemas } = await import("./api.schemas");
|
|
156
|
+
const r = schemas["authWecomAdminAppsGet"].data.safeParse(data);
|
|
157
|
+
if (!r.success)
|
|
158
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminAppsGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
159
|
+
}
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw await toApiError(e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type AuthWecomAdminStatusPostBody = z.input<(typeof schemas)["authWecomAdminStatusPost"]["body"]>;
|
|
168
|
+
export type AuthWecomAdminStatusPostData = z.infer<(typeof schemas)["authWecomAdminStatusPost"]["data"]>;
|
|
169
|
+
|
|
170
|
+
export async function authWecomAdminStatusPost(body: AuthWecomAdminStatusPostBody): Promise<AuthWecomAdminStatusPostData> {
|
|
171
|
+
const client = ensureReq();
|
|
172
|
+
try {
|
|
173
|
+
const env = await client.post(`auth/wecom/admin/status`, { json: body }).json<OjEnvelope<AuthWecomAdminStatusPostData>>();
|
|
174
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
175
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
176
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
177
|
+
const data = env.data as AuthWecomAdminStatusPostData;
|
|
178
|
+
if (import.meta.env.DEV) {
|
|
179
|
+
const { schemas } = await import("./api.schemas");
|
|
180
|
+
const r = schemas["authWecomAdminStatusPost"].data.safeParse(data);
|
|
181
|
+
if (!r.success)
|
|
182
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminStatusPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
throw await toApiError(e);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type AuthWecomAdminInstallGetData = z.infer<(typeof schemas)["authWecomAdminInstallGet"]["data"]>;
|
|
192
|
+
|
|
193
|
+
export async function authWecomAdminInstallGet(): Promise<AuthWecomAdminInstallGetData> {
|
|
194
|
+
const client = ensureReq();
|
|
195
|
+
try {
|
|
196
|
+
const env = await client.get(`auth/wecom/admin/install`).json<OjEnvelope<AuthWecomAdminInstallGetData>>();
|
|
197
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
198
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
199
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
200
|
+
const data = env.data as AuthWecomAdminInstallGetData;
|
|
201
|
+
if (import.meta.env.DEV) {
|
|
202
|
+
const { schemas } = await import("./api.schemas");
|
|
203
|
+
const r = schemas["authWecomAdminInstallGet"].data.safeParse(data);
|
|
204
|
+
if (!r.success)
|
|
205
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminInstallGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
206
|
+
}
|
|
207
|
+
return data;
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
throw await toApiError(e);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function authWecomLoginGet(): Promise<unknown> {
|
|
215
|
+
const client = ensureReq();
|
|
216
|
+
try {
|
|
217
|
+
const env = await client.get(`auth/wecom/login`).json<OjEnvelope<unknown>>();
|
|
218
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
219
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
220
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
221
|
+
const data = env.data as unknown;
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
throw await toApiError(e);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function authWecomCallbackGet(): Promise<unknown> {
|
|
230
|
+
const client = ensureReq();
|
|
231
|
+
try {
|
|
232
|
+
const env = await client.get(`auth/wecom/callback`).json<OjEnvelope<unknown>>();
|
|
233
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
234
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
235
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
236
|
+
const data = env.data as unknown;
|
|
237
|
+
return data;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
throw await toApiError(e);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type AuthWecomTicketPostBody = z.input<(typeof schemas)["authWecomTicketPost"]["body"]>;
|
|
245
|
+
|
|
246
|
+
export async function authWecomTicketPost(body: AuthWecomTicketPostBody): Promise<unknown> {
|
|
247
|
+
const client = ensureReq();
|
|
248
|
+
try {
|
|
249
|
+
const env = await client.post(`auth/wecom/ticket`, { json: body }).json<OjEnvelope<unknown>>();
|
|
250
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
251
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
252
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
253
|
+
const data = env.data as unknown;
|
|
254
|
+
return data;
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
throw await toApiError(e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function authWecomEventGet(): Promise<unknown> {
|
|
262
|
+
const client = ensureReq();
|
|
263
|
+
try {
|
|
264
|
+
const env = await client.get(`auth/wecom/event`).json<OjEnvelope<unknown>>();
|
|
265
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
266
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
267
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
268
|
+
const data = env.data as unknown;
|
|
269
|
+
return data;
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
throw await toApiError(e);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function authWecomEventPost(): Promise<unknown> {
|
|
277
|
+
const client = ensureReq();
|
|
278
|
+
try {
|
|
279
|
+
const env = await client.post(`auth/wecom/event`).json<OjEnvelope<unknown>>();
|
|
280
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
281
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
282
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
283
|
+
const data = env.data as unknown;
|
|
284
|
+
return data;
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
throw await toApiError(e);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -4,11 +4,13 @@ tables:
|
|
|
4
4
|
pk: id
|
|
5
5
|
columns:
|
|
6
6
|
id: {type: integer, autoincrement: true}
|
|
7
|
+
tenant_id: {type: text, null: false}
|
|
7
8
|
category: {type: text, null: false}
|
|
8
9
|
value: {type: integer, null: false}
|
|
9
10
|
home_line:
|
|
10
11
|
pk: id
|
|
11
12
|
columns:
|
|
12
13
|
id: {type: integer, autoincrement: true}
|
|
14
|
+
tenant_id: {type: text, null: false}
|
|
13
15
|
day: {type: integer, null: false}
|
|
14
16
|
value: {type: integer, null: false}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AuthProvider, AuthType } from "@oj-module/runtime";
|
|
2
|
+
|
|
3
|
+
import { fetchLogin, fetchLogout, fetchUserInfo, request, useAuthStore } from "@oj-module/runtime";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 登录模块的认证 provider(P5 接缝)。
|
|
7
|
+
*
|
|
8
|
+
* 为什么必须注册:`useAuthStore.login()` 只认两条路——provider 或内置 `auth/login`。
|
|
9
|
+
* 企微登录拿到的是**票据**而不是口令,走不了内置路;而 store 的写入(token 持久化、
|
|
10
|
+
* 之后 guard 拉用户信息)必须只有一个入口,所以由本 provider 接管并把其余方式
|
|
11
|
+
* **原样委托**回内置实现(`fetchLogin/fetchLogout/fetchUserInfo`,行为零变化)。
|
|
12
|
+
*
|
|
13
|
+
* 契约(见 runtime `store/auth-provider.ts`):provider 对返回值自负责,失败请直接
|
|
14
|
+
* reject——runtime 原样透传,错误提示归登录页。
|
|
15
|
+
*/
|
|
16
|
+
export const authProvider: AuthProvider = {
|
|
17
|
+
async login(payload): Promise<AuthType> {
|
|
18
|
+
if (payload.method !== "wecom")
|
|
19
|
+
return fetchLogin(payload);
|
|
20
|
+
|
|
21
|
+
const ticket = payload.ticket?.trim() ?? "";
|
|
22
|
+
if (!ticket)
|
|
23
|
+
throw new Error("缺少企业微信登录票据,请从企业微信重新进入");
|
|
24
|
+
|
|
25
|
+
// 服务端只认 `{ ticket }`(schema 校验 minLength 32):它由 302 落地点带过来,
|
|
26
|
+
// 只在内存里存在一瞬间,不落 localStorage。
|
|
27
|
+
//
|
|
28
|
+
// throwHttpErrors:false —— ky 默认把非 2xx 抛成 HTTPError,message 是
|
|
29
|
+
// "Request failed with status code 401",登录页只能原样贴给用户看。
|
|
30
|
+
// 这里自己读信封,把后端 `fail(401, "登录票据无效或已使用")` 的 msg 透上去。
|
|
31
|
+
const res = await request.post("auth/wecom/ticket", { json: { ticket }, throwHttpErrors: false });
|
|
32
|
+
const env = await res
|
|
33
|
+
.json<{ code: number, msg?: string, data?: { access_token?: string, refresh_token?: string } }>()
|
|
34
|
+
.catch(() => undefined);
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw new Error(env?.msg || `企业微信登录失败(HTTP ${res.status})`);
|
|
37
|
+
return {
|
|
38
|
+
token: env?.data?.access_token ?? "",
|
|
39
|
+
refreshToken: env?.data?.refresh_token ?? "",
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async logout(): Promise<void> {
|
|
44
|
+
await fetchLogout({ refreshToken: useAuthStore.getState().refreshToken });
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
getUserInfo: () => fetchUserInfo(),
|
|
48
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { defineModule } from "@oj-module/runtime";
|
|
1
|
+
import { defineModule, useAuthStore, useTenantStore } from "@oj-module/runtime";
|
|
2
2
|
import { lazy } from "react";
|
|
3
|
+
import { authProvider } from "./auth-provider";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 登录模块:提供 `/login` 路由与内容区。
|
|
@@ -56,4 +57,23 @@ export default defineModule({
|
|
|
56
57
|
"zh-CN": () => import("./locales/zh-CN.json"),
|
|
57
58
|
"en-US": () => import("./locales/en-US.json"),
|
|
58
59
|
},
|
|
60
|
+
lifecycle: {
|
|
61
|
+
/**
|
|
62
|
+
* 租户上下文(L2):请求头由**运行时**注入(`runtime/store/tenant.ts`),模块不再注册
|
|
63
|
+
* headerProvider——宿主 shell 的 hydrateUser / solution-routes 都在任何模块 onInit 之前
|
|
64
|
+
* 就发请求,模块注册必然晚一步(2026-10-30 冷启动 e2e 实测两个 400 missing tenant header)。
|
|
65
|
+
* 模块这里只保留「登出清租户」这一业务语义。
|
|
66
|
+
*/
|
|
67
|
+
async onInit(ctx) {
|
|
68
|
+
// 认证 provider:仅接管「企微票据换会话」,其余方式原样委托内置链(见 auth-provider.ts)。
|
|
69
|
+
// 注册时机必须在任何登录动作之前——onInit 先于路由渲染。
|
|
70
|
+
ctx.register.authProvider(authProvider);
|
|
71
|
+
// 登出必须清租户:否则下一个用户会带着上一个用户的租户头(跨租户请求 fail-closed 403,
|
|
72
|
+
// 但单租户部署会静默落到别人的 default 视图)。token 由非空转空即视为登出。
|
|
73
|
+
useAuthStore.subscribe((state, prev) => {
|
|
74
|
+
if (prev.token && !state.token)
|
|
75
|
+
useTenantStore.getState().clear();
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
},
|
|
59
79
|
});
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
"password": "Password",
|
|
7
7
|
"submit": "Sign in",
|
|
8
8
|
"success": "Signed in",
|
|
9
|
+
"tenant": {
|
|
10
|
+
"label": "Enterprise code",
|
|
11
|
+
"hint": "Only needed on multi-tenant deployments (e.g. WeCom corp_id); leave empty otherwise",
|
|
12
|
+
"toggle": "Sign in with enterprise code",
|
|
13
|
+
"hide": "Hide enterprise code"
|
|
14
|
+
},
|
|
9
15
|
"changePwd": {
|
|
10
16
|
"title": "Change Password",
|
|
11
17
|
"hint": "After first login or an admin reset, you must change your password before using the system.",
|
|
@@ -21,6 +27,10 @@
|
|
|
21
27
|
"local": "Password",
|
|
22
28
|
"ldap": "LDAP",
|
|
23
29
|
"sso": "SSO",
|
|
24
|
-
"sso.action": "Sign in with SSO"
|
|
30
|
+
"sso.action": "Sign in with SSO",
|
|
31
|
+
"wecom": "WeCom",
|
|
32
|
+
"wecom.action": "Sign in with WeCom",
|
|
33
|
+
"wecom.hint": "Enter the enterprise code (corp_id, visible in the WeCom console), or use the dedicated sign-in link from your admin",
|
|
34
|
+
"wecom.exchanging": "Completing WeCom sign-in…"
|
|
25
35
|
}
|
|
26
36
|
}
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
"password": "密码",
|
|
7
7
|
"submit": "登 录",
|
|
8
8
|
"success": "登录成功",
|
|
9
|
+
"tenant": {
|
|
10
|
+
"label": "企业代码",
|
|
11
|
+
"hint": "多租户部署才需要填(如企微 corp_id);单租户部署留空即可",
|
|
12
|
+
"toggle": "使用企业代码登录",
|
|
13
|
+
"hide": "收起企业代码"
|
|
14
|
+
},
|
|
9
15
|
"changePwd": {
|
|
10
16
|
"title": "修改密码",
|
|
11
17
|
"hint": "首次登录或管理员重置口令后,需先修改密码才能使用系统。",
|
|
@@ -21,6 +27,10 @@
|
|
|
21
27
|
"local": "账号密码",
|
|
22
28
|
"ldap": "LDAP",
|
|
23
29
|
"sso": "SSO",
|
|
24
|
-
"sso.action": "使用 SSO 登录"
|
|
30
|
+
"sso.action": "使用 SSO 登录",
|
|
31
|
+
"wecom": "企业微信",
|
|
32
|
+
"wecom.action": "使用企业微信登录",
|
|
33
|
+
"wecom.hint": "填写企业代码(企微后台可见的 corp_id),或直接使用管理员收到的专属登录链接",
|
|
34
|
+
"wecom.exchanging": "正在完成企业微信登录…"
|
|
25
35
|
}
|
|
26
36
|
}
|
|
@@ -1,27 +1,51 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_TENANT,
|
|
3
|
+
fetchLoginMethods,
|
|
4
|
+
getRedirectPath,
|
|
5
|
+
resolveApiBase,
|
|
6
|
+
useAuthStore,
|
|
7
|
+
useTenantStore,
|
|
8
|
+
} from "@oj-module/runtime";
|
|
2
9
|
import { Alert, Button, Form, Input, Tabs, Typography } from "antd";
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
10
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
11
|
import { useTranslation } from "react-i18next";
|
|
5
12
|
import { useLocation, useNavigate } from "react-router";
|
|
13
|
+
import { parseWecomLanding, wecomAuthorizePath } from "../wecom";
|
|
6
14
|
|
|
7
|
-
type LoginMethod = "local" | "ldap" | "sso";
|
|
15
|
+
type LoginMethod = "local" | "ldap" | "sso" | "wecom";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 已实现的登录方式(顺序即 tab 顺序)。
|
|
19
|
+
* 后端 `methods` 是**发现结果**、这里是**渲染能力**:后端多报一种而本页没有对应
|
|
20
|
+
* renderer 时宁可不显示(点了也没实现),新增方式 = 这里加一项 + 渲染分支加一路。
|
|
21
|
+
*/
|
|
22
|
+
const KNOWN_METHODS: LoginMethod[] = ["local", "ldap", "sso", "wecom"];
|
|
23
|
+
|
|
24
|
+
function isKnownMethod(x: string): x is LoginMethod {
|
|
25
|
+
return (KNOWN_METHODS as string[]).includes(x);
|
|
26
|
+
}
|
|
8
27
|
|
|
9
28
|
interface LoginFormValues {
|
|
10
29
|
username: string
|
|
11
30
|
password: string
|
|
31
|
+
/** 企业代码(多租户部署才需要,L2:登录带租户) */
|
|
32
|
+
tenant?: string
|
|
12
33
|
}
|
|
13
34
|
|
|
14
35
|
/**
|
|
15
36
|
* 登录页内容区。
|
|
16
37
|
*
|
|
17
38
|
* 只写内容区:视口/品牌区/工具区/页脚由框架 FullscreenLayout 兜住。
|
|
18
|
-
* 只依赖框架出口(useAuthStore / getRedirectPath / fetchLoginMethods)与共享依赖
|
|
39
|
+
* 只依赖框架出口(useAuthStore / getRedirectPath / fetchLoginMethods / withSiteBase)与共享依赖
|
|
19
40
|
* (antd / react-router),零框架内部 import。
|
|
20
41
|
*
|
|
21
42
|
* 登录方式由后端匿名端点 `GET /system/login-methods` 发现(local 恒在,
|
|
22
|
-
* ldap/sso 由系统设置开关)。多于一种方式时渲染切换 tab;local/ldap 走同一表单,
|
|
23
|
-
* 仅把 `method` 透传给运行时 `login()
|
|
24
|
-
*
|
|
43
|
+
* ldap/sso/wecom 由系统设置开关)。多于一种方式时渲染切换 tab;local/ldap 走同一表单,
|
|
44
|
+
* 仅把 `method` 透传给运行时 `login()`;sso 与 wecom 走整页跳转(拿不到 302 的
|
|
45
|
+
* opaque redirect,不能用 XHR)。
|
|
46
|
+
*
|
|
47
|
+
* 企微回跳落地:`/login?wecom_ticket=&tenant=` —— 先落租户再换票(服务端要求头与票据
|
|
48
|
+
* 租户一致),票换出的 token 由 runtime 统一写库(见 ../auth-provider.ts)。
|
|
25
49
|
*/
|
|
26
50
|
export default function LoginPage() {
|
|
27
51
|
const { t } = useTranslation();
|
|
@@ -32,11 +56,27 @@ export default function LoginPage() {
|
|
|
32
56
|
const [loading, setLoading] = useState(false);
|
|
33
57
|
const [methods, setMethods] = useState<LoginMethod[]>(["local"]);
|
|
34
58
|
const [active, setActive] = useState<LoginMethod>("local");
|
|
59
|
+
// 企业代码入口默认收起:单租户部署(绝大多数)保持今天的登录页外观,
|
|
60
|
+
// 只有服务商/客户带 `?tenant=` 或手工展开时才需要填。
|
|
61
|
+
// 例外:持久化租户不是部署默认值时必须展开——租户头是**隐式**跟着请求走的
|
|
62
|
+
// (runtime 每请求注入),藏起来等于让上一次的租户无声地把这次登录带走。
|
|
63
|
+
const [showTenant, setShowTenant] = useState(() => useTenantStore.getState().tenant !== DEFAULT_TENANT);
|
|
64
|
+
// 表单里的企业代码:带上 persisted 值(URL `?tenant=` 已先落到 store),用户看得见才改得动
|
|
65
|
+
const [tenant] = useState(() => {
|
|
66
|
+
const current = useTenantStore.getState().tenant;
|
|
67
|
+
return current === DEFAULT_TENANT ? "" : current;
|
|
68
|
+
});
|
|
69
|
+
// 企微 tab 的企业代码(优先用已持久化的值,单租户的 default 不预填)
|
|
70
|
+
const [corp, setCorp] = useState(() => tenant);
|
|
71
|
+
|
|
72
|
+
// 企微 302 落地参数:只在首次渲染解析一次(ticket 一次性,解析结果必须稳定)
|
|
73
|
+
const [landing] = useState(() => parseWecomLanding(search));
|
|
74
|
+
const exchangedRef = useRef(false);
|
|
35
75
|
|
|
36
76
|
useEffect(() => {
|
|
37
77
|
fetchLoginMethods()
|
|
38
78
|
.then((r) => {
|
|
39
|
-
const m = r.methods.filter(
|
|
79
|
+
const m = r.methods.filter(isKnownMethod);
|
|
40
80
|
if (!m.length)
|
|
41
81
|
return;
|
|
42
82
|
setMethods(m);
|
|
@@ -48,11 +88,40 @@ export default function LoginPage() {
|
|
|
48
88
|
});
|
|
49
89
|
}, []);
|
|
50
90
|
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
// 换票只做一次:ticket 单次有效,重复 POST 只会拿到 401 并盖掉真正的错误
|
|
93
|
+
if (!landing || exchangedRef.current)
|
|
94
|
+
return;
|
|
95
|
+
exchangedRef.current = true;
|
|
96
|
+
void (async () => {
|
|
97
|
+
setLoading(true);
|
|
98
|
+
setError(undefined);
|
|
99
|
+
try {
|
|
100
|
+
// 租户先落 store:provider 每请求读 getState,服务端会比对「头值 == 票据租户」
|
|
101
|
+
if (landing.tenant)
|
|
102
|
+
useTenantStore.getState().setTenant(landing.tenant);
|
|
103
|
+
await login({ username: "", password: "", method: "wecom", ticket: landing.ticket });
|
|
104
|
+
navigate(getRedirectPath(search), { replace: true });
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
108
|
+
setLoading(false);
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
}, [landing, login, navigate, search]);
|
|
112
|
+
|
|
51
113
|
async function onFinish(values: LoginFormValues, method: LoginMethod) {
|
|
52
114
|
setLoading(true);
|
|
53
115
|
setError(undefined);
|
|
54
116
|
try {
|
|
55
|
-
|
|
117
|
+
// L2:登录请求必须带租户头。先落 store(provider 每请求读 getState),再登录。
|
|
118
|
+
// 字段留空即「回到部署默认」——否则上一个用户/上一次企微登录留下的租户头
|
|
119
|
+
// 会把这次本地登录静默带到别家企业(表现为账号不存在,界面上看不出原因)。
|
|
120
|
+
if (values.tenant?.trim())
|
|
121
|
+
useTenantStore.getState().setTenant(values.tenant);
|
|
122
|
+
else
|
|
123
|
+
useTenantStore.getState().clear();
|
|
124
|
+
await login({ username: values.username, password: values.password, method });
|
|
56
125
|
navigate(getRedirectPath(search));
|
|
57
126
|
}
|
|
58
127
|
catch (e) {
|
|
@@ -65,7 +134,17 @@ export default function LoginPage() {
|
|
|
65
134
|
|
|
66
135
|
function ssoLogin() {
|
|
67
136
|
// SSO 重定向端点由宿主/平台提供(如 GET /auth/sso),模板侧仅跳转。
|
|
68
|
-
|
|
137
|
+
// 与企微同口径拼 API base:它是**后端端点**(要发 302 到 IdP),拼站点 base
|
|
138
|
+
// 只会落到 SPA 兜底页(点了没反应,一个请求都不发)。
|
|
139
|
+
window.location.assign(`${resolveApiBase()}/auth/sso`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function wecomLogin() {
|
|
143
|
+
// 整页跳转(拿不到 302 的 opaque redirect):授权入口是**后端端点**,
|
|
144
|
+
// 必须拼 API base(部署期可为 /api 或网关前缀);拼站点 base 只会落到
|
|
145
|
+
// SPA 兜底页——点击后页面原地重载、一个 auth 请求都不发。
|
|
146
|
+
// 企业代码不在这里写 store——只有服务端回跳带的 tenant 才是权威值。
|
|
147
|
+
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
69
148
|
}
|
|
70
149
|
|
|
71
150
|
const showTabs = methods.length > 1;
|
|
@@ -78,12 +157,58 @@ export default function LoginPage() {
|
|
|
78
157
|
<Form.Item name="password" label={t("login:page.password")} rules={[{ required: true }]}>
|
|
79
158
|
<Input.Password />
|
|
80
159
|
</Form.Item>
|
|
160
|
+
{showTenant && (
|
|
161
|
+
<Form.Item
|
|
162
|
+
name="tenant"
|
|
163
|
+
label={t("login:page.tenant.label")}
|
|
164
|
+
extra={t("login:page.tenant.hint")}
|
|
165
|
+
initialValue={tenant}
|
|
166
|
+
>
|
|
167
|
+
<Input autoComplete="off" placeholder="ww..." />
|
|
168
|
+
</Form.Item>
|
|
169
|
+
)}
|
|
81
170
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
|
82
171
|
{t("login:page.submit")}
|
|
83
172
|
</Button>
|
|
173
|
+
<Button type="link" size="small" block onClick={() => setShowTenant(v => !v)}>
|
|
174
|
+
{t(showTenant ? "login:page.tenant.hide" : "login:page.tenant.toggle")}
|
|
175
|
+
</Button>
|
|
84
176
|
</Form>
|
|
85
177
|
);
|
|
86
178
|
|
|
179
|
+
// 企微:只需企业代码(服务商后台生成的专属链接会预填,客户也可手输)
|
|
180
|
+
// 受控值不走 antd 表单(Form.Item 无 name),故显式给 id/htmlFor:
|
|
181
|
+
// 少了这层关联,标签只是视觉文本,读屏与 getByLabel 都取不到输入框。
|
|
182
|
+
const wecomCorpId = "wecom-corp";
|
|
183
|
+
const wecomPanel = (
|
|
184
|
+
<Form layout="vertical" onFinish={wecomLogin}>
|
|
185
|
+
<Form.Item
|
|
186
|
+
label={t("login:page.tenant.label")}
|
|
187
|
+
htmlFor={wecomCorpId}
|
|
188
|
+
extra={t("login:method.wecom.hint")}
|
|
189
|
+
>
|
|
190
|
+
<Input
|
|
191
|
+
id={wecomCorpId}
|
|
192
|
+
autoComplete="off"
|
|
193
|
+
placeholder="ww..."
|
|
194
|
+
value={corp}
|
|
195
|
+
onChange={e => setCorp(e.target.value)}
|
|
196
|
+
/>
|
|
197
|
+
</Form.Item>
|
|
198
|
+
<Button type="primary" htmlType="submit" block>
|
|
199
|
+
{t("login:method.wecom.action")}
|
|
200
|
+
</Button>
|
|
201
|
+
</Form>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
function panelOf(m: LoginMethod) {
|
|
205
|
+
if (m === "sso")
|
|
206
|
+
return <Button type="primary" block onClick={ssoLogin}>{t("login:method.sso.action")}</Button>;
|
|
207
|
+
if (m === "wecom")
|
|
208
|
+
return wecomPanel;
|
|
209
|
+
return form(m);
|
|
210
|
+
}
|
|
211
|
+
|
|
87
212
|
return (
|
|
88
213
|
<>
|
|
89
214
|
<Typography.Title level={3}>
|
|
@@ -92,22 +217,26 @@ export default function LoginPage() {
|
|
|
92
217
|
<Typography.Paragraph type="secondary">
|
|
93
218
|
{t("login:page.subtitle")}
|
|
94
219
|
</Typography.Paragraph>
|
|
220
|
+
{landing && loading && (
|
|
221
|
+
<Alert style={{ marginBottom: 16 }} type="info" message={t("login:method.wecom.exchanging")} showIcon />
|
|
222
|
+
)}
|
|
95
223
|
{error && <Alert style={{ marginBottom: 16 }} type="error" message={error} showIcon />}
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
224
|
+
{/* 换票期间不渲染 tab:避免用户在跳转前又点一次登录 */}
|
|
225
|
+
{landing && loading
|
|
226
|
+
? null
|
|
227
|
+
: showTabs
|
|
228
|
+
? (
|
|
229
|
+
<Tabs
|
|
230
|
+
activeKey={active}
|
|
231
|
+
onChange={key => setActive(key as LoginMethod)}
|
|
232
|
+
items={methods.map(m => ({
|
|
233
|
+
key: m,
|
|
234
|
+
label: t(`login:method.${m}`),
|
|
235
|
+
children: panelOf(m),
|
|
236
|
+
}))}
|
|
237
|
+
/>
|
|
238
|
+
)
|
|
239
|
+
: panelOf(methods[0] ?? "local")}
|
|
111
240
|
</>
|
|
112
241
|
);
|
|
113
242
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企微登录的**纯逻辑**(无 react / 无 request / 无 runtime 依赖),便于 node 侧回归:
|
|
3
|
+
* 授权入口 URL 拼装、回跳落地参数解析。
|
|
4
|
+
*
|
|
5
|
+
* 与后端的分工(§5.4/§11.5):
|
|
6
|
+
* · 发起:浏览器整页跳 `/auth/wecom/login?corp=` → 后端 302 到企微(前端拿不到 opaque redirect,
|
|
7
|
+
* 所以必须 window.location.assign,不能用 XHR/fetch);
|
|
8
|
+
* · 回跳:后端 302 回 `/login?wecom_ticket=&tenant=`,本文件把这两个参数解析出来;
|
|
9
|
+
* · 换票:ticket → POST /auth/wecom/ticket(由 auth-provider 完成,store 写入仍归 runtime)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 回跳落地参数名(后端 loginLandingUrl 拼的,改这里必须同时改后端) */
|
|
13
|
+
export const WECOM_TICKET_PARAM = "wecom_ticket";
|
|
14
|
+
export const WECOM_TENANT_PARAM = "tenant";
|
|
15
|
+
|
|
16
|
+
export interface WecomLanding {
|
|
17
|
+
/** 一次性登录票据(180 秒、只能用一次) */
|
|
18
|
+
ticket: string
|
|
19
|
+
/** 票据所属企业代码;空串表示后端没带(此时沿用本地已存的租户) */
|
|
20
|
+
tenant: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 解析回跳落地参数。非企微落地(普通打开登录页)返回 null——
|
|
25
|
+
* 调用方据此决定「走表单」还是「直接换票」。
|
|
26
|
+
*/
|
|
27
|
+
export function parseWecomLanding(search: string): WecomLanding | null {
|
|
28
|
+
const params = new URLSearchParams(search);
|
|
29
|
+
const ticket = params.get(WECOM_TICKET_PARAM)?.trim() ?? "";
|
|
30
|
+
if (!ticket)
|
|
31
|
+
return null;
|
|
32
|
+
return { ticket, tenant: params.get(WECOM_TENANT_PARAM)?.trim() ?? "" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 授权入口(**后端端点**路径,调用方负责拼 `resolveApiBase()` 后整页跳转)。
|
|
37
|
+
* 拼站点 base 是错的:请求会落到 SPA 兜底页,点了不动且无从排查——
|
|
38
|
+
* `/auth/wecom/login` 的真实前缀由部署的 API base 决定(可为 /api 或网关前缀)。
|
|
39
|
+
* `corp` 为空时**照样跳**:后端会回 400 并说明缺企业代码,比前端静默不动更容易排查。
|
|
40
|
+
*/
|
|
41
|
+
export function wecomAuthorizePath(corp: string, agent?: string): string {
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
const c = corp.trim();
|
|
44
|
+
if (c)
|
|
45
|
+
params.set("corp", c);
|
|
46
|
+
const a = agent?.trim();
|
|
47
|
+
if (a)
|
|
48
|
+
params.set("agent", a);
|
|
49
|
+
const qs = params.toString();
|
|
50
|
+
return `/auth/wecom/login${qs ? `?${qs}` : ""}`;
|
|
51
|
+
}
|
|
@@ -220,9 +220,12 @@ export async function markAllRead(userId: string | number): Promise<void> {
|
|
|
220
220
|
// oj 0.1.81 的 orIgnore 仅跟随 VALUES 的 upsert 子句,不与 fromSelect(SELECT 源) 组合
|
|
221
221
|
// (实测:code 500 "orIgnore with fromSelect is not supported on sqlite")。原 SQL 三方言通用
|
|
222
222
|
// (sqlite OR IGNORE / pg ON CONFLICT DO NOTHING / mysql ON DUPLICATE KEY),退回裸 SQL。
|
|
223
|
+
// 多租户(0015):裸 SQL 必须显式写 tenant_id 且把当前租户作为绑定参数传进去,
|
|
224
|
+
// 否则 sql_guard=deny 直接拒(缺列 / 参数不含租户值两种都会被拦)。
|
|
225
|
+
const tenant = http.tenantId;
|
|
223
226
|
await db.exec(
|
|
224
|
-
"INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) SELECT id, ?, ? FROM notifications_broadcast",
|
|
225
|
-
[uid, now],
|
|
227
|
+
"INSERT OR IGNORE INTO notifications_broadcast_read (tenant_id, broadcast_id, user_id, read_time) SELECT tenant_id, id, ?, ? FROM notifications_broadcast WHERE tenant_id = ?",
|
|
228
|
+
[uid, now, tenant],
|
|
226
229
|
);
|
|
227
230
|
}
|
|
228
231
|
|
|
@@ -231,9 +234,10 @@ export async function clearNotices(userId: string | number): Promise<void> {
|
|
|
231
234
|
const uid = Number(userId);
|
|
232
235
|
await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).delete().run();
|
|
233
236
|
// 保留 db.exec:INSERT OR IGNORE ... SELECT 同 markAllRead(构造器 orIgnore 不与
|
|
234
|
-
// fromSelect 组合,实测 500),原 SQL
|
|
237
|
+
// fromSelect 组合,实测 500),原 SQL 三方言通用。多租户同上:tenant_id 显式 + 绑定参数。
|
|
238
|
+
const tenant = http.tenantId;
|
|
235
239
|
await db.exec(
|
|
236
|
-
"INSERT OR IGNORE INTO notifications_broadcast_hidden (broadcast_id, user_id, create_time) SELECT id, ?, ? FROM notifications_broadcast",
|
|
237
|
-
[uid, nowStr()],
|
|
240
|
+
"INSERT OR IGNORE INTO notifications_broadcast_hidden (tenant_id, broadcast_id, user_id, create_time) SELECT tenant_id, id, ?, ? FROM notifications_broadcast WHERE tenant_id = ?",
|
|
241
|
+
[uid, nowStr(), tenant],
|
|
238
242
|
);
|
|
239
243
|
}
|