@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,105 @@
1
+ /**
2
+ * PortalGuard — Portal 级 Guard 组件
3
+ *
4
+ * 基于 portal-config.ts 中的角色定义,派生出命名 Guard 组件。
5
+ * 替代在每个路由中重复传入 allowedRoles 的模式。
6
+ *
7
+ * 用法:
8
+ * import { AdminGuard, PlatformGuard, SecurityGuard } from '@autional/shared';
9
+ *
10
+ * // admin-console 路由:
11
+ * <AdminGuard><DashboardPage /></AdminGuard>
12
+ * <AdminGuard allowedRoles={['user_mgmt']}><UsersPage /></AdminGuard> // 覆盖默认角色
13
+ *
14
+ * @see portal-config.ts
15
+ */
16
+
17
+ 'use client';
18
+
19
+ import React from 'react';
20
+ import { RequireAuth } from './RequireAuth';
21
+ import { ADMIN_GUARDS, PLATFORM_GUARDS, SECURITY_GUARDS } from '../auth/portal-config';
22
+
23
+ interface PortalGuardProps {
24
+ children: React.ReactNode;
25
+ allowedRoles?: readonly string[];
26
+ fallback?: React.ReactNode;
27
+ loadingFallback?: React.ReactNode;
28
+ /** 确定性未知 slug(by-slug HTTP 404)时渲染的 404 页(透传 RequireAuth) */
29
+ notFound?: React.ReactNode;
30
+ }
31
+
32
+ /**
33
+ * Admin Console Guard — 默认 roles: ['super_admin', 'admin']
34
+ */
35
+ export function AdminGuard({ children, allowedRoles, ...rest }: PortalGuardProps) {
36
+ return (
37
+ <RequireAuth allowedRoles={allowedRoles || ADMIN_GUARDS.admin.roles} {...rest}>
38
+ {children}
39
+ </RequireAuth>
40
+ );
41
+ }
42
+
43
+ /**
44
+ * 用户管理 Guard — roles: ['super_admin', 'admin', 'user_manager']
45
+ */
46
+ export function UserMgmtGuard({ children, allowedRoles, ...rest }: PortalGuardProps) {
47
+ return (
48
+ <RequireAuth allowedRoles={allowedRoles || ADMIN_GUARDS.userMgmt.roles} {...rest}>
49
+ {children}
50
+ </RequireAuth>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * 安全只读 Guard — roles: ['super_admin', 'admin', 'security_admin']
56
+ */
57
+ export function SecurityGuard({ children, allowedRoles, ...rest }: PortalGuardProps) {
58
+ return (
59
+ <RequireAuth allowedRoles={allowedRoles || SECURITY_GUARDS.security.roles} {...rest}>
60
+ {children}
61
+ </RequireAuth>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Platform Console Guard — roles: ['super_admin', 'admin']
67
+ */
68
+ export function PlatformGuard({ children, allowedRoles, ...rest }: PortalGuardProps) {
69
+ return (
70
+ <RequireAuth allowedRoles={allowedRoles || PLATFORM_GUARDS.superAdmin.roles} {...rest}>
71
+ {children}
72
+ </RequireAuth>
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Security Admin Guard — roles: ['super_admin', 'security_admin']
78
+ * 用于 security-dashboard 中仅 security_admin 可访问的平台级页面
79
+ */
80
+ export function SecurityAdminGuard({ children, allowedRoles, ...rest }: PortalGuardProps) {
81
+ return (
82
+ <RequireAuth allowedRoles={allowedRoles || SECURITY_GUARDS.securityAdmin.roles} {...rest}>
83
+ {children}
84
+ </RequireAuth>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Auditor Guard — roles: ['auditor']
90
+ * 仅 auditor 可访问的纯审计页面(只读)
91
+ */
92
+ export function AuditorGuard({ children, allowedRoles, ...rest }: PortalGuardProps) {
93
+ return (
94
+ <RequireAuth allowedRoles={allowedRoles || SECURITY_GUARDS.auditor.roles} {...rest}>
95
+ {children}
96
+ </RequireAuth>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Token-only Guard — 仅需登录,无角色限制
102
+ */
103
+ export function AuthGuard({ children, ...rest }: PortalGuardProps) {
104
+ return <RequireAuth {...rest}>{children}</RequireAuth>;
105
+ }
@@ -0,0 +1,220 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { AuthService, setBootstrapLock } from '../auth/service';
3
+ import { buildLoginUrl } from '../auth/roles';
4
+ import { traceRedirect } from '../auth/auth-trace';
5
+ import { initiateOAuthLogin } from '../auth/oauth-login';
6
+ import {
7
+ useTenantRoute,
8
+ resolveEffectiveClientId,
9
+ isSameDomainOAuth,
10
+ } from '../auth/tenant-route-middleware';
11
+ import { useAuthMachine } from '../auth/auth-machine';
12
+
13
+ interface RequireAuthProps {
14
+ children: React.ReactNode;
15
+ allowedRoles?: readonly string[];
16
+ fallback?: React.ReactNode;
17
+ loadingFallback?: React.ReactNode;
18
+ /** 确定性未知 slug(by-slug HTTP 404)时渲染的 404 页;缺省用内置极简 404 兜底 */
19
+ notFound?: React.ReactNode;
20
+ }
21
+
22
+ /**
23
+ * 内置极简 404(零依赖,供未注入 notFound 的站点兜底 —— shared 不能 import 各
24
+ * app 的 not-found/page)。样式用 CSS 变量 var(--color-*),.dark 下自动反转。
25
+ */
26
+ function DefaultTenantNotFound() {
27
+ const zh =
28
+ typeof document !== 'undefined' &&
29
+ (document.documentElement.lang || '').toLowerCase().startsWith('zh');
30
+ return (
31
+ <div
32
+ role="alert"
33
+ style={{
34
+ display: 'flex',
35
+ flexDirection: 'column',
36
+ alignItems: 'center',
37
+ justifyContent: 'center',
38
+ minHeight: '40vh',
39
+ gap: '0.5rem',
40
+ width: '100%',
41
+ }}
42
+ >
43
+ <h1 style={{ fontSize: '3rem', fontWeight: 700, color: 'var(--color-text-muted)' }}>404</h1>
44
+ <p style={{ color: 'var(--color-text-secondary)' }}>
45
+ {zh ? '页面不存在或已被移除' : 'Page not found'}
46
+ </p>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ /**
52
+ * AUTH-03:未认证态下用户选择「停留」(取消被动登录跳转)后的可恢复 UI——
53
+ * 原实现停留后仍渲染 null(正文空白无任何出口),现替换为提示 + 「前往登录」出口。
54
+ * 零依赖、CSS 变量主题(同 DefaultTenantNotFound);按钮用固定 #2563eb(白字
55
+ * 5.17:1 过 AA),不引品牌色(品牌色可能对比度不足,见 AUTH-05)。
56
+ */
57
+ function DefaultSignInRequired({ onSignIn }: { onSignIn: () => void }) {
58
+ const zh =
59
+ typeof document !== 'undefined' &&
60
+ (document.documentElement.lang || '').toLowerCase().startsWith('zh');
61
+ return (
62
+ <div
63
+ role="alert"
64
+ style={{
65
+ display: 'flex',
66
+ flexDirection: 'column',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ minHeight: '40vh',
70
+ gap: '0.75rem',
71
+ width: '100%',
72
+ }}
73
+ >
74
+ <h1 style={{ fontSize: '1.25rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>
75
+ {zh ? '需要登录' : 'Sign-in required'}
76
+ </h1>
77
+ <p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', margin: 0 }}>
78
+ {zh ? '此页面需登录后访问' : 'This page requires sign-in'}
79
+ </p>
80
+ <button
81
+ type="button"
82
+ onClick={onSignIn}
83
+ style={{
84
+ border: 0,
85
+ borderRadius: 6,
86
+ padding: '8px 18px',
87
+ fontSize: '0.875rem',
88
+ cursor: 'pointer',
89
+ background: '#2563eb',
90
+ color: '#fff',
91
+ }}
92
+ >
93
+ {zh ? '前往登录' : 'Go to sign-in'}
94
+ </button>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function RequireAuth({
100
+ children,
101
+ allowedRoles,
102
+ fallback,
103
+ loadingFallback,
104
+ notFound,
105
+ }: RequireAuthProps) {
106
+ const machine = useAuthMachine();
107
+ const tenantRoute = useTenantRoute();
108
+ // 「停留」已选(被动登录跳转被用户取消)——渲染恢复态而非空白(AUTH-03)
109
+ const [stayed, setStayed] = useState(false);
110
+
111
+ // 登录跳转的单一出口:effect(进入未认证态)与恢复态「前往登录」按钮共用。
112
+ // force=true 跳过 OAuth 10s 防重入窗口(用户显式重试)。
113
+ const triggerLogin = useCallback(
114
+ (force = false) => {
115
+ if (typeof window === 'undefined') return;
116
+ const cfg = (window as any).__APP_CONFIG__;
117
+ const envClientId = cfg?.VITE_OAUTH_CLIENT_ID || '';
118
+ const effectiveClientId = resolveEffectiveClientId(tenantRoute, envClientId);
119
+
120
+ if (effectiveClientId && (isSameDomainOAuth(tenantRoute) || envClientId)) {
121
+ void initiateOAuthLogin(effectiveClientId, undefined, {
122
+ onStay: () => setStayed(true),
123
+ force,
124
+ });
125
+ return;
126
+ }
127
+
128
+ // Cross-domain or no client: redirect to auth-pages login
129
+ // from_requireauth=1 与 service.ts 的 onUnauthorized 同口径:auth 侧据此先做会话复检
130
+ // (有会话直接回跳,避免「已登录还被要求再登一次」)
131
+ // interstitial:未认证深链被动弹登录 → authTrace 提示(可停留);重入抑制在其内。
132
+ setTimeout(() => {
133
+ traceRedirect(buildLoginUrl(window.location.href, true), {
134
+ reason: 'unauthenticated',
135
+ kind: 'interstitial',
136
+ onStay: () => setStayed(true),
137
+ });
138
+ }, 0);
139
+ },
140
+ [tenantRoute],
141
+ );
142
+
143
+ // F-W6 闸门:URL slug 确定性不存在(by-slug HTTP 404)且本站未配 env 固定
144
+ // client(admin-console 等单租户旁路不适用)→ 本地 404,不发弹跳。
145
+ // 不然:门户 → buildLoginUrl → auth 侧有会话走「回跳 redirect」→ 回到本页
146
+ // → 再弹跳,构成无限整页往返(verification-W5-patch §6)。
147
+ // 网络错误/5xx 不置 unknownSlug(fail-open 仍走登录漏斗);仅 unauthenticated 终态生效。
148
+ const envClientId =
149
+ typeof window !== 'undefined' ? (window as any).__APP_CONFIG__?.VITE_OAUTH_CLIENT_ID || '' : '';
150
+ const unknownSlugGate =
151
+ machine.status === 'unauthenticated' && tenantRoute.unknownSlug && !envClientId;
152
+
153
+ // 挂载时抑制 401 抢跑:若当前域配置了 OAuth client 且本页无有效 token
154
+ // (跨域 admin 场景),任何无 token API 请求的 401 都会触发 onUnauthorized →
155
+ // buildLoginUrl 302 到 auth 登录页,抢跑下方 OAuth PKCE 决策,形成时序竞争。
156
+ // 上锁后未登录跳转的唯一出口是下方 effect 的 OAuth/buildLoginUrl 决策。
157
+ useEffect(() => {
158
+ if (typeof window === 'undefined') return;
159
+ const cfg = (window as any).__APP_CONFIG__;
160
+ const envClientId = cfg?.VITE_OAUTH_CLIENT_ID || '';
161
+ if (!envClientId) return;
162
+ const rawToken = AuthService.getAccessToken();
163
+ const hasToken = !!rawToken && rawToken !== 'undefined' && rawToken !== 'null';
164
+ if (!hasToken) setBootstrapLock(true);
165
+ }, []);
166
+
167
+ // Redirect logic: only runs when machine says unauthenticated
168
+ useEffect(() => {
169
+ if (machine.status !== 'unauthenticated') return;
170
+ if (typeof window === 'undefined') return;
171
+ if (tenantRoute.loading) return; // wait for OAuth config
172
+
173
+ const cfg = (window as any).__APP_CONFIG__;
174
+ const envClientId = cfg?.VITE_OAUTH_CLIENT_ID || '';
175
+
176
+ // F-W6 闸门:确定性未知 slug → 不发起任何弹跳,由渲染分支给本地 404
177
+ if (tenantRoute.unknownSlug && !envClientId) return;
178
+
179
+ triggerLogin(false);
180
+ }, [machine.status, tenantRoute, triggerLogin]);
181
+
182
+ if (machine.status === 'checking' || machine.status === 'bootstrap') {
183
+ return loadingFallback ? <>{loadingFallback}</> : null;
184
+ }
185
+
186
+ if (unknownSlugGate) {
187
+ return notFound ? <>{notFound}</> : <DefaultTenantNotFound />;
188
+ }
189
+
190
+ if (machine.status === 'unauthenticated' || machine.status === 'redirecting') {
191
+ // AUTH-03:「停留」后原实现恒 null(空白死路)→ 给恢复态与再发起出口
192
+ if (stayed) return <DefaultSignInRequired onSignIn={() => triggerLogin(true)} />;
193
+ return null;
194
+ }
195
+
196
+ // status === 'ready' or 'authenticated'
197
+ // 守卫三原则(ADR-AB1-01 / A-445 修复口径):
198
+ // ① 精确角色相等:allowedRoles.includes(role) 直通——禁止任何子串/前缀判定
199
+ // (历史缺陷:权限码对角色白名单做子串包含判定,security_admin 借 14 条
200
+ // `*:admin` 权限码穿透一切含 'admin' 的白名单路由 = A-445);
201
+ // ② '*' 单豁免:持全量通配 '*' 的非白名单角色放行(唯一豁免通道);
202
+ // ③ 空权限 fail-closed:非白名单角色且无 '*' ⇒ 拒绝(fallback / null),
203
+ // 空权限数组不再构成放行旁路。
204
+ // role === null 保持穿透为锁定现语义(叠加语义):更严闸门(AuthGuard /
205
+ // PortalGuard)在更外层裁决未知角色,本组件不把 null 判为拒绝
206
+ // (对抗用例④为防误改回归锁,改前先改裁定)。
207
+ if (allowedRoles && allowedRoles.length > 0) {
208
+ const role = AuthService.getCurrentRole();
209
+ if (role && !allowedRoles.includes(role)) {
210
+ const permissions = AuthService.getPermissions();
211
+ const hasWildcard = !!permissions && permissions.some((p: string) => p === '*');
212
+ if (!hasWildcard) {
213
+ if (fallback) return <>{fallback}</>;
214
+ return null;
215
+ }
216
+ }
217
+ }
218
+
219
+ return <>{children}</>;
220
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * TenantIndexGuard — 租户 slug 白名单守卫(TASK-425,AC-010)
3
+ *
4
+ * 从 end-user-portal App.tsx L65-111 抽取泛化,供 4 个租户门户复用
5
+ * (不复制 4 份,违反 DRY)。
6
+ *
7
+ * - usePublicTenantSlugs: 调公开 tenants API(无认证端点),staleTime 5m 缓存,
8
+ * retry 1,任何失败返回空数组(放行语义由 TenantIndexGuard 处理)。
9
+ * - TenantIndexGuard: index 路由守卫(P0-2),防止未知路径被贪婪渲染为
10
+ * Dashboard/首页。404 页由各 portal 通过 notFound prop 注入
11
+ * (shared 组件不能 import 具体 app 的 not-found/page)。
12
+ *
13
+ * ⚠️ 依赖约束: shared 包 dependencies 无 @autional/ui,禁止 import @autional/ui
14
+ * (会引入未声明依赖/循环依赖)。loading 默认值用内联 div 骨架,
15
+ * 样式用 CSS 变量 var(--color-*),在 .dark 下自动反转(AGENTS.md 设计规范)。
16
+ * ⚠️ useTenantSlugFromUrl 内部相对导入('../auth/slug-from-url'),
17
+ * 不要从包名 '@autional/shared' 导入自身。
18
+ */
19
+
20
+ 'use client';
21
+
22
+ import { type ReactNode } from 'react';
23
+ import { useQuery } from '@tanstack/react-query';
24
+ import { useTenantSlugFromUrl } from '../auth/slug-from-url';
25
+
26
+ /** 公开租户信息(public tenants API 返回项) */
27
+ export interface PublicTenantInfo {
28
+ name?: string;
29
+ slug?: string;
30
+ id?: string;
31
+ }
32
+
33
+ /**
34
+ * 公开租户列表(无认证端点,用于校验 URL slug 有效性)。
35
+ * 从 end-user-portal App.tsx 原样抽取。
36
+ * - staleTime 5m(plan §5 资源列:public tenants API 有缓存)
37
+ * - retry 1;失败返回 [](白名单空数组放行,避免 API 挂掉时全站 404)
38
+ *
39
+ * ⚠️ 响应契约:tenant-service ListPublicTenants 返回 dto_base.ListResponse
40
+ * (`{code, message, items, total, pagination, timestamp}`,items 才是数组)。
41
+ * 解析顺序:items → data(旧兼容)→ 原始对象,均非数组则返回 []。
42
+ */
43
+ export function usePublicTenantSlugs() {
44
+ return useQuery<Array<PublicTenantInfo>>({
45
+ queryKey: ['public-tenants'],
46
+ queryFn: async () => {
47
+ try {
48
+ const res = await fetch('/bff/tenant/api/v1/tenant/public/tenants');
49
+ if (!res.ok) return [];
50
+ const json = await res.json();
51
+ const list = json?.items ?? json?.data ?? json ?? [];
52
+ return Array.isArray(list) ? list : [];
53
+ } catch {
54
+ return [];
55
+ }
56
+ },
57
+ staleTime: 5 * 60 * 1000,
58
+ retry: 1,
59
+ });
60
+ }
61
+
62
+ export interface TenantIndexGuardProps {
63
+ /** 白名单校验通过后渲染的 index 内容(各 portal 的 Dashboard/首页) */
64
+ children?: ReactNode;
65
+ /** 白名单校验失败渲染的 404 页(各 portal 注入自己的 NotFoundPage) */
66
+ notFound?: ReactNode;
67
+ /** loading 占位(缺省渲染内联简单骨架,不依赖 @autional/ui) */
68
+ loading?: ReactNode;
69
+ }
70
+
71
+ /**
72
+ * 默认 loading 占位 — 内联骨架(零依赖)。
73
+ * 不使用 @autional/ui(shared 包无该依赖,避免未声明依赖/循环依赖);
74
+ * 样式用 CSS 变量 var(--color-*),在 .dark 下自动反转。
75
+ */
76
+ function DefaultLoadingSkeleton() {
77
+ return (
78
+ <>
79
+ <style>{`@keyframes autional-guard-spin { to { transform: rotate(360deg); } }`}</style>
80
+ <div
81
+ role="status"
82
+ aria-live="polite"
83
+ aria-label={typeof document !== 'undefined' && (document.documentElement.lang || '').toLowerCase().startsWith('zh') ? '加载中' : 'Loading'}
84
+ style={{
85
+ display: 'flex',
86
+ alignItems: 'center',
87
+ justifyContent: 'center',
88
+ minHeight: '40vh',
89
+ width: '100%',
90
+ }}
91
+ >
92
+ <div
93
+ style={{
94
+ width: '2rem',
95
+ height: '2rem',
96
+ borderRadius: '9999px',
97
+ border: '3px solid var(--color-border-subtle)',
98
+ borderTopColor: 'var(--color-brand)',
99
+ animation: 'autional-guard-spin 0.8s linear infinite',
100
+ }}
101
+ />
102
+ </div>
103
+ </>
104
+ );
105
+ }
106
+
107
+ /**
108
+ * index 路由守卫(P0-2):防止未知路径被贪婪渲染为 Dashboard/首页。
109
+ * 逻辑与 end-user-portal 现状一致(L94-111):
110
+ * - URL 多段(如 /acme-corp/xyz):basename 已剥离 slug,内部单段必为未知路由段 → notFound
111
+ * - URL 单段(如 /not-found):校验 slug 是否在公开租户列表 → 无效 → notFound
112
+ * - URL 单段且 slug 有效(如 /acme-corp)→ children
113
+ * - 白名单为空数组时放行(public tenants API 挂掉时退化为不拦截,与 end-user 现状一致)
114
+ */
115
+ export function TenantIndexGuard({ children, notFound, loading }: TenantIndexGuardProps) {
116
+ const { data: tenants, isLoading } = usePublicTenantSlugs();
117
+ const slug = useTenantSlugFromUrl();
118
+
119
+ if (typeof window !== 'undefined') {
120
+ const fullSegs = window.location.pathname.split('/').filter(Boolean);
121
+ if (fullSegs.length > 1) return <>{notFound}</>;
122
+ }
123
+
124
+ if (isLoading && !tenants) return <>{loading ?? <DefaultLoadingSkeleton />}</>;
125
+
126
+ if (slug && tenants && tenants.length > 0 && !tenants.some((t) => (t.name || t.slug) === slug)) {
127
+ return <>{notFound}</>;
128
+ }
129
+
130
+ return <>{children}</>;
131
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import { useCurrentTenantId } from '../hooks/useAuth';
3
+ import { getPortalUrl } from '../config';
4
+ import { traceRedirect } from '../auth/auth-trace';
5
+ import { usePublicTenantSlugs } from './TenantIndexGuard';
6
+
7
+ /**
8
+ * 裸根 `/` 漏斗:门户入口必须带租户段(/:tenantSlug)。
9
+ * - 已有会话且能解析出 slug → 整页跳 /<slug>/(不经过 brand)
10
+ * - 否则整页跳 brand 选品牌(brand → auth 登录 → 回本门户 /<slug>/)
11
+ *
12
+ * slug 解析**唯一权威 = 公开租户名单**(id → name,name 即 slug)。
13
+ * 不得用会话 store 的 tenants[].name —— 那是 identity `/auth/me/tenants` 的
14
+ * **展示名**(如 "Demo Tenant"),拼进 URL 会落 /Demo%20Tenant/ → 404(回归修复;
15
+ * 与 auth 站 8859e4d 的 pickSessionSlug 同口径)。
16
+ *
17
+ * 注:本包刻意不依赖 react-router(它只属于各 app),故不走 <Navigate>。
18
+ */
19
+ export function TenantRootRedirect() {
20
+ const currentTenantId = useCurrentTenantId();
21
+ const { data: knownTenants, isSuccess: tenantsLoaded } = usePublicTenantSlugs();
22
+
23
+ const slug = useMemo(() => {
24
+ if (!currentTenantId || !tenantsLoaded) return undefined;
25
+ const match = (knownTenants ?? []).find((t) => t.id === currentTenantId);
26
+ return match?.name || match?.slug || undefined;
27
+ }, [currentTenantId, knownTenants, tenantsLoaded]);
28
+
29
+ useEffect(() => {
30
+ if (typeof window === 'undefined') return;
31
+ if (slug) {
32
+ const base = window.location.pathname.replace(/\/+$/, '');
33
+ traceRedirect(`${base}/${slug}/`, { reason: 'funnel-slug' });
34
+ return;
35
+ }
36
+ // 有会话但公开名单尚未返回:等待(名单是 slug 唯一权威,不得抢跑漏斗 brand)。
37
+ // 无会话(currentTenantId 空)不受此闸,立即漏斗。
38
+ if (currentTenantId && !tenantsLoaded) return;
39
+ const brand = getPortalUrl('brand');
40
+ // 门户不可派生(如经 IP 主机访问):保持当前页,不导航(与 EntryRouter 同约定)
41
+ if (!brand) return;
42
+ const target = `${brand}/?redirect=${encodeURIComponent(
43
+ window.location.origin + window.location.pathname,
44
+ )}`;
45
+ traceRedirect(target, { reason: 'funnel-brand' });
46
+ }, [slug, currentTenantId, tenantsLoaded]);
47
+
48
+ return null;
49
+ }
@@ -0,0 +1,165 @@
1
+ // @vitest-environment jsdom
2
+ // ============================================================
3
+ // TASK-AB1-29 · RequireAuth 对抗测试 6 类(RC-4 / A-445)
4
+ // 修复前基线(2026-10-04):本文件对未修复代码运行,组①③必须红(fail-open 实证);
5
+ // 修复(TASK-AB1-01)后 6 类全绿,即 A-445 子串穿透永久钉死。
6
+ // 14 条 `*:admin` 弹药清单以 findings §1.1 实扫枚举为准(防清单漂移)。
7
+ // ============================================================
8
+ import '@testing-library/jest-dom/vitest';
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { render, screen, cleanup } from '@testing-library/react';
11
+
12
+ const { mockState } = vi.hoisted(() => ({
13
+ mockState: {
14
+ machineStatus: 'ready' as string,
15
+ role: 'admin' as string | null,
16
+ permissions: [] as string[],
17
+ },
18
+ }));
19
+
20
+ vi.mock('../../auth/auth-machine', () => ({
21
+ useAuthMachine: () => ({ status: mockState.machineStatus }),
22
+ }));
23
+
24
+ vi.mock('../../auth/tenant-route-middleware', async (importOriginal) => ({
25
+ ...(await importOriginal<typeof import('../../auth/tenant-route-middleware')>()),
26
+ useTenantRoute: () => ({
27
+ slug: null,
28
+ tenantId: null,
29
+ oauthClientId: null,
30
+ loading: false,
31
+ notFound: false,
32
+ unknownSlug: false,
33
+ }),
34
+ }));
35
+
36
+ vi.mock('../../auth/oauth-login', () => ({
37
+ initiateOAuthLogin: vi.fn(),
38
+ }));
39
+
40
+ vi.mock('../../auth/service', () => ({
41
+ AuthService: {
42
+ getAccessToken: () => null,
43
+ getCurrentRole: () => mockState.role,
44
+ getPermissions: () => mockState.permissions,
45
+ },
46
+ setBootstrapLock: vi.fn(),
47
+ }));
48
+
49
+ import { RequireAuth } from '../RequireAuth';
50
+
51
+ /** findings §1.1:security_admin 存量持有的 14 条 `*:admin` 弹药(实扫自 platform_rbac.go catalog) */
52
+ const AMMO_14 = [
53
+ 'tenant:admin',
54
+ 'saml:admin',
55
+ 'mfa:admin',
56
+ 'billing:admin',
57
+ 'notification:admin',
58
+ 'pay:admin',
59
+ 'compliance:admin',
60
+ 'wallet:admin',
61
+ 'point:admin',
62
+ 'oauth:admin',
63
+ 'secret:admin',
64
+ 'status:admin',
65
+ 'identity:admin',
66
+ 'storage:admin',
67
+ ];
68
+
69
+ const ADMIN_ONLY = ['super_admin', 'admin'] as const;
70
+ const SECURITY_READ = ['super_admin', 'admin', 'security_admin'] as const;
71
+
72
+ function renderGuard(checkpoint: string, props: Record<string, unknown>) {
73
+ return render(
74
+ <RequireAuth
75
+ fallback={<div data-testid={`forbidden-${checkpoint}`}>forbidden</div>}
76
+ {...props}
77
+ >
78
+ <div data-testid={`protected-${checkpoint}`}>protected</div>
79
+ </RequireAuth>,
80
+ );
81
+ }
82
+
83
+ beforeEach(() => {
84
+ mockState.machineStatus = 'ready';
85
+ mockState.role = 'admin';
86
+ mockState.permissions = [];
87
+ delete (window as any).__APP_CONFIG__;
88
+ });
89
+
90
+ afterEach(() => {
91
+ cleanup();
92
+ });
93
+
94
+ describe('RequireAuth 对抗 6 类(A-445 / RC-4)', () => {
95
+ it('① security_admin + 14 条 *:admin 弹药 + Admin 白名单 ⇒ 拦截(子串穿透不得成立)', () => {
96
+ mockState.role = 'security_admin';
97
+ mockState.permissions = [...AMMO_14];
98
+
99
+ renderGuard('c1', { allowedRoles: ADMIN_ONLY });
100
+
101
+ expect(screen.getByTestId('forbidden-c1')).toBeInTheDocument();
102
+ expect(screen.queryByTestId('protected-c1')).toBeNull();
103
+ });
104
+
105
+ it('② admin / super_admin ⇒ 放行', () => {
106
+ mockState.role = 'admin';
107
+ const r1 = renderGuard('c2a', { allowedRoles: ADMIN_ONLY });
108
+ expect(screen.getByTestId('protected-c2a')).toBeInTheDocument();
109
+ r1.unmount();
110
+
111
+ mockState.role = 'super_admin';
112
+ renderGuard('c2b', { allowedRoles: ADMIN_ONLY });
113
+ expect(screen.getByTestId('protected-c2b')).toBeInTheDocument();
114
+ });
115
+
116
+ it('③ user_manager / 空权限 ⇒ fail-closed 拦截', () => {
117
+ mockState.role = 'user_manager';
118
+ mockState.permissions = [];
119
+
120
+ renderGuard('c3', { allowedRoles: ADMIN_ONLY });
121
+
122
+ expect(screen.getByTestId('forbidden-c3')).toBeInTheDocument();
123
+ expect(screen.queryByTestId('protected-c3')).toBeNull();
124
+ });
125
+
126
+ it('④ role=null ⇒ 穿透(现语义锁定,防误改)', () => {
127
+ mockState.role = null;
128
+ mockState.permissions = [];
129
+
130
+ renderGuard('c4', { allowedRoles: ADMIN_ONLY });
131
+
132
+ expect(screen.getByTestId('protected-c4')).toBeInTheDocument();
133
+ });
134
+
135
+ it('⑤ 持 \"*\" 的非白名单角色 ⇒ 放行(豁免对照组)', () => {
136
+ mockState.role = 'operator';
137
+ mockState.permissions = ['*'];
138
+
139
+ renderGuard('c5', { allowedRoles: ADMIN_ONLY });
140
+
141
+ expect(screen.getByTestId('protected-c5')).toBeInTheDocument();
142
+ });
143
+
144
+ it('⑥ security_admin + SecurityRead(含 security_admin)白名单 ⇒ 放行(对照组)', () => {
145
+ mockState.role = 'security_admin';
146
+ mockState.permissions = ['audit:read', 'compliance:read'];
147
+
148
+ renderGuard('c6', { allowedRoles: SECURITY_READ });
149
+
150
+ expect(screen.getByTestId('protected-c6')).toBeInTheDocument();
151
+ });
152
+
153
+ it('⑦ allowedRoles 未配置 ⇒ 直通 children(AC-AB1-04 回归锁)', () => {
154
+ mockState.role = 'member';
155
+ mockState.permissions = ['user:read'];
156
+
157
+ render(
158
+ <RequireAuth>
159
+ <div data-testid="protected-c7">protected</div>
160
+ </RequireAuth>,
161
+ );
162
+
163
+ expect(screen.getByTestId('protected-c7')).toBeInTheDocument();
164
+ });
165
+ });