You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
OneAuth/ui/vbase.js

309 lines
8.7 KiB
JavaScript

class VBase {
constructor(baseURL, scope) {
if (!baseURL) throw new Error('VBase: baseURL is required');
if (!scope) throw new Error('VBase: scope is required');
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;