Spaces:
Running
Running
| /** | |
| * 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); | |