diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-15 02:16:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-15 02:21:01 +0200 |
| commit | 73ad8e4eb566fe682107fa7e50ef624591199e99 (patch) | |
| tree | ff0017d014d46d8835487c080dca55c6def7fd6b /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | bdefcd025604f2c3009fe5e0cc01213c2ba62a6a (diff) | |
| download | meshbay-73ad8e4eb566fe682107fa7e50ef624591199e99.tar.gz | |
feat(hub): session lifetime is an admin setting, and a browser signs out when idle
Browser idle sign-out (media playback counts as activity; not the desktop app),
refresh idle window and maximum session length, in hours. Sign-out now revokes
on the hub, and the profile has "sign out everywhere".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 94 |
1 files changed, 66 insertions, 28 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 367774f..a249ccf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -13,8 +13,9 @@ import { HUB, navigate, session, getCachedGroupIndex, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, - refreshAccessToken, + refreshAccessToken, logoutOnHub, } from './hub-client.js'; +import { startIdleWatch, markActive } from './idle.js'; import { GroupPage } from './group-page.js'; import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; @@ -741,12 +742,49 @@ function App() { const resolved = resolveTheme(theme); + // The name of the last session. A failed renewal clears the stored session, + // name included, and the device sign-in below needs it to try again. + const lastUsernameRef = useRef(user ? user.username : null); + if (user) lastUsernameRef.current = user.username; + // Set by a deliberate sign-out, which the device key must not undo. + const signedOutRef = useRef(false); + + // Sign in with this device's key. Desktop only: resolves false in a browser, + // or with no key, or with a key the hub no longer knows. + const signInWithDevice = useCallback(async (username) => { + if (!username || !platform.device.available) return false; + try { + const signed = await platform.device.sign(username); + if (!signed) return false; + const data = await hubFetch('/v1/users/auth', { + method: 'POST', + body: { username, timestamp: signed.timestamp, + signature: signed.signature }, + }); + const me = await hubFetch('/v1/users/me', { token: data.access_token }); + const u = { username, userId: me.user_id, token: data.access_token, + refreshToken: data.refresh_token, role: me.role }; + signedOutRef.current = false; + setAuth(u); + setUser(u); + return true; + } catch { + return false; + } + }, []); + // Keep the session alive without anyone having to think about it. useEffect(() => { // A renewal can happen inside hubFetch, well away from any render. This is // how the component learns about it — including a failed one, which sets // null and lands on the login page instead of failing every later call. - setAuthChangeListener((auth) => setUser(auth)); + setAuthChangeListener((auth) => { + setUser(auth); + // A renewal the hub refused — the session outlived its idle window, say, + // on a laptop that slept through it. The desktop application signs back + // in with its device key instead of showing the form; a browser has none. + if (!auth && !signedOutRef.current) signInWithDevice(lastUsernameRef.current); + }); // On mount above all: a tab reopened tomorrow holds an hour-old access // token and a refresh token good for a month, and used to greet its owner @@ -891,32 +929,14 @@ function App() { if (deviceTried || user) { setDeviceTried(true); return; } let cancelled = false; (async () => { - try { - // `loadAuth` keeps the username even when the tokens in it are stale, - // and `app://meshbay` is a stable origin, so localStorage survives a - // relaunch. A fresh install has nothing here and asks for a passphrase, - // which is right: the first sign-in is what registers the device. - const saved = loadAuth(); - const username = saved && saved.username; - if (!username) return; - const signed = await platform.device.sign(username); - if (!signed) return; - const data = await hubFetch('/v1/users/auth', { - method: 'POST', - body: { username, timestamp: signed.timestamp, - signature: signed.signature }, - }); - const me = await hubFetch('/v1/users/me', { token: data.access_token }); - if (cancelled) return; - const u = { username, userId: me.user_id, token: data.access_token, - refreshToken: data.refresh_token, role: me.role }; - setAuth(u); - setUser(u); - } catch { - // Falls through to the sign-in form, which is the honest outcome. - } finally { - if (!cancelled) setDeviceTried(true); - } + // `loadAuth` keeps the username even when the tokens in it are stale, + // and `app://meshbay` is a stable origin, so localStorage survives a + // relaunch. A fresh install has nothing here and asks for a passphrase, + // which is right: the first sign-in is what registers the device. A + // refusal falls through to the sign-in form, the honest outcome. + const saved = loadAuth(); + await signInWithDevice(saved && saved.username); + if (!cancelled) setDeviceTried(true); })(); return () => { cancelled = true; }; }, []); @@ -977,6 +997,10 @@ function App() { // for the passphrase again. The key is generated and held by the main // process; what travels here is only its public half. await registerThisDevice(token); + // Before the session lands, so the idle watch starting with it does not + // read the last-active time of whoever used this browser before. + markActive(true); + signedOutRef.current = false; // setAuth, not saveAuth: it is the one writer that also updates the copy // hubFetch renews from. Storing the session without it left the renewal // path with no refresh token to present. @@ -984,9 +1008,13 @@ function App() { setUser(u); }, logout: () => { + signedOutRef.current = true; // Navigating away leaves transfers running; signing out does not. They // are moving data on tokens that are about to stop being ours. transfers.reset(); + // Revoked on the hub too, so a copy of the refresh token is worth + // nothing. Read before the next line clears it. + logoutOnHub(); setAuth(null); setUser(null); setGroups([]); @@ -994,6 +1022,16 @@ function App() { }, }; + // A browser signs itself out after a stretch with nobody at it (idle.js). Not + // the desktop application: its owner's machine, which the device key would + // sign straight back in anyway. + const idleHours = hubInfo && hubInfo.browser_idle_hours; + const signedInId = user ? user.userId : null; + useEffect(() => { + if (!signedInId || platform.isNative || !idleHours) return undefined; + return startIdleWatch(idleHours * 3600 * 1000, () => authCtx.logout()); + }, [signedInId, idleHours]); + // Group membership is baked into the access token at login and the hub does not // push updates, so someone invited after they signed in carries a token that // says they are in nothing. Refreshing re-reads membership from the database. |