@autional-cn/shared 0.1.0-rc

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 (81) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +47 -0
  3. package/src/api/__tests__/entry-plane.test.ts +119 -0
  4. package/src/api/bff-client.ts +94 -0
  5. package/src/api/client.ts +174 -0
  6. package/src/api/entry-plane.ts +61 -0
  7. package/src/api/trace.ts +21 -0
  8. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  9. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  10. package/src/auth/__tests__/build-login-url.test.ts +219 -0
  11. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  12. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  13. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  14. package/src/auth/__tests__/session-client-id.test.ts +124 -0
  15. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  16. package/src/auth/auth-machine.ts +151 -0
  17. package/src/auth/auth-queries.ts +118 -0
  18. package/src/auth/build-tenant-urls.ts +48 -0
  19. package/src/auth/oauth-client-from-slug.ts +119 -0
  20. package/src/auth/oauth-client-id-store.ts +41 -0
  21. package/src/auth/oauth-login.ts +291 -0
  22. package/src/auth/pkce.ts +141 -0
  23. package/src/auth/portal-config.ts +68 -0
  24. package/src/auth/roles.ts +102 -0
  25. package/src/auth/service.ts +368 -0
  26. package/src/auth/slug-from-url.ts +92 -0
  27. package/src/auth/store.ts +226 -0
  28. package/src/auth/tenant-route-middleware.ts +113 -0
  29. package/src/auth/tenant-slug-context.tsx +9 -0
  30. package/src/branding/BrandingInitializer.tsx +60 -0
  31. package/src/branding/brand-color.ts +155 -0
  32. package/src/branding/branding-cache.ts +71 -0
  33. package/src/branding/extract-branding.ts +27 -0
  34. package/src/branding/index.ts +15 -0
  35. package/src/branding/tenant-store.ts +13 -0
  36. package/src/branding/types.ts +14 -0
  37. package/src/branding/useBranding.ts +85 -0
  38. package/src/components/AuditStatsOnly.tsx +65 -0
  39. package/src/components/OAuthCallbackPage.tsx +56 -0
  40. package/src/components/PortalGuard.tsx +105 -0
  41. package/src/components/RequireAuth.tsx +142 -0
  42. package/src/components/TenantIndexGuard.tsx +131 -0
  43. package/src/components/__tests__/RequireAuth.test.tsx +244 -0
  44. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  45. package/src/config/__tests__/portal-resolver.test.ts +247 -0
  46. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  47. package/src/config/constants.ts +27 -0
  48. package/src/config/index.ts +411 -0
  49. package/src/config/resolve-basename.ts +57 -0
  50. package/src/constants/api-paths.ts +450 -0
  51. package/src/generated/api.ts +12566 -0
  52. package/src/generated/queries.ts +12623 -0
  53. package/src/generated/types.ts +13888 -0
  54. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  55. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  56. package/src/hooks/useAuth.ts +239 -0
  57. package/src/hooks/useBootstrap.ts +74 -0
  58. package/src/hooks/useCurrentRole.ts +18 -0
  59. package/src/hooks/useLogout.ts +33 -0
  60. package/src/hooks/usePermission.ts +116 -0
  61. package/src/hooks/useSodMode.ts +73 -0
  62. package/src/index.ts +209 -0
  63. package/src/lib/push.ts +41 -0
  64. package/src/seo/dom-utils.ts +94 -0
  65. package/src/seo/index.ts +6 -0
  66. package/src/seo/jsonld.ts +29 -0
  67. package/src/seo/types.ts +29 -0
  68. package/src/seo/usePageMeta.ts +17 -0
  69. package/src/seo/usePageTitle.ts +14 -0
  70. package/src/seo/useSEO.ts +80 -0
  71. package/src/types/index.ts +185 -0
  72. package/src/utils/__tests__/mock-test.ts +15 -0
  73. package/src/utils/browser.ts +6 -0
  74. package/src/utils/case.ts +44 -0
  75. package/src/utils/error.ts +26 -0
  76. package/src/utils/format.ts +63 -0
  77. package/src/utils/password-transmission.test.ts +145 -0
  78. package/src/utils/password-transmission.ts +253 -0
  79. package/src/utils/response.ts +62 -0
  80. package/src/utils/vite.ts +4 -0
  81. package/src/vite-env.d.ts +18 -0
