AuthorBot Cursor commited on
Commit
ccbebe0
Β·
1 Parent(s): ba51f8f

Fix logout on page refresh by restoring sessions via refresh token and cookies.

Browse files
app/admin/templates/admin.html CHANGED
@@ -881,7 +881,9 @@ document.getElementById('login-btn').onclick = async () => {
881
 
882
  ['email', 'password'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-btn').click(); }));
883
 
884
- document.getElementById('logout-btn').onclick = () => { auth.clearSession(); location.reload(); };
 
 
885
 
886
  function showDashboard() {
887
  document.getElementById('auth-screen').style.display = 'none';
 
881
 
882
  ['email', 'password'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-btn').click(); }));
883
 
884
+ document.getElementById('logout-btn').onclick = function () {
885
+ auth.logout().finally(function () { location.reload(); });
886
+ };
887
 
888
  function showDashboard() {
889
  document.getElementById('auth-screen').style.display = 'none';
app/api/schemas_router.py CHANGED
@@ -31,17 +31,20 @@ def _set_refresh_cookie(response: Response, refresh_token: str) -> None:
31
  secure=_cookie_secure(),
32
  samesite="lax",
33
  max_age=7 * 24 * 60 * 60,
 
34
  )
35
 
36
 
37
  @router.post("/auth/register", response_model=TokenResponse, status_code=201, tags=["Auth"])
38
- async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
39
  """Register a new author account."""
40
- return await AuthService(db).register(
41
  email=payload.email,
42
  password=payload.password,
43
  full_name=payload.full_name,
44
  )
 
 
45
 
46
 
47
  @router.post("/auth/login", response_model=TokenResponse, tags=["Auth"])
@@ -75,7 +78,7 @@ async def refresh(request: Request, response: Response, db: AsyncSession = Depen
75
  @router.post("/auth/logout", status_code=204, tags=["Auth"])
76
  async def logout(response: Response, _=Depends(get_current_user)):
77
  """Invalidate refresh token cookie."""
78
- response.delete_cookie("refresh_token")
79
 
80
 
81
  @router.get("/auth/me", response_model=UserResponse, tags=["Auth"])
 
31
  secure=_cookie_secure(),
32
  samesite="lax",
33
  max_age=7 * 24 * 60 * 60,
34
+ path="/",
35
  )
36
 
37
 
38
  @router.post("/auth/register", response_model=TokenResponse, status_code=201, tags=["Auth"])
39
+ async def register(payload: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)):
40
  """Register a new author account."""
41
+ result = await AuthService(db).register(
42
  email=payload.email,
43
  password=payload.password,
44
  full_name=payload.full_name,
45
  )
46
+ _set_refresh_cookie(response, result["refresh_token"])
47
+ return result
48
 
49
 
50
  @router.post("/auth/login", response_model=TokenResponse, tags=["Auth"])
 
78
  @router.post("/auth/logout", status_code=204, tags=["Auth"])
79
  async def logout(response: Response, _=Depends(get_current_user)):
80
  """Invalidate refresh token cookie."""
81
+ response.delete_cookie("refresh_token", path="/")
82
 
83
 
84
  @router.get("/auth/me", response_model=UserResponse, tags=["Auth"])
app/superadmin/templates/superadmin.html CHANGED
@@ -481,7 +481,9 @@ document.getElementById('sa-login-btn').onclick = async () => {
481
  };
482
 
483
  ['sa-user', 'sa-pass'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('sa-login-btn').click(); }));
484
- document.getElementById('sa-logout-btn').onclick = () => { auth.clearSession(); location.reload(); };
 
 
485
 
486
  function showDashboard() {
487
  document.getElementById('auth-screen').style.display = 'none';
 
481
  };
482
 
483
  ['sa-user', 'sa-pass'].forEach(id => document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('sa-login-btn').click(); }));
484
+ document.getElementById('sa-logout-btn').onclick = function () {
485
+ auth.logout().finally(function () { location.reload(); });
486
+ };
487
 
