aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-15 02:16:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-15 02:21:01 +0200
commit73ad8e4eb566fe682107fa7e50ef624591199e99 (patch)
treeff0017d014d46d8835487c080dca55c6def7fd6b /packages/meshbay-hub/src/meshbay_hub/static/app.js
parentbdefcd025604f2c3009fe5e0cc01213c2ba62a6a (diff)
downloadmeshbay-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.js94
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.