@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,199 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { useQuery } from '@tanstack/react-query';
5
+ import { getAccessToken, useAuthStore } from '../auth/store';
6
+ import { apiClient } from '../api/client';
7
+ import { getPortalUrl } from '../config';
8
+ import { useCurrentRole } from './useCurrentRole';
9
+
10
+ /** 平台租户 well-known ULID(service-core base/constant TenantPlatformID,全环境同值) */
11
+ export const PLATFORM_TENANT_ID = '01KSQCBNVMS6SX64PJS937CE33';
12
+
13
+ /** 管理面平面角色集(admin/security 门户)——与登录入口门禁同集:member/guest 登录被拒 40000503 */
14
+ export const ADMIN_PLANE_ROLES: readonly string[] = [
15
+ 'super_admin',
16
+ 'admin',
17
+ 'security_admin',
18
+ 'user_manager',
19
+ ];
20
+
21
+ /**
22
+ * 门户可见性 = 入口门禁的镜像(谁能登录谁才看见):
23
+ * - platform:仅平台租户成员
24
+ * - admin / security:仅管理面角色
25
+ * - 其余 code:不限制(叠加原则,未知门户不误伤)
26
+ */
27
+ export function isPortalVisible(
28
+ code: string,
29
+ ctx: { role: string | null; isPlatformMember: boolean },
30
+ ): boolean {
31
+ if (code === 'platform') return ctx.isPlatformMember;
32
+ if (code === 'admin' || code === 'security') {
33
+ return ctx.role !== null && ADMIN_PLANE_ROLES.includes(ctx.role);
34
+ }
35
+ return true;
36
+ }
37
+
38
+ /** 平台门户目录项(映射自 tenant-service 应用列表响应,is_platform=true) */
39
+ export interface PortalCatalogEntry {
40
+ code: string;
41
+ name: string;
42
+ description?: string;
43
+ /** getPortalUrl 按 code 拼装(slug 白名单门户带租户段,域根门户落根 URL) */
44
+ url: string;
45
+ order: number;
46
+ }
47
+
48
+ /**
49
+ * 数据面端点平面(网关路由隔离矩阵 §3.3):
50
+ * - `self`:user 受众自视图端点 `/tenants/{id}/applications`(entry_plane=api;auth/user 门户)
51
+ * - `admin`:管理面双入口端点 `/admin/tenants/{id}/applications`(audiences [admin, platform];
52
+ * admin/security/platform 控制台共用。api 平面在该路由上 403,反之亦然)
53
+ */
54
+ export type PortalCatalogAudience = 'self' | 'admin';
55
+
56
+ export interface UsePortalCatalogOptions {
57
+ tenantId?: string | null;
58
+ /** 拼门户 URL 的第二参 slug(非 slug 白名单门户自动忽略,见 config.getPortalUrl) */
59
+ slug?: string | null;
60
+ /**
61
+ * 可见性/角色判定用(缺省回退 store 当前角色 useCurrentRole)。
62
+ * 显式 null = 按“无角色”过滤(管理面门户与 allowed_roles 门槛均不可见)。
63
+ */
64
+ role?: string | null;
65
+ audience?: PortalCatalogAudience;
66
+ enabled?: boolean;
67
+ exclude?: readonly string[];
68
+ }
69
+
70
+ export interface UsePortalCatalogResult {
71
+ /** exclude + 可见性(入口门禁镜像)+ allowed_roles → order 升序(门户切换器 / 磁贴网格用) */
72
+ portals: PortalCatalogEntry[];
73
+ /** 同一可见集合、保持服务端顺序(偏好配置面板用) */
74
+ allPortals: PortalCatalogEntry[];
75
+ isLoading: boolean;
76
+ isError: boolean;
77
+ refetch: () => void;
78
+ }
79
+
80
+ // 「切换门户」清单默认排除:auth/landing 非切换目标;authenticator 为移动 App 形态
81
+ // (TOTP/push),与其他门户功能形态不同,不列入桌面门户清单
82
+ const DEFAULT_EXCLUDE: readonly string[] = ['auth', 'landing', 'authenticator'];
83
+ const PORTAL_CATALOG_STALE_TIME = 60_000;
84
+
85
+ interface RawPortalApplication {
86
+ code: string;
87
+ name: string;
88
+ description?: string;
89
+ order?: number;
90
+ /** allowedRoles 为 apiClient camelCase 归一后的键(snake 形态兼容后端透出前的原始键) */
91
+ config?: {
92
+ portal?: { allowedRoles?: readonly string[]; allowed_roles?: readonly string[] };
93
+ } | null;
94
+ }
95
+
96
+ function toEntry(app: RawPortalApplication, slug?: string): PortalCatalogEntry {
97
+ return {
98
+ code: app.code,
99
+ name: app.name,
100
+ description: app.description,
101
+ url: getPortalUrl(app.code, slug),
102
+ order: app.order ?? 0,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * 平台门户目录(应用清单,is_platform=true 全租户同集)。
108
+ * 消费侧按所在控制台平面传 audience;403/网络失败 → isError(不静默吞),
109
+ * 调用方可据此降级(隐藏切换器或静态兜底)。401 由 apiClient 拦截器预刷新 + 重试自愈。
110
+ */
111
+ export function usePortalCatalog(options: UsePortalCatalogOptions = {}): UsePortalCatalogResult {
112
+ const {
113
+ tenantId,
114
+ slug,
115
+ role: roleProp,
116
+ audience = 'self',
117
+ enabled = true,
118
+ exclude = DEFAULT_EXCLUDE,
119
+ } = options;
120
+ const token = getAccessToken();
121
+ const currentRole = useCurrentRole();
122
+ const role = roleProp !== undefined ? roleProp : currentRole;
123
+ const isPlatformMember = useAuthStore((s) =>
124
+ s.tenants.some((t) => t.id === PLATFORM_TENANT_ID),
125
+ );
126
+
127
+ const query = useQuery({
128
+ // slug 只影响 URL 拼装(memo 层),不进 key:同一份目录不随 slug 重复拉取
129
+ queryKey: ['portal-catalog', audience, tenantId],
130
+ staleTime: PORTAL_CATALOG_STALE_TIME,
131
+ retry: false,
132
+ enabled: enabled && !!tenantId && !!token,
133
+ queryFn: async (): Promise<RawPortalApplication[]> => {
134
+ // 走 apiClient:请求前预刷新 + 401→刷新→重试 + X-Tenant-ID 注入(裸 fetch 无此链路)
135
+ const apiPrefix = audience === 'admin' ? '/tenant/api/v1/admin' : '/tenant/api/v1';
136
+ const res = await apiClient.get(`${apiPrefix}/tenants/${tenantId}/applications`, {
137
+ params: { type: 'portal', is_platform: 'true', status: 'active' },
138
+ });
139
+ // 响应拦截器已 unwrap {code,data}/{code,items} 并做 camelCase;此处只做形状容错
140
+ const body: unknown = res.data;
141
+ if (Array.isArray(body)) return body as RawPortalApplication[];
142
+ if (body && typeof body === 'object') {
143
+ const shaped = body as {
144
+ items?: unknown;
145
+ data?: unknown;
146
+ code?: number;
147
+ message?: string;
148
+ };
149
+ if (Array.isArray(shaped.items)) return shaped.items as RawPortalApplication[];
150
+ if (Array.isArray(shaped.data)) return shaped.data as RawPortalApplication[];
151
+ if (typeof shaped.code === 'number' && shaped.code !== 0) {
152
+ throw new Error(shaped.message || `portal catalog request failed (code ${shaped.code})`);
153
+ }
154
+ }
155
+ return [];
156
+ },
157
+ });
158
+
159
+ // exclude 过滤后保持服务端顺序
160
+ const excludedPortals = useMemo(() => {
161
+ const excluded = new Set(exclude);
162
+ return (query.data ?? []).filter((app) => !excluded.has(app.code));
163
+ }, [query.data, exclude]);
164
+
165
+ // 可见性(入口门禁镜像)+ allowed_roles 叠加
166
+ // (allowed_roles 数据通路后端尚未透出——tenant-service 列表 DTO 无 config 字段;
167
+ // 存在时叠加生效,见 usePortalCatalog 审计记录)
168
+ const visiblePortals = useMemo(
169
+ () =>
170
+ excludedPortals.filter((app) => {
171
+ if (!isPortalVisible(app.code, { role, isPlatformMember })) return false;
172
+ const allowedRoles =
173
+ app.config?.portal?.allowedRoles ?? app.config?.portal?.allowed_roles;
174
+ return !allowedRoles || allowedRoles.includes(role ?? '');
175
+ }),
176
+ [excludedPortals, role, isPlatformMember],
177
+ );
178
+
179
+ const allPortals = useMemo(
180
+ () => visiblePortals.map((app) => toEntry(app, slug || undefined)),
181
+ [visiblePortals, slug],
182
+ );
183
+
184
+ const portals = useMemo(
185
+ () =>
186
+ visiblePortals
187
+ .map((app) => toEntry(app, slug || undefined))
188
+ .sort((a, b) => a.order - b.order),
189
+ [visiblePortals, slug],
190
+ );
191
+
192
+ return {
193
+ portals,
194
+ allPortals,
195
+ isLoading: query.isLoading,
196
+ isError: query.isError,
197
+ refetch: query.refetch,
198
+ };
199
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * useSodMode — 租户 SoD 模式 Hook
3
+ *
4
+ * 通过 tenant-service API 读取当前租户的 sod_mode 配置,
5
+ * 决定 `admin` 角色对审计功能的可见性。
6
+ *
7
+ * 模式:
8
+ * 'single' — 小客户模式(默认): admin 可看 L1 统计 + L2 操作反馈
9
+ * 'strict' — 大客户/受监管: admin 仅看 L1 统计摘要
10
+ *
11
+ * API: GET /tenant/api/v1/admin/tenants/{tenantId}/sod-config (apiClient baseURL=/bff)
12
+ * PUT /tenant/api/v1/admin/tenants/{tenantId}/sod-config
13
+ * {tenantId} 为租户 ULID —— 后端 GetSodConfig 按租户主键(id)查找,传 slug 必 404。
14
+ *
15
+ * @see document/architecture/decisions/007-security-dashboard-audit.md §2.3
16
+ */
17
+
18
+ 'use client';
19
+
20
+ import { useMemo } from 'react';
21
+ import { useQuery } from '@tanstack/react-query';
22
+ import { apiClient } from '../api/client';
23
+ import { API as API_PATHS } from '../constants/api-paths';
24
+ import { extractItem } from '../utils/response';
25
+ import { useAuthStore } from '../auth/store';
26
+ import { useCurrentRole } from './useCurrentRole';
27
+
28
+ export type SodMode = 'single' | 'strict';
29
+
30
+ /**
31
+ * 响应字段为 camelCase —— axios 响应拦截器(api/client.ts)对后端
32
+ * `{tenant_id, sod_mode, updated_at}` 做过 camelCaseKeys 转换。
33
+ */
34
+ interface SodConfigResponse {
35
+ tenantId: string;
36
+ sodMode: SodMode;
37
+ updatedAt: string;
38
+ }
39
+
40
+ /**
41
+ * 返回当前租户的 sod_mode。
42
+ * 从 tenant-service 的 /admin/tenants/{ULID}/sod-config 读取(经 /bff 网关)。
43
+ *
44
+ * 返回 null 表示模式未知(无租户 / 加载中 / 请求失败)—— 不再折叠成 'single':
45
+ * 'single' 仅表示服务端确认的小客户模式;请求失败经 react-query 错误通道暴露。
46
+ * (U97: 此前路径缺 /tenant 服务段 + 传 slug + catch 吞错 + 读 snake_case 键,
47
+ * 四连缺陷叠加使 strict 租户的审计 L2 限制恒不生效。)
48
+ */
49
+ export function useSodMode(): SodMode | null {
50
+ const tenantId = useAuthStore((s) => s.currentTenantId);
51
+
52
+ const { data: config } = useQuery<SodConfigResponse | null>({
53
+ queryKey: ['sod-config', tenantId],
54
+ queryFn: () => fetchSodConfig(tenantId),
55
+ staleTime: 5 * 60 * 1000, // 5 min cache
56
+ retry: 1,
57
+ enabled: !!tenantId,
58
+ });
59
+
60
+ return config?.sodMode ?? null;
61
+ }
62
+
63
+ async function fetchSodConfig(tenantId: string | null): Promise<SodConfigResponse | null> {
64
+ if (!tenantId) return null;
65
+ // 路径须循 /<服务>/api/v1/... 惯例(U97: 此前漏 /tenant 服务段 → 网关 404)
66
+ const res = await apiClient.get(API_PATHS.TENANT.SOD_CONFIG(tenantId));
67
+ return extractItem<SodConfigResponse>(res.data);
68
+ }
69
+
70
+ /**
71
+ * 判断当前用户在 strict 模式下是否被限制查看审计详情。
72
+ * 模式未知(null)时不判定为受限(维持既有 fail-open 语义)。
73
+ */
74
+ export function useIsAuditRestricted(): boolean {
75
+ const sodMode = useSodMode();
76
+ const role = useCurrentRole();
77
+
78
+ return useMemo(() => {
79
+ return sodMode === 'strict' && role === 'admin';
80
+ }, [sodMode, role]);
81
+ }
package/src/index.ts ADDED
@@ -0,0 +1,247 @@
1
+ // API
2
+ export { apiClient, createApiClient } from './api/client';
3
+ export {
4
+ setTraceParentProvider,
5
+ getTraceParentHeader,
6
+ resetSessionTraceId,
7
+ enableOtelBridge,
8
+ } from './api/trace';
9
+ export { bffLogin, bffRefresh, bffLogout, isBFFAvailable } from './api/bff-client';
10
+
11
+ // Auth
12
+ export {
13
+ useAuthStore,
14
+ getAccessToken,
15
+ getRefreshToken,
16
+ getCurrentTenantId,
17
+ getCurrentRole,
18
+ loginWithTokens,
19
+ logout,
20
+ isTokenExpired,
21
+ refreshAccessToken,
22
+ } from './auth/store';
23
+ export { AuthService } from './auth/service';
24
+ export { generatePKCE, generateCodeChallenge } from './auth/pkce';
25
+ export type { PKCEPair } from './auth/pkce';
26
+ export {
27
+ initiateOAuthLogin,
28
+ handleOAuthCallback,
29
+ isOAuthEnabled,
30
+ OAuthCallbackError,
31
+ } from './auth/oauth-login';
32
+ export { decodeJwtPayload } from './auth/jwt-payload';
33
+ export { buildLoginUrl } from './auth/roles';
34
+ export { traceRedirect, traceEvent, traceDump } from './auth/auth-trace';
35
+ export type { AuthTraceEntry, AuthTraceRedirectOptions } from './auth/auth-trace';
36
+
37
+ // Types
38
+ export type {
39
+ User,
40
+ LoginRequest,
41
+ LoginResponse,
42
+ RegisterRequest,
43
+ Tenant,
44
+ TenantMember,
45
+ Role,
46
+ Permission,
47
+ Application,
48
+ AuditLog,
49
+ Session,
50
+ Department,
51
+ ListResponse,
52
+ PageRequest,
53
+ ApiError,
54
+ ApiResponse,
55
+ } from './types';
56
+
57
+ // Hooks
58
+ export {
59
+ useAuth,
60
+ useAuthActions,
61
+ useIsAuthenticated,
62
+ useUser,
63
+ useUserId,
64
+ useAccessToken,
65
+ useAuthPermissions,
66
+ useCurrentTenantId,
67
+ useTenants,
68
+ } from './hooks/useAuth';
69
+ export { useCurrentTenantIdOr } from './hooks/useCurrentTenantIdOr';
70
+ export type { AuthSnapshot, AuthActions } from './hooks/useAuth';
71
+ export { usePermission } from './hooks/usePermission';
72
+ export type { PermissionResult } from './hooks/usePermission';
73
+ export { useLogout } from './hooks/useLogout';
74
+ export { useBootstrap } from './hooks/useBootstrap';
75
+ export type { BootstrapState } from './hooks/useBootstrap';
76
+ export { useSodMode, useIsAuditRestricted } from './hooks/useSodMode';
77
+ export type { SodMode } from './hooks/useSodMode';
78
+ export { useCurrentRole } from './hooks/useCurrentRole';
79
+ export {
80
+ usePortalCatalog,
81
+ isPortalVisible,
82
+ PLATFORM_TENANT_ID,
83
+ ADMIN_PLANE_ROLES,
84
+ } from './hooks/usePortalCatalog';
85
+ export type {
86
+ PortalCatalogEntry,
87
+ PortalCatalogAudience,
88
+ UsePortalCatalogOptions,
89
+ UsePortalCatalogResult,
90
+ } from './hooks/usePortalCatalog';
91
+
92
+ // Constants
93
+ export { API as API_PATHS } from './constants/api-paths';
94
+
95
+ // Config
96
+ export {
97
+ // U351:AUTH_PAGES_URL 别名导出已移除(字符串模板误用会静默产出函数源码);
98
+ // 消费方一律 `getAUTH_PAGES_URL()`(其余别名族 93 行起保留,登记为同族潜伏面)。
99
+ getAUTH_PAGES_URL,
100
+ getADMIN_CONSOLE_URL as ADMIN_CONSOLE_URL,
101
+ getADMIN_CONSOLE_URL,
102
+ getDEVELOPER_PORTAL_URL as DEVELOPER_PORTAL_URL,
103
+ getDEVELOPER_PORTAL_URL,
104
+ getEND_USER_PORTAL_URL as END_USER_PORTAL_URL,
105
+ getEND_USER_PORTAL_URL,
106
+ getSECURITY_DASHBOARD_URL as SECURITY_DASHBOARD_URL,
107
+ getSECURITY_DASHBOARD_URL,
108
+ getSTATUS_PAGE_URL as STATUS_PAGE_URL,
109
+ getSTATUS_PAGE_URL,
110
+ getLANDING_SITE_URL as LANDING_SITE_URL,
111
+ getLANDING_SITE_URL,
112
+ getAUTHENTICATOR_APP_URL as AUTHENTICATOR_APP_URL,
113
+ getAUTHENTICATOR_APP_URL,
114
+ getTRUST_CENTER_URL as TRUST_CENTER_URL,
115
+ getTRUST_CENTER_URL,
116
+ getPLATFORM_CONSOLE_URL as PLATFORM_CONSOLE_URL,
117
+ getPLATFORM_CONSOLE_URL,
118
+ API_BASE_URL,
119
+ BASE_PATH,
120
+ ROUTER_BASENAME,
121
+ getRouterBasename,
122
+ VITE_BASE,
123
+ appPath,
124
+ navigateTo,
125
+ crossAppUrl,
126
+ isValidRedirect,
127
+ // Portal URL resolver (VITE_PORTAL_CONFIG driven)
128
+ getRootDomain,
129
+ getPortalUrl,
130
+ getAppPortalSlugs,
131
+ } from './config';
132
+ export {
133
+ SITE_DOMAIN,
134
+ SITE_EMAIL,
135
+ SITE_BASE_URL,
136
+ AUTH_DOMAIN,
137
+ USER_DOMAIN,
138
+ APP_DOMAIN,
139
+ AUTHENTICATOR_DOMAIN,
140
+ AUTH_BASE_URL,
141
+ USER_BASE_URL,
142
+ APP_BASE_URL,
143
+ AUTHENTICATOR_BASE_URL,
144
+ DEFAULT_OG_IMAGE,
145
+ DEFAULT_LOGO,
146
+ } from './config/constants';
147
+
148
+ // SEO
149
+ export * from './seo';
150
+
151
+ // Components
152
+ export { RequireAuth } from './components/RequireAuth';
153
+ export { TenantRootRedirect } from './components/TenantRootRedirect';
154
+ export { OAuthCallbackPage } from './components/OAuthCallbackPage';
155
+ export { TenantSlugProvider, useTenantSlug } from './auth/tenant-slug-context';
156
+ export { useTenantSlugFromUrl, extractSlugFromPath, registerNonTenantSegments, clearNonTenantSegments } from './auth/slug-from-url';
157
+ export { useOAuthClientIdFromUrl, fetchOAuthClientIdBySlug } from './auth/oauth-client-from-slug';
158
+ export {
159
+ useTenantRoute,
160
+ resolveEffectiveClientId,
161
+ isSameDomainOAuth,
162
+ } from './auth/tenant-route-middleware';
163
+ export {
164
+ usePermissionsQuery,
165
+ useTenantsQuery,
166
+ useMeQuery,
167
+ AUTH_DATA_STALE_TIME,
168
+ } from './auth/auth-queries';
169
+ export { authQueryKeys } from './auth/auth-queries';
170
+ export type { TenantRouteResult } from './auth/tenant-route-middleware';
171
+ export {
172
+ AdminGuard,
173
+ UserMgmtGuard,
174
+ SecurityGuard,
175
+ SecurityAdminGuard,
176
+ AuditorGuard,
177
+ PlatformGuard,
178
+ AuthGuard,
179
+ } from './components/PortalGuard';
180
+ export { AuditStatsOnly } from './components/AuditStatsOnly';
181
+ export { TenantIndexGuard, usePublicTenantSlugs } from './components/TenantIndexGuard';
182
+ export type { TenantIndexGuardProps } from './components/TenantIndexGuard';
183
+ export { buildTenantUrls } from './auth/build-tenant-urls';
184
+ export { resolvePortalBasename } from './config/resolve-basename';
185
+
186
+ // 生成代码的命名空间再导出(GeneratedApi / ApiGenerated / GeneratedTypes / ApiTypes)。
187
+ // 说明:这 4 行原先指向 @autional/api-generated —— 那是各站逐站副本里用
188
+ // file:../../scripts/generate/api-generated-package 提供的本地包,一旦 shared 走 npm 发布
189
+ // 就必然解析不到(authenticator / security 的 tsc 就是这么炸的:TS2305 has no exported member)。
190
+ // 本包内的 src/generated/* 是同一份生成结果,因此改指包内相对路径;
191
+ // 消费方实际用到的类型面由逐站 tsc 验证。
192
+ export * as GeneratedApi from './generated/api';
193
+ export * as GeneratedTypes from './generated/types';
194
+ export * as ApiGenerated from './generated/api';
195
+ export * as ApiTypes from './generated/types';
196
+
197
+ // Branding(租户品牌 → CSS 变量 / favicon / customCss)
198
+ export {
199
+ useBranding,
200
+ applyBrandColors,
201
+ BrandingInitializer,
202
+ useTenantBrandingStore,
203
+ extractBranding,
204
+ readCachedBranding,
205
+ writeCachedBranding,
206
+ BRANDING_CACHE_PREFIX,
207
+ } from './branding';
208
+ export type { Branding } from './branding';
209
+
210
+ // Lib
211
+ export { getVapidPublicKey, subscribeBrowserPush, unsubscribeBrowserPush } from './lib/push';
212
+
213
+ // Utils
214
+ export { urlBase64ToUint8Array } from './utils/browser';
215
+ export { camelCaseKeys, snakeCaseKeys } from './utils/case';
216
+ // 分页/形状单点(H008 收敛口径):新页面一律经 toPageParams / fromPageResult 适配
217
+ export { toPageParams, fromPageResult } from './utils/page';
218
+ export type { PageParams, PageResult } from './utils/page';
219
+ export { extractApiErrorMessage, extractApiError, createHandleApiError } from './utils/error';
220
+ // 查询状态分类单点(RC-B4-01 / ADR-B4-01):error/空/无权判定的唯一语义源
221
+ export {
222
+ classifyQueryState,
223
+ getHttpStatus,
224
+ isForbiddenError,
225
+ isRetryableError,
226
+ isEmptyData,
227
+ } from './utils/query-state';
228
+ export type { QueryState, QueryStateInput } from './utils/query-state';
229
+ export {
230
+ extractList,
231
+ extractItem,
232
+ extractPagination,
233
+ extractListResult,
234
+ fetchList,
235
+ fetchItem,
236
+ fetchListResult,
237
+ } from './utils/response';
238
+ export { formatTime, formatDate, formatRelativeTime, setLocaleGetter } from './utils/format';
239
+ export { normalizeViteBase } from './utils/vite';
240
+ export {
241
+ processPasswordForTransmission,
242
+ hashPasswordForTransmission,
243
+ } from './utils/password-transmission';
244
+ export type { TransmissionResult } from './utils/password-transmission';
245
+ export type { ExtractedApiError } from './utils/error';
246
+ export type { PaginationInfo, ListResult } from './utils/response';
247
+ // force rebuild 1781616516
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ vi.mock('../../generated/api', () => ({
4
+ pushVapidPublicKey: vi.fn(),
5
+ }));
6
+
7
+ import { getVapidPublicKey } from '../push';
8
+ import { pushVapidPublicKey } from '../../generated/api';
9
+
10
+ const mockPushVapidPublicKey = vi.mocked(pushVapidPublicKey);
11
+
12
+ describe('getVapidPublicKey', () => {
13
+ // 回归锁(2026-10-05):响应拦截器(api/client.ts)把 data.public_key 深转为
14
+ // camelCase,此前此处死读 public_key ⇒ 线上「启用 Push 通知」100% 抛
15
+ // "Failed to get VAPID public key"(authenticator W4/AU-33 验证腿实证)。
16
+ it('reads camelCase publicKey(拦截器转换后的形状)', async () => {
17
+ mockPushVapidPublicKey.mockResolvedValue({ publicKey: 'BPwrXxzEBIzhhbZcFnDb-85' });
18
+ await expect(getVapidPublicKey()).resolves.toBe('BPwrXxzEBIzhhbZcFnDb-85');
19
+ });
20
+
21
+ it('tolerates snake_case public_key(双键兜底)', async () => {
22
+ mockPushVapidPublicKey.mockResolvedValue({ public_key: 'snake-key' });
23
+ await expect(getVapidPublicKey()).resolves.toBe('snake-key');
24
+ });
25
+
26
+ it('throws when payload carries neither key', async () => {
27
+ mockPushVapidPublicKey.mockResolvedValue({});
28
+ await expect(getVapidPublicKey()).rejects.toThrow('Failed to get VAPID public key');
29
+ });
30
+ });
@@ -0,0 +1,44 @@
1
+ import { pushVapidPublicKey } from '../generated/api';
2
+ import { extractItem } from '../utils/response';
3
+ import { urlBase64ToUint8Array } from '../utils/browser';
4
+
5
+ export async function getVapidPublicKey(): Promise<string> {
6
+ const res = await pushVapidPublicKey();
7
+ // 拦截器已把 data.public_key 深转为 camelCase(api/client.ts);
8
+ // snake 兜底与本包既有双键读取惯例一致。
9
+ const data = extractItem(res) as { publicKey?: string; public_key?: string } | null;
10
+ const key = data?.publicKey ?? data?.public_key;
11
+ if (!key) {
12
+ throw new Error('Failed to get VAPID public key');
13
+ }
14
+ return key;
15
+ }
16
+
17
+ export async function subscribeBrowserPush(
18
+ vapidPublicKey: string,
19
+ ): Promise<PushSubscription | null> {
20
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
21
+ const registration = await navigator.serviceWorker.ready;
22
+ const existing = await registration.pushManager.getSubscription();
23
+ if (existing) return existing;
24
+ try {
25
+ return await registration.pushManager.subscribe({
26
+ userVisibleOnly: true,
27
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
28
+ });
29
+ } catch (err) {
30
+ console.error('Push subscription failed:', err);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function unsubscribeBrowserPush(): Promise<boolean> {
36
+ if (!('serviceWorker' in navigator)) return false;
37
+ const registration = await navigator.serviceWorker.ready;
38
+ const sub = await registration.pushManager.getSubscription();
39
+ if (sub) {
40
+ await sub.unsubscribe();
41
+ return true;
42
+ }
43
+ return false;
44
+ }
@@ -0,0 +1,94 @@
1
+ export function upsertMeta(
2
+ attribute: string,
3
+ value: string,
4
+ attrName: 'name' | 'property' = 'name',
5
+ ): () => void {
6
+ const selector = `meta[${attrName}="${attribute}"]`;
7
+ const el = document.querySelector<HTMLMetaElement>(selector);
8
+ const previous = el?.content;
9
+
10
+ if (el) {
11
+ el.content = value;
12
+ } else {
13
+ const meta = document.createElement('meta');
14
+ meta.setAttribute(attrName, attribute);
15
+ meta.content = value;
16
+ document.head.appendChild(meta);
17
+ return () => {
18
+ meta.remove();
19
+ };
20
+ }
21
+
22
+ return () => {
23
+ if (previous !== undefined) {
24
+ el.content = previous;
25
+ }
26
+ };
27
+ }
28
+
29
+ export function upsertCanonical(href: string): () => void {
30
+ let link = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
31
+ const previous = link?.href;
32
+
33
+ if (link) {
34
+ link.href = href;
35
+ } else {
36
+ link = document.createElement('link');
37
+ link.rel = 'canonical';
38
+ link.href = href;
39
+ document.head.appendChild(link);
40
+ return () => {
41
+ link!.remove();
42
+ };
43
+ }
44
+
45
+ return () => {
46
+ if (previous !== undefined && link) {
47
+ link.href = previous;
48
+ }
49
+ };
50
+ }
51
+
52
+ export function upsertLink(
53
+ rel: string,
54
+ href: string,
55
+ extraAttrs?: Record<string, string>,
56
+ ): () => void {
57
+ const selector = `link[rel="${rel}"]${extraAttrs?.hreflang ? `[hreflang="${extraAttrs.hreflang}"]` : ''}`;
58
+ const existing = document.querySelector<HTMLLinkElement>(selector);
59
+ const previous = existing?.href;
60
+
61
+ if (existing) {
62
+ existing.href = href;
63
+ return () => {
64
+ if (previous !== undefined) {
65
+ existing.href = previous;
66
+ }
67
+ };
68
+ } else {
69
+ const link = document.createElement('link');
70
+ link.rel = rel;
71
+ link.href = href;
72
+ if (extraAttrs) {
73
+ Object.entries(extraAttrs).forEach(([k, v]) => link.setAttribute(k, v));
74
+ }
75
+ document.head.appendChild(link);
76
+ return () => {
77
+ link.remove();
78
+ };
79
+ }
80
+ }
81
+
82
+ export function injectJSONLD(schemas: object[]): () => void {
83
+ schemas.forEach((schema, idx) => {
84
+ const script = document.createElement('script');
85
+ script.type = 'application/ld+json';
86
+ script.setAttribute('data-seo-jsonld', String(idx));
87
+ script.textContent = JSON.stringify(schema);
88
+ document.head.appendChild(script);
89
+ });
90
+
91
+ return () => {
92
+ document.querySelectorAll('script[data-seo-jsonld]').forEach((el) => el.remove());
93
+ };
94
+ }