cabloy 5.1.240 → 5.1.242

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.
Files changed (55) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +1 -1
  4. package/repo-docs/backend/i18n-guide.md +109 -11
  5. package/repo-docs/backend/openapi-guide.md +18 -0
  6. package/repo-docs/frontend/environment-config-guide.md +43 -0
  7. package/repo-docs/frontend/module-scope.md +88 -0
  8. package/repo-docs/frontend/system-startup-guide.md +31 -0
  9. package/repo-e2e/specs/home-user-account.spec.ts +53 -8
  10. package/vona/packages-vona/vona/package.json +1 -1
  11. package/vona/pnpm-lock.yaml +17 -142
  12. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/dto/operatorContext.ts +4 -1
  13. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/service/operator.ts +1 -0
  14. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/operatorAccess.test.ts +1 -0
  15. package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +1 -0
  16. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  17. package/vona/src/suite/a-home/modules/home-user/src/dto/accountCurrent.ts +3 -0
  18. package/vona/src/suite/a-home/modules/home-user/src/dto/accountProfileUpdate.ts +9 -2
  19. package/vona/src/suite/a-home/modules/home-user/src/dto/passportUser.ts +3 -0
  20. package/vona/src/suite/a-home/modules/home-user/src/entity/user.ts +3 -0
  21. package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +7 -3
  22. package/vona/src/suite/a-home/modules/home-user/src/service/userAdapter.ts +1 -0
  23. package/vona/src/suite/a-home/modules/home-user/test/account.test.ts +133 -8
  24. package/vona/src/suite/a-home/modules/home-user/test/accountOpenapi.test.ts +32 -5
  25. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +159 -1
  26. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  27. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  28. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/package.json +1 -1
  29. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/bean.captcha.ts +271 -137
  30. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/bean/cacheRedis.captcha.ts +199 -2
  31. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/lib/captchaLua.ts +90 -0
  32. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captcha.ts +15 -1
  33. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/src/types/captchaProvider.ts +38 -1
  34. package/vona/src/suite-vendor/a-captcha/package.json +1 -1
  35. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  36. package/vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.ts +54 -8
  37. package/vona/src/suite-vendor/a-vona/modules/a-permission/test/permission.test.ts +12 -15
  38. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
  39. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  40. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/user.ts +1 -0
  41. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  42. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/schemas.ts +68 -52
  43. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/types.ts +530 -344
  44. package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/page/dashboard/controller.tsx +7 -1
  45. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +6 -1
  46. package/zova/src/suite/a-home/modules/home-base/src/config/locale/en-us.ts +1 -0
  47. package/zova/src/suite/a-home/modules/home-base/src/config/locale/zh-cn.ts +1 -0
  48. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +1 -1
  49. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +3 -1
  50. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +4 -0
  51. package/zova/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +1 -0
  52. package/zova/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +1 -0
  53. package/zova/src/suite/a-home/modules/home-user/src/model/account.ts +2 -1
  54. package/zova/src/suite/a-home/modules/home-user/src/page/account/controller.tsx +17 -3
  55. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/test/aggregate.test.ts +0 -247
