@autional-cn/shared 0.1.0-rc.3 → 0.1.0-rc.5

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.
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo } from 'react';
2
2
  import { useCurrentTenantId } from '../hooks/useAuth';
3
3
  import { getPortalUrl } from '../config';
4
+ import { traceRedirect } from '../auth/auth-trace';
4
5
  import { usePublicTenantSlugs } from './TenantIndexGuard';
5
6
 
6
7
  /**
@@ -29,7 +30,7 @@ export function TenantRootRedirect() {
29
30
  if (typeof window === 'undefined') return;
30
31
  if (slug) {
31
32
  const base = window.location.pathname.replace(/\/+$/, '');
32
- window.location.replace(`${base}/${slug}/`);
33
+ traceRedirect(`${base}/${slug}/`, { reason: 'funnel-slug' });
33
34
  return;
34
35
  }
35
36
  // 有会话但公开名单尚未返回:等待(名单是 slug 唯一权威,不得抢跑漏斗 brand)。
@@ -38,7 +39,7 @@ export function TenantRootRedirect() {
38
39
  const target = `${getPortalUrl('brand')}/?redirect=${encodeURIComponent(
39
40
  window.location.origin + window.location.pathname,
40
41
  )}`;
41
- window.location.replace(target);
42
+ traceRedirect(target, { reason: 'funnel-brand' });
42
43
  }, [slug, currentTenantId, tenantsLoaded]);
43
44
 
44
45
  return null;
@@ -55,8 +55,33 @@ import { getPortalUrl } from '../../config';
55
55
 
56
56
  const originalWindowLocation = window.location;
57
57
 
58
+ /**
59
+ * 每例取全新模块实例:auth-trace 的模块级重入抑制(pending)与「停留」复位
60
+ * 是同页语义,跨用例泄漏会让后续 interstitial 被静默抑制(假红/假绿)。
61
+ */
62
+ async function freshRequireAuth() {
63
+ vi.resetModules();
64
+ const mod = await import('../RequireAuth');
65
+ return mod.RequireAuth;
66
+ }
67
+
68
+ /** authTrace 的提示条/确认页挂在 document.body(非 React 树),手动清理防跨用例残留。 */
69
+ function cleanupAuthTraceNodes() {
70
+ document.getElementById('auth-trace-notice')?.remove();
71
+ document.getElementById('auth-trace-confirm')?.remove();
72
+ }
73
+
74
+ function clickGoNow(): void {
75
+ const btn = Array.from(document.querySelectorAll<HTMLButtonElement>('#auth-trace-notice button')).find(
76
+ (b) => b.textContent === '立即前往',
77
+ );
78
+ expect(btn).toBeTruthy();
79
+ btn!.click();
80
+ }
81
+
58
82
  beforeEach(() => {
59
83
  vi.clearAllMocks();
84
+ document.documentElement.lang = 'zh-CN';
60
85
  mockState.machineStatus = 'unauthenticated';
61
86
  mockState.tenantRoute = {
62
87
  slug: null,
@@ -85,22 +110,30 @@ afterEach(() => {
85
110
  }
86
111
  // vitest globals 未开启 ⇒ RTL 不会自动 cleanup,手动清理防跨用例 DOM 污染
87
112
  cleanup();
113
+ cleanupAuthTraceNodes();
88
114
  });
89
115
 
90
116
  describe('RequireAuth 未登录出口', () => {
91
- it('无可用 client_id → 跳 auth 登录页并带 from_requireauth=1', async () => {
117
+ it('无可用 client_id → 提示条(可停留)后跳 auth 登录页并带 from_requireauth=1', async () => {
118
+ const RequireAuthFresh = await freshRequireAuth();
119
+
92
120
  render(
93
- <RequireAuth>
121
+ <RequireAuthFresh>
94
122
  <div>protected</div>
95
- </RequireAuth>,
123
+ </RequireAuthFresh>,
96
124
  );
97
125
 
126
+ // 层①:被动弹登录先出提示条(不再静默整页弹走)
127
+ await waitFor(() => expect(document.getElementById('auth-trace-notice')).toBeTruthy());
128
+ clickGoNow();
129
+
98
130
  await waitFor(() => {
99
131
  expect(mockState.replace).toHaveBeenCalled();
100
132
  });
101
133
  const url = String(mockState.replace.mock.calls[0][0]);
102
134
  expect(url).toContain('from_requireauth=1');
103
135
  expect(url).toContain('redirect=');
136
+ expect(url).toContain('rt=localhost.unauthenticated.');
104
137
  expect(url.startsWith(getPortalUrl('auth'))).toBe(true);
105
138
  expect(mockState.initiate).not.toHaveBeenCalled();
106
139
  });
@@ -230,12 +263,16 @@ describe('RequireAuth F-W6 确定性 404 闸门', () => {
230
263
 
231
264
  it('非 unknownSlug(网络错误 fail-open)→ 维持原漏斗(回归锁)', async () => {
232
265
  // 默认 tenantRoute: notFound=true, unknownSlug=false —— 即 by-slug 网络错误场景
266
+ const RequireAuthFresh = await freshRequireAuth();
267
+
233
268
  render(
234
- <RequireAuth notFound={<div data-testid="tenant-404">no such tenant</div>}>
269
+ <RequireAuthFresh notFound={<div data-testid="tenant-404">no such tenant</div>}>
235
270
  <div>protected</div>
236
- </RequireAuth>,
271
+ </RequireAuthFresh>,
237
272
  );
238
273
 
274
+ await waitFor(() => expect(document.getElementById('auth-trace-notice')).toBeTruthy());
275
+ clickGoNow();
239
276
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
240
277
  const url = String(mockState.replace.mock.calls[0][0]);
241
278
  expect(url).toContain('from_requireauth=1');
@@ -58,6 +58,13 @@ function expectedBrandFunnel() {
58
58
  )}`;
59
59
  }
60
60
 
61
+ /** 剥除 authTrace 面包屑(rt=)后比较;rt 装饰本身由专门断言锁定。 */
62
+ function withoutRt(url: string): string {
63
+ const u = new URL(url, 'http://trace.invalid');
64
+ u.searchParams.delete('rt');
65
+ return u.pathname + u.search;
66
+ }
67
+
61
68
  const originalWindowLocation = window.location;
62
69
 
63
70
  beforeEach(() => {
@@ -102,7 +109,8 @@ describe('TenantRootRedirect 裸根漏斗', () => {
102
109
 
103
110
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
104
111
  const url = String(mockState.replace.mock.calls[0][0]);
105
- expect(url).toBe('/demo/');
112
+ expect(url).toContain('rt=localhost.funnel-slug.');
113
+ expect(withoutRt(url)).toBe('/demo/');
106
114
  expect(url).not.toContain('Demo%20Tenant');
107
115
  expect(url).not.toContain('Demo');
108
116
  });
@@ -114,7 +122,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
114
122
  render(wrapper(<TenantRootRedirect />));
115
123
 
116
124
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
117
- expect(String(mockState.replace.mock.calls[0][0])).toBe('/demo-fallback/');
125
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe('/demo-fallback/');
118
126
  });
119
127
 
120
128
  it('有会话但名单未返回(加载中)→ 等待,不抢跑 brand 漏斗', async () => {
@@ -133,7 +141,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
133
141
  render(wrapper(<TenantRootRedirect />));
134
142
 
135
143
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
136
- expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
144
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
137
145
  });
138
146
 
139
147
  it('有会话但名单无匹配(陈旧会话)→ 漏斗 brand', async () => {
@@ -143,7 +151,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
143
151
  render(wrapper(<TenantRootRedirect />));
144
152
 
145
153
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
146
- expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
154
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
147
155
  });
148
156
 
149
157
  it('名单接口失败(返回空数组)→ 有会话按无匹配处理,漏斗 brand(不卡死)', async () => {
@@ -153,6 +161,6 @@ describe('TenantRootRedirect 裸根漏斗', () => {
153
161
  render(wrapper(<TenantRootRedirect />));
154
162
 
155
163
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
156
- expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
164
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
157
165
  });
158
166
  });
@@ -1,6 +1,6 @@
1
1
  // Auto-generated from swagger.json annotations
2
2
  // DO NOT EDIT — run `python scripts/generate_api_ts.py` to regenerate
3
- // Generated: 2026-08-22 19:20:02
3
+ // Generated: 2026-09-30 17:04:31
4
4
 
5
5
  // @ts-nocheck — auto-generated; validated by check-generated-api.py
6
6
  import type * as Types from './types';
@@ -6855,6 +6855,20 @@ export async function mfaWebauthnCredentialsByCredentialsPut(webauthnCredentialI
6855
6855
  // Notification Service
6856
6856
  // ============================================================
6857
6857
 
6858
+ /**
6859
+ * 管理端列出公告
6860
+ * 管理端分页查询本租户公告列表,支持按状态过滤和关键词搜索;与用户面 /announcements 同体(同租户范围),走 admin 受众供控制台读取 参考:CAN-SPAM Act (15 U.S.C. §7701) — Commercial Email Compliance。
6861
+ */
6862
+ export async function adminAnnouncements(params?: {
6863
+ page?: number; // 页码
6864
+ page_size?: number; // 每页条数
6865
+ status?: string; // 状态过滤: draft/scheduled/published/expired
6866
+ search?: string; // 搜索关键词
6867
+ }) {
6868
+ const res = await api.get(`/notification/api/v1/admin/announcements`, { params });
6869
+ return res.data;
6870
+ }
6871
+
6858
6872
  /**
6859
6873
  * 创建公告
6860
6874
  * 创建新的公告,支持草稿/定时发布,可指定目标租户和角色 参考:CAN-SPAM Act (15 U.S.C. §7701) — Commercial Email Compliance。
@@ -7224,15 +7238,25 @@ export async function notificationsStats() {
7224
7238
 
7225
7239
  /**
7226
7240
  * SSE实时通知流
7227
- * 通过Server-Sent Events实时推送通知,客户端需传入user_id查询参数进行订阅 参考:CAN-SPAM Act (15 U.S.C. §7701) — Commercial Email Compliance。
7241
+ * 通过Server-Sent Events实时推送通知。P5-4:经 POST /notifications/stream-ticket 换取一次性票据后以 ?ticket= 建流(浏览器 EventSource 主路径);非浏览器客户端可走 Authorization 头 JWT(?token= 兼容回退已删除)。订阅身份以认证上下文(票据/JWT claims)为准。 参考:CAN-SPAM Act (15 U.S.C. §7701) — Commercial Email Compliance。
7228
7242
  */
7229
7243
  export async function notificationsStream(params?: {
7230
- user_id: string; // 用户ID
7244
+ ticket?: string; // 一次性短期票据(P5-4 主路径,60s 内一次性消费)
7245
+ user_id?: string; // 用户ID(已弃用回退:仅在认证上下文无用户时使用)
7231
7246
  }) {
7232
7247
  const res = await api.get(`/notification/api/v1/notifications/stream`, { params });
7233
7248
  return res.data;
7234
7249
  }
7235
7250
 
7251
+ /**
7252
+ * 签发 SSE 短期票据
7253
+ * 为 /notifications/stream 签发一次性票据(P5-4):浏览器 EventSource 不能携带 Authorization 头,故以 JWT 保护的本端点换取 60s 一次性票据,再以 ?ticket= 建流,避免 access token 进入 URL/接入日志。需 JWT 认证(正常 Authorization 头调用)。
7254
+ */
7255
+ export async function notificationsStreamTicketPost() {
7256
+ const res = await api.post(`/notification/api/v1/notifications/stream-ticket`);
7257
+ return res.data;
7258
+ }
7259
+
7236
7260
  /**
7237
7261
  * 列出通知模板
7238
7262
  * 获取所有通知模板列表,支持包含已停用模板的选项 参考:CAN-SPAM Act (15 U.S.C. §7701) — Commercial Email Compliance。
@@ -7377,7 +7401,7 @@ export async function pushSubscriptionsBySubscriptions(subscriptionId: string) {
7377
7401
 
7378
7402
  /**
7379
7403
  * 获取 VAPID 公钥
7380
- * 返回 Web Push VAPID 公钥,供前端注册 Push 订阅时使用 参考:CAN-SPAM Act (15 U.S.C. §7701) — Commercial Email Compliance。
7404
+ * 返回 Web Push VAPID 公钥,供前端注册 Push 订阅时使用(来源:communication-service) 参考:CAN-SPAM Act (15 U.S.C. §7701) — Commercial Email Compliance。
7381
7405
  */
7382
7406
  export async function pushVapidPublicKey() {
7383
7407
  const res = await api.get(`/notification/api/v1/push/vapid-public-key`);
@@ -1,6 +1,6 @@
1
1
  // Auto-generated from swagger.json annotations
2
2
  // DO NOT EDIT — run `python scripts/generate_api_ts.py` to regenerate
3
- // Generated: 2026-08-22 19:20:02
3
+ // Generated: 2026-09-30 17:04:31
4
4
 
5
5
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
6
6
  import * as api from './api';
@@ -2599,6 +2599,14 @@ export function useMfaWebauthnCredentialsByCredentials(webauthnCredentialId: str
2599
2599
 
2600
2600
  // --- notification-service ---
2601
2601
 
2602
+ /** 管理端列出公告 */
2603
+ export function useAdminAnnouncements(params?: any) {
2604
+ return useQuery({
2605
+ queryKey: ['notification-service', params] as const,
2606
+ queryFn: () => api.adminAnnouncements(params),
2607
+ });
2608
+ }
2609
+
2602
2610
  /** 管理员查看指定用户通知列表 */
2603
2611
  export function useAdminNotifications(params?: any) {
2604
2612
  return useQuery({
@@ -9153,6 +9161,17 @@ export function usePostNotificationsSend_from_template() {
9153
9161
  });
9154
9162
  }
9155
9163
 
9164
+ /** 签发 SSE 短期票据 */
9165
+ export function usePostNotificationsStream_ticket() {
9166
+ const qc = useQueryClient();
9167
+ return useMutation({
9168
+ mutationFn: () => api.notificationsStreamTicketPost(),
9169
+ onSuccess: () => {
9170
+ qc.invalidateQueries({ queryKey: ['notification-service'] });
9171
+ },
9172
+ });
9173
+ }
9174
+
9156
9175
  /** 发送测试通知 */
9157
9176
  export function usePostNotificationsTest() {
9158
9177
  const qc = useQueryClient();
@@ -1,6 +1,6 @@
1
1
  // Auto-generated from swagger.json annotations
2
2
  // DO NOT EDIT — run `python scripts/generate_api_ts.py` to regenerate
3
- // Generated: 2026-08-22 06:50:43
3
+ // Generated: 2026-09-30 17:04:31
4
4
 
5
5
  // ============================================================
6
6
  // Shared generic types
@@ -7516,6 +7516,71 @@ export type COSEAlgorithmIdentifier = "-7" | "-8" | "-9" | "-19" | "-35" | "-36"
7516
7516
  // notification-service
7517
7517
  // ============================================================
7518
7518
 
7519
+ export interface DataResponsegitee_com_autional_servicenotification_internal_handler_dto_EventMappingResponse {
7520
+ code?: number;
7521
+ data?: EventMappingResponse;
7522
+ message?: string;
7523
+ timestamp?: string;
7524
+ }
7525
+
7526
+ export interface DataResponsegitee_com_autional_servicenotification_internal_handler_dto_ExportUserDataResponse {
7527
+ code?: number;
7528
+ data?: ExportUserDataResponse;
7529
+ message?: string;
7530
+ timestamp?: string;
7531
+ }
7532
+
7533
+ export interface DataResponsegitee_com_autional_servicenotification_internal_handler_dto_NotificationPreferencesResponse {
7534
+ code?: number;
7535
+ data?: NotificationPreferencesResponse;
7536
+ message?: string;
7537
+ timestamp?: string;
7538
+ }
7539
+
7540
+ export interface DataResponsegitee_com_autional_servicenotification_internal_handler_dto_NotificationResponse {
7541
+ code?: number;
7542
+ data?: NotificationResponse;
7543
+ message?: string;
7544
+ timestamp?: string;
7545
+ }
7546
+
7547
+ export interface DataResponsegitee_com_autional_servicenotification_internal_handler_dto_StreamTicketResponse {
7548
+ code?: number;
7549
+ data?: StreamTicketResponse;
7550
+ message?: string;
7551
+ timestamp?: string;
7552
+ }
7553
+
7554
+ export interface DataResponsegitee_com_autional_servicenotification_internal_handler_dto_TemplateResponse {
7555
+ code?: number;
7556
+ data?: TemplateResponse;
7557
+ message?: string;
7558
+ timestamp?: string;
7559
+ }
7560
+
7561
+ export interface DataResponsehandler_HealthErrorResponse {
7562
+ code?: number;
7563
+ data?: HealthErrorResponse;
7564
+ message?: string;
7565
+ timestamp?: string;
7566
+ }
7567
+
7568
+ export interface DataResponsemap_string_string {
7569
+ code?: number;
7570
+ data?: Map_string_string;
7571
+ message?: string;
7572
+ timestamp?: string;
7573
+ }
7574
+
7575
+ export interface ListResponsegitee_com_autional_servicenotification_internal_handler_dto_NotificationResponse {
7576
+ code?: number;
7577
+ items?: NotificationResponse[]; // 统一使用 items
7578
+ message?: string;
7579
+ pagination?: PageInfo; // 分页信息(嵌套对象)
7580
+ timestamp?: string;
7581
+ total?: number; // 总条数(平铺,便于直接读取)
7582
+ }
7583
+
7519
7584
  export interface AnnouncementDetailResponse {
7520
7585
  code?: number;
7521
7586
  data?: AnnouncementResponse;
@@ -7641,48 +7706,6 @@ export interface CreateGlobalVariableRequest {
7641
7706
  value: string;
7642
7707
  }
7643
7708
 
7644
- export interface DataResponsedto_ExportUserDataResponse {
7645
- code?: number;
7646
- data?: ExportUserDataResponse;
7647
- message?: string;
7648
- timestamp?: string;
7649
- }
7650
-
7651
- export interface DataResponsedto_NotificationPreferencesResponse {
7652
- code?: number;
7653
- data?: NotificationPreferencesResponse;
7654
- message?: string;
7655
- timestamp?: string;
7656
- }
7657
-
7658
- export interface DataResponsedto_NotificationResponse {
7659
- code?: number;
7660
- data?: NotificationResponse;
7661
- message?: string;
7662
- timestamp?: string;
7663
- }
7664
-
7665
- export interface DataResponsedto_TemplateResponse {
7666
- code?: number;
7667
- data?: TemplateResponse;
7668
- message?: string;
7669
- timestamp?: string;
7670
- }
7671
-
7672
- export interface DataResponsehandler_HealthErrorResponse {
7673
- code?: number;
7674
- data?: HealthErrorResponse;
7675
- message?: string;
7676
- timestamp?: string;
7677
- }
7678
-
7679
- export interface DataResponsemap_string_string {
7680
- code?: number;
7681
- data?: Map_string_string;
7682
- message?: string;
7683
- timestamp?: string;
7684
- }
7685
-
7686
7709
  export interface DeleteNotificationDetailResponse {
7687
7710
  code?: number;
7688
7711
  data?: DeleteNotificationResponse;
@@ -7776,13 +7799,20 @@ export interface InternalPushSendRequest {
7776
7799
  userId?: string; // @example user-001
7777
7800
  }
7778
7801
 
7779
- export interface ListResponsedto_NotificationResponse {
7780
- code?: number;
7781
- items?: NotificationResponse[]; // 统一使用 items
7782
- message?: string;
7783
- pagination?: PageInfo; // 分页信息(嵌套对象)
7784
- timestamp?: string;
7785
- total?: number; // 总条数(平铺,便于直接读取)
7802
+ export interface InternalSendExternalRequest {
7803
+ channel: string; // @example email
7804
+ locale?: string; // @example en-US
7805
+ recipient: string; // @example user@example.com
7806
+ templateCode: string; // @example status_subscription_confirm
7807
+ variables?: Record<string, string>;
7808
+ }
7809
+
7810
+ export interface InternalUpsertEventMappingRequest {
7811
+ channel?: string; // 空 → in_app | @example in_app
7812
+ eventType: string; // @example user.registered
7813
+ isEnabled?: boolean; // 空 → true
7814
+ priority?: string; // 空 → medium | @example medium
7815
+ templateCode: string; // @example welcome
7786
7816
  }
7787
7817
 
7788
7818
  export interface MarkReadDetailResponse {
@@ -8008,6 +8038,12 @@ export interface SendNotificationRequest {
8008
8038
  userId: string; // 用户ID | @example usr_abc123
8009
8039
  }
8010
8040
 
8041
+ /** 一次性 SSE 票据:60s 内有效、消费一次即失效;以 ?ticket= 建立 EventSource 流 */
8042
+ export interface StreamTicketResponse {
8043
+ expiresIn?: number; // 有效期(秒) | @example 60
8044
+ ticket?: string; // 一次性票据 | @example 1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2
8045
+ }
8046
+
8011
8047
  export interface TestNotificationDetailResponse {
8012
8048
  code?: number;
8013
8049
  data?: TestNotificationResponse;
@@ -8873,6 +8909,13 @@ export interface DataResponsedomain_PointIntegrityResult {
8873
8909
  timestamp?: string;
8874
8910
  }
8875
8911
 
8912
+ export interface DataResponsedto_ExportUserDataResponse {
8913
+ code?: number;
8914
+ data?: ExportUserDataResponse;
8915
+ message?: string;
8916
+ timestamp?: string;
8917
+ }
8918
+
8876
8919
  export interface DeleteUserDataRequest {
8877
8920
  userId: string;
8878
8921
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { useCallback } from 'react';
8
8
  import { buildLogoutUrl } from '../auth/roles';
9
+ import { traceRedirect } from '../auth/auth-trace';
9
10
 
10
11
  /**
11
12
  * 统一的登出 Hook
@@ -26,7 +27,8 @@ export function useLogout() {
26
27
 
27
28
  // buildLogoutUrl = auth 域裸根 + `logout=1` 登出意图标记:入口路由据此先
28
29
  // 终结会话再落 brand,而不是把带会话的回程当普通深链直送登录页(会静默重登)。
29
- window.location.href = buildLogoutUrl(currentUrl);
30
+ // funnel:用户主动行为,静默直跳(assign 保持原 href 历史语义);仅记 trace。
31
+ traceRedirect(buildLogoutUrl(currentUrl), { reason: 'logout', assign: true });
30
32
  };
31
33
  performLogout();
32
34
  }, []);
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export { generatePKCE, generateCodeChallenge } from './auth/pkce';
25
25
  export type { PKCEPair } from './auth/pkce';
26
26
  export { initiateOAuthLogin, handleOAuthCallback, isOAuthEnabled } from './auth/oauth-login';
27
27
  export { buildLoginUrl } from './auth/roles';
28
+ export { traceRedirect, traceEvent, traceDump } from './auth/auth-trace';
29
+ export type { AuthTraceEntry, AuthTraceRedirectOptions } from './auth/auth-trace';
28
30
 
29
31
  // Types
30
32
  export type {