@svadmin/core 0.25.4 → 0.26.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.25.4",
3
+ "version": "0.26.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
package/src/http.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * HTTP fetch 工具 — 带 CSRF 防护、401 自动跳转、403 拦截的 fetch wrapper。
3
+ */
4
+
5
+ export interface FetchWithInterceptorOptions {
6
+ /** 401 时跳转的登录页路径 */
7
+ loginPath?: string;
8
+ /** 403 时抛出的错误消息 */
9
+ forbiddenMessage?: string;
10
+ /** 是否自动添加 X-Requested-With header (CSRF 防护) */
11
+ csrfProtection?: boolean;
12
+ /** 自定义 fetch 实现,便于测试或 SSR 环境注入 */
13
+ fetchImpl?: FetchWithInterceptor;
14
+ /** 自定义 401 重定向处理,便于测试或 SSR 环境注入 */
15
+ onUnauthorized?: (loginPath: string, returnTo: string) => void;
16
+ }
17
+
18
+ export type FetchWithInterceptor = (url: string, init?: RequestInit) => Promise<Response>;
19
+
20
+ const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions, 'fetchImpl' | 'onUnauthorized'>> & {
21
+ fetchImpl: FetchWithInterceptor;
22
+ onUnauthorized: (loginPath: string, returnTo: string) => void;
23
+ } = {
24
+ loginPath: "/auth/login",
25
+ forbiddenMessage: "Forbidden: 该操作已被安全策略拒绝",
26
+ csrfProtection: true,
27
+ get fetchImpl() {
28
+ if (typeof globalThis !== "undefined" && typeof globalThis.fetch === "function") {
29
+ return (url: string, init?: RequestInit) => globalThis.fetch(url, init);
30
+ }
31
+ return async () => {
32
+ throw new Error("No fetch implementation found. Pass fetchImpl in options.");
33
+ };
34
+ },
35
+ onUnauthorized: (loginPath, returnTo) => {
36
+ if (typeof window !== "undefined") {
37
+ window.location.href = `${loginPath}?returnTo=${returnTo}`;
38
+ }
39
+ },
40
+ };
41
+
42
+ /**
43
+ * 带拦截器的 fetch — 自动注入 CSRF header,401 跳转登录,403 抛异常。
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const fetcher = createFetchWithInterceptor({ loginPath: '/login' });
48
+ * const res = await fetcher('/api/users');
49
+ * ```
50
+ */
51
+ export function createFetchWithInterceptor(
52
+ options: FetchWithInterceptorOptions = {},
53
+ ): FetchWithInterceptor {
54
+ const opts = { ...DEFAULT_OPTIONS, ...options };
55
+
56
+ return async (url: string, init: RequestInit = {}): Promise<Response> => {
57
+ const headers = new Headers(init.headers || {});
58
+
59
+ if (
60
+ opts.csrfProtection &&
61
+ init.method &&
62
+ ["POST", "PUT", "DELETE", "PATCH"].includes(init.method.toUpperCase())
63
+ ) {
64
+ headers.set("X-Requested-With", "XMLHttpRequest");
65
+ }
66
+
67
+ const res = await opts.fetchImpl(url, { ...init, headers });
68
+
69
+ if (!res.ok) {
70
+ if (res.status === 401) {
71
+ const returnTo = typeof window !== "undefined"
72
+ ? encodeURIComponent(window.location.pathname)
73
+ : "";
74
+ opts.onUnauthorized(opts.loginPath, returnTo);
75
+ throw new Error("Unauthorized");
76
+ }
77
+ if (res.status === 403) {
78
+ throw new Error(opts.forbiddenMessage);
79
+ }
80
+ }
81
+ return res;
82
+ };
83
+ }
84
+
85
+ /**
86
+ * 便捷全局实例 — 使用默认配置。
87
+ */
88
+ export const fetchWithInterceptor = createFetchWithInterceptor();
package/src/index.ts CHANGED
@@ -32,7 +32,7 @@ export {
32
32
  export { matchRoute, navigate, currentPath, setActiveRouterProvider, beforeEach, afterEach, resetRouter } from './router';
33
33
  export type { RouteGuard } from './router';
34
34
  export { readURLState, writeURLState } from './url-sync';
35
- export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions.svelte';
35
+ export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync, createFeatureGate } from './permissions.svelte';
36
36
  export { useLive, useSubscription, usePublish } from './live.svelte';
37
37
  export { toast, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue, resetToast } from './toast.svelte';
38
38
  export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
@@ -72,7 +72,7 @@ export type {
72
72
  } from './types';
73
73
  export type { InvalidateScope } from './options.svelte';
74
74
  export type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
75
- export type { Action, CanParams, CanResult, AccessControlProvider } from './permissions.svelte';
75
+ export type { Action, CanParams, CanResult, AccessControlProvider, FeatureGateConfig, FeatureGateUser } from './permissions.svelte';
76
76
  export type { AuditEntry, AuditHandler } from './audit';
77
77
  export { useCan } from './useCan';
78
78
  export type { UseCanOptions, UseCanResult } from './useCan';