@@ -19,12 +19,18 @@ export class ControllerPageDashboard extends BeanControllerPageBase {
19
19
 
20
20
  protected render() {
21
21
  const query = this.queryOperatorContext;
22
+ const operator = query.data!;
23
+ const user = this.$passport.user;
24
+ const displayName =
25
+ user && String(user.id) === String(operator.userId)
26
+ ? this.$passport.displayName
27
+ : operator.displayName || operator.userName;
22
28
  return (
23
29
  <div class="p-6">
24
30
  <h1 class="text-2xl font-semibold">{this.scope.locale.CommerceAdministration()}</h1>
25
31
  <dl class="mt-4 grid max-w-xl grid-cols-[auto_1fr] gap-x-4 gap-y-2">
26
32
  <dt>{this.scope.locale.Operator()}</dt>
27
- <dd>{query.data!.userName}</dd>
33
+ <dd>{displayName}</dd>
28
34
  <dt>{this.scope.locale.Instance()}</dt>
29
35
  <dd>{query.data!.instanceName || 'default'}</dd>
30
36
  </dl>
@@ -2596,6 +2596,7 @@ export interface components {
2596
2596
  | undefined;
2597
2597
  'home-user.dto.accountCurrent': {
2598
2598
  name: string;
2599
+ displayName?: string | undefined;
2599
2600
  avatar?: string | undefined;
2600
2601
  locale?: unknown;
2601
2602
  tz?: string | undefined;
@@ -2605,7 +2606,7 @@ export interface components {
2605
2606
  };
2606
2607
  'home-user.dto.accountProfileUpdate': {
2607
2608
  /** @description Display name */
2608
- name: string;
2609
+ displayName?: string | undefined;
2609
2610
  /**
2610
2611
  * Avatar
2611
2612
  * @description Absolute HTTPS avatar URL or API delivery URL
@@ -2673,6 +2674,7 @@ export interface components {
2673
2674
  'home-user.dto.passportUser': {
2674
2675
  id: number | string;
2675
2676
  name: string;
2677
+ displayName?: string | undefined;
2676
2678
  avatar?: string | undefined;
2677
2679
  email?: string | undefined;
2678
2680
  mobile?: string | undefined;
@@ -2783,6 +2785,7 @@ export interface components {
2783
2785
  instanceName: string;
2784
2786
  userId: string;
2785
2787
  userName: string;
2788
+ displayName?: string | undefined;
2786
2789
  };
2787
2790
  'commerce-promotion.dto.couponMineItem': {
2788
2791
  id: number | string;
@@ -3215,7 +3218,9 @@ export interface components {
3215
3218
  shippedAt: Date;
3216
3219
  };
3217
3220
  'commerce-trade.dto.orderShip': {
3221
+ /** @description Carrier */
3218
3222
  carrier: string;
3223
+ /** @description Tracking number */
3219
3224
  trackingNumber: string;
3220
3225
  };
3221
3226
  'commerce-trade.dto.orderSelectRes': {
@@ -5,6 +5,7 @@ export default {
5
5
  LanguageEnglish: 'English',
6
6
  LanguageChinese: 'Chinese',
7
7
  LanguageJapanese: 'Japanese',
8
+ LanguageTraditionalChinese: 'Traditional Chinese',
8
9
  VonaIntegratedSsr: 'Vona integrated SSR',
9
10
  ZovaStandaloneSsr: 'Zova standalone SSR',
10
11
  SsrSite: 'SSR Site',
@@ -5,6 +5,7 @@ export default {
5
5
  LanguageEnglish: '英语',
6
6
  LanguageChinese: '简体中文',
7
7
  LanguageJapanese: '日语',
8
+ LanguageTraditionalChinese: '繁体中文',
8
9
  VonaIntegratedSsr: 'Vona 集成式 SSR',
9
10
  ZovaStandaloneSsr: 'Zova 独立式 SSR',
10
11
  SsrSite: 'SSR 站点',
@@ -18,7 +18,7 @@ export class RenderUser extends BeanRenderBase {
18
18
  this.$scopeBase.config.avatar.empty
19
19
  }
20
20
  />
21
- {this.$passport.user?.name}
21
+ {this.$passport.displayName}
22
22
  </summary>
23
23
  <ul class="bg-base-100 rounded-t-none p-2 w-32">
24
24
  <li>
@@ -21,7 +21,9 @@ export class RenderUser extends BeanRenderBase {
21
21
  ) || this.$scopeBase.config.avatar.empty
22
22
  }
23
23
  />
24
- {this.$passport.user?.name || this.scope.locale.NotLoggedIn()}
24
+ {this.$passport.isAuthenticated
25
+ ? this.$passport.displayName
26
+ : this.scope.locale.NotLoggedIn()}
25
27
  </summary>
26
28
  <ul class="bg-base-100 rounded-t-none p-2 w-40">
27
29
  {this.$passport.isAuthenticated ? (
@@ -187,6 +187,10 @@ export class ModelPassport extends BeanModelBase {
187
187
  return this.passport?.user;
188
188
  }
189
189
 
190
+ get displayName(): string | undefined {
191
+ return this.user?.displayName || this.user?.name;
192
+ }
193
+
190
194
  get roles() {
191
195
  return this.passport?.roles;
192
196
  }
@@ -2,6 +2,7 @@ export default {
2
2
  AccountSettings: 'Account Settings',
3
3
  AccountProfile: 'Profile',
4
4
  AccountSecurity: 'Account Security',
5
+ AccountUserName: 'User name',
5
6
  AccountDisplayName: 'Display name',
6
7
  AccountLocale: 'Locale',
7
8
  AccountTimezone: 'Time zone',
@@ -2,6 +2,7 @@ export default {
2
2
  AccountSettings: '账户设置',
3
3
  AccountProfile: '个人资料',
4
4
  AccountSecurity: '账户安全',
5
+ AccountUserName: '用户名',
5
6
  AccountDisplayName: '显示名称',
6
7
  AccountLocale: '语言区域',
7
8
  AccountTimezone: '时区',
@@ -21,8 +21,9 @@ export interface IModelOptionsAccount extends IDecoratorModelOptions {}
21
21
 
22
22
  export type AccountProfileUpdateCommand = Omit<
23
23
  ApiApiHomeUserAccountupdateProfileRequestBody,
24
- 'avatar' | 'locale' | 'tz'
24
+ 'displayName' | 'avatar' | 'locale' | 'tz'
25
25
  > & {
26
+ displayName?: string | null;
26
27
  avatar?: string | null;
27
28
  locale?: string | null;
28
29
  tz?: string | null;
@@ -81,7 +81,11 @@ export class ControllerPageAccount extends BeanControllerPageBase {
81
81
  }
82
82
 
83
83
  async submitProfile(data: TypeFormOnSubmitData<ProfileDraft>) {
84
- const current = await this.$$modelAccount.updateProfile().mutateAsync(data.value);
84
+ const displayName = data.value.displayName;
85
+ const current = await this.$$modelAccount.updateProfile().mutateAsync({
86
+ ...data.value,
87
+ displayName: displayName === undefined ? undefined : displayName?.trim() || null,
88
+ });
85
89
  this._resetProfileDraft(current);
86
90
  this.profileMessage = this.scope.locale.AccountProfileSaved();
87
91
  }
@@ -119,7 +123,7 @@ export class ControllerPageAccount extends BeanControllerPageBase {
119
123
  private _resetProfileDraft(current = this.queryCurrent?.data) {
120
124
  if (!current) return;
121
125
  this.profileDraft = {
122
- name: current.name,
126
+ displayName: current.displayName ?? '',
123
127
  avatar: current.avatar ?? undefined,
124
128
  locale: typeof current.locale === 'string' ? current.locale : undefined,
125
129
  tz: current.tz ?? undefined,
@@ -254,8 +258,18 @@ export class ControllerPageAccount extends BeanControllerPageBase {
254
258
  );
255
259
  }}
256
260
  />
261
+ <label class="form-control gap-2">
262
+ <span class="label-text">{this.scope.locale.AccountUserName()}</span>
263
+ <input
264
+ class="input"
265
+ type="text"
266
+ readonly
267
+ autocomplete="username"
268
+ value={this.queryCurrent?.data?.name}
269
+ />
270
+ </label>
257
271
  <ZFormFieldPreset
258
- name="name"
272
+ name="displayName"
259
273
  render="basic-input:formFieldInput"
260
274
  options={{ type: 'text' }}
261
275
  />
@@ -1,247 +0,0 @@
1
- import type { IMiddlewareSystemOptionsAggregate, RateLimitMode } from 'vona-module-a-ratelimit';
2
-
3
- import assert from 'node:assert/strict';
4
- import { randomUUID } from 'node:crypto';
5
- import { once } from 'node:events';
6
- import { createServer } from 'node:http';
7
- import { describe, it } from 'node:test';
8
- import { setTimeout } from 'node:timers/promises';
9
- import { cast, compose } from 'vona';
10
- import { app } from 'vona-mock';
11
- import { MiddlewareSystemAggregate, ServiceRateLimit } from 'vona-module-a-ratelimit';
12
-
13
- const exemptPath = '/api/test/vona/performAction/rateLimitExempt';
14
- const limitedPath = '/api/test/vona/performAction/rateLimit';
15
-
16
- type AdmitCommand = (key: string, windowMs: string) => Promise<[number, number]>;
17
-
18
- function createService(command: AdmitCommand) {
19
- const service = app.bean._newBean(ServiceRateLimit);
20
- Object.defineProperty(service, 'bean', {
21
- value: { redis: { get: () => ({ rateLimitAdmit: command }) } },
22
- });
23
- return service;
24
- }
25
-
26
- type Request = (path: string, options?: RequestInit) => Promise<Response>;
27
-
28
- async function withGate(
29
- mode: RateLimitMode,
30
- limit: number,
31
- command: AdmitCommand,
32
- fn: (request: Request) => Promise<void>,
33
- ) {
34
- await app.bean.executor.mockCtx(async () => {
35
- const middleware = app.bean._newBean(MiddlewareSystemAggregate);
36
- Object.defineProperty(middleware, 'scope', {
37
- value: { service: { rateLimit: createService(command) } },
38
- });
39
- const policyName = `test-aggregate-${randomUUID()}`;
40
- // Keep admission and its listener local; never patch the shared application chain.
41
- const chain = compose([
42
- (_ctx, next) => middleware.execute({ rateLimit: { mode, limit } }, next),
43
- ...app.middleware,
44
- ]);
45
- const server = createServer((req, res) => {
46
- void app.bean.executor
47
- .newCtx(
48
- () => {
49
- app.ctx.onionsDynamic = {
50
- interceptor: { 'a-ratelimit:rateLimit': { rateLimit: { name: policyName } } },
51
- };
52
- return cast(app).handleRequest(app.ctx, chain);
53
- },
54
- { dbInfo: { level: 0 }, innerAccess: false, req, res },
55
- )
56
- .catch(error => res.destroy(error));
57
- });
58
- try {
59
- server.listen(0, '127.0.0.1');
60
- await once(server, 'listening');
61
- const address = server.address();
62
- assert.ok(address && typeof address !== 'string');
63
- await fn((path, options) =>
64
- fetch(`http://127.0.0.1:${address.port}${path}`, {
65
- ...options,
66
- headers: { accept: 'application/json', ...options?.headers },
67
- signal: AbortSignal.timeout(10_000),
68
- }),
69
- );
70
- } finally {
71
- await new Promise<void>((resolve, reject) => {
72
- server.close(error => (error ? reject(error) : resolve()));
73
- server.closeAllConnections();
74
- });
75
- const redis = app.bean.redis.get('limiter');
76
- const prefix = redis.options.keyPrefix ?? '';
77
- const keys = await redis.keys(`${prefix}rate-limit:v1:*:${encodeURIComponent(policyName)}:*`);
78
- if (keys.length) await redis.del(...keys.map(key => key.slice(prefix.length)));
79
- }
80
- });
81
- }
82
-
83
- function counter(): AdmitCommand {
84
- let current = 0;
85
- return async () => [++current, 1_000];
86
- }
87
-
88
- describe('aggregate.test.ts', { concurrency: false }, () => {
89
- it('shares an atomic Redis budget across request contexts and expires its counter', async () => {
90
- const key = `test:aggregate:${randomUUID()}`;
91
- const redis = cast(app.bean.redis.get('limiter'));
92
- const keys: string[] = [];
93
- // Isolate this test's bucket without changing the production key dimensions or TTL.
94
- const service = createService((actualKey, windowMs) => {
95
- keys.push(actualKey);
96
- return redis.rateLimitAdmit(key, windowMs);
97
- });
98
- try {
99
- const results = await Promise.all(
100
- [0, 1, 2, 3].map(index =>
101
- app.bean.executor.mockCtx(async () => {
102
- app.ctx.req.url = `/aggregate-${index}`;
103
- return service.admitAggregate({
104
- mode: 'enforce',
105
- limit: 2,
106
- });
107
- }),
108
- ),
109
- );
110
- assert.equal(results.filter(result => result.allowed).length, 2);
111
- assert.ok(keys.every(actualKey => /^rate-limit:aggregate:v1:\d+$/.test(actualKey)));
112
- assert.equal(await redis.get(key), '4');
113
- const ttl = await redis.pttl(key);
114
- assert.ok(ttl > 0 && ttl <= 1_000);
115
- await setTimeout(ttl + 20);
116
- assert.equal(await redis.get(key), null);
117
- await app.bean.executor.mockCtx(async () => {
118
- const result = await service.admitAggregate({
119
- mode: 'enforce',
120
- limit: 2,
121
- });
122
- assert.equal(result.allowed, true);
123
- assert.equal(result.remaining, 1);
124
- });
125
- } finally {
126
- await redis.del(key);
127
- }
128
- });
129
-
130
- it('counts health, preflight, unknown paths and identity-exempt actions in one HTTP budget', async () => {
131
- await withGate('enforce', 3, counter(), async request => {
132
- const health = await request('/health/live');
133
- assert.equal(health.status, 200);
134
- await health.text();
135
- const preflight = await request('/aggregate-unknown', {
136
- method: 'OPTIONS',
137
- headers: { 'origin': 'https://example.test', 'access-control-request-method': 'GET' },
138
- });
139
- assert.notEqual(preflight.status, 429);
140
- await preflight.text();
141
- const unknown = await request(`/aggregate-unknown-${randomUUID()}`);
142
- assert.equal(unknown.status, 404);
143
- await unknown.text();
144
- const rejected = await request(exemptPath);
145
- assert.equal(rejected.status, 429);
146
- assert.equal(rejected.headers.get('retry-after'), '1');
147
- assert.equal(rejected.headers.get('ratelimit-limit'), null);
148
- assert.deepEqual(await rejected.json(), { code: 429, message: 'Too Many Requests' });
149
- });
150
- });
151
-
152
- it('observes excess requests without rejecting or replacing identity headers', async () => {
153
- await withGate('observe', 1, counter(), async request => {
154
- for (let index = 0; index < 2; index++) {
155
- const response = await request(exemptPath);
156
- assert.equal(response.status, 200);
157
- assert.equal(response.headers.get('ratelimit-limit'), null);
158
- assert.equal(response.headers.get('retry-after'), null);
159
- await response.text();
160
- }
161
- });
162
- await withGate('enforce', 100, counter(), async request => {
163
- for (let index = 0; index < 2; index++) {
164
- const response = await request(limitedPath);
165
- assert.equal(response.status, 200);
166
- assert.equal(response.headers.get('ratelimit-limit'), '2');
167
- await response.text();
168
- }
169
- const response = await request(limitedPath);
170
- assert.equal(response.status, 429);
171
- assert.equal(response.headers.get('ratelimit-limit'), '2');
172
- await response.text();
173
- });
174
- });
175
-
176
- it('fails open only in observe mode and preserves HTTP 503 for HTML callers', async () => {
177
- const unavailable: AdmitCommand = async () => {
178
- throw new Error('redis unavailable');
179
- };
180
- await withGate('observe', 1, unavailable, async request => {
181
- const response = await request('/health/live');
182
- assert.equal(response.status, 200);
183
- await response.text();
184
- });
185
- await withGate('enforce', 1, unavailable, async request => {
186
- const response = await request(exemptPath, { headers: { accept: 'text/html' } });
187
- assert.equal(response.status, 503);
188
- assert.match(response.headers.get('content-type')!, /application\/json/);
189
- assert.equal(response.headers.get('retry-after'), null);
190
- assert.deepEqual(await response.json(), { code: 503, message: 'Service Unavailable' });
191
- });
192
- });
193
-
194
- it('keeps competing gates and the shared application listener independent', async () => {
195
- await Promise.all([
196
- withGate(
197
- 'enforce',
198
- 1,
199
- async () => [2, 1_000],
200
- async request => {
201
- const response = await request('/health/live');
202
- assert.equal(response.status, 429);
203
- await response.text();
204
- const address = app.server.address();
205
- assert.ok(address && typeof address !== 'string');
206
- const shared = await fetch(`http://127.0.0.1:${address.port}/health/live`, {
207
- signal: AbortSignal.timeout(10_000),
208
- });
209
- assert.equal(shared.status, 200);
210
- await shared.text();
211
- },
212
- ),
213
- withGate(
214
- 'enforce',
215
- 1,
216
- async () => [1, 1_000],
217
- async request => {
218
- const response = await request('/health/live');
219
- assert.equal(response.status, 200);
220
- await response.text();
221
- },
222
- ),
223
- ]);
224
- });
225
-
226
- it('requires valid explicit configuration even in observe mode', async () => {
227
- await app.bean.executor.mockCtx(async () => {
228
- const middleware = app.bean._newBean(MiddlewareSystemAggregate);
229
- let downstream = false;
230
- const next = async () => {
231
- downstream = true;
232
- };
233
- for (const options of [
234
- {},
235
- { rateLimit: { mode: 'observe', limit: 0 } },
236
- { rateLimit: { mode: 'observe', limit: 1.5 } },
237
- { rateLimit: { mode: 'invalid', limit: 1 } },
238
- ]) {
239
- await assert.rejects(
240
- middleware.execute(options as IMiddlewareSystemOptionsAggregate, next),
241
- /rate-limit aggregate/,
242
- );
243
- }
244
- assert.equal(downstream, false);
245
- });
246
- });
247
- });