@autional/shared 0.0.0-stage → 0.1.0-rc.32

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 (115) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +49 -3
  3. package/src/api/__tests__/contract.test.ts +172 -0
  4. package/src/api/__tests__/entry-plane.test.ts +119 -0
  5. package/src/api/__tests__/namespace.test.ts +27 -0
  6. package/src/api/__tests__/preflight-refresh.test.ts +89 -0
  7. package/src/api/bff-client.ts +94 -0
  8. package/src/api/client.ts +191 -0
  9. package/src/api/entry-plane.ts +61 -0
  10. package/src/api/namespace.ts +14 -0
  11. package/src/api/trace.ts +21 -0
  12. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  13. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  14. package/src/auth/__tests__/auth-trace.test.ts +321 -0
  15. package/src/auth/__tests__/build-login-url.test.ts +259 -0
  16. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  17. package/src/auth/__tests__/jwt-payload.test.ts +49 -0
  18. package/src/auth/__tests__/logout-redirect.test.ts +44 -0
  19. package/src/auth/__tests__/logout-revoke.test.ts +219 -0
  20. package/src/auth/__tests__/oauth-callback-error-codes.test.ts +69 -0
  21. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  22. package/src/auth/__tests__/oauth-initiate-stay.test.ts +118 -0
  23. package/src/auth/__tests__/oauth-login-user-resolution.test.ts +185 -0
  24. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  25. package/src/auth/__tests__/session-client-id.test.ts +147 -0
  26. package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
  27. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  28. package/src/auth/auth-machine.ts +151 -0
  29. package/src/auth/auth-queries.ts +118 -0
  30. package/src/auth/auth-trace.ts +511 -0
  31. package/src/auth/build-tenant-urls.ts +48 -0
  32. package/src/auth/jwt-payload.ts +21 -0
  33. package/src/auth/oauth-client-from-slug.ts +119 -0
  34. package/src/auth/oauth-client-id-store.ts +41 -0
  35. package/src/auth/oauth-login.ts +388 -0
  36. package/src/auth/pkce.ts +141 -0
  37. package/src/auth/portal-config.ts +68 -0
  38. package/src/auth/roles.ts +120 -0
  39. package/src/auth/service.ts +433 -0
  40. package/src/auth/slug-from-url.ts +92 -0
  41. package/src/auth/store.ts +227 -0
  42. package/src/auth/tenant-route-middleware.ts +113 -0
  43. package/src/auth/tenant-slug-context.tsx +9 -0
  44. package/src/branding/BrandingInitializer.tsx +60 -0
  45. package/src/branding/__tests__/brand-vars.test.ts +126 -0
  46. package/src/branding/brand-color.ts +193 -0
  47. package/src/branding/branding-cache.ts +71 -0
  48. package/src/branding/extract-branding.ts +27 -0
  49. package/src/branding/index.ts +15 -0
  50. package/src/branding/tenant-store.ts +13 -0
  51. package/src/branding/types.ts +14 -0
  52. package/src/branding/useBranding.ts +101 -0
  53. package/src/components/AuditStatsOnly.tsx +65 -0
  54. package/src/components/OAuthCallbackPage.tsx +83 -0
  55. package/src/components/PortalGuard.tsx +105 -0
  56. package/src/components/RequireAuth.tsx +220 -0
  57. package/src/components/TenantIndexGuard.tsx +131 -0
  58. package/src/components/TenantRootRedirect.tsx +49 -0
  59. package/src/components/__tests__/RequireAuth.adversarial.test.tsx +165 -0
  60. package/src/components/__tests__/RequireAuth.stay.test.tsx +131 -0
  61. package/src/components/__tests__/RequireAuth.test.tsx +296 -0
  62. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  63. package/src/components/__tests__/TenantRootRedirect.test.tsx +180 -0
  64. package/src/config/__tests__/portal-resolver.test.ts +302 -0
  65. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  66. package/src/config/constants.ts +27 -0
  67. package/src/config/index.ts +426 -0
  68. package/src/config/resolve-basename.ts +57 -0
  69. package/src/constants/api-paths.ts +450 -0
  70. package/src/generated/api.ts +13012 -0
  71. package/src/generated/queries.ts +12984 -0
  72. package/src/generated/types.ts +13982 -0
  73. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  74. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  75. package/src/hooks/__tests__/useCurrentTenantIdOr.test.tsx +38 -0
  76. package/src/hooks/__tests__/useLogout.test.tsx +61 -0
  77. package/src/hooks/__tests__/usePermission.map.test.tsx +156 -0
  78. package/src/hooks/__tests__/usePortalCatalog.test.tsx +301 -0
  79. package/src/hooks/__tests__/useSodMode.test.tsx +205 -0
  80. package/src/hooks/useAuth.ts +239 -0
  81. package/src/hooks/useBootstrap.ts +74 -0
  82. package/src/hooks/useCurrentRole.ts +18 -0
  83. package/src/hooks/useCurrentTenantIdOr.ts +29 -0
  84. package/src/hooks/useLogout.ts +42 -0
  85. package/src/hooks/usePermission.ts +128 -0
  86. package/src/hooks/usePortalCatalog.ts +199 -0
  87. package/src/hooks/useSodMode.ts +81 -0
  88. package/src/index.ts +247 -0
  89. package/src/lib/__tests__/push.test.ts +30 -0
  90. package/src/lib/push.ts +44 -0
  91. package/src/seo/dom-utils.ts +94 -0
  92. package/src/seo/index.ts +6 -0
  93. package/src/seo/jsonld.ts +29 -0
  94. package/src/seo/types.ts +29 -0
  95. package/src/seo/usePageMeta.ts +17 -0
  96. package/src/seo/usePageTitle.ts +14 -0
  97. package/src/seo/useSEO.ts +80 -0
  98. package/src/types/index.ts +190 -0
  99. package/src/utils/__tests__/case.test.ts +94 -0
  100. package/src/utils/__tests__/error.test.ts +72 -0
  101. package/src/utils/__tests__/mock-test.ts +15 -0
  102. package/src/utils/__tests__/page.test.ts +54 -0
  103. package/src/utils/__tests__/query-state.test.ts +120 -0
  104. package/src/utils/browser.ts +15 -0
  105. package/src/utils/case.ts +59 -0
  106. package/src/utils/error.ts +35 -0
  107. package/src/utils/format.ts +63 -0
  108. package/src/utils/page.ts +57 -0
  109. package/src/utils/password-transmission.test.ts +145 -0
  110. package/src/utils/password-transmission.ts +253 -0
  111. package/src/utils/query-state.ts +85 -0
  112. package/src/utils/response.ts +71 -0
  113. package/src/utils/vite.ts +4 -0
  114. package/src/vite-env.d.ts +19 -0
  115. package/README.md +0 -3