@@ -127,3 +127,7 @@ export type {
127
127
  TaskListResult,
128
128
  TaskSubscription,
129
129
  } from './types';
130
+
131
+ // HTTP fetch utilities
132
+ export { createFetchWithInterceptor, fetchWithInterceptor } from './http';
133
+ export type { FetchWithInterceptor, FetchWithInterceptorOptions } from './http';
@@ -95,3 +95,69 @@ export async function canAccessAsync(resourceOrBatch: string | CanParams[], acti
95
95
 
96
96
  return provider.can({ resource: resourceOrBatch, action: action!, params }) as Promise<CanResult>;
97
97
  }
98
+
99
+ // ─── Feature Gate ─────────────────────────────────────────────
100
+
101
+ /**
102
+ * 角色层级定义 (高权 → 低权)
103
+ */
104
+ const ROLE_HIERARCHY = ["superadmin", "admin", "manager", "user", "viewer", "guest"];
105
+
106
+ export interface FeatureGateConfig {
107
+ /** 允许访问的角色列表 */
108
+ roles?: string[];
109
+ /** 需要的最低角色 (含以上所有角色) */
110
+ minRole?: string;
111
+ /** 需要的权限列表 (全部匹配) */
112
+ permissions?: string[];
113
+ }
114
+
115
+ export interface FeatureGateUser {
116
+ role: string;
117
+ permissions: string[];
118
+ }
119
+
120
+ /**
121
+ * 创建功能门控函数 — 基于角色和权限判断用户是否可访问某功能。
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const canUseCode = createFeatureGate({
126
+ * minRole: 'manager',
127
+ * permissions: ['code:execute'],
128
+ * });
129
+ *
130
+ * if (canUseCode(user)) { ... }
131
+ * ```
132
+ */
133
+ export function createFeatureGate(config: FeatureGateConfig): (user: FeatureGateUser) => boolean {
134
+ return (user: FeatureGateUser): boolean => {
135
+ // 角色白名单
136
+ if (config.roles && !config.roles.includes(user.role)) {
137
+ return false;
138
+ }
139
+
140
+ // 最低角色层级检查
141
+ if (config.minRole) {
142
+ const userIndex = ROLE_HIERARCHY.indexOf(user.role);
143
+ const minIndex = ROLE_HIERARCHY.indexOf(config.minRole);
144
+ if (userIndex === -1 || minIndex === -1 || userIndex > minIndex) {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ // 权限检查 (全部匹配)
150
+ if (config.permissions && config.permissions.length > 0) {
151
+ const hasAll = config.permissions.every((permission) => {
152
+ if (user.permissions.includes("*")) return true;
153
+ if (user.permissions.includes(permission)) return true;
154
+ const [resource] = permission.split(":");
155
+ if (resource && user.permissions.includes(`${resource}:*`)) return true;
156
+ return false;
157
+ });
158
+ if (!hasAll) return false;
159
+ }
160
+
161
+ return true;
162
+ };
163
+ }
@@ -43,6 +43,20 @@ function extendQuery<Q extends object, E extends Record<string, unknown>>(
43
43
 
44
44
  export type MaybeGetter<T> = T | (() => T);
45
45
 
46
+ function cloneQueryKeyPart<T>(value: T): T {
47
+ if (Array.isArray(value)) {
48
+ return value.map(item => cloneQueryKeyPart(item)) as T;
49
+ }
50
+ if (value && typeof value === 'object') {
51
+ const proto = Object.getPrototypeOf(value);
52
+ if (proto !== Object.prototype && proto !== null) return value;
53
+ return Object.fromEntries(
54
+ Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, cloneQueryKeyPart(entry)])
55
+ ) as T;
56
+ }
57
+ return value;
58
+ }
59
+
46
60
  export interface UseListOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
47
61
  resource?: KnownResources;
48
62
  pagination?: Pagination;
@@ -71,15 +85,19 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
71
85
  const resource = getResource();
72
86
  const provider = getDataProviderForResource(resource, opts.dataProviderName);
73
87
  const queryOptions = opts.queryOptions;
88
+ const pagination = cloneQueryKeyPart(opts.pagination);
89
+ const sorters = cloneQueryKeyPart(opts.sorters);
90
+ const filters = cloneQueryKeyPart(opts.filters);
91
+ const meta = cloneQueryKeyPart(opts.meta);
74
92
 
75
93
  return {
76
- queryKey: [opts.dataProviderName, resource, 'list', opts.pagination, opts.sorters, opts.filters, opts.meta],
94
+ queryKey: [opts.dataProviderName, resource, 'list', pagination, sorters, filters, meta],
77
95
  queryFn: async () => provider.getList<TData>({
78
96
  resource,
79
- pagination: opts.pagination,
80
- sorters: opts.sorters,
81
- filters: opts.filters,
82
- meta: opts.meta,
97
+ pagination,
98
+ sorters,
99
+ filters,
100
+ meta,
83
101
  }),
84
102
  enabled: queryOptions?.enabled ?? true,
85
103
  staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,