class VBase { constructor(scope, baseURL) { if (!scope) throw new Error('VBase: scope is required'); if (!baseURL) throw new Error('VBase: baseURL is required'); if (!baseURL) { baseURL = '' } if (baseURL === '' || baseURL === '/') { baseURL = window.location.origin } this.baseURL = baseURL; this.scope = scope; this.tokenKey = 'vbase_access_token'; this.refreshTokenKey = 'vbase_refresh_token'; this.userKey = 'vbase_user_info'; this.orgKey = 'vbase_current_org'; this._token = localStorage.getItem(this.tokenKey) || ''; this._refreshToken = localStorage.getItem(this.refreshTokenKey) || ''; this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null'); this._currentOrg = JSON.parse(localStorage.getItem(this.orgKey) || 'null'); } // Getters get token() { return this._token; } get refreshToken() { return this._refreshToken; } get user() { return this._user; } get currentOrg() { return this._currentOrg; } // Setters set token(val) { this._token = val; if (val) localStorage.setItem(this.tokenKey, val); else localStorage.removeItem(this.tokenKey); } set refreshToken(val) { this._refreshToken = val; if (val) localStorage.setItem(this.refreshTokenKey, val); else localStorage.removeItem(this.refreshTokenKey); } set user(val) { this._user = val; if (val) localStorage.setItem(this.userKey, JSON.stringify(val)); else localStorage.removeItem(this.userKey); } set currentOrg(val) { this._currentOrg = val; if (val) localStorage.setItem(this.orgKey, JSON.stringify(val)); else localStorage.removeItem(this.orgKey); } // API Helpers async request(method, path, data = null, headers = {}) { const url = `${this.baseURL}${path}`; const config = { method, headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders(), ...headers } }; if (data) config.body = JSON.stringify(data); const response = await fetch(url, config); const resData = await response.json(); if (!response.ok) { // Include resData in the error so caller can access the response body const error = new Error(resData.message || `Request failed: ${response.status}`); Object.assign(error, resData); throw error; } if (resData.code && resData.code !== 200) { throw new Error(resData.message || 'API Error'); } return resData.data || resData; } // Auth Actions async login(username, password) { try { const data = await this.request('POST', '/api/auth/login', { username, password }); if (data.access_token) { this.token = data.access_token; if (data.refresh_token) this.refreshToken = data.refresh_token; this.user = data.user; // Set user directly from login response return true; } return false; } catch (e) { throw e; } } async logout(redirect) { try { // Optional: Call server logout // await this.request('POST', '/api/auth/logout'); } catch (e) { console.warn('Logout API failed', e); } finally { this.clear(); if (redirect) { location.href = redirect; } else { location.reload(); } } } async refresh() { if (!this.refreshToken) throw new Error("No refresh token"); try { const data = await this.request('POST', '/api/auth/refresh', { refresh_token: this.refreshToken }); if (data.access_token) { this.token = data.access_token; if (data.refresh_token) this.refreshToken = data.refresh_token; return true; } return false; } catch (e) { this.logout(); throw e; } } async fetchUser() { const user = await this.request('GET', '/api/auth/me'); this.user = user; return user; } // Auth Headers getAuthHeaders() { const headers = {}; if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } if (this.currentOrg && this.currentOrg.id) { headers['X-Org-ID'] = this.currentOrg.id; } return headers; } // 检查是否有全局管理员权限 (*:* 或 scope:*:*) _isAdmin() { const perms = this.user?.permissions || []; for (const p of perms) { const permID = p.permission_id || p; const resourceID = p.resource_id || '*'; // 必须是全局权限 (resource_id 为 * 或空) 且是通配符权限 if (resourceID === '*' && (permID === '*:*' || permID === `${this.scope}:*:*`)) { return true; } } return false; } // Permission Check // 基础权限检查 (permissionID 格式: "resource:action") checkPerm(permissionID) { if (!this.user) return false; if (!permissionID) return true; // 全局管理员直接通过 if (this._isAdmin()) return true; const perms = this.user.permissions || []; for (const p of perms) { if (this._matchPermission(p.permission_id || p, permissionID)) { return true; } } return false; } // 检查对特定资源的权限 // permissionID: "resource:action" // resourceID: 资源实例ID checkPermOnResource(permissionID, resourceID) { if (!this.user) return false; if (!permissionID) return true; // 全局管理员直接通过 if (this._isAdmin()) return true; if (!resourceID) return this.checkPerm(permissionID); const perms = this.user.permissions || []; for (const p of perms) { const permID = p.permission_id || p; const permResourceID = p.resource_id || '*'; if (this._matchPermission(permID, permissionID)) { if (permResourceID === '*' || permResourceID === resourceID) { return true; } } } return false; } // 满足任一权限即可 checkPermAny(...permissionIDs) { for (const pid of permissionIDs) { if (this.checkPerm(pid)) return true; } return false; } // 满足所有权限 checkPermAll(...permissionIDs) { for (const pid of permissionIDs) { if (!this.checkPerm(pid)) return false; } return true; } // 内部方法: 权限匹配 // 支持通配符: *:*, resource:* // 后端权限可能是 "scope:resource:action" 或 "resource:action" _matchPermission(storedPerm, wantPerm) { if (storedPerm === wantPerm) return true; if (storedPerm === '*:*') return true; // 从后端存储的权限中提取 resource:action 部分 let havePerm = storedPerm; const haveParts = storedPerm.split(':'); if (haveParts.length === 3) { // scope:resource:action -> resource:action havePerm = `${haveParts[1]}:${haveParts[2]}`; } if (havePerm === wantPerm) return true; // 通配符匹配 (resource:* 匹配 resource:any_action) const [haveRes, haveAct] = havePerm.split(':'); const [wantRes, wantAct] = wantPerm.split(':'); if (haveRes === '*' || (haveRes === wantRes && (haveAct === '*' || haveAct === wantAct))) { return true; } return false; } hasRole(role) { if (!this.user) return false; // 全局管理员拥有所有角色 if (this._isAdmin()) return true; const userRoles = this.user.roles || []; return userRoles.includes(role); } // State Management clear() { this.token = ''; this.refreshToken = ''; this.user = null; this.currentOrg = null; } isExpired(token) { if (!token) token = this.token; if (!token) return true; try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const payload = JSON.parse(window.atob(base64)); const now = Math.floor(Date.now() / 1000); return payload.exp && payload.exp < now; } catch (e) { return true; } } wrapAxios(axiosInstance) { // Request Interceptor axiosInstance.interceptors.request.use(config => { const headers = this.getAuthHeaders(); for (const key in headers) { config.headers[key] = headers[key]; } return config; }, error => Promise.reject(error)); // Response Interceptor axiosInstance.interceptors.response.use(response => { const res = response.data; return res || response; }, async error => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { await this.refresh(); const headers = this.getAuthHeaders(); originalRequest.headers['Authorization'] = headers['Authorization']; return axiosInstance(originalRequest); } catch (e) { this.logout('/login?redirect=' + encodeURIComponent(window.location.pathname)); return Promise.reject(e); } } return Promise.reject(error?.response?.data || error); }); } } export default VBase;