488
  function showDashboard() {
489
  document.getElementById('auth-screen').style.display = 'none';
static/auth-client.js CHANGED
@@ -1,10 +1,32 @@
1
  /**
2
  * AuthorBot Auth Client β€” shared by admin + superadmin SPAs.
3
- * Stores access + refresh tokens, auto-refreshes on 401, survives tab idle.
 
4
  */
5
  (function (global) {
6
  'use strict';
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  function AuthClient(options) {
9
  this.apiBase = options.apiBase;
10
  this.accessKey = options.accessKey || 'access_token';
@@ -37,18 +59,22 @@
37
  if (this.slugKey) localStorage.removeItem(this.slugKey);
38
  };
39
 
 
 
 
 
40
  AuthClient.prototype.refreshAccessToken = function () {
41
  var self = this;
42
  if (this._refreshing) return this._refreshing;
43
 
44
  var refresh = this.getRefreshToken();
45
- if (!refresh) return Promise.resolve(false);
46
 
47
- this._refreshing = fetch(this.apiBase + '/auth/refresh', {
48
  method: 'POST',
49
  headers: { 'Content-Type': 'application/json' },
50
- body: JSON.stringify({ refresh_token: refresh }),
51
- })
52
  .then(function (res) {
53
  if (!res.ok) return false;
54
  return res.json();
@@ -64,55 +90,97 @@
64
  return this._refreshing;
65
  };
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  AuthClient.prototype.restoreSession = function (validateFn) {
68
  var self = this;
69
- if (!this.getAccessToken()) return Promise.resolve(false);
70
 
71
- return this.request('GET', '/auth/me')
72
- .then(function (user) { return validateFn ? validateFn(user) : true; })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  .catch(function () {
74
- return self.refreshAccessToken().then(function (ok) {
75
- if (!ok) {
76
  self.clearSession();
77
  return false;
78
  }
79
- return self.request('GET', '/auth/me')
80
- .then(function (user) { return validateFn ? validateFn(user) : true; })
81
- .catch(function () {
82
- self.clearSession();
83
- return false;
84
- });
85
  });
 
 
 
 
86
  });
87
  };
88
 
89
  AuthClient.prototype.request = function (method, path, body, retried) {
90
  var self = this;
91
- var token = this.getAccessToken();
92
- var headers = { Authorization: 'Bearer ' + token };
93
 
94
- var opts = { method: method, headers: headers };
95
- if (body !== undefined && body !== null) {
96
- headers['Content-Type'] = 'application/json';
97
- opts.body = JSON.stringify(body);
98
- }
99
 
100
- return fetch(this.apiBase + path, opts)
101
- .catch(function () {
102
- throw new Error('Cannot reach server. Check your connection and try again.');
103
- })
104
- .then(function (res) {
105
- if (res.status === 401 && !retried) {
106
- return self.refreshAccessToken().then(function (ok) {
107
- if (!ok) {
108
- self.clearSession();
109
- throw new Error('Session expired β€” please sign in again');
110
- }
111
- return self.request(method, path, body, true);
112
- });
113
- }
114
- return res;
115
- });
 
 
 
 
 
 
 
 
 
 
116
  };
117
 
118
  AuthClient.prototype._parse = function (res) {
@@ -146,57 +214,75 @@
146
 
147
  AuthClient.prototype.upload = function (path, formData, retried) {
148
  var self = this;
149
- var token = this.getAccessToken();
150
- return fetch(this.apiBase + path, {
151
- method: 'POST',
152
- headers: { Authorization: 'Bearer ' + token },
153
- body: formData,
154
- })
155
- .catch(function () {
156
- throw new Error('Cannot reach server. Check your connection and try again.');
157
- })
158
- .then(function (res) {
159
- if (res.status === 401 && !retried) {
160
- return self.refreshAccessToken().then(function (ok) {
161
- if (!ok) {
162
- self.clearSession();
163
- throw new Error('Session expired β€” please sign in again');
164
- }
165
- return self.upload(path, formData, true);
166
- });
167
- }
168
- return res;
169
- });
 
 
 
 
 
 
 
 
170
  };
171
 
172
  AuthClient.prototype.download = function (path, retried) {
173
  var self = this;
174
- var token = this.getAccessToken();
175
- return fetch(this.apiBase + path, { headers: { Authorization: 'Bearer ' + token } })
176
- .catch(function () {
177
- throw new Error('Cannot reach server. Check your connection and try again.');
178
- })
179
- .then(function (res) {
180
- if (res.status === 401 && !retried) {
181
- return self.refreshAccessToken().then(function (ok) {
182
- if (!ok) {
183
- self.clearSession();
184
- throw new Error('Session expired β€” please sign in again');
185
- }
186
- return self.download(path, true);
187
- });
188
- }
189
- return res;
190
- });
 
 
 
 
 
 
 
 
 
 
191
  };
192
 
193
  AuthClient.prototype.login = function (email, password) {
194
  var self = this;
195
- return fetch(this.apiBase + '/auth/login', {
196
  method: 'POST',
197
  headers: { 'Content-Type': 'application/json' },
198
  body: JSON.stringify({ email: email, password: password }),
199
- })
200
  .then(function (res) { return res.json().then(function (data) { return { res: res, data: data }; }); })
201
  .then(function (_ref) {
202
  var res = _ref.res, data = _ref.data;
@@ -210,5 +296,12 @@
210
  });
211
  };
212
 
 
 
 
 
 
 
 
213
  global.AuthorBotAuthClient = AuthClient;
214
  })(window);
 
1
  /**
2
  * AuthorBot Auth Client β€” shared by admin + superadmin SPAs.
3
+ * Stores access + refresh tokens in localStorage, httponly refresh cookie as backup.
4
+ * Auto-refreshes on load and on 401 β€” survives page refresh and tab idle.
5
  */
6
  (function (global) {
7
  'use strict';
8
 
9
+ var FETCH_OPTS = { credentials: 'include' };
10
+
11
+ function decodeJwtPayload(token) {
12
+ try {
13
+ var part = token.split('.')[1];
14
+ if (!part) return null;
15
+ var json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
16
+ return JSON.parse(json);
17
+ } catch (e) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function isAccessTokenExpired(token, skewSeconds) {
23
+ skewSeconds = skewSeconds || 60;
24
+ if (!token) return true;
25
+ var payload = decodeJwtPayload(token);
26
+ if (!payload || !payload.exp) return true;
27
+ return (payload.exp - skewSeconds) <= Date.now() / 1000;
28
+ }
29
+
30
  function AuthClient(options) {
31
  this.apiBase = options.apiBase;
32
  this.accessKey = options.accessKey || 'access_token';
 
59
  if (this.slugKey) localStorage.removeItem(this.slugKey);
60
  };
61
 
62
+ AuthClient.prototype.hasSession = function () {
63
+ return !!(this.getAccessToken() || this.getRefreshToken());
64
+ };
65
+
66
  AuthClient.prototype.refreshAccessToken = function () {
67
  var self = this;
68
  if (this._refreshing) return this._refreshing;
69
 
70
  var refresh = this.getRefreshToken();
71
+ var body = refresh ? JSON.stringify({ refresh_token: refresh }) : '{}';
72
 
73
+ this._refreshing = fetch(this.apiBase + '/auth/refresh', Object.assign({}, FETCH_OPTS, {
74
  method: 'POST',
75
  headers: { 'Content-Type': 'application/json' },
76
+ body: body,
77
+ }))
78
  .then(function (res) {
79
  if (!res.ok) return false;
80
  return res.json();
 
90
  return this._refreshing;
91
  };
92
 
93
+ AuthClient.prototype.ensureValidAccessToken = function () {
94
+ var self = this;
95
+ var access = this.getAccessToken();
96
+ var refresh = this.getRefreshToken();
97
+
98
+ if (access && !isAccessTokenExpired(access)) {
99
+ return Promise.resolve(true);
100
+ }
101
+ if (!refresh) {
102
+ return Promise.resolve(false);
103
+ }
104
+ return this.refreshAccessToken();
105
+ };
106
+
107
  AuthClient.prototype.restoreSession = function (validateFn) {
108
  var self = this;
 
109
 
110
+ if (!this.hasSession()) {
111
+ return Promise.resolve(false);
112
+ }
113
+
114
+ function loadProfile() {
115
+ return self.get('/auth/me').then(function (user) {
116
+ if (self.slugKey && user && user.id) {
117
+ localStorage.setItem(self.slugKey, user.id);
118
+ }
119
+ if (validateFn && !validateFn(user)) {
120
+ return false;
121
+ }
122
+ return true;
123
+ });
124
+ }
125
+
126
+ return this.ensureValidAccessToken()
127
+ .then(function (ok) {
128
+ if (!ok) {
129
+ self.clearSession();
130
+ return false;
131
+ }
132
+ return loadProfile();
133
+ })
134
  .catch(function () {
135
+ return self.refreshAccessToken().then(function (refreshed) {
136
+ if (!refreshed) {
137
  self.clearSession();
138
  return false;
139
  }
140
+ return loadProfile();
 
 
 
 
 
141
  });
142
+ })
143
+ .catch(function () {
144
+ self.clearSession();
145
+ return false;
146
  });
147
  };
148
 
149
  AuthClient.prototype.request = function (method, path, body, retried) {
150
  var self = this;
 
 
151
 
152
+ return this.ensureValidAccessToken().then(function (ok) {
153
+ if (!ok) {
154
+ self.clearSession();
155
+ throw new Error('Session expired β€” please sign in again');
156
+ }
157
 
158
+ var token = self.getAccessToken();
159
+ var headers = { Authorization: 'Bearer ' + token };
160
+
161
+ var opts = Object.assign({}, FETCH_OPTS, { method: method, headers: headers });
162
+ if (body !== undefined && body !== null) {
163
+ headers['Content-Type'] = 'application/json';
164
+ opts.body = JSON.stringify(body);
165
+ }
166
+
167
+ return fetch(self.apiBase + path, opts)
168
+ .catch(function () {
169
+ throw new Error('Cannot reach server. Check your connection and try again.');
170
+ })
171
+ .then(function (res) {
172
+ if (res.status === 401 && !retried) {
173
+ return self.refreshAccessToken().then(function (refreshed) {
174
+ if (!refreshed) {
175
+ self.clearSession();
176
+ throw new Error('Session expired β€” please sign in again');
177
+ }
178
+ return self.request(method, path, body, true);
179
+ });
180
+ }
181
+ return res;
182
+ });
183
+ });
184
  };
185
 
186
  AuthClient.prototype._parse = function (res) {
 
214
 
215
  AuthClient.prototype.upload = function (path, formData, retried) {
216
  var self = this;
217
+
218
+ return this.ensureValidAccessToken().then(function (ok) {
219
+ if (!ok) {
220
+ self.clearSession();
221
+ throw new Error('Session expired β€” please sign in again');
222
+ }
223
+
224
+ var token = self.getAccessToken();
225
+ return fetch(self.apiBase + path, Object.assign({}, FETCH_OPTS, {
226
+ method: 'POST',
227
+ headers: { Authorization: 'Bearer ' + token },
228
+ body: formData,
229
+ }))
230
+ .catch(function () {
231
+ throw new Error('Cannot reach server. Check your connection and try again.');
232
+ })
233
+ .then(function (res) {
234
+ if (res.status === 401 && !retried) {
235
+ return self.refreshAccessToken().then(function (refreshed) {
236
+ if (!refreshed) {
237
+ self.clearSession();
238
+ throw new Error('Session expired β€” please sign in again');
239
+ }
240
+ return self.upload(path, formData, true);
241
+ });
242
+ }
243
+ return res;
244
+ });
245
+ });
246
  };
247
 
248
  AuthClient.prototype.download = function (path, retried) {
249
  var self = this;
250
+
251
+ return this.ensureValidAccessToken().then(function (ok) {
252
+ if (!ok) {
253
+ self.clearSession();
254
+ throw new Error('Session expired β€” please sign in again');
255
+ }
256
+
257
+ var token = self.getAccessToken();
258
+ return fetch(self.apiBase + path, Object.assign({}, FETCH_OPTS, {
259
+ headers: { Authorization: 'Bearer ' + token },
260
+ }))
261
+ .catch(function () {
262
+ throw new Error('Cannot reach server. Check your connection and try again.');
263
+ })
264
+ .then(function (res) {
265
+ if (res.status === 401 && !retried) {
266
+ return self.refreshAccessToken().then(function (refreshed) {
267
+ if (!refreshed) {
268
+ self.clearSession();
269
+ throw new Error('Session expired β€” please sign in again');
270
+ }
271
+ return self.download(path, true);
272
+ });
273
+ }
274
+ return res;
275
+ });
276
+ });
277
  };
278
 
279
  AuthClient.prototype.login = function (email, password) {
280
  var self = this;
281
+ return fetch(this.apiBase + '/auth/login', Object.assign({}, FETCH_OPTS, {
282
  method: 'POST',
283
  headers: { 'Content-Type': 'application/json' },
284
  body: JSON.stringify({ email: email, password: password }),
285
+ }))
286
  .then(function (res) { return res.json().then(function (data) { return { res: res, data: data }; }); })
287
  .then(function (_ref) {
288
  var res = _ref.res, data = _ref.data;
 
296
  });
297
  };
298
 
299
+ AuthClient.prototype.logout = function () {
300
+ var self = this;
301
+ return this.post('/auth/logout').catch(function () {}).finally(function () {
302
+ self.clearSession();
303
+ });
304
+ };
305
+
306
  global.AuthorBotAuthClient = AuthClient;
307
  })(window);