Chat surface package (packages/chat/) with conversation list, message thread, compose bar, participant sidebar, create conversation dialog, typing indicators, read receipts, message editing, and message deletion. Built on chat-core library with realtime event subscriptions. Fix stale token login deadlock: REST client auth endpoints excluded from 401 retry loop, 8s defensive boot timeout on auth.boot(). Fix bundled package permissions: auto-grant with NULL granted_by and set status active on install. Switch docker-compose to named volume for remote Docker host compat. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
JavaScript
280 lines
10 KiB
JavaScript
// ==========================================
|
|
// Switchboard Core — 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 _booting = false;
|
|
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);
|
|
sessionStorage.removeItem('sw-chat-active');
|
|
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) {
|
|
// No tokens in localStorage — clear any stale sb_token cookie
|
|
// so Go SSR middleware doesn't trust a leftover cookie.
|
|
document.cookie = 'sb_token=; path=/; max-age=0';
|
|
return;
|
|
}
|
|
|
|
// Unknown token age — schedule refresh soon
|
|
_scheduleRefresh(60);
|
|
|
|
// Suppress _on401Failure redirect during boot — boot handles 401 itself.
|
|
_booting = true;
|
|
|
|
// Defensive timeout: boot MUST complete within 8s.
|
|
// If stale tokens cause a network hang or unexpected deadlock,
|
|
// clear tokens and let the login page render.
|
|
let timedOut = false;
|
|
const bootTimeout = setTimeout(() => {
|
|
timedOut = true;
|
|
console.warn('[sw.auth] Boot timed out — clearing stale tokens');
|
|
_clearTokens();
|
|
_booting = false;
|
|
}, 8000);
|
|
|
|
// Fetch permissions (validates the access token)
|
|
try {
|
|
await _fetchPermissions();
|
|
} catch (e) {
|
|
if (timedOut) return;
|
|
if (e.status === 401) {
|
|
// Token expired — try refresh
|
|
const ok = await auth.refresh();
|
|
if (timedOut) return;
|
|
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);
|
|
}
|
|
} finally {
|
|
clearTimeout(bootTimeout);
|
|
_booting = false;
|
|
}
|
|
|
|
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();
|
|
// During boot(), auth.boot() handles 401 gracefully — don't redirect.
|
|
if (_booting) return;
|
|
// Already on login page — don't redirect (prevents blank-page reload loop
|
|
// when stale localStorage tokens trigger 401 during SDK boot on /login).
|
|
const base = window.__BASE__ || '';
|
|
const loginPath = base + '/login';
|
|
const here = window.location.pathname.replace(/\/+$/, '');
|
|
if (here === loginPath) return;
|
|
window.location.href = loginPath;
|
|
},
|
|
|
|
_setAuth(data) { _setAuth(data); },
|
|
_setRestClient(client) { _restClient = client; },
|
|
_setEmit(fn) { _emit = fn; },
|
|
};
|
|
|
|
return auth;
|
|
}
|