@@ -0,0 +1,511 @@
1
+ /**
2
+ * authTrace —— auth 整页跳转的统一出口与三层可观测(#48)。
3
+ *
4
+ * 三层:
5
+ * ① interstitial 提示:**被动**整页跳转(会话终结 / 未认证深链)执行前显示提示条
6
+ * (原因 + 目标 host + 停留 / 立即前往),短延迟后自动前往。「停留」取消本次跳转;
7
+ * 页面数据面虽已失效,但用户知情(消除「静默弹走」)。
8
+ * ② confirm 确认页:`?debug=auth`(或 __authTrace.on())时所有整页跳转不自动执行,
9
+ * 停在遮罩确认页(跳转详情 + 最近 trace 列表 + 继续 / 留)。debug 标志随跳转
10
+ * 跨站透传,排障可全程跟随。
11
+ * ③ 结构化日志:`[auth-trace]` console 行 + sessionStorage 环形缓冲(跨 SPA 路由、
12
+ * 同 tab 存活)+ `?rt=` 跨站面包屑(目标 URL 追加,下游站读取接续)+
13
+ * `__authTrace.dump()` 一键导出(排障取证)。
14
+ *
15
+ * kind 语义(提示层的适用面):
16
+ * - 'interstitial':被动跳转(会话终结 / buildLoginUrl 弹登录)→ 提示 + 延迟(可停留)。
17
+ * - 'funnel'(缺省):预期漏斗(裸根 → brand / 登出 / OAuth 发起与回跳)→ 静默直跳,
18
+ * 仅记 trace(用户主动或流程本身的必经步骤,加延迟只拖慢)。
19
+ * - debug 模式不分 kind,一律确认页。
20
+ *
21
+ * 隐私红线:缓冲与面包屑中的 URL 一律剥除凭据类 query(token/code/state 等),
22
+ * 不落任何 PII。模块零依赖(不 import React / 各站设施),显式调用即用。
23
+ */
24
+
25
+ const BUFFER_KEY = 'auth-trace-v1';
26
+ const DEBUG_KEY = 'auth-trace-debug';
27
+ const RT_PARAM = 'rt';
28
+ const DEBUG_PARAM = 'debug';
29
+ const DEBUG_VALUE = 'auth';
30
+ const BUFFER_LIMIT = 50;
31
+ const INTERSTITIAL_DELAY_MS = 1500;
32
+ const Z_INDEX = '2147483000';
33
+
34
+ /** URL query 中不得进入 trace 缓冲的凭据类键(前缀匹配)。 */
35
+ const SENSITIVE_KEYS = ['token', 'access_token', 'refresh_token', 'id_token', 'code', 'state', 'secret', 'password'];
36
+
37
+ export interface AuthTraceEntry {
38
+ /** epoch ms */
39
+ t: number;
40
+ /** 事件类型:redirect / handoff / stay / suppressed / error-exit / … */
41
+ ev: string;
42
+ reason?: string;
43
+ from?: string;
44
+ to?: string;
45
+ }
46
+
47
+ export interface AuthTraceRedirectOptions {
48
+ reason: string;
49
+ kind?: 'interstitial' | 'funnel';
50
+ /** true 用 location.href(新增历史),缺省 false 用 location.replace */
51
+ assign?: boolean;
52
+ /** 覆盖缺省延迟(ms),仅 interstitial 生效 */
53
+ delayMs?: number;
54
+ /**
55
+ * 「停留」被选择(提示条「停留」/ 确认页「留在本页」)时回调(AUTH-03)。
56
+ * 被动跳转(interstitial)取消后原页可能已无内容可渲染(如 RequireAuth 的
57
+ * 未认证空白态),调用方借此恢复可交互 UI。
58
+ */
59
+ onStay?: () => void;
60
+ }
61
+
62
+ function isBrowser(): boolean {
63
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
64
+ }
65
+
66
+ function station(): string {
67
+ if (!isBrowser()) return 'ssr';
68
+ return window.location.hostname.split('.')[0] || 'unknown';
69
+ }
70
+
71
+ function isZh(): boolean {
72
+ if (!isBrowser()) return false;
73
+ return (document.documentElement.lang || '').toLowerCase().startsWith('zh');
74
+ }
75
+
76
+ /** 剥除 URL 中的凭据类 query 参数(前缀命中 SENSITIVE_KEYS 即剥),保留其余。 */
77
+ function sanitizeUrl(raw: string): string {
78
+ try {
79
+ const u = new URL(raw, isBrowser() ? window.location.origin : 'https://x.invalid');
80
+ for (const key of Array.from(u.searchParams.keys())) {
81
+ if (SENSITIVE_KEYS.some((s) => key.toLowerCase() === s || key.toLowerCase().startsWith(`${s}_`))) {
82
+ u.searchParams.delete(key);
83
+ }
84
+ }
85
+ return u.toString();
86
+ } catch {
87
+ return raw.split(/[?#]/)[0];
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------- 缓冲(环形)
92
+
93
+ let bufferLoaded = false;
94
+ let buffer: AuthTraceEntry[] = [];
95
+
96
+ function readBuffer(): AuthTraceEntry[] {
97
+ if (!isBrowser()) return [];
98
+ if (!bufferLoaded) {
99
+ bufferLoaded = true;
100
+ try {
101
+ const raw = sessionStorage.getItem(BUFFER_KEY);
102
+ const parsed = raw ? JSON.parse(raw) : [];
103
+ buffer = Array.isArray(parsed) ? parsed.slice(-BUFFER_LIMIT) : [];
104
+ } catch {
105
+ buffer = [];
106
+ }
107
+ }
108
+ return buffer;
109
+ }
110
+
111
+ function writeBuffer(): void {
112
+ if (!isBrowser()) return;
113
+ try {
114
+ sessionStorage.setItem(BUFFER_KEY, JSON.stringify(buffer.slice(-BUFFER_LIMIT)));
115
+ } catch {
116
+ // 存储受限(隐私模式 / 配额)时静默降级为仅 console
117
+ }
118
+ }
119
+
120
+ function push(ev: string, extra?: Partial<AuthTraceEntry>): AuthTraceEntry {
121
+ const entry: AuthTraceEntry = { t: Date.now(), ev, ...extra };
122
+ readBuffer().push(entry);
123
+ if (buffer.length > BUFFER_LIMIT) buffer = buffer.slice(-BUFFER_LIMIT);
124
+ writeBuffer();
125
+ if (isBrowser()) {
126
+ // eslint-disable-next-line no-console
127
+ console.info('[auth-trace]', entry.ev, entry.reason ?? '', entry.to ?? '');
128
+ }
129
+ return entry;
130
+ }
131
+
132
+ // ---------------------------------------------------------------- debug 与水印
133
+
134
+ function readRTP(): string | null {
135
+ if (!isBrowser()) return null;
136
+ try {
137
+ return new URLSearchParams(window.location.search).get(RT_PARAM);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function inDebug(): boolean {
144
+ if (!isBrowser()) return false;
145
+ try {
146
+ if (new URLSearchParams(window.location.search).get(DEBUG_PARAM) === DEBUG_VALUE) return true;
147
+ return sessionStorage.getItem(DEBUG_KEY) === '1';
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ let initialized = false;
154
+
155
+ /** 懒初始化:吸收跨站面包屑(?rt=)与 debug 标志;同 URL 重复调用无副作用。 */
156
+ function ensureInit(): void {
157
+ if (!isBrowser() || initialized) return;
158
+ initialized = true;
159
+
160
+ let urlDebug = false;
161
+ try {
162
+ urlDebug = new URLSearchParams(window.location.search).get(DEBUG_PARAM) === DEBUG_VALUE;
163
+ if (urlDebug) sessionStorage.setItem(DEBUG_KEY, '1');
164
+ } catch {
165
+ /* noop */
166
+ }
167
+
168
+ const rt = readRTP();
169
+ if (rt) {
170
+ const [prevStation, prevReason, prevTs] = rt.split('.');
171
+ if (prevStation && prevReason) {
172
+ push('handoff', {
173
+ from: `${prevStation}@${prevTs ? parseInt(prevTs, 36) : ''}`,
174
+ reason: prevReason,
175
+ });
176
+ }
177
+ }
178
+
179
+ (window as any).__authTrace = {
180
+ version: 1,
181
+ dump: traceDump,
182
+ entries: () => readBuffer().slice(),
183
+ on: () => {
184
+ try {
185
+ sessionStorage.setItem(DEBUG_KEY, '1');
186
+ } catch {
187
+ /* noop */
188
+ }
189
+ return 'auth-trace debug ON(整页跳转将停在确认页;随跳转跨站透传)';
190
+ },
191
+ off: () => {
192
+ try {
193
+ sessionStorage.removeItem(DEBUG_KEY);
194
+ } catch {
195
+ /* noop */
196
+ }
197
+ return 'auth-trace debug OFF';
198
+ },
199
+ };
200
+
201
+ if (urlDebug || rt) {
202
+ // eslint-disable-next-line no-console
203
+ console.info('[auth-trace] station', station(), urlDebug ? '(debug=on)' : '', rt ? `(handoff from ${rt.split('.')[0]})` : '');
204
+ }
205
+ }
206
+
207
+ // ---------------------------------------------------------------- 目标 URL 装饰
208
+
209
+ /** 目标 URL 追加 rt=(本站 + 原因 + 时间)与 debug 透传(debug 开启时)。 */
210
+ function decorateTarget(target: string, reason: string): string {
211
+ if (!isBrowser()) return target;
212
+ try {
213
+ const u = new URL(target, window.location.origin);
214
+ if (!u.searchParams.has(RT_PARAM)) {
215
+ u.searchParams.set(RT_PARAM, `${station()}.${reason}.${Date.now().toString(36)}`);
216
+ }
217
+ if (inDebug() && !u.searchParams.has(DEBUG_PARAM)) {
218
+ u.searchParams.set(DEBUG_PARAM, DEBUG_VALUE);
219
+ }
220
+ return u.toString();
221
+ } catch {
222
+ return target;
223
+ }
224
+ }
225
+
226
+ // ---------------------------------------------------------------- 跳转执行
227
+
228
+ let pending = false;
229
+
230
+ function go(target: string, assign: boolean): void {
231
+ pending = true;
232
+ try {
233
+ if (assign) window.location.href = target;
234
+ else window.location.replace(target);
235
+ } catch (e) {
236
+ // 非法目标不得冒泡炸掉调用方页面(effect 内抛出会被 ErrorBoundary 捕成整页错误)。
237
+ // 记 trace 并保持当前页——例如 IP 主机下派生的 `brand.127.0.0.1`(非法 IPv4)。
238
+ pending = false;
239
+ push('error-exit', { reason: 'invalid-target', to: sanitizeUrl(target) });
240
+ if (typeof console !== 'undefined') console.warn('[auth-trace] redirect rejected:', String(e));
241
+ }
242
+ }
243
+
244
+ function removeNode(id: string): void {
245
+ if (!isBrowser()) return;
246
+ const el = document.getElementById(id);
247
+ if (el && el.parentNode) el.parentNode.removeChild(el);
248
+ }
249
+
250
+ // ---------------------------------------------------------------- 层 ① 提示条
251
+
252
+ const NOTICE_ID = 'auth-trace-notice';
253
+
254
+ function reasonText(reason: string): { zh: string; en: string } {
255
+ const map: Record<string, { zh: string; en: string }> = {
256
+ 'session-expired': { zh: '会话已过期', en: 'Session expired' },
257
+ unauthenticated: { zh: '需要登录', en: 'Sign-in required' },
258
+ };
259
+ return map[reason] ?? { zh: '即将离开本页', en: 'Leaving this page' };
260
+ }
261
+
262
+ function showInterstitial(target: string, opts: AuthTraceRedirectOptions, onStay: () => void): void {
263
+ const zh = isZh();
264
+ const text = reasonText(opts.reason);
265
+ const delay = opts.delayMs ?? INTERSTITIAL_DELAY_MS;
266
+
267
+ removeNode(NOTICE_ID);
268
+ const box = document.createElement('div');
269
+ box.id = NOTICE_ID;
270
+ box.setAttribute('role', 'status');
271
+ box.style.cssText = [
272
+ 'position:fixed',
273
+ 'left:50%',
274
+ 'bottom:24px',
275
+ 'transform:translateX(-50%)',
276
+ `z-index:${Z_INDEX}`,
277
+ 'display:flex',
278
+ 'align-items:center',
279
+ 'gap:12px',
280
+ 'max-width:calc(100vw - 32px)',
281
+ 'padding:10px 14px',
282
+ 'border-radius:8px',
283
+ 'background:rgba(17,24,39,.94)',
284
+ 'color:#f9fafb',
285
+ 'font-size:13px',
286
+ 'line-height:1.4',
287
+ 'font-family:system-ui,-apple-system,sans-serif',
288
+ 'box-shadow:0 8px 24px rgba(0,0,0,.28)',
289
+ ].join(';');
290
+
291
+ const label = document.createElement('span');
292
+ label.textContent = zh
293
+ ? `${text.zh},即将前往登录(${delay / 1000} 秒后自动跳转)`
294
+ : `${text.en} — redirecting to sign-in in ${delay / 1000}s`;
295
+ box.appendChild(label);
296
+
297
+ const mkButton = (txt: string, primary: boolean, onClick: () => void) => {
298
+ const b = document.createElement('button');
299
+ b.type = 'button';
300
+ b.textContent = txt;
301
+ b.style.cssText = [
302
+ 'border:0',
303
+ 'border-radius:6px',
304
+ 'padding:5px 10px',
305
+ 'font-size:13px',
306
+ 'cursor:pointer',
307
+ primary ? 'background:#2563eb;color:#fff' : 'background:rgba(255,255,255,.14);color:#f9fafb',
308
+ ].join(';');
309
+ b.addEventListener('click', onClick);
310
+ return b;
311
+ };
312
+
313
+ const timer = setTimeout(() => {
314
+ removeNode(NOTICE_ID);
315
+ go(target, !!opts.assign);
316
+ }, delay);
317
+
318
+ box.appendChild(mkButton(zh ? '停留' : 'Stay', false, () => {
319
+ clearTimeout(timer);
320
+ removeNode(NOTICE_ID);
321
+ onStay();
322
+ }));
323
+ box.appendChild(mkButton(zh ? '立即前往' : 'Go now', true, () => {
324
+ clearTimeout(timer);
325
+ removeNode(NOTICE_ID);
326
+ go(target, !!opts.assign);
327
+ }));
328
+
329
+ document.body.appendChild(box);
330
+ }
331
+
332
+ // ---------------------------------------------------------------- 层 ② 确认页
333
+
334
+ const CONFIRM_ID = 'auth-trace-confirm';
335
+
336
+ function showConfirm(target: string, opts: AuthTraceRedirectOptions, onStay: () => void): void {
337
+ const zh = isZh();
338
+ removeNode(CONFIRM_ID);
339
+
340
+ const overlay = document.createElement('div');
341
+ overlay.id = CONFIRM_ID;
342
+ overlay.style.cssText = [
343
+ 'position:fixed',
344
+ 'inset:0',
345
+ `z-index:${Z_INDEX}`,
346
+ 'display:flex',
347
+ 'align-items:center',
348
+ 'justify-content:center',
349
+ 'background:rgba(15,23,42,.55)',
350
+ 'font-family:system-ui,-apple-system,sans-serif',
351
+ ].join(';');
352
+
353
+ const card = document.createElement('div');
354
+ card.style.cssText = [
355
+ 'width:min(560px,calc(100vw - 40px))',
356
+ 'max-height:80vh',
357
+ 'overflow:auto',
358
+ 'background:#fff',
359
+ 'color:#111827',
360
+ 'border-radius:10px',
361
+ 'padding:18px 20px',
362
+ 'box-shadow:0 20px 48px rgba(0,0,0,.32)',
363
+ 'font-size:13px',
364
+ ].join(';');
365
+
366
+ const title = document.createElement('h2');
367
+ title.textContent = zh ? '整页跳转确认(authTrace debug)' : 'Full-page redirect (authTrace debug)';
368
+ title.style.cssText = 'margin:0 0 10px;font-size:15px;font-weight:600';
369
+ card.appendChild(title);
370
+
371
+ const detail = document.createElement('pre');
372
+ detail.textContent = [
373
+ `reason: ${opts.reason}`,
374
+ `kind: ${opts.kind ?? 'funnel'}`,
375
+ `from: ${sanitizeUrl(window.location.href)}`,
376
+ `to: ${target}`,
377
+ '',
378
+ ...(readBuffer().slice(-10).map((e) => `${new Date(e.t).toISOString()} ${e.ev} ${e.reason ?? ''} ${e.to ?? e.from ?? ''}`)),
379
+ ].join('\n');
380
+ detail.style.cssText = [
381
+ 'margin:0 0 14px',
382
+ 'padding:10px',
383
+ 'border-radius:6px',
384
+ 'background:#f3f4f6',
385
+ 'white-space:pre-wrap',
386
+ 'word-break:break-all',
387
+ 'font-size:12px',
388
+ 'line-height:1.5',
389
+ 'max-height:46vh',
390
+ 'overflow:auto',
391
+ ].join(';');
392
+ card.appendChild(detail);
393
+
394
+ const mkButton = (txt: string, primary: boolean, onClick: () => void) => {
395
+ const b = document.createElement('button');
396
+ b.type = 'button';
397
+ b.textContent = txt;
398
+ b.style.cssText = [
399
+ primary ? 'background:#2563eb;color:#fff' : 'background:#e5e7eb;color:#111827',
400
+ 'border:0',
401
+ 'border-radius:6px',
402
+ 'padding:7px 14px',
403
+ 'font-size:13px',
404
+ 'cursor:pointer',
405
+ 'margin-right:8px',
406
+ ].join(';');
407
+ b.addEventListener('click', onClick);
408
+ return b;
409
+ };
410
+
411
+ card.appendChild(mkButton(zh ? '继续前往' : 'Continue', true, () => {
412
+ removeNode(CONFIRM_ID);
413
+ go(target, !!opts.assign);
414
+ }));
415
+ card.appendChild(mkButton(zh ? '留在本页' : 'Stay', false, () => {
416
+ removeNode(CONFIRM_ID);
417
+ onStay();
418
+ }));
419
+
420
+ overlay.appendChild(card);
421
+ document.body.appendChild(overlay);
422
+ }
423
+
424
+ // ---------------------------------------------------------------- 公开 API
425
+
426
+ /**
427
+ * 统一整页跳转出口:记 trace →(debug 时)确认页 →(interstitial 时)提示条 →
428
+ * 执行 location.replace/href。
429
+ *
430
+ * 重入语义:pending 期间新的 interstitial 请求被抑制(记 suppressed,防双提示/双跳);
431
+ * funnel 请求不被抑制(用户主动 / 流程必经,优先且覆盖 pending)。
432
+ */
433
+ export function traceRedirect(target: string, opts: AuthTraceRedirectOptions): void {
434
+ if (!isBrowser()) return;
435
+ ensureInit();
436
+ const kind = opts.kind ?? 'funnel';
437
+ const decorated = decorateTarget(target, opts.reason);
438
+ const from = sanitizeUrl(window.location.href);
439
+
440
+ if (pending && kind === 'interstitial') {
441
+ push('suppressed', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
442
+ return;
443
+ }
444
+
445
+ push('redirect', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
446
+
447
+ const onStay = () => {
448
+ pending = false;
449
+ push('stay', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
450
+ if (opts.onStay) {
451
+ try {
452
+ opts.onStay();
453
+ } catch (e) {
454
+ // 回调异常不得冒泡打断停留处理(trace 已记、pending 已复位)
455
+ if (typeof console !== 'undefined') console.warn('[auth-trace] onStay rejected:', String(e));
456
+ }
457
+ }
458
+ };
459
+
460
+ if (inDebug()) {
461
+ pending = true;
462
+ showConfirm(decorated, opts, onStay);
463
+ return;
464
+ }
465
+
466
+ if (kind === 'interstitial') {
467
+ pending = true;
468
+ showInterstitial(decorated, opts, onStay);
469
+ return;
470
+ }
471
+
472
+ go(decorated, !!opts.assign);
473
+ }
474
+
475
+ /**
476
+ * 清空 trace 缓冲与持久副本(AUTH-08 登出卫生)。
477
+ * sessionStorage 跨整页导航天然保留——不清则上一会话的 trace 会驻留到下一
478
+ * 会话(同一 tab 换人登录的场景)。
479
+ */
480
+ export function traceClear(): void {
481
+ if (!isBrowser()) return;
482
+ buffer = [];
483
+ bufferLoaded = true;
484
+ try {
485
+ sessionStorage.removeItem(BUFFER_KEY);
486
+ } catch {
487
+ /* noop */
488
+ }
489
+ }
490
+
491
+ /** 记一条非跳转 trace 事件(如 error 页出口、OAuth 链路里程碑)。 */
492
+ export function traceEvent(ev: string, extra?: { reason?: string; from?: string; to?: string }): void {
493
+ if (!isBrowser()) return;
494
+ ensureInit();
495
+ push(ev, extra);
496
+ }
497
+
498
+ /** 导出全部缓冲 + 环境摘要(`copy(__authTrace.dump())` 取证用)。 */
499
+ export function traceDump(): string {
500
+ if (!isBrowser()) return '';
501
+ ensureInit();
502
+ const header = [
503
+ `authTrace v1 @${station()}`,
504
+ `url: ${sanitizeUrl(window.location.href)}`,
505
+ `debug: ${inDebug() ? 'on' : 'off'}`,
506
+ `ua: ${navigator.userAgent}`,
507
+ `entries: ${readBuffer().length}`,
508
+ ];
509
+ const lines = readBuffer().map((e) => `${new Date(e.t).toISOString()} ${e.ev} ${e.reason ?? ''} ${e.to ?? e.from ?? ''}`);
510
+ return [...header, '', ...lines].join('\n');
511
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 租户直达 URL 构造工具。
3
+ * 替代之前由后端返回的 loginUrl/adminUrl,前端根据自身运行时 origin 自行拼接。
4
+ *
5
+ * URL 约定(所有 Portal 统一遵循):
6
+ * 登录页: {portalOrigin}/{slug}/login?redirect={portalOrigin}/{slug}/admin
7
+ * 管理页: {portalOrigin}/{slug}/admin
8
+ */
9
+
10
+ export interface TenantUrls {
11
+ /** 租户登录页直达 URL(含 redirect 参数指向管理后台) */
12
+ loginUrl: string;
13
+ /** 租户管理后台直达 URL */
14
+ adminUrl: string;
15
+ }
16
+
17
+ /**
18
+ * 根据租户 slug 构造登录和管理后台直达 URL。
19
+ * 使用当前页面的 origin,适用于所有 Portal(app/user/auth 等)。
20
+ *
21
+ * @param slug - 租户 slug(即 tenant.name)
22
+ * @param portalOrigin - 可选,指定 portal 域名。默认用 `window.location.origin`
23
+ * @returns 包含 loginUrl 和 adminUrl 的对象
24
+ *
25
+ * @example
26
+ * const { loginUrl, adminUrl } = buildTenantUrls('acme-corp');
27
+ * // loginUrl = "https://app.iam.tianv.com/acme-corp/login?redirect=https://app.iam.tianv.com/acme-corp/admin"
28
+ * // adminUrl = "https://app.iam.tianv.com/acme-corp/admin"
29
+ *
30
+ * @example 使用 auth 域名的登录入口
31
+ * const { loginUrl } = buildTenantUrls('acme-corp', 'https://auth.iam.tianv.com');
32
+ * // loginUrl = "https://auth.iam.tianv.com/acme-corp/login?redirect=https://app.iam.tianv.com/acme-corp/admin"
33
+ */
34
+ export function buildTenantUrls(
35
+ slug: string,
36
+ portalOrigin?: string,
37
+ adminOrigin?: string,
38
+ ): TenantUrls {
39
+ const portal = portalOrigin || (typeof window !== 'undefined' ? window.location.origin : '/');
40
+ const admin = adminOrigin || portal;
41
+ const adminPath = `${admin}/${slug}/admin`;
42
+ const loginPath = `${portal}/${slug}/login`;
43
+
44
+ return {
45
+ loginUrl: `${loginPath}?redirect=${encodeURIComponent(adminPath)}`,
46
+ adminUrl: adminPath,
47
+ };
48
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * JWT payload 段解码(base64url → JSON)——共享层唯一的 JWT 载荷解码入口。
3
+ *
4
+ * JWT 段是 base64url 编码(RFC 7515 §2:`-`/`_` 替代 `+`/`/`、去填充),而 atob 只认
5
+ * 标准 base64:载荷含 `-`/`_` 时裸 atob 抛 InvalidCharacterError。实测事故(PL-70):
6
+ * store 的 isTokenExpired 经裸 atob 解码失败 → 误判「已过期」→ 请求拦截器预判式刷新 →
7
+ * 无 RT 的模拟会话被清会话弹登录。凡解码 JWT 载荷一律走本函数,勿再裸 atob。
8
+ */
9
+ export function decodeJwtPayload(token: string): Record<string, unknown> | null {
10
+ try {
11
+ const segment = token.split('.')[1];
12
+ if (!segment) return null;
13
+ const normalized = segment.replace(/-/g, '+').replace(/_/g, '/');
14
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
15
+ const binary = atob(padded);
16
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
17
+ return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { PublicAuthConfigResponse } from '../generated/types';
5
+ import { extractSlugFromPath } from './slug-from-url';
6
+
7
+ /**
8
+ * Resolve OAuth client ID from URL slug.
9
+ * Fetches public auth-config by slug and extracts oauthClientId.
10
+ *
11
+ * Returns:
12
+ * - { status: 'loading' } while fetching
13
+ * - { status: 'resolved', slug, clientId, config } on success
14
+ * - { status: 'not-found' } if no slug in URL, env client pinned, or no oauth client configured
15
+ * - { status: 'unknown-slug', slug } if by-slug is a deterministic HTTP 404 (slug 不存在)
16
+ */
17
+ export type SlugResolution =
18
+ | { status: 'loading' }
19
+ | { status: 'resolved'; slug: string; clientId: string; config: PublicAuthConfigResponse }
20
+ | { status: 'not-found' }
21
+ | { status: 'unknown-slug'; slug: string };
22
+
23
+ /**
24
+ * 公开 auth-config 拉取三态(F-W6 门户侧闸门的判别依据):
25
+ * - ok:HTTP 2xx,data 为解包后的配置体;
26
+ * - unknown-slug:**HTTP 404** —— identity 按「租户不存在」返回
27
+ * (public_handler.go GetTenantAuthConfigBySlug:GetTenantBySlug 失败/空 → ErrCodeNotFound)。
28
+ * 已知租户但未配 OAuth client 时返回 200(client 字段为空)⇒ 不走本态。
29
+ * - error:网络错误 / 非 404 非 2xx / 解析失败 —— 必须 fail-open,不得据此判死。
30
+ */
31
+ type PublicAuthConfigFetch =
32
+ | { kind: 'ok'; data: Record<string, any> }
33
+ | { kind: 'unknown-slug' }
34
+ | { kind: 'error' };
35
+
36
+ /**
37
+ * 拉取公开 auth-config(原始响应,snake/camel 混合未归一)。
38
+ * 用 raw fetch 绕过 apiClient 401 拦截器 —— 公开配置必须在 RequireAuth 跳转前解析完成。
39
+ */
40
+ async function fetchPublicAuthConfig(slug: string): Promise<PublicAuthConfigFetch> {
41
+ try {
42
+ const res = await fetch(
43
+ `/bff/identity/api/v1/public/auth-config/by-slug/${encodeURIComponent(slug)}`,
44
+ );
45
+ if (res.status === 404) return { kind: 'unknown-slug' };
46
+ if (!res.ok) return { kind: 'error' };
47
+ const json: any = await res.json();
48
+ return { kind: 'ok', data: (json?.data || json || {}) as Record<string, any> };
49
+ } catch {
50
+ return { kind: 'error' };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 按 slug 实时解析 OAuth client_id(非 hook 版本)。
56
+ * 供登录页 from_requireauth 分支缓存未命中时回源(TASK-09 / ADR-04)。
57
+ * 404(未知 slug)与网络错误统一返回 null —— 调用方只关心「能不能拿到 client」。
58
+ */
59
+ export async function fetchOAuthClientIdBySlug(slug: string): Promise<string | null> {
60
+ const fetched = await fetchPublicAuthConfig(slug);
61
+ if (fetched.kind !== 'ok') return null;
62
+ return fetched.data.oauthClientId || fetched.data.oauth_client_id || null;
63
+ }
64
+
65
+ export function useOAuthClientIdFromUrl(): SlugResolution {
66
+ const [resolution, setResolution] = useState<SlugResolution>({ status: 'loading' });
67
+
68
+ useEffect(() => {
69
+ if (typeof window === 'undefined') {
70
+ setResolution({ status: 'not-found' });
71
+ return;
72
+ }
73
+
74
+ const slug = extractSlugFromPath(window.location.pathname);
75
+ if (!slug) {
76
+ setResolution({ status: 'not-found' });
77
+ return;
78
+ }
79
+
80
+ // 若当前应用已通过 VITE_OAUTH_CLIENT_ID 固定了 OAuth client(如 admin-console),
81
+ // 无需再按 URL slug 解析——避免把 /users、/roles 等应用路径误当租户 slug 发起 404 请求。
82
+ const cfg = (window as any).__APP_CONFIG__;
83
+ if (cfg?.VITE_OAUTH_CLIENT_ID) {
84
+ setResolution({ status: 'not-found' });
85
+ return;
86
+ }
87
+
88
+ let cancelled = false;
89
+ void fetchPublicAuthConfig(slug).then((fetched) => {
90
+ if (cancelled) return;
91
+ if (fetched.kind === 'unknown-slug') {
92
+ setResolution({ status: 'unknown-slug', slug });
93
+ return;
94
+ }
95
+ if (fetched.kind !== 'ok') {
96
+ setResolution({ status: 'not-found' });
97
+ return;
98
+ }
99
+ // Raw fetch doesn't camelCase keys — handle both snake_case and camelCase
100
+ const clientId = fetched.data.oauthClientId || fetched.data.oauth_client_id;
101
+ if (clientId) {
102
+ setResolution({
103
+ status: 'resolved',
104
+ slug,
105
+ clientId,
106
+ config: fetched.data as PublicAuthConfigResponse,
107
+ });
108
+ } else {
109
+ setResolution({ status: 'not-found' });
110
+ }
111
+ });
112
+
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, []);
117
+
118
+ return resolution;
119
+ }