|
|
|
|
|
/**
|
|
|
|
|
|
* VBase - Scoped RBAC 权限管理客户端
|
|
|
|
|
|
* 对应后端: github.com/veypi/vigo/contrib/auth.Auth 接口
|
|
|
|
|
|
*
|
|
|
|
|
|
* Token 基于 HttpOnly Cookie 管理,JS 不可读,防沙箱泄露。
|
|
|
|
|
|
* 非浏览器客户端仍可通过 Authorization header 传递 token。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// 权限等级常量 (与后端一致)
|
|
|
|
|
|
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.userKey = 'vbase_user';
|
|
|
|
|
|
this.users = users;
|
|
|
|
|
|
|
|
|
|
|
|
this._user = null;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cached = JSON.parse(localStorage.getItem(this.userKey));
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
this._user = cached;
|
|
|
|
|
|
this._cachePublicUser(cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
this._pendingUserIDs = new Set();
|
|
|
|
|
|
this._loadingUserIDs = new Set();
|
|
|
|
|
|
this._resolvedUserIDs = new Set();
|
|
|
|
|
|
this._pendingUserFlush = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 验证登录状态
|
|
|
|
|
|
this.fetchUser().catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== Getters / Setters ==========
|
|
|
|
|
|
|
|
|
|
|
|
get user() { return this._user; }
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
...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) {
|
|
|
|
|
|
const error = new Error(resData.message || 'API Error');
|
|
|
|
|
|
Object.assign(error, resData);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return resData.data || resData;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 认证 ==========
|
|
|
|
|
|
|
|
|
|
|
|
/** 用户名密码登录 */
|
|
|
|
|
|
async login(username, password) {
|
|
|
|
|
|
const data = await this.request('POST', '/api/auth/login', { username, password });
|
|
|
|
|
|
if (data.user) {
|
|
|
|
|
|
this.user = data.user;
|
|
|
|
|
|
await this.fetchUser();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** OAuth 回调 */
|
|
|
|
|
|
async oauthCallback(provider, code, state) {
|
|
|
|
|
|
const data = await this.request('GET', `/api/auth/callback/${provider}?code=${code}&state=${state}`);
|
|
|
|
|
|
if (data.user) {
|
|
|
|
|
|
this.user = data.user;
|
|
|
|
|
|
await this.fetchUser();
|
|
|
|
|
|
}
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 绑定已有账号 */
|
|
|
|
|
|
async bindAccount(tempToken, username, password) {
|
|
|
|
|
|
const data = await this.request('POST', '/api/auth/bind', {
|
|
|
|
|
|
temp_token: tempToken,
|
|
|
|
|
|
username,
|
|
|
|
|
|
password,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (data.user) {
|
|
|
|
|
|
this.user = data.user;
|
|
|
|
|
|
await this.fetchUser();
|
|
|
|
|
|
}
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 绑定并注册 */
|
|
|
|
|
|
async bindRegister(tempToken, username, email) {
|
|
|
|
|
|
const data = await this.request('POST', '/api/auth/bind-register', {
|
|
|
|
|
|
temp_token: tempToken,
|
|
|
|
|
|
username,
|
|
|
|
|
|
email,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (data.user) {
|
|
|
|
|
|
this.user = data.user;
|
|
|
|
|
|
await 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Token 刷新(后端自动处理,前端可主动调用) */
|
|
|
|
|
|
async refresh() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.request('POST', '/api/auth/refresh', {});
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 获取当前用户信息及权限 */
|
|
|
|
|
|
async fetchUser() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const user = await this.request('GET', '/api/auth/me');
|
|
|
|
|
|
this.user = user;
|
|
|
|
|
|
return user;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
this.clear();
|
|
|
|
|
|
throw e;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清除登录状态 */
|
|
|
|
|
|
clear() {
|
|
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 权限检查 ==========
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
PermCreate(code) { return this.Perm(code, Level.Create); }
|
|
|
|
|
|
PermRead(code) { return this.Perm(code, Level.Read); }
|
|
|
|
|
|
PermWrite(code) { return this.Perm(code, Level.Write); }
|
|
|
|
|
|
PermAdmin(code) { return this.Perm(code, Level.Admin); }
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 内部方法 ==========
|
|
|
|
|
|
|
|
|
|
|
|
_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 (permLevel === Level.Admin) {
|
|
|
|
|
|
if (permID === '*' || targetPermID.startsWith(permID + ':') || permID === targetPermID) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (permLevel >= requiredLevel) {
|
|
|
|
|
|
if (permID === targetPermID) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_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;
|