/** * VBase - Scoped RBAC 权限管理客户端 * 对应后端: github.com/veypi/vigo/contrib/auth.Auth 接口 */ // 权限等级常量 (与后端一致) export const Level = { None: 0, Create: 1, // 001 创建 (奇数层) Read: 2, // 010 读取 (偶数层) Write: 4, // 100 写入 (偶数层) ReadWrite: 6, // 110 读写 (偶数层) Admin: 7, // 111 管理员 (完全控制) }; class VBase { constructor(scope, baseURL, login_page, users = {}) { if (!scope) throw new Error('VBase: scope is required'); if (!baseURL) baseURL = window.location.origin; if (baseURL === '' || baseURL === '/') baseURL = window.location.origin; this.baseURL = baseURL; this.scope = scope; this.login_page = login_page || (baseURL + '/login') this.tokenKey = `token`; this.refreshTokenKey = `refresh_token`; this.userKey = `vbase_user`; this.users = users; this._token = localStorage.getItem(this.tokenKey) || ''; this._refreshToken = localStorage.getItem(this.refreshTokenKey) || ''; this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null'); this._pendingUserIDs = new Set(); this._loadingUserIDs = new Set(); this._resolvedUserIDs = new Set(); this._pendingUserFlush = null; this._cachePublicUser(this._user); if (this._token) { this.fetchUser() } } // ========== Getters ========== get token() { return this._token; } get refreshToken() { return this._refreshToken; } get user() { return this._user; } // ========== 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); this._cachePublicUser(val); } // ========== API 请求 ========== 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) { 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; } // ========== 认证相关 ========== async login(username, password) { 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 this.fetchUser() return true; } return false; } /** * OAuth Callback Handler * @param {string} provider * @param {string} code * @param {string} state * @returns {Promise} Response data */ async oauthCallback(provider, code, state) { const data = await this.request('GET', `/api/auth/callback/${provider}?code=${code}&state=${state}`); // If login success directly if (data.access_token || data.token) { this.token = data.access_token || data.token; if (data.refresh_token) this.refreshToken = data.refresh_token; if (data.user) this.user = data.user; this.fetchUser() } return data; } /** * Bind existing account * @param {string} tempToken * @param {string} username * @param {string} password * @returns {Promise} */ async bindAccount(tempToken, username, password) { const data = await this.request('POST', '/api/auth/bind', { temp_token: tempToken, username, password }); if (data.access_token || data.token) { this.token = data.access_token || data.token; if (data.refresh_token) this.refreshToken = data.refresh_token; this.fetchUser() } return data; } /** * Register and bind new account * @param {string} tempToken * @param {string} username * @param {string} email * @returns {Promise} */ async bindRegister(tempToken, username, email) { const data = await this.request('POST', '/api/auth/bind-register', { temp_token: tempToken, username, email }); if (data.access_token || data.token) { this.token = data.access_token || data.token; if (data.refresh_token) this.refreshToken = data.refresh_token; if (data.user) this.user = data.user; this.fetchUser() } return data; } async logout(redirect) { try { await this.request('POST', '/api/auth/logout'); } catch (e) { console.warn('Logout API failed', e); } finally { this.clear(); const redirectUrl = redirect || window.location.pathname + window.location.search; location.href = this.login_page + '?redirect=' + encodeURIComponent(redirectUrl); } } async refresh() { if (!this.refreshToken) return false; 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) { console.warn(e) return false } } async fetchUser() { const user = await this.request('GET', '/api/auth/me').catch(e => { this.clear() }); this.user = user; return user; } getAuthHeaders() { const headers = {}; if (this.token) headers['Authorization'] = `Bearer ${this.token}`; return headers; } clear() { this.token = ''; this.refreshToken = ''; this.user = null; for (const id of Object.keys(this.users)) { delete this.users[id]; } this._pendingUserIDs.clear(); this._loadingUserIDs.clear(); this._resolvedUserIDs.clear(); this._pendingUserFlush = null; } User(id) { if (!id) return {}; if (!this.users[id]) { this.users[id] = {}; } if (!this._resolvedUserIDs.has(id) && !this._loadingUserIDs.has(id)) { this._pendingUserIDs.add(id); if (!this._pendingUserFlush) { this._pendingUserFlush = Promise.resolve().then(() => this._flushUserRequests()); } } return this.users[id]; } 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; } } // ========== 权限检查 (与后端接口一致) ========== /** * 检查权限 * @param {string} code - 权限码,如 "resource:instance" * @param {number} level - 需要的权限等级 * @returns {boolean} */ Perm(code, level = Level.Read) { if (!this.user) return false; if (!code) return true; const perms = this.user.permissions || []; return this._checkPermissionLevel(perms, code, level); } /** * 检查创建权限 (level 1,检查奇数层) * @param {string} code - 权限码 * @returns {boolean} */ PermCreate(code) { return this.Perm(code, Level.Create); } /** * 检查读取权限 (level 2,检查偶数层) * @param {string} code - 权限码 * @returns {boolean} */ PermRead(code) { return this.Perm(code, Level.Read); } /** * 检查更新权限 (level 4,检查偶数层) * @param {string} code - 权限码 * @returns {boolean} */ PermWrite(code) { return this.Perm(code, Level.Write); } /** * 检查管理员权限 (level 7,检查偶数层) * @param {string} code - 权限码 * @returns {boolean} */ PermAdmin(code) { return this.Perm(code, Level.Admin); } // ========== 内部方法 ========== /** * 核心权限检查逻辑 (与后端 checkPermissionLevel 一致) */ _checkPermissionLevel(perms, targetPermID, requiredLevel) { for (const p of perms) { const permID = p.permission_id || p; const permLevel = p.level !== undefined ? p.level : Level.Read; // 1. 管理员特权 (Level 7 且是父级或同级) if (permLevel === Level.Admin) { if (permID === '*' || targetPermID.startsWith(permID + ':') || permID === targetPermID) { return true; } } // 2. 普通权限匹配 if (permLevel >= requiredLevel) { if (permID === targetPermID) { return true; } } } return false; } // ========== Axios 集成 ========== wrapAxios(axiosInstance) { // 请求拦截器 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)); // 响应拦截器 axiosInstance.interceptors.response.use( response => 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(); return Promise.reject(e); } } return Promise.reject(error); } ); } wrapFetch(urlprefix) { const originalFetch = window.fetch; const self = this; return async function wrappedFetch(input, init = {}) { let url; if (typeof input === 'string') { url = input; } else if (input instanceof Request) { url = input.url; } else { url = String(input); } if (url.startsWith('@')) { url = url.slice(1) } else if (urlprefix && !url.startsWith('http://') && !url.startsWith('https://')) { url = urlprefix + url; } const headers = { ...init.headers }; const authHeaders = self.getAuthHeaders(); for (const [key, value] of Object.entries(authHeaders)) { if (!headers[key]) { headers[key] = value; } } return originalFetch(url, { ...init, headers }); }; } _cachePublicUser(user) { if (!user?.id) return; const cached = { ...user, name: user.name || user.nickname || user.username || '', icon: user.icon || user.avatar || '', avatar: user.avatar || user.icon || '', }; if (!this.users[cached.id]) { this.users[cached.id] = {}; } Object.assign(this.users[cached.id], cached); this._resolvedUserIDs.add(cached.id); this._loadingUserIDs.delete(cached.id); } async _flushUserRequests() { const ids = [...this._pendingUserIDs].filter(Boolean); this._pendingUserIDs.clear(); this._pendingUserFlush = null; if (ids.length === 0) return; for (const id of ids) { this._loadingUserIDs.add(id); } try { const res = await this.request('POST', '/api/auth/users', { ids }); const items = Array.isArray(res?.items) ? res.items : []; const found = new Set(); for (const item of items) { this._cachePublicUser(item); found.add(item.id); } for (const id of ids) { this._loadingUserIDs.delete(id); if (!found.has(id)) { this._resolvedUserIDs.add(id); } } } catch (error) { for (const id of ids) { this._loadingUserIDs.delete(id); } console.warn('VBase: batch fetch users failed', error); } } } export default VBase;