@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,113 @@
1
+ /**
2
+ * useTenantRoute — 路由层中间件
3
+ *
4
+ * 从 URL slug 解析租户配置,与认证逻辑解耦。
5
+ * 代替 RequireAuth 直接使用 useOAuthClientIdFromUrl。
6
+ *
7
+ * 职责:
8
+ * - 从 URL 提取 tenant slug
9
+ * - 请求 OAuth 客户端配置 (slug → clientId)
10
+ * - 自动将 tenantId 写入 AuthService
11
+ * - 暴露 loading/notFound 状态给路由层
12
+ *
13
+ * @see docs/architecture/auth-next-issues-analysis.md
14
+ */
15
+
16
+ 'use client';
17
+
18
+ import { useEffect } from 'react';
19
+ import { useOAuthClientIdFromUrl } from './oauth-client-from-slug';
20
+ import { AuthService } from './service';
21
+ import { getPortalUrl } from '../config';
22
+
23
+ export interface TenantRouteResult {
24
+ /** 租户 slug(从 URL 提取) */
25
+ slug: string | null;
26
+ /** 租户 ID(从 OAuth 配置获取) */
27
+ tenantId: string | null;
28
+ /** OAuth 客户端 ID */
29
+ oauthClientId: string | null;
30
+ /** 是否正在加载(API 请求中) */
31
+ loading: boolean;
32
+ /** 未找到(无 slug / 无 OAuth 配置 / slug 不存在) */
33
+ notFound: boolean;
34
+ /**
35
+ * URL slug 确定性不存在(by-slug HTTP 404)。
36
+ * 门户口径(F-W6):本地渲染 404、不发弹跳 —— 否则与 auth 侧
37
+ * 「有会话 → 回跳 redirect」构成无限整页往返。
38
+ * 网络错误/5xx 不置本标记(fail-open,仍走原登录漏斗)。
39
+ */
40
+ unknownSlug: boolean;
41
+ }
42
+
43
+ /**
44
+ * 路由层中间件:从 URL slug 解析租户配置。
45
+ *
46
+ * 用法:
47
+ * ```tsx
48
+ * function AppLayout() {
49
+ * const tenantRoute = useTenantRoute();
50
+ * if (tenantRoute.loading) return <Spin />;
51
+ * return <App tenantSlug={tenantRoute.slug} />;
52
+ * }
53
+ * ```
54
+ */
55
+ export function useTenantRoute(): TenantRouteResult {
56
+ const slugResolution = useOAuthClientIdFromUrl();
57
+
58
+ // Auto-store tenantId in AuthService when resolved
59
+ useEffect(() => {
60
+ if (slugResolution.status === 'resolved' && slugResolution.config.tenantId) {
61
+ AuthService.updateCurrentTenant(slugResolution.config.tenantId);
62
+ }
63
+ }, [slugResolution.status]);
64
+
65
+ // slug 在 useOAuthClientIdFromUrl 中通过 extractSlugFromPath 从 URL 提取,
66
+ // 通过 SlugResolution.resolved.slug 传递,无需从 API 响应中读取。
67
+ const slug = slugResolution.status === 'resolved' ? slugResolution.slug : null;
68
+
69
+ const unknownSlug = slugResolution.status === 'unknown-slug';
70
+
71
+ return {
72
+ slug,
73
+ tenantId: slugResolution.status === 'resolved' ? slugResolution.config.tenantId || null : null,
74
+ oauthClientId: slugResolution.status === 'resolved' ? slugResolution.clientId : null,
75
+ loading: slugResolution.status === 'loading',
76
+ notFound: slugResolution.status === 'not-found' || unknownSlug,
77
+ unknownSlug,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * 获取有效的 OAuth clientId:配置 > 环境变量 > slug 解析。
83
+ * 这是 redirect 决策的核心逻辑,提取为纯函数便于测试。
84
+ */
85
+ export function resolveEffectiveClientId(
86
+ tenantRoute: TenantRouteResult,
87
+ envClientId?: string,
88
+ ): string | null {
89
+ // slug 解析出的 clientId 优先(多租户场景)
90
+ if (tenantRoute.oauthClientId) return tenantRoute.oauthClientId;
91
+ // 环境变量其次(单租户/开发场景)
92
+ if (envClientId) return envClientId;
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * 判断 OAuth 是否在同域(slug-resolved 或同域 auth-pages)。
98
+ * SSR 安全:在服务端环境下始终返回 false。
99
+ */
100
+ export function isSameDomainOAuth(tenantRoute: TenantRouteResult): boolean {
101
+ if (typeof window === 'undefined') return false; // SSR guard
102
+ if (tenantRoute.notFound) return false;
103
+ // slug 解析到的 client 一定是同域的(API 在同一 gateway 后)
104
+ if (tenantRoute.oauthClientId) return true;
105
+ // 检查 auth-pages URL 是否同域
106
+ try {
107
+ const authPagesUrl = getPortalUrl('auth');
108
+ if (!authPagesUrl) return false;
109
+ return new URL(authPagesUrl).origin === window.location.origin;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ const TenantSlugContext = createContext<string | undefined>(undefined);
4
+
5
+ export const TenantSlugProvider = TenantSlugContext.Provider;
6
+
7
+ export function useTenantSlug(): string | undefined {
8
+ return useContext(TenantSlugContext);
9
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useQuery } from '@tanstack/react-query';
5
+ import { useTenantSlugFromUrl } from '../auth/slug-from-url';
6
+ import { tenantPublicTenantsByTenants } from '../generated/api';
7
+ import { extractBranding } from './extract-branding';
8
+ import { readCachedBranding, writeCachedBranding } from './branding-cache';
9
+ import { useTenantBrandingStore } from './tenant-store';
10
+ import type { Branding } from './types';
11
+
12
+ const TTL = 6 * 60 * 60 * 1000; // 6h,与公开租户接口的缓存口径一致
13
+
14
+ export interface BrandingInitializerProps {
15
+ /**
16
+ * 覆盖 slug 来源。缺省按 URL 首段取(共享层口径);
17
+ * auth 站传入自己的判定——它要求至少两段,裸 /{slug} 视为无租户上下文。
18
+ */
19
+ slug?: string | null;
20
+ /** 接口尚未返回时的兜底品牌(auth 站用 auth-config 里的 branding 段)。 */
21
+ fallback?: Branding | null;
22
+ }
23
+
24
+ /** 按 slug 拉公开品牌并写入 store;渲染 `null`,只做接线。 */
25
+ export function BrandingInitializer({ slug: slugProp, fallback }: BrandingInitializerProps) {
26
+ const urlSlug = useTenantSlugFromUrl();
27
+ const slug = slugProp !== undefined ? slugProp : urlSlug;
28
+ const setBranding = useTenantBrandingStore((s) => s.setBranding);
29
+
30
+ // 缓存先到:首屏立即有品牌(未命中则由 useBranding 回落 tokens 缺省)
31
+ useEffect(() => {
32
+ if (!slug) {
33
+ setBranding(null);
34
+ return;
35
+ }
36
+ const cached = readCachedBranding(slug);
37
+ if (cached) setBranding(cached);
38
+ }, [slug, setBranding]);
39
+
40
+ const { data } = useQuery<Branding | null>({
41
+ queryKey: ['tenant-branding', slug],
42
+ queryFn: async () => {
43
+ if (!slug) return null;
44
+ const res = await tenantPublicTenantsByTenants(slug);
45
+ const branding = extractBranding(res);
46
+ if (branding) writeCachedBranding(slug, branding);
47
+ return branding;
48
+ },
49
+ enabled: Boolean(slug),
50
+ staleTime: TTL,
51
+ gcTime: TTL * 2,
52
+ });
53
+
54
+ useEffect(() => {
55
+ const resolved = data ?? fallback ?? null;
56
+ if (resolved) setBranding(resolved);
57
+ }, [data, fallback, setBranding]);
58
+
59
+ return null;
60
+ }
@@ -0,0 +1,155 @@
1
+ // brand-color.ts — OKLab (Ottosson) 颜色转换 + WCAG 2.x 对比度 + 暗色品牌推导
2
+ // 纯函数,零外部依赖。移植自 butler/requirements/design/brand-contrast.mjs(保留全部常数)
3
+ // 防御 (N3): 非法 hex 输入(非 #rgb/#rrggbb、空串、null/undefined)返回原值/安全默认,不抛错,
4
+ // 避免租户数据异常产生 invalid CSS 变量值。
5
+
6
+ type Rgb = [number, number, number];
7
+
8
+ const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
9
+
10
+ /** 归一化为小写 #rrggbb(支持 #rgb 展开 + 大写 hex);非法输入返回 null */
11
+ function normalizeHex(hex: unknown): string | null {
12
+ if (typeof hex !== 'string') return null;
13
+ const h = hex.trim();
14
+ if (!HEX_RE.test(h)) return null;
15
+ let digits = h.slice(1).toLowerCase();
16
+ if (digits.length === 3)
17
+ digits = digits
18
+ .split('')
19
+ .map((c) => c + c)
20
+ .join('');
21
+ return `#${digits}`;
22
+ }
23
+
24
+ // ── WCAG 2.x 相对亮度 / 对比度(脚本 L4-20)──
25
+ function hexToRgb(hex: string): Rgb {
26
+ const norm = normalizeHex(hex);
27
+ if (!norm) return [0, 0, 0];
28
+ const d = norm.slice(1);
29
+ return [0, 2, 4].map((i) => parseInt(d.slice(i, i + 2), 16) / 255) as Rgb;
30
+ }
31
+ function lin(c: number): number {
32
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
33
+ }
34
+ function lum(rgb: Rgb): number {
35
+ const [r, g, b] = rgb.map(lin) as [number, number, number];
36
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
37
+ }
38
+ function contrast(a: Rgb, b: Rgb): number {
39
+ const l1 = lum(a);
40
+ const l2 = lum(b);
41
+ const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
42
+ return (hi + 0.05) / (lo + 0.05);
43
+ }
44
+
45
+ // ── sRGB ↔ OKLab (Ottosson,脚本 L22-46,含 Math.cbrt 与输出钳位) ──
46
+ function srgbToOklab(rgb: Rgb): [number, number, number] {
47
+ const [r, g, b] = rgb.map(lin) as [number, number, number];
48
+ let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
49
+ let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
50
+ let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
51
+ l = Math.cbrt(l);
52
+ m = Math.cbrt(m);
53
+ s = Math.cbrt(s);
54
+ return [
55
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
56
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
57
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
58
+ ];
59
+ }
60
+ function oklabToSrgb([L, a, b]: [number, number, number]): Rgb {
61
+ const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
62
+ const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
63
+ const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
64
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
65
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
66
+ const b2 = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
67
+ const conv = (c: number): number => {
68
+ c = Math.min(1, Math.max(0, c)); // 钳位(脚本 L42)
69
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
70
+ };
71
+ return [conv(r), conv(g), conv(b2)];
72
+ }
73
+ function rgbToHex(rgb: Rgb): string {
74
+ return (
75
+ '#' +
76
+ rgb
77
+ .map((c) =>
78
+ Math.round(c * 255)
79
+ .toString(16)
80
+ .padStart(2, '0'),
81
+ )
82
+ .join('')
83
+ );
84
+ }
85
+
86
+ /** hex → OKLCH [L, C, H](防御: 非法输入返回 [0, 0, 0],不抛错) */
87
+ export function hexToOklch(hex: string): [number, number, number] {
88
+ if (normalizeHex(hex) === null) return [0, 0, 0];
89
+ const [L, a, b] = srgbToOklab(hexToRgb(hex));
90
+ return [L, Math.hypot(a, b), Math.atan2(b, a)];
91
+ }
92
+
93
+ /** OKLCH [L, C, H] → hex(防御: 非法输入返回 '#000000',不抛错) */
94
+ export function oklchToHex(oklch: [number, number, number]): string {
95
+ if (
96
+ !Array.isArray(oklch) ||
97
+ oklch.length < 3 ||
98
+ oklch.slice(0, 3).some((v) => typeof v !== 'number' || Number.isNaN(v))
99
+ ) {
100
+ return '#000000';
101
+ }
102
+ const [L, C, H] = oklch;
103
+ return rgbToHex(oklabToSrgb([L, C * Math.cos(H), C * Math.sin(H)]));
104
+ }
105
+
106
+ /** 向白色混入比例 t: OKLCH[L+(1-L)·t, C·(1-t), H](保持色相,抬升 L、压降 C,脚本 L58-63) */
107
+ function mixWhite(hex: string, t: number): string {
108
+ const [L, C, H] = hexToOklch(hex);
109
+ return oklchToHex([L + (1 - L) * t, C * (1 - t), H]);
110
+ }
111
+
112
+ /** 内部函数(不导出): 二分 24 迭代求最小 t∈[0,cap],使 mixWhite(hex,t) 对 refBg 对比度 ≥ target。
113
+ * 供 deriveDarkColor / deriveDarkHover 复用,避免二次二分(ADR-003)。 */
114
+ function deriveT(hex: string, refBg: string, target: number, cap: number): number {
115
+ let lo = 0;
116
+ let hi = cap;
117
+ const bgRgb = hexToRgb(refBg);
118
+ for (let i = 0; i < 24; i++) {
119
+ const mid = (lo + hi) / 2;
120
+ if (contrast(hexToRgb(mixWhite(hex, mid)), bgRgb) >= target) hi = mid;
121
+ else lo = mid;
122
+ }
123
+ return (lo + hi) / 2;
124
+ }
125
+
126
+ /** 推导暗色品牌变体 = mixWhite(hex, deriveT(...))。
127
+ * D3 锁定默认参数: refBg='#0a2940', target=4.55, cap=0.85。
128
+ * 防御 (N3): 非法 hex → 返回原值不抛错(不产生 invalid CSS 变量值)。 */
129
+ export function deriveDarkColor(hex: string, refBg = '#0a2940', target = 4.55, cap = 0.85): string {
130
+ if (normalizeHex(hex) === null) return hex;
131
+ const bg = normalizeHex(refBg) ?? '#0a2940';
132
+ const tgt = typeof target === 'number' && !Number.isNaN(target) ? target : 4.55;
133
+ const c = typeof cap === 'number' && !Number.isNaN(cap) ? Math.max(0, Math.min(cap, 1)) : 0.85;
134
+ return mixWhite(hex, deriveT(hex, bg, tgt, c));
135
+ }
136
+
137
+ /** on-brand 文本色: 选 '#ffffff' 与 '#0a0f1a' 中对比度更高者(保证 ≥4.5)。
138
+ * 防御: 非法 fill → '#0a0f1a'(安全默认,不抛错)。 */
139
+ export function pickOnColor(fill: string): '#ffffff' | '#0a0f1a' {
140
+ if (normalizeHex(fill) === null) return '#0a0f1a';
141
+ const white: Rgb = [1, 1, 1];
142
+ const dark = hexToRgb('#0a0f1a');
143
+ return contrast(white, hexToRgb(fill)) >= contrast(dark, hexToRgb(fill)) ? '#ffffff' : '#0a0f1a';
144
+ }
145
+
146
+ /** hover 暗色变体 = mixWhite(color, min(t+0.08, 0.92)),t 复用 deriveT(ADR-003/D4)。
147
+ * 防御 (N3): 非法 hex → 返回原值不抛错。 */
148
+ export function deriveDarkHover(hex: string, refBg = '#0a2940', target = 4.55, cap = 0.85): string {
149
+ if (normalizeHex(hex) === null) return hex;
150
+ const bg = normalizeHex(refBg) ?? '#0a2940';
151
+ const tgt = typeof target === 'number' && !Number.isNaN(target) ? target : 4.55;
152
+ const c = typeof cap === 'number' && !Number.isNaN(cap) ? Math.max(0, Math.min(cap, 1)) : 0.85;
153
+ const t = deriveT(hex, bg, tgt, c);
154
+ return mixWhite(hex, Math.min(t + 0.08, 0.92));
155
+ }
@@ -0,0 +1,71 @@
1
+ import type { Branding } from './types';
2
+ import { extractBranding } from './extract-branding';
3
+
4
+ /**
5
+ * 缓存键。三套前缀都要认——顺序不能改,越靠前越早、越权威。
6
+ *
7
+ * page-init:tenant-branding: 页面内联脚本在 React 之前写入 → 首帧就有缓存色可上
8
+ * tenant-branding: 运行时写入(writeCachedBranding)
9
+ * page-init:auth-config: / auth-config: auth-config 里也带 branding 段,作为兜底
10
+ *
11
+ * 只认第二套的代价是实测过的:首帧读不到缓存,先闪一次品牌默认色再跳到租户色。
12
+ * auth 站的同类实现一直读的是 page-init 键,这里把能力并到共享版,避免两套各读各的。
13
+ */
14
+ export const BRANDING_CACHE_PREFIX = 'tenant-branding:';
15
+ export const PAGE_INIT_BRANDING_PREFIX = 'page-init:tenant-branding:';
16
+ export const AUTH_CONFIG_PREFIX = 'auth-config:';
17
+ export const PAGE_INIT_AUTH_CONFIG_PREFIX = 'page-init:auth-config:';
18
+
19
+ function get(key: string): string | null {
20
+ try {
21
+ return localStorage.getItem(key);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** 兼容 `{data, _ts}` 包装与裸对象两种历史写入形态。 */
28
+ function unwrap(raw: string | null): Branding | null {
29
+ if (!raw) return null;
30
+ try {
31
+ const parsed = JSON.parse(raw) as { data?: Branding } | Branding;
32
+ return ((parsed as { data?: Branding })?.data ?? parsed) as Branding;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** auth-config 缓存里也带 branding 段——作为最后兜底。 */
39
+ function fromAuthConfig(slug: string): Branding | null {
40
+ for (const prefix of [PAGE_INIT_AUTH_CONFIG_PREFIX, AUTH_CONFIG_PREFIX]) {
41
+ const raw = get(prefix + slug);
42
+ if (!raw) continue;
43
+ try {
44
+ const cfg = JSON.parse(raw) as { data?: { branding?: unknown } };
45
+ const branding = extractBranding(cfg?.data?.branding);
46
+ if (branding) return branding;
47
+ } catch {
48
+ /* 坏数据视为没有 */
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function readCachedBranding(slug: string): Branding | null {
55
+ return (
56
+ unwrap(get(PAGE_INIT_BRANDING_PREFIX + slug)) ??
57
+ unwrap(get(BRANDING_CACHE_PREFIX + slug)) ??
58
+ fromAuthConfig(slug)
59
+ );
60
+ }
61
+
62
+ export function writeCachedBranding(slug: string, branding: Branding): void {
63
+ try {
64
+ localStorage.setItem(
65
+ BRANDING_CACHE_PREFIX + slug,
66
+ JSON.stringify({ data: branding, _ts: Date.now() }),
67
+ );
68
+ } catch {
69
+ /* storage full or unavailable */
70
+ }
71
+ }
@@ -0,0 +1,27 @@
1
+ import type { Branding } from './types';
2
+
3
+ /** 从 public tenant 接口响应里取 branding 段(兼容 snake_case / camelCase,且兼容已解包的对象)。 */
4
+ export function extractBranding(raw: unknown): Branding | null {
5
+ if (!raw || typeof raw !== 'object') return null;
6
+ const record = raw as Record<string, unknown>;
7
+ const source = (record.branding ?? record) as Record<string, string | undefined>;
8
+ if (!source || typeof source !== 'object') return null;
9
+
10
+ const logoUrl = source.logo_url || source.logoUrl || '';
11
+ const primaryColor = source.primary_color || source.primaryColor || '';
12
+ if (!logoUrl && !primaryColor) return null;
13
+
14
+ return {
15
+ primaryColor,
16
+ primaryColorDark: source.primary_color_dark || source.primaryColorDark || undefined,
17
+ logoUrl,
18
+ faviconUrl: source.favicon_url || source.faviconUrl || '',
19
+ customCss: source.custom_css || source.customCss || '',
20
+ secondaryColor: source.secondary_color || source.secondaryColor || undefined,
21
+ companyName: source.company_name || source.companyName || undefined,
22
+ loginPageTitle: source.login_page_title || source.loginPageTitle || undefined,
23
+ loginPageDescription: source.login_page_description || source.loginPageDescription || undefined,
24
+ privacyPolicyUrl: source.privacy_policy_url || source.privacyPolicyUrl || undefined,
25
+ termsOfServiceUrl: source.terms_of_service_url || source.termsOfServiceUrl || undefined,
26
+ };
27
+ }
@@ -0,0 +1,15 @@
1
+ export { useBranding, applyBrandColors } from './useBranding';
2
+ export { BrandingInitializer } from './BrandingInitializer';
3
+ export type { BrandingInitializerProps } from './BrandingInitializer';
4
+ export { useTenantBrandingStore } from './tenant-store';
5
+ export { extractBranding } from './extract-branding';
6
+ export {
7
+ readCachedBranding,
8
+ writeCachedBranding,
9
+ BRANDING_CACHE_PREFIX,
10
+ PAGE_INIT_BRANDING_PREFIX,
11
+ AUTH_CONFIG_PREFIX,
12
+ PAGE_INIT_AUTH_CONFIG_PREFIX,
13
+ } from './branding-cache';
14
+ export { deriveDarkColor, deriveDarkHover, pickOnColor, hexToOklch, oklchToHex } from './brand-color';
15
+ export type { Branding } from './types';
@@ -0,0 +1,13 @@
1
+ import { create } from 'zustand';
2
+ import type { Branding } from './types';
3
+
4
+ interface TenantBrandingState {
5
+ branding: Branding | null;
6
+ setBranding: (branding: Branding | null) => void;
7
+ }
8
+
9
+ /** 品牌 store —— `BrandingInitializer` 写入,`useBranding` 消费并落到 CSS 变量。 */
10
+ export const useTenantBrandingStore = create<TenantBrandingState>((set) => ({
11
+ branding: null,
12
+ setBranding: (branding) => set({ branding }),
13
+ }));
@@ -0,0 +1,14 @@
1
+ /** 租户公开品牌契约(与 tenant-service `GET /tenant/public/tenants/{slug}` 的 branding 段一致)。 */
2
+ export interface Branding {
3
+ primaryColor: string;
4
+ primaryColorDark?: string;
5
+ logoUrl: string;
6
+ faviconUrl: string;
7
+ customCss: string;
8
+ secondaryColor?: string;
9
+ companyName?: string;
10
+ loginPageTitle?: string;
11
+ loginPageDescription?: string;
12
+ privacyPolicyUrl?: string;
13
+ termsOfServiceUrl?: string;
14
+ }
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import { useLayoutEffect, useRef } from 'react';
4
+ import { useTenantSlugFromUrl } from '../auth/slug-from-url';
5
+ import { deriveDarkColor, deriveDarkHover, pickOnColor } from './brand-color';
6
+ import { readCachedBranding } from './branding-cache';
7
+ import { useTenantBrandingStore } from './tenant-store';
8
+
9
+ /** 品牌注入的 7 个 CSS 变量(与 auth 站同口径,tokens.css 提供缺省值)。 */
10
+ const BRAND_VARS = [
11
+ '--color-brand-base',
12
+ '--color-brand-hover-base',
13
+ '--color-brand-dark',
14
+ '--color-brand-dark-hover',
15
+ '--color-on-brand-base',
16
+ '--color-on-brand',
17
+ '--color-on-brand-dark',
18
+ ];
19
+
20
+ export function applyBrandColors(color: string, darkOverride?: string): void {
21
+ const root = document.documentElement;
22
+ if (!color) {
23
+ BRAND_VARS.forEach((v) => root.style.removeProperty(v));
24
+ return;
25
+ }
26
+ const dark = darkOverride || deriveDarkColor(color);
27
+ root.style.setProperty('--color-brand-base', color);
28
+ root.style.setProperty('--color-brand-hover-base', color);
29
+ root.style.setProperty('--color-brand-dark', dark);
30
+ root.style.setProperty('--color-brand-dark-hover', deriveDarkHover(color));
31
+ root.style.setProperty('--color-on-brand-base', pickOnColor(color));
32
+ root.style.setProperty('--color-on-brand-dark', pickOnColor(dark));
33
+ }
34
+
35
+ /**
36
+ * 把当前租户品牌落到 DOM(CSS 变量 / favicon / customCss)。
37
+ * 需与 `<BrandingInitializer/>` 同时挂载:前者负责写 store,这里负责呈现。
38
+ */
39
+ export function useBranding(): void {
40
+ const branding = useTenantBrandingStore((s) => s.branding);
41
+ const slug = useTenantSlugFromUrl();
42
+ const styleRef = useRef<HTMLStyleElement | null>(null);
43
+ const primedSlugRef = useRef<string | null>(null);
44
+
45
+ // 首帧前用缓存上色,避免默认品牌色闪一下
46
+ useLayoutEffect(() => {
47
+ if (!slug || primedSlugRef.current === slug) return;
48
+ primedSlugRef.current = slug;
49
+ const cached = readCachedBranding(slug);
50
+ applyBrandColors(cached?.primaryColor ?? '', cached?.primaryColorDark);
51
+ }, [slug]);
52
+
53
+ useLayoutEffect(() => {
54
+ if (!branding) {
55
+ applyBrandColors('');
56
+ return;
57
+ }
58
+
59
+ applyBrandColors(branding.primaryColor, branding.primaryColorDark);
60
+
61
+ if (branding.faviconUrl) {
62
+ let favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
63
+ if (!favicon) {
64
+ favicon = document.createElement('link');
65
+ favicon.rel = 'icon';
66
+ document.head.appendChild(favicon);
67
+ }
68
+ favicon.href = branding.faviconUrl;
69
+ }
70
+
71
+ if (branding.customCss) {
72
+ if (!styleRef.current) {
73
+ styleRef.current = document.createElement('style');
74
+ styleRef.current.setAttribute('data-tenant-css', '');
75
+ document.head.appendChild(styleRef.current);
76
+ }
77
+ styleRef.current.textContent = branding.customCss;
78
+ }
79
+
80
+ return () => {
81
+ applyBrandColors('');
82
+ if (styleRef.current) styleRef.current.textContent = '';
83
+ };
84
+ }, [branding]);
85
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * AuditStatsOnly — 审计统计摘要组件
3
+ *
4
+ * 在 strict sod_mode 下,admin 角色仅能看到聚合统计卡片,
5
+ * 不能看到审计日志明细行、全文检索、导出等功能。
6
+ *
7
+ * 不使用 antd 依赖(shared 包不直接依赖 antd),
8
+ * 使用纯 Tailwind CSS + HTML 实现轻量提示卡片。
9
+ *
10
+ * @see document/architecture/decisions/007-security-dashboard-audit.md §2.2
11
+ */
12
+
13
+ 'use client';
14
+
15
+ import React from 'react';
16
+
17
+ interface AuditStatsOnlyProps {
18
+ title?: string;
19
+ description?: string;
20
+ children?: React.ReactNode;
21
+ }
22
+
23
+ /**
24
+ * 审计受限提示组件。
25
+ * 在 strict 模式下替换完整审计页面内容。
26
+ *
27
+ * 可选的 children 用于放置自定义的统计卡片内容。
28
+ */
29
+ export function AuditStatsOnly({ title, description, children }: AuditStatsOnlyProps) {
30
+ return (
31
+ <div className="flex flex-col items-center justify-center min-h-[40vh] p-8">
32
+ <div className="w-full max-w-2xl bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-slate-700 p-8">
33
+ <div className="flex flex-col items-center text-center space-y-4">
34
+ {/* Lock icon via inline SVG (no external dependency) */}
35
+ <svg
36
+ className="w-12 h-12 text-gray-400 dark:text-gray-500"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ viewBox="0 0 24 24"
40
+ xmlns="http://www.w3.org/2000/svg"
41
+ >
42
+ <path
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ strokeWidth={1.5}
46
+ d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
47
+ />
48
+ </svg>
49
+ <h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 !mb-1">
50
+ {title || '统计摘要'}
51
+ </h4>
52
+ <p className="text-sm text-gray-500 dark:text-gray-400 max-w-md">
53
+ {description ||
54
+ '在当前模式下,您只能查看审计统计摘要。如需查看完整审计日志,请联系您的安全管理员或使用 security-dashboard 的审计员账号登录。'}
55
+ </p>
56
+ {children && (
57
+ <div className="w-full mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
58
+ {children}
59
+ </div>
60
+ )}
61
+ </div>
62
+ </div>
63
+ </div>
64
+ );
65
+ }