/** * AuthorBot Auth Client — shared by admin + superadmin SPAs. * Stores access + refresh tokens in localStorage, httponly refresh cookie as backup. * Auto-refreshes on load and on 401 — survives page refresh and tab idle. */ (function (global) { 'use strict'; var FETCH_OPTS = { credentials: 'include' }; function decodeJwtPayload(token) { try { var part = token.split('.')[1]; if (!part) return null; var json = atob(part.replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(json); } catch (e) { return null; } } function isAccessTokenExpired(token, skewSeconds) { skewSeconds = skewSeconds || 60; if (!token) return true; var payload = decodeJwtPayload(token); if (!payload || !payload.exp) return true; return (payload.exp - skewSeconds) <= Date.now() / 1000; } function AuthClient(options) { this.apiBase = options.apiBase; this.accessKey = options.accessKey || 'access_token'; this.refreshKey = options.refreshKey || 'refresh_token'; this.slugKey = options.slugKey || null; this.onSessionExpired = options.onSessionExpired || null; this._refreshing = null; var self = this; if (typeof window !== 'undefined') { window.addEventListener('storage', function (e) { if (e.key === self.accessKey || e.key === self.refreshKey) { self._refreshing = null; } }); } } AuthClient.prototype.getAccessToken = function () { return localStorage.getItem(this.accessKey); }; AuthClient.prototype.getRefreshToken = function () { return localStorage.getItem(this.refreshKey); }; AuthClient.prototype.getAuthorSlug = function () { return this.slugKey ? localStorage.getItem(this.slugKey) : null; }; AuthClient.prototype.saveSession = function (data) { if (data.access_token) localStorage.setItem(this.accessKey, data.access_token); if (data.refresh_token) localStorage.setItem(this.refreshKey, data.refresh_token); if (this.slugKey && data.author_slug) localStorage.setItem(this.slugKey, data.author_slug); }; AuthClient.prototype.clearSession = function () { localStorage.removeItem(this.accessKey); localStorage.removeItem(this.refreshKey); if (this.slugKey) localStorage.removeItem(this.slugKey); }; AuthClient.prototype._notifySessionExpired = function () { if (typeof this.onSessionExpired === 'function') { try { this.onSessionExpired(); } catch (e) { /* ignore */ } } }; AuthClient.prototype._hasValidAccessInStorage = function () { return !!this.getAccessToken() && !isAccessTokenExpired(this.getAccessToken()); }; AuthClient.prototype._sessionGone = function () { this.clearSession(); this._notifySessionExpired(); throw new Error('Session expired — please sign in again'); }; AuthClient.prototype._transientAuthFailure = function () { if (this.getRefreshToken()) { throw new Error('Cannot reach server. Your session is still active — try again shortly.'); } return this._sessionGone(); }; AuthClient.prototype.hasSession = function () { return !!(this.getAccessToken() || this.getRefreshToken()); }; AuthClient.prototype.refreshAccessToken = function (opts) { var self = this; opts = opts || {}; if (this._refreshing) return this._refreshing; var refresh = opts.refreshToken || this.getRefreshToken(); if (!refresh) return Promise.resolve(false); var access = this.getAccessToken(); var headers = { 'Content-Type': 'application/json' }; if (access) headers.Authorization = 'Bearer ' + access; var body = JSON.stringify({ refresh_token: refresh }); this._refreshing = fetch(this.apiBase + '/auth/refresh', Object.assign({}, FETCH_OPTS, { method: 'POST', headers: headers, body: body, })) .then(function (res) { if (res.status === 401 || res.status === 403) { if (!opts.storageRetry) { var latestRefresh = localStorage.getItem(self.refreshKey); var latestAccess = localStorage.getItem(self.accessKey); if (latestRefresh && latestRefresh !== refresh) { return self.refreshAccessToken({ refreshToken: latestRefresh, storageRetry: true }); } if (latestAccess && latestAccess !== access && !isAccessTokenExpired(latestAccess)) { return true; } } self.clearSession(); return false; } if (!res.ok) return false; return res.json(); }) .then(function (data) { if (!data || !data.access_token) return false; self.saveSession(data); return true; }) .catch(function () { // Network error — do NOT clear session. User may just be offline. return false; }) .finally(function () { self._refreshing = null; }); return this._refreshing; }; AuthClient.prototype.ensureValidAccessToken = function () { var self = this; var access = this.getAccessToken(); var refresh = this.getRefreshToken(); if (access && !isAccessTokenExpired(access)) { return Promise.resolve(true); } if (!refresh) { return Promise.resolve(false); } return this.refreshAccessToken(); }; AuthClient.prototype.restoreSession = function (validateFn) { var self = this; if (!this.hasSession()) { return Promise.resolve(false); } function loadProfile() { return self.get('/auth/me').then(function (user) { if (self.slugKey && user && user.id) { localStorage.setItem(self.slugKey, user.id); } if (validateFn && !validateFn(user)) { return false; } return true; }); } return this.ensureValidAccessToken() .then(function (ok) { if (!ok) { // Token refresh returned false — but only clear if refresh token itself is gone // or server explicitly rejected it (401). Transient failures return false too, // so we check: if we still have a refresh token, keep session and let user retry. if (!self.getRefreshToken()) { self.clearSession(); return false; } // Refresh token exists but refresh failed (server cold-starting?) — keep session. // The access token may still be valid; try loading profile anyway. var access = self.getAccessToken(); if (!access) return false; return loadProfile(); } return loadProfile(); }) .catch(function (err) { // Network error / server down — DON'T clear session. // Tokens are still valid; user just can't reach the server right now. var msg = (err && err.message) || ''; var isAuthError = msg.indexOf('Session expired') !== -1 || msg.indexOf('401') !== -1 || msg.indexOf('403') !== -1; if (isAuthError) { // Explicit auth rejection — clear session self.clearSession(); return false; } // Transient error (502, network, timeout, cold start) — keep session alive. console.warn('restoreSession: transient error, keeping session:', msg); return true; }); }; AuthClient.prototype.request = function (method, path, body, retried) { var self = this; return this.ensureValidAccessToken().then(function (ok) { if (!ok) { if (self._hasValidAccessInStorage()) { return; } if (!self.getRefreshToken()) { return self._sessionGone(); } return self._transientAuthFailure(); } var token = self.getAccessToken(); var headers = { Authorization: 'Bearer ' + token }; var opts = Object.assign({}, FETCH_OPTS, { method: method, headers: headers }); if (body !== undefined && body !== null) { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } return fetch(self.apiBase + path, opts) .catch(function () { throw new Error('Cannot reach server. Check your connection and try again.'); }) .then(function (res) { if (res.status === 401 && !retried) { return self.refreshAccessToken().then(function (refreshed) { if (!refreshed) { if (self._hasValidAccessInStorage()) { return self.request(method, path, body, true); } if (!self.getRefreshToken()) { return self._sessionGone(); } return self._transientAuthFailure(); } return self.request(method, path, body, true); }); } return res; }); }); }; AuthClient.prototype._parse = function (res) { return res.text().then(function (text) { var data = {}; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { detail: text }; } if (!res.ok) { var detail = data.detail; if (Array.isArray(detail)) detail = detail.map(function (e) { return e.msg || JSON.stringify(e); }).join(', '); throw new Error(detail || ('HTTP ' + res.status)); } return data; }); }; AuthClient.prototype.get = function (path) { return this.request('GET', path).then(this._parse.bind(this)); }; AuthClient.prototype.post = function (path, body) { return this.request('POST', path, body).then(this._parse.bind(this)); }; AuthClient.prototype.put = function (path, body) { return this.request('PUT', path, body).then(this._parse.bind(this)); }; AuthClient.prototype.patch = function (path, body) { return this.request('PATCH', path, body).then(this._parse.bind(this)); }; AuthClient.prototype.delete = function (path) { return this.request('DELETE', path).then(this._parse.bind(this)); }; AuthClient.prototype.upload = function (path, formData, retried) { var self = this; return this.ensureValidAccessToken().then(function (ok) { if (!ok) { if (self._hasValidAccessInStorage()) { return; } if (!self.getRefreshToken()) { return self._sessionGone(); } return self._transientAuthFailure(); } var token = self.getAccessToken(); return fetch(self.apiBase + path, Object.assign({}, FETCH_OPTS, { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: formData, })) .catch(function () { throw new Error('Cannot reach server. Check your connection and try again.'); }) .then(function (res) { if (res.status === 401 && !retried) { return self.refreshAccessToken().then(function (refreshed) { if (!refreshed) { if (self._hasValidAccessInStorage()) { return self.upload(path, formData, true); } if (!self.getRefreshToken()) { return self._sessionGone(); } return self._transientAuthFailure(); } return self.upload(path, formData, true); }); } return res; }); }); }; AuthClient.prototype.download = function (path, retried) { var self = this; return this.ensureValidAccessToken().then(function (ok) { if (!ok) { if (self._hasValidAccessInStorage()) { return; } if (!self.getRefreshToken()) { return self._sessionGone(); } return self._transientAuthFailure(); } var token = self.getAccessToken(); return fetch(self.apiBase + path, Object.assign({}, FETCH_OPTS, { headers: { Authorization: 'Bearer ' + token }, })) .catch(function () { throw new Error('Cannot reach server. Check your connection and try again.'); }) .then(function (res) { if (res.status === 401 && !retried) { return self.refreshAccessToken().then(function (refreshed) { if (!refreshed) { if (self._hasValidAccessInStorage()) { return self.download(path, true); } if (!self.getRefreshToken()) { return self._sessionGone(); } return self._transientAuthFailure(); } return self.download(path, true); }); } return res; }); }); }; AuthClient.prototype.login = function (email, password) { var self = this; return fetch(this.apiBase + '/auth/login', Object.assign({}, FETCH_OPTS, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }), })) .then(function (res) { return res.json().then(function (data) { return { res: res, data: data }; }); }) .then(function (_ref) { var res = _ref.res, data = _ref.data; if (!res.ok) { var detail = data.detail; if (Array.isArray(detail)) detail = detail.map(function (e) { return e.msg || JSON.stringify(e); }).join(', '); throw new Error(detail || 'Login failed'); } self.saveSession(data); return data; }); }; AuthClient.prototype.logout = function () { var self = this; return this.post('/auth/logout').catch(function () {}).finally(function () { self.clearSession(); }); }; global.AuthorBotAuthClient = AuthClient; })(window);