@@ -0,0 +1,56 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { handleOAuthCallback } from '../auth/oauth-login';
3
+
4
+ // 这个组件在 9 个站点各存一份,三处用户可见文案此前**三种写法并存**:
5
+ // admin「正在登录…」 / platform「登录中…」 / 其余 7 站是英文「Signing in...」
6
+ // 也就是说 7 个中文门户在 OAuth 回调时会显示英文文案。
7
+ //
8
+ // 采用与权威 shared/ui/ErrorBoundary.tsx 完全相同的约定:用 <html lang> 判定文案语言,
9
+ // 默认中文——各 portal 的 I18nProvider 都会在 languageChanged 时同步它。
10
+ // (ErrorBoundary 当初就是因为各 portal 的 t() 约定不一致,才改用这个零依赖信号。)
11
+ const TEXT = {
12
+ zh: { signingIn: '正在登录…', failed: '登录失败', retry: '重试' },
13
+ en: { signingIn: 'Signing in...', failed: 'Login failed', retry: 'Try again' },
14
+ };
15
+
16
+ function text(): typeof TEXT.zh {
17
+ if (typeof document === 'undefined') return TEXT.zh;
18
+ const lang = (document.documentElement.getAttribute('lang') || '').toLowerCase();
19
+ return lang.startsWith('zh') ? TEXT.zh : TEXT.en;
20
+ }
21
+
22
+ export function OAuthCallbackPage() {
23
+ const [error, setError] = useState('');
24
+ const [loading, setLoading] = useState(true);
25
+ const t = text();
26
+
27
+ useEffect(() => {
28
+ handleOAuthCallback().catch((e) => {
29
+ setError(e.message);
30
+ setLoading(false);
31
+ });
32
+ }, []);
33
+
34
+ if (loading) {
35
+ return (
36
+ <div className="flex h-screen items-center justify-center bg-neutral-50">
37
+ <div className="flex flex-col items-center gap-3">
38
+ <div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
39
+ <p className="text-sm text-neutral-500">{t.signingIn}</p>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <div className="flex h-screen items-center justify-center bg-neutral-50">
47
+ <div className="max-w-sm rounded-lg border border-red-200 bg-red-50 p-6 text-center">
48
+ <p className="text-sm font-medium text-red-700">{t.failed}</p>
49
+ <p className="mt-1 text-xs text-red-600">{error}</p>
50
+ <a href="/" className="mt-4 inline-block text-sm text-blue-600 underline">
51
+ {t.retry}
52
+ </a>
53
+ </div>
54
+ </div>
55
+ );
56
+ }
@@ -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-cn/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,142 @@
1
+ import { useEffect } from 'react';
2
+ import { AuthService, setBootstrapLock } from '../auth/service';
3
+ import { buildLoginUrl } from '../auth/roles';
4
+ import { initiateOAuthLogin } from '../auth/oauth-login';
5
+ import {
6
+ useTenantRoute,
7
+ resolveEffectiveClientId,
8
+ isSameDomainOAuth,
9
+ } from '../auth/tenant-route-middleware';
10
+ import { useAuthMachine } from '../auth/auth-machine';
11
+
12
+ interface RequireAuthProps {
13
+ children: React.ReactNode;
14
+ allowedRoles?: readonly string[];
15
+ fallback?: React.ReactNode;
16
+ loadingFallback?: React.ReactNode;
17
+ /** 确定性未知 slug(by-slug HTTP 404)时渲染的 404 页;缺省用内置极简 404 兜底 */
18
+ notFound?: React.ReactNode;
19
+ }
20
+
21
+ /**
22
+ * 内置极简 404(零依赖,供未注入 notFound 的站点兜底 —— shared 不能 import 各
23
+ * app 的 not-found/page)。样式用 CSS 变量 var(--color-*),.dark 下自动反转。
24
+ */
25
+ function DefaultTenantNotFound() {
26
+ const zh =
27
+ typeof document !== 'undefined' &&
28
+ (document.documentElement.lang || '').toLowerCase().startsWith('zh');
29
+ return (
30
+ <div
31
+ role="alert"
32
+ style={{
33
+ display: 'flex',
34
+ flexDirection: 'column',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ minHeight: '40vh',
38
+ gap: '0.5rem',
39
+ width: '100%',
40
+ }}
41
+ >
42
+ <h1 style={{ fontSize: '3rem', fontWeight: 700, color: 'var(--color-text-muted)' }}>404</h1>
43
+ <p style={{ color: 'var(--color-text-secondary)' }}>
44
+ {zh ? '页面不存在或已被移除' : 'Page not found'}
45
+ </p>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export function RequireAuth({
51
+ children,
52
+ allowedRoles,
53
+ fallback,
54
+ loadingFallback,
55
+ notFound,
56
+ }: RequireAuthProps) {
57
+ const machine = useAuthMachine();
58
+ const tenantRoute = useTenantRoute();
59
+
60
+ // F-W6 闸门:URL slug 确定性不存在(by-slug HTTP 404)且本站未配 env 固定
61
+ // client(admin-console 等单租户旁路不适用)→ 本地 404,不发弹跳。
62
+ // 不然:门户 → buildLoginUrl → auth 侧有会话走「回跳 redirect」→ 回到本页
63
+ // → 再弹跳,构成无限整页往返(verification-W5-patch §6)。
64
+ // 网络错误/5xx 不置 unknownSlug(fail-open 仍走登录漏斗);仅 unauthenticated 终态生效。
65
+ const envClientId =
66
+ typeof window !== 'undefined' ? (window as any).__APP_CONFIG__?.VITE_OAUTH_CLIENT_ID || '' : '';
67
+ const unknownSlugGate =
68
+ machine.status === 'unauthenticated' && tenantRoute.unknownSlug && !envClientId;
69
+
70
+ // 挂载时抑制 401 抢跑:若当前域配置了 OAuth client 且本页无有效 token
71
+ // (跨域 admin 场景),任何无 token API 请求的 401 都会触发 onUnauthorized →
72
+ // buildLoginUrl 302 到 auth 登录页,抢跑下方 OAuth PKCE 决策,形成时序竞争。
73
+ // 上锁后未登录跳转的唯一出口是下方 effect 的 OAuth/buildLoginUrl 决策。
74
+ useEffect(() => {
75
+ if (typeof window === 'undefined') return;
76
+ const cfg = (window as any).__APP_CONFIG__;
77
+ const envClientId = cfg?.VITE_OAUTH_CLIENT_ID || '';
78
+ if (!envClientId) return;
79
+ const rawToken = AuthService.getAccessToken();
80
+ const hasToken = !!rawToken && rawToken !== 'undefined' && rawToken !== 'null';
81
+ if (!hasToken) setBootstrapLock(true);
82
+ }, []);
83
+
84
+ // Redirect logic: only runs when machine says unauthenticated
85
+ useEffect(() => {
86
+ if (machine.status !== 'unauthenticated') return;
87
+ if (typeof window === 'undefined') return;
88
+ if (tenantRoute.loading) return; // wait for OAuth config
89
+
90
+ const cfg = (window as any).__APP_CONFIG__;
91
+ const envClientId = cfg?.VITE_OAUTH_CLIENT_ID || '';
92
+
93
+ // F-W6 闸门:确定性未知 slug → 不发起任何弹跳,由渲染分支给本地 404
94
+ if (tenantRoute.unknownSlug && !envClientId) return;
95
+
96
+ const effectiveClientId = resolveEffectiveClientId(tenantRoute, envClientId);
97
+
98
+ if (effectiveClientId && (isSameDomainOAuth(tenantRoute) || envClientId)) {
99
+ initiateOAuthLogin(effectiveClientId);
100
+ return;
101
+ }
102
+
103
+ // Cross-domain or no client: redirect to auth-pages login
104
+ // from_requireauth=1 与 service.ts 的 onUnauthorized 同口径:auth 侧据此先做会话复检
105
+ // (有会话直接回跳,避免「已登录还被要求再登一次」)
106
+ setTimeout(() => {
107
+ window.location.replace(buildLoginUrl(window.location.href, true));
108
+ }, 0);
109
+ }, [machine.status, tenantRoute]);
110
+
111
+ if (machine.status === 'checking' || machine.status === 'bootstrap') {
112
+ return loadingFallback ? <>{loadingFallback}</> : null;
113
+ }
114
+
115
+ if (unknownSlugGate) {
116
+ return notFound ? <>{notFound}</> : <DefaultTenantNotFound />;
117
+ }
118
+
119
+ if (machine.status === 'unauthenticated' || machine.status === 'redirecting') {
120
+ return null;
121
+ }
122
+
123
+ // status === 'ready' or 'authenticated'
124
+ if (allowedRoles && allowedRoles.length > 0) {
125
+ const role = AuthService.getCurrentRole();
126
+ if (role && !allowedRoles.includes(role)) {
127
+ const permissions = AuthService.getPermissions();
128
+ if (!permissions || permissions.length === 0) {
129
+ return loadingFallback ? <>{loadingFallback}</> : <>{children}</>;
130
+ }
131
+ const hasAdminPermission = permissions.some(
132
+ (p: string) => p === '*' || allowedRoles.some((r) => p.includes(r)),
133
+ );
134
+ if (!hasAdminPermission) {
135
+ if (fallback) return <>{fallback}</>;
136
+ return null;
137
+ }
138
+ }
139
+ }
140
+
141
+ return <>{children}</>;
142
+ }
@@ -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-cn/ui,禁止 import @autional-cn/ui
14
+ * (会引入未声明依赖/循环依赖)。loading 默认值用内联 div 骨架,
15
+ * 样式用 CSS 变量 var(--color-*),在 .dark 下自动反转(AGENTS.md 设计规范)。
16
+ * ⚠️ useTenantSlugFromUrl 内部相对导入('../auth/slug-from-url'),
17
+ * 不要从包名 '@autional-cn/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-cn/ui) */
68
+ loading?: ReactNode;
69
+ }
70
+
71
+ /**
72
+ * 默认 loading 占位 — 内联骨架(零依赖)。
73
+ * 不使用 @autional-cn/ui(shared 包无该依赖,避免未声明依赖/循环依赖);
74
+ * 样式用 CSS 变量 var(--color-*),在 .dark 下自动反转。
75
+ */
76
+ function DefaultLoadingSkeleton() {
77
+ return (
78
+ <>
79
+ <style>{`@keyframes authms-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: 'authms-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,244 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { render, screen, waitFor, cleanup } from '@testing-library/react';
5
+
6
+ // ============================================================
7
+ // L6(D9 / ADR-04):RequireAuth 的未登录出口
8
+ // - 能解析出 OAuth client → 起 PKCE(initiateOAuthLogin)
9
+ // - 不能解析 → 跳 auth 登录页,且**必须**带 from_requireauth=1
10
+ // (与 service.ts onUnauthorized 同口径:auth 侧先做会话复检,避免重登一次)
11
+ // ============================================================
12
+
13
+ const { mockState } = vi.hoisted(() => ({
14
+ mockState: {
15
+ machineStatus: 'unauthenticated' as string,
16
+ tenantRoute: {
17
+ slug: null as string | null,
18
+ tenantId: null as string | null,
19
+ oauthClientId: null as string | null,
20
+ loading: false,
21
+ notFound: true,
22
+ unknownSlug: false,
23
+ },
24
+ token: null as string | null,
25
+ replace: vi.fn(),
26
+ initiate: vi.fn(),
27
+ setBootstrapLock: vi.fn(),
28
+ },
29
+ }));
30
+
31
+ vi.mock('../../auth/auth-machine', () => ({
32
+ useAuthMachine: () => ({ status: mockState.machineStatus }),
33
+ }));
34
+
35
+ vi.mock('../../auth/tenant-route-middleware', async (importOriginal) => ({
36
+ ...(await importOriginal<typeof import('../../auth/tenant-route-middleware')>()),
37
+ useTenantRoute: () => mockState.tenantRoute,
38
+ }));
39
+
40
+ vi.mock('../../auth/oauth-login', () => ({
41
+ initiateOAuthLogin: (...args: any[]) => mockState.initiate(...args),
42
+ }));
43
+
44
+ vi.mock('../../auth/service', () => ({
45
+ AuthService: {
46
+ getAccessToken: () => mockState.token,
47
+ getCurrentRole: () => null,
48
+ getPermissions: () => [],
49
+ },
50
+ setBootstrapLock: (...args: any[]) => mockState.setBootstrapLock(...args),
51
+ }));
52
+
53
+ import { RequireAuth } from '../RequireAuth';
54
+ import { getPortalUrl } from '../../config';
55
+
56
+ const originalWindowLocation = window.location;
57
+
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ mockState.machineStatus = 'unauthenticated';
61
+ mockState.tenantRoute = {
62
+ slug: null,
63
+ tenantId: null,
64
+ oauthClientId: null,
65
+ loading: false,
66
+ notFound: true,
67
+ unknownSlug: false,
68
+ };
69
+ mockState.token = null;
70
+ delete (window as any).__APP_CONFIG__;
71
+
72
+ delete (window as any).location;
73
+ (window as any).location = Object.defineProperties(
74
+ {},
75
+ {
76
+ ...Object.getOwnPropertyDescriptors(originalWindowLocation),
77
+ replace: { get: () => mockState.replace },
78
+ },
79
+ );
80
+ });
81
+
82
+ afterEach(() => {
83
+ if (window.location !== originalWindowLocation) {
84
+ Object.defineProperty(window, 'location', { value: originalWindowLocation, writable: true });
85
+ }
86
+ // vitest globals 未开启 ⇒ RTL 不会自动 cleanup,手动清理防跨用例 DOM 污染
87
+ cleanup();
88
+ });
89
+
90
+ describe('RequireAuth 未登录出口', () => {
91
+ it('无可用 client_id → 跳 auth 登录页并带 from_requireauth=1', async () => {
92
+ render(
93
+ <RequireAuth>
94
+ <div>protected</div>
95
+ </RequireAuth>,
96
+ );
97
+
98
+ await waitFor(() => {
99
+ expect(mockState.replace).toHaveBeenCalled();
100
+ });
101
+ const url = String(mockState.replace.mock.calls[0][0]);
102
+ expect(url).toContain('from_requireauth=1');
103
+ expect(url).toContain('redirect=');
104
+ expect(url.startsWith(getPortalUrl('auth'))).toBe(true);
105
+ expect(mockState.initiate).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('slug 解析出 client_id → 发起 PKCE,不做登录页跳转', async () => {
109
+ mockState.tenantRoute = {
110
+ slug: 'demo',
111
+ tenantId: 't1',
112
+ oauthClientId: 'cid-from-slug',
113
+ loading: false,
114
+ notFound: false,
115
+ unknownSlug: false,
116
+ };
117
+
118
+ render(
119
+ <RequireAuth>
120
+ <div>protected</div>
121
+ </RequireAuth>,
122
+ );
123
+
124
+ await waitFor(() => {
125
+ expect(mockState.initiate).toHaveBeenCalledWith('cid-from-slug');
126
+ });
127
+ expect(mockState.replace).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('env 兜底 client_id(__APP_CONFIG__)→ 同样发起 PKCE', async () => {
131
+ (window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
132
+
133
+ render(
134
+ <RequireAuth>
135
+ <div>protected</div>
136
+ </RequireAuth>,
137
+ );
138
+
139
+ await waitFor(() => {
140
+ expect(mockState.initiate).toHaveBeenCalledWith('cid-from-env');
141
+ });
142
+ expect(mockState.replace).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it('无 token + env client_id → 上 bootstrap 锁(抑制 401 抢跑)', async () => {
146
+ (window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
147
+
148
+ render(
149
+ <RequireAuth>
150
+ <div>protected</div>
151
+ </RequireAuth>,
152
+ );
153
+
154
+ await waitFor(() => {
155
+ expect(mockState.setBootstrapLock).toHaveBeenCalledWith(true);
156
+ });
157
+ });
158
+ });
159
+
160
+ // ============================================================
161
+ // F-W6 闸门(verification-W5-patch §6):未知 slug 死链 × auth 域有会话
162
+ // 曾构成无限整页往返(门户 → buildLoginUrl → auth 回跳 redirect → 门户 → …)。
163
+ // 修法:by-slug 确定性 404(unknownSlug)且本站未配 env client → 本地 404,不弹跳。
164
+ // 约束:网络错误/5xx 不置 unknownSlug(fail-open 仍走漏斗);env client 站点不触发。
165
+ // ============================================================
166
+ describe('RequireAuth F-W6 确定性 404 闸门', () => {
167
+ it('unknownSlug + 无 env client → 不发弹跳、渲染注入的 404', async () => {
168
+ mockState.tenantRoute = {
169
+ slug: null,
170
+ tenantId: null,
171
+ oauthClientId: null,
172
+ loading: false,
173
+ notFound: true,
174
+ unknownSlug: true,
175
+ };
176
+
177
+ render(
178
+ <RequireAuth notFound={<div data-testid="tenant-404">no such tenant</div>}>
179
+ <div>protected</div>
180
+ </RequireAuth>,
181
+ );
182
+
183
+ await waitFor(() => expect(screen.getByTestId('tenant-404')).toBeInTheDocument());
184
+ expect(mockState.replace).not.toHaveBeenCalled();
185
+ expect(mockState.initiate).not.toHaveBeenCalled();
186
+ expect(screen.queryByText('protected')).toBeNull();
187
+ });
188
+
189
+ it('unknownSlug + 无 notFound 注入 → 渲染内置极简 404(不空白)', async () => {
190
+ mockState.tenantRoute = {
191
+ slug: null,
192
+ tenantId: null,
193
+ oauthClientId: null,
194
+ loading: false,
195
+ notFound: true,
196
+ unknownSlug: true,
197
+ };
198
+
199
+ render(
200
+ <RequireAuth>
201
+ <div>protected</div>
202
+ </RequireAuth>,
203
+ );
204
+
205
+ await waitFor(() => expect(screen.getByText('404')).toBeInTheDocument());
206
+ expect(mockState.replace).not.toHaveBeenCalled();
207
+ });
208
+
209
+ it('unknownSlug + env client(admin-console 旁路)→ 闸门不生效,仍走 PKCE', async () => {
210
+ (window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
211
+ mockState.tenantRoute = {
212
+ slug: null,
213
+ tenantId: null,
214
+ oauthClientId: null,
215
+ loading: false,
216
+ notFound: true,
217
+ unknownSlug: true,
218
+ };
219
+
220
+ render(
221
+ <RequireAuth notFound={<div data-testid="tenant-404">no such tenant</div>}>
222
+ <div>protected</div>
223
+ </RequireAuth>,
224
+ );
225
+
226
+ await waitFor(() => expect(mockState.initiate).toHaveBeenCalledWith('cid-from-env'));
227
+ expect(screen.queryByTestId('tenant-404')).toBeNull();
228
+ expect(mockState.replace).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it('非 unknownSlug(网络错误 fail-open)→ 维持原漏斗(回归锁)', async () => {
232
+ // 默认 tenantRoute: notFound=true, unknownSlug=false —— 即 by-slug 网络错误场景
233
+ render(
234
+ <RequireAuth notFound={<div data-testid="tenant-404">no such tenant</div>}>
235
+ <div>protected</div>
236
+ </RequireAuth>,
237
+ );
238
+
239
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
240
+ const url = String(mockState.replace.mock.calls[0][0]);
241
+ expect(url).toContain('from_requireauth=1');
242
+ expect(screen.queryByTestId('tenant-404')).toBeNull();
243
+ });
244
+ });