Changeset 0.37.3 (#215)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
245
src/js/sw/sdk/auth.js
Normal file
245
src/js/sw/sdk/auth.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: Auth Module
|
||||
// ==========================================
|
||||
// Owns token storage, login/logout/refresh lifecycle,
|
||||
// and the permissions/teams/policies cache.
|
||||
//
|
||||
// Factory: createAuth()
|
||||
// Wire after creation: _setRestClient(client), _setEmit(fn)
|
||||
// ==========================================
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
|
||||
|
||||
/**
|
||||
* Create the auth module.
|
||||
* Must call _setRestClient() and _setEmit() before use.
|
||||
*
|
||||
* @returns {object} auth
|
||||
*/
|
||||
export function createAuth() {
|
||||
|
||||
// ── Private state ───────────────────────────
|
||||
|
||||
let _accessToken = null;
|
||||
let _refreshToken = null;
|
||||
let _user = null;
|
||||
let _permissions = new Set();
|
||||
let _teams = [];
|
||||
let _groups = [];
|
||||
let _policies = {};
|
||||
let _refreshTimer = null;
|
||||
let _refreshPromise = null;
|
||||
let _restClient = null;
|
||||
let _emit = () => {};
|
||||
|
||||
// ── Token persistence ───────────────────────
|
||||
|
||||
function _saveTokens() {
|
||||
localStorage.setItem(_storageKey, JSON.stringify({
|
||||
accessToken: _accessToken,
|
||||
refreshToken: _refreshToken,
|
||||
user: _user,
|
||||
}));
|
||||
// Cookie sync for Go template page auth
|
||||
if (_accessToken) {
|
||||
document.cookie = `sb_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`;
|
||||
} else {
|
||||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||||
}
|
||||
}
|
||||
|
||||
function _loadTokens() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
|
||||
if (saved) {
|
||||
_accessToken = saved.accessToken || null;
|
||||
_refreshToken = saved.refreshToken || null;
|
||||
_user = saved.user || null;
|
||||
}
|
||||
} catch (_) { /* corrupt storage */ }
|
||||
}
|
||||
|
||||
function _clearTokens() {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_user = null;
|
||||
_permissions = new Set();
|
||||
_teams = [];
|
||||
_groups = [];
|
||||
_policies = {};
|
||||
localStorage.removeItem(_storageKey);
|
||||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||||
if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
|
||||
}
|
||||
|
||||
// ── Token lifecycle ─────────────────────────
|
||||
|
||||
function _setAuth(data) {
|
||||
_accessToken = data.access_token;
|
||||
_refreshToken = data.refresh_token;
|
||||
_user = data.user;
|
||||
_saveTokens();
|
||||
_scheduleRefresh(data.expires_in || 900);
|
||||
}
|
||||
|
||||
function _scheduleRefresh(expiresIn) {
|
||||
if (_refreshTimer) clearTimeout(_refreshTimer);
|
||||
// Refresh at 80% of expiry (e.g., 12min for 15min token)
|
||||
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
|
||||
_refreshTimer = setTimeout(async () => {
|
||||
if (!_refreshToken) return;
|
||||
console.debug('[sw.auth] Proactive token refresh');
|
||||
try {
|
||||
const data = await _restClient.post('/api/v1/auth/refresh', {
|
||||
refresh_token: _refreshToken,
|
||||
});
|
||||
_setAuth(data);
|
||||
_emit('auth.refresh', {}, { localOnly: true });
|
||||
} catch (e) {
|
||||
console.warn('[sw.auth] Proactive refresh failed, token still valid');
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
|
||||
// ── Permissions fetch ───────────────────────
|
||||
|
||||
async function _fetchPermissions() {
|
||||
try {
|
||||
const data = await _restClient.get('/api/v1/profile/permissions');
|
||||
_permissions = new Set(data.permissions || []);
|
||||
_teams = Object.freeze(data.teams || []);
|
||||
_groups = Object.freeze(data.groups || []);
|
||||
_policies = Object.freeze(data.policies || {});
|
||||
_emit('auth.permissions.changed', {}, { localOnly: true });
|
||||
} catch (e) {
|
||||
console.warn('[sw.auth] Failed to fetch permissions:', e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────
|
||||
|
||||
const auth = {
|
||||
// ── Getters ─────────────────────────────
|
||||
|
||||
get isAuthenticated() { return !!_accessToken; },
|
||||
|
||||
get user() {
|
||||
if (!_user) return null;
|
||||
return Object.freeze({
|
||||
id: _user.id,
|
||||
username: _user.username,
|
||||
display_name: _user.display_name || _user.username,
|
||||
email: _user.email || null,
|
||||
role: _user.role || 'user',
|
||||
avatar: _user.avatar_url || _user.avatar || null,
|
||||
});
|
||||
},
|
||||
|
||||
get permissions() { return _permissions; },
|
||||
get teams() { return _teams; },
|
||||
get groups() { return _groups; },
|
||||
get policies() { return _policies; },
|
||||
|
||||
// ── Lifecycle ───────────────────────────
|
||||
|
||||
/**
|
||||
* Login with credentials. Stores tokens, fetches permissions, emits event.
|
||||
*/
|
||||
async login(login, password) {
|
||||
const data = await _restClient.post('/api/v1/auth/login', { login, password });
|
||||
_setAuth(data);
|
||||
await _fetchPermissions();
|
||||
_emit('auth.login', { user: auth.user }, { localOnly: true });
|
||||
return auth.user;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout. Clears tokens, emits event. Server call is best-effort.
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
await _restClient.post('/api/v1/auth/logout', { refresh_token: _refreshToken });
|
||||
} catch (_) { /* best effort */ }
|
||||
_clearTokens();
|
||||
_emit('auth.logout', {}, { localOnly: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Manual token refresh. Returns true on success, false on failure.
|
||||
*/
|
||||
async refresh() {
|
||||
if (_refreshPromise) return _refreshPromise;
|
||||
_refreshPromise = (async () => {
|
||||
try {
|
||||
const data = await _restClient.post('/api/v1/auth/refresh', {
|
||||
refresh_token: _refreshToken,
|
||||
});
|
||||
_setAuth(data);
|
||||
return true;
|
||||
} catch (_) {
|
||||
_clearTokens();
|
||||
return false;
|
||||
} finally {
|
||||
_refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return _refreshPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Boot sequence. Called by index.js on startup.
|
||||
* Loads tokens from localStorage, validates, fetches permissions.
|
||||
*/
|
||||
async boot() {
|
||||
_loadTokens();
|
||||
if (!_refreshToken) return;
|
||||
|
||||
// Unknown token age — schedule refresh soon
|
||||
_scheduleRefresh(60);
|
||||
|
||||
// Fetch permissions (validates the access token)
|
||||
try {
|
||||
await _fetchPermissions();
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
// Token expired — try refresh
|
||||
const ok = await auth.refresh();
|
||||
if (ok) {
|
||||
try { await _fetchPermissions(); }
|
||||
catch (_) { _clearTokens(); }
|
||||
}
|
||||
// If refresh failed, _clearTokens already called
|
||||
} else {
|
||||
// Network error or other — keep tokens, user may be offline
|
||||
console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (_accessToken) {
|
||||
_emit('auth.boot', { user: auth.user }, { localOnly: true });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Internal (called by rest-client / index.js) ──
|
||||
|
||||
_getToken() { return _accessToken; },
|
||||
|
||||
async _onRefresh() {
|
||||
if (_refreshToken) return auth.refresh();
|
||||
return false;
|
||||
},
|
||||
|
||||
_on401Failure() {
|
||||
_clearTokens();
|
||||
const base = window.__BASE__ || '';
|
||||
window.location.href = base + '/login';
|
||||
},
|
||||
|
||||
_setRestClient(client) { _restClient = client; },
|
||||
_setEmit(fn) { _emit = fn; },
|
||||
};
|
||||
|
||||
return auth;
|
||||
}
|
||||
Reference in New Issue
Block a user