|
|
|
|
@ -1,32 +1,41 @@
|
|
|
|
|
/**
|
|
|
|
|
* 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) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
if (!baseURL) baseURL = window.location.origin;
|
|
|
|
|
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.tokenKey = `vbase_token`;
|
|
|
|
|
this.refreshTokenKey = `vbase_refresh_token`;
|
|
|
|
|
this.userKey = `vbase_user`;
|
|
|
|
|
|
|
|
|
|
this._token = localStorage.getItem(this.tokenKey) || '';
|
|
|
|
|
this._refreshToken = localStorage.getItem(this.refreshTokenKey) || '';
|
|
|
|
|
this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Getters
|
|
|
|
|
// ========== Getters ==========
|
|
|
|
|
get token() { return this._token; }
|
|
|
|
|
get refreshToken() { return this._refreshToken; }
|
|
|
|
|
get user() { return this._user; }
|
|
|
|
|
|
|
|
|
|
// Setters
|
|
|
|
|
// ========== Setters ==========
|
|
|
|
|
set token(val) {
|
|
|
|
|
this._token = val;
|
|
|
|
|
if (val) localStorage.setItem(this.tokenKey, val);
|
|
|
|
|
@ -45,7 +54,7 @@ class VBase {
|
|
|
|
|
else localStorage.removeItem(this.userKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API Helpers
|
|
|
|
|
// ========== API 请求 ==========
|
|
|
|
|
async request(method, path, data = null, headers = {}) {
|
|
|
|
|
const url = `${this.baseURL}${path}`;
|
|
|
|
|
const config = {
|
|
|
|
|
@ -62,7 +71,6 @@ class VBase {
|
|
|
|
|
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;
|
|
|
|
|
@ -75,35 +83,27 @@ class VBase {
|
|
|
|
|
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
|
|
|
|
|
this.user = data.user;
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
if (redirect) location.href = redirect;
|
|
|
|
|
else location.reload();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -129,145 +129,116 @@ class VBase {
|
|
|
|
|
return user;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auth Headers
|
|
|
|
|
getAuthHeaders() {
|
|
|
|
|
const headers = {};
|
|
|
|
|
if (this.token) {
|
|
|
|
|
headers['Authorization'] = `Bearer ${this.token}`;
|
|
|
|
|
}
|
|
|
|
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
|
|
|
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;
|
|
|
|
|
clear() {
|
|
|
|
|
this.token = '';
|
|
|
|
|
this.refreshToken = '';
|
|
|
|
|
this.user = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 检查对特定资源的权限
|
|
|
|
|
// permissionID: "resource:action"
|
|
|
|
|
// resourceID: 资源实例ID
|
|
|
|
|
checkPermOnResource(permissionID, resourceID) {
|
|
|
|
|
// ========== 权限检查 (与后端接口一致) ==========
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 检查权限
|
|
|
|
|
* @param {string} code - 权限码,如 "resource:instance"
|
|
|
|
|
* @param {number} level - 需要的权限等级
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
Perm(code, level = Level.Read) {
|
|
|
|
|
if (!this.user) return false;
|
|
|
|
|
if (!permissionID) return true;
|
|
|
|
|
// 全局管理员直接通过
|
|
|
|
|
if (this._isAdmin()) return true;
|
|
|
|
|
if (!resourceID) return this.checkPerm(permissionID);
|
|
|
|
|
if (!code) return true;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
return this._checkPermissionLevel(perms, code, level);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 满足任一权限即可
|
|
|
|
|
checkPermAny(...permissionIDs) {
|
|
|
|
|
for (const pid of permissionIDs) {
|
|
|
|
|
if (this.checkPerm(pid)) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
/**
|
|
|
|
|
* 检查创建权限 (level 1,检查奇数层)
|
|
|
|
|
* @param {string} code - 权限码
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
PermCreate(code) {
|
|
|
|
|
return this.Perm(code, Level.Create);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 满足所有权限
|
|
|
|
|
checkPermAll(...permissionIDs) {
|
|
|
|
|
for (const pid of permissionIDs) {
|
|
|
|
|
if (!this.checkPerm(pid)) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
/**
|
|
|
|
|
* 检查读取权限 (level 2,检查偶数层)
|
|
|
|
|
* @param {string} code - 权限码
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
PermRead(code) {
|
|
|
|
|
return this.Perm(code, Level.Read);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 内部方法: 权限匹配
|
|
|
|
|
// 支持通配符: *:*, resource:*
|
|
|
|
|
// 后端权限可能是 "scope:resource:action" 或 "resource:action"
|
|
|
|
|
_matchPermission(storedPerm, wantPerm) {
|
|
|
|
|
if (storedPerm === wantPerm) return true;
|
|
|
|
|
if (storedPerm === '*:*') return true;
|
|
|
|
|
/**
|
|
|
|
|
* 检查更新权限 (level 4,检查偶数层)
|
|
|
|
|
* @param {string} code - 权限码
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
PermWrite(code) {
|
|
|
|
|
return this.Perm(code, Level.Write);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 从后端存储的权限中提取 resource:action 部分
|
|
|
|
|
let havePerm = storedPerm;
|
|
|
|
|
const haveParts = storedPerm.split(':');
|
|
|
|
|
if (haveParts.length === 3) {
|
|
|
|
|
// scope:resource:action -> resource:action
|
|
|
|
|
havePerm = `${haveParts[1]}:${haveParts[2]}`;
|
|
|
|
|
/**
|
|
|
|
|
* 检查管理员权限 (level 7,检查偶数层)
|
|
|
|
|
* @param {string} code - 权限码
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
PermAdmin(code) {
|
|
|
|
|
return this.Perm(code, Level.Admin);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (havePerm === wantPerm) return true;
|
|
|
|
|
// ========== 内部方法 ==========
|
|
|
|
|
|
|
|
|
|
// 通配符匹配 (resource:* 匹配 resource:any_action)
|
|
|
|
|
const [haveRes, haveAct] = havePerm.split(':');
|
|
|
|
|
const [wantRes, wantAct] = wantPerm.split(':');
|
|
|
|
|
/**
|
|
|
|
|
* 核心权限检查逻辑 (与后端 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;
|
|
|
|
|
|
|
|
|
|
if (haveRes === '*' || (haveRes === wantRes && (haveAct === '*' || haveAct === wantAct))) {
|
|
|
|
|
// 1. 管理员特权 (Level 7 且是父级或同级)
|
|
|
|
|
if (permLevel === Level.Admin) {
|
|
|
|
|
if (permID === '*' || targetPermID.startsWith(permID + ':') || permID === targetPermID) {
|
|
|
|
|
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);
|
|
|
|
|
// 2. 普通权限匹配
|
|
|
|
|
if (permLevel >= requiredLevel) {
|
|
|
|
|
if (permID === targetPermID) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========== Axios 集成 ==========
|
|
|
|
|
|
|
|
|
|
wrapAxios(axiosInstance) {
|
|
|
|
|
// Request Interceptor
|
|
|
|
|
// 请求拦截器
|
|
|
|
|
axiosInstance.interceptors.request.use(config => {
|
|
|
|
|
const headers = this.getAuthHeaders();
|
|
|
|
|
for (const key in headers) {
|
|
|
|
|
@ -276,11 +247,10 @@ class VBase {
|
|
|
|
|
return config;
|
|
|
|
|
}, error => Promise.reject(error));
|
|
|
|
|
|
|
|
|
|
// Response Interceptor
|
|
|
|
|
axiosInstance.interceptors.response.use(response => {
|
|
|
|
|
const res = response.data;
|
|
|
|
|
return res || response;
|
|
|
|
|
}, async error => {
|
|
|
|
|
// 响应拦截器
|
|
|
|
|
axiosInstance.interceptors.response.use(
|
|
|
|
|
response => response.data || response,
|
|
|
|
|
async error => {
|
|
|
|
|
const originalRequest = error.config;
|
|
|
|
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
|
|
|
originalRequest._retry = true;
|
|
|
|
|
@ -295,7 +265,8 @@ class VBase {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Promise.reject(error?.response?.data || error);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|