summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
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
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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py97
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js94
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/idle.js77
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js26
20 files changed, 491 insertions, 39 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 397b4d9..7ca1e68 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -42,6 +42,8 @@ class SettingsPatchRequest(BaseModel):
mail: dict[str, int] | None = None
# The sign-in lockout's two numbers, each optional, as for mail.
login: dict[str, int] | None = None
+ # Session lifetime, in hours, each optional.
+ session: dict[str, int] | None = None
# ── Instance settings ────────────────────────────────────────────────────────
@@ -59,6 +61,9 @@ async def _settings_payload(db: AsyncSession) -> dict:
"login": await hub_settings.login_limits(db),
"login_defaults": dict(hub_settings.LOGIN_DEFAULTS),
"login_bounds": {k: list(v) for k, v in hub_settings.LOGIN_BOUNDS.items()},
+ "session": await hub_settings.session_limits(db),
+ "session_defaults": dict(hub_settings.SESSION_DEFAULTS),
+ "session_bounds": {k: list(v) for k, v in hub_settings.SESSION_BOUNDS.items()},
}
@@ -138,6 +143,26 @@ async def admin_patch_settings(
))
await db.commit()
+ if body.session:
+ unknown = sorted(set(body.session) - set(hub_settings.SESSION_KEYS))
+ if unknown:
+ raise HTTPException(
+ status_code=422, detail=f"Unknown session setting(s): {unknown}")
+ changed = []
+ for key, value in body.session.items():
+ clamped = hub_settings.clamp_session_value(key, value)
+ await hub_settings.set_raw(db, f"session.{key}", str(clamped))
+ changed.append(f"{key}={clamped}")
+ log.info("Session lifetime changed by %s: %s",
+ current_user.username, ", ".join(changed))
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_session_update",
+ ip_address="admin",
+ detail=", ".join(changed)[:255],
+ ))
+ await db.commit()
+
return await _settings_payload(db)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 5a3eb4b..94e9b3c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -40,6 +40,10 @@ async def hub_info(db: AsyncSession = Depends(get_db)):
# read it without opening the source.
"federation": federation.FEDERATION_ENABLED,
"captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "",
+ # How long a browser tab stays signed in with nobody at it. The page
+ # measures it (static/idle.js); the hub only says how long.
+ "browser_idle_hours":
+ (await hub_settings.session_limits(db))["browser_idle_hours"],
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 9150909..a74f6af 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -50,8 +50,28 @@ def set_config(cfg: HubConfig) -> None:
def _ttl() -> int:
return _cfg.jwt.access_token_ttl if _cfg else 3600
-def _refresh_ttl() -> int:
- return _cfg.jwt.refresh_token_ttl if _cfg else 86400 * 30
+async def _refresh_expiry(db: AsyncSession, family_id: str | None = None) -> datetime:
+ """
+ When a refresh token stops being accepted.
+
+ Sliding: each renewal moves it `refresh_idle_hours` past now, so a session
+ in use stays open — but never past `max_hours` after the sign-in that
+ started its family. The idle window never drops below the access token's
+ own life plus an hour: shorter, and a session would lapse between two
+ renewals of a tab that is being used.
+ """
+ limits = await hub_settings.session_limits(db)
+ now = datetime.now(timezone.utc)
+ idle = max(limits["refresh_idle_hours"] * 3600, _ttl() + 3600)
+ started = now
+ if family_id is not None:
+ first = await db.scalar(
+ select(func.min(RefreshToken.created_at))
+ .where(RefreshToken.family_id == family_id))
+ if first is not None:
+ started = first if first.tzinfo else first.replace(tzinfo=timezone.utc)
+ return min(now + timedelta(seconds=idle),
+ started + timedelta(hours=limits["max_hours"]))
VERIFICATION_TTL = 86400 # 24 hours
VERIFICATION_MAX_ATTEMPTS = 10
@@ -134,6 +154,10 @@ class RefreshRequest(BaseModel):
refresh_token: str
+class LogoutRequest(BaseModel):
+ refresh_token: str
+
+
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/register", status_code=201)
@@ -404,7 +428,7 @@ async def login(
raw_rt, rt_hash = generate_refresh_token()
family_id = str(uuid.uuid4())
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ expires_at = await _refresh_expiry(db)
db.add(RefreshToken(
user_id=user.id, token_hash=rt_hash,
family_id=family_id, expires_at=expires_at,
@@ -586,7 +610,7 @@ async def device_auth(
group_ids = [gid for (gid,) in memberships.all()]
access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids)
raw_rt, rt_hash = generate_refresh_token()
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ expires_at = await _refresh_expiry(db)
db.add(RefreshToken(user_id=user.id, token_hash=rt_hash,
family_id=str(uuid.uuid4()), expires_at=expires_at))
db.add(IPLog(user_id=user.id, event="device_auth",
@@ -633,12 +657,21 @@ async def token_refresh(
if not user or user.status != "active":
raise HTTPException(status_code=401, detail="User not found or suspended")
+ # The family's first sign-in was longer ago than any session may last.
+ expires_at = await _refresh_expiry(db, rt.family_id)
+ if expires_at <= datetime.now(timezone.utc):
+ await db.execute(
+ update(RefreshToken)
+ .where(RefreshToken.family_id == rt.family_id)
+ .values(revoked=True))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Session expired")
+
# Revoke old token
rt.revoked = True
# Issue new refresh token in the same family
new_raw_rt, new_rt_hash = generate_refresh_token()
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
db.add(RefreshToken(
user_id=user.id, token_hash=new_rt_hash,
family_id=rt.family_id, expires_at=expires_at,
@@ -882,6 +915,58 @@ class ChangePasswordRequest(BaseModel):
new_auth_key: str
+@router.post("/logout")
+@limiter.limit("20/minute")
+async def logout(
+ body: LogoutRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ End this session on the hub, not only in the browser.
+
+ The browser already forgets its tokens and keys on sign-out; this makes the
+ refresh token it held worthless to anyone who copied it. The token is the
+ credential, so no access token is asked for — it may well have expired. An
+ unknown or already revoked token gets the same answer.
+ """
+ rt = await db.scalar(select(RefreshToken).where(
+ RefreshToken.token_hash == hash_refresh_token(body.refresh_token)))
+ if rt is not None:
+ await db.execute(
+ update(RefreshToken)
+ .where(RefreshToken.family_id == rt.family_id)
+ .values(revoked=True))
+ db.add(IPLog(user_id=rt.user_id, event="logout", ip_address=client_ip(request)))
+ await db.commit()
+ return {"status": "signed_out"}
+
+
+@router.post("/me/sessions/revoke")
+@limiter.limit("5/minute")
+async def revoke_all_sessions(
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Sign out everywhere: no refresh token of this account renews any more.
+
+ Access tokens already issued run out on their own, within their lifetime.
+ A desktop device key is not a session and is left alone — removing the
+ device is what stops it signing back in.
+ """
+ result = await db.execute(
+ update(RefreshToken)
+ .where(RefreshToken.user_id == current_user.id,
+ RefreshToken.revoked.is_(False))
+ .values(revoked=True))
+ db.add(IPLog(user_id=current_user.id, event="sessions_revoked",
+ ip_address=client_ip(request)))
+ await db.commit()
+ return {"revoked": result.rowcount}
+
+
@router.post("/password")
@limiter.limit("5/minute")
async def change_password(
@@ -918,7 +1003,7 @@ async def change_password(
group_ids = [gid for (gid,) in memberships.all()]
access_token = issue_access_token(current_user.id, ttl=_ttl(), groups=group_ids)
raw_rt, rt_hash = generate_refresh_token()
- expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ expires_at = await _refresh_expiry(db)
db.add(RefreshToken(
user_id=current_user.id, token_hash=rt_hash,
family_id=str(uuid.uuid4()), expires_at=expires_at,
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 827538c..876583e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -9,6 +9,7 @@ Priority (highest first):
Production config file example: /etc/meshbay/hub.toml
"""
+import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -18,6 +19,8 @@ try:
except ImportError:
import tomli as tomllib # type: ignore[no-redef]
+log = logging.getLogger(__name__)
+
DEFAULT_CONFIG_PATHS = [
Path("/etc/meshbay/hub.toml"),
Path.home() / ".config" / "meshbay" / "hub.toml",
@@ -47,8 +50,9 @@ class HubIdentityConfig:
@dataclass
class JWTConfig:
# How long an access token stays good. It is not the session — the refresh
- # token below is, and the SPA renews against it well before this runs out,
- # so a film or a working day never meets this number.
+ # token is, and the SPA renews against it well before this runs out, so a
+ # film or a working day never meets this number. How long the session
+ # lasts is an admin setting (`hub_settings.SESSION_*`), not configuration.
#
# What it does bound is a token that leaks: revoking a member or suspending
# an account both take effect at once (the hub reloads the account on every
@@ -58,7 +62,6 @@ class JWTConfig:
# exercised constantly rather than twice, and cannot rot unnoticed the way
# it did when nothing used it at all.
access_token_ttl: int = 14400 # 4 hours
- refresh_token_ttl: int = 86400 * 30 # 30 days
@dataclass
@@ -161,7 +164,11 @@ def load_config(path: Path | None = None) -> HubConfig:
cfg.identity.admin_usernames = list(admins)
if jwt := raw.get("jwt", {}):
cfg.jwt.access_token_ttl = jwt.get("access_token_ttl", cfg.jwt.access_token_ttl)
- cfg.jwt.refresh_token_ttl = jwt.get("refresh_token_ttl", cfg.jwt.refresh_token_ttl)
+ # One source for the session's length: a value left here would
+ # otherwise look authoritative and change nothing.
+ if "refresh_token_ttl" in jwt:
+ log.warning("%s: [jwt] refresh_token_ttl is ignored — session "
+ "lifetime is set in the admin panel", p)
if ml := raw.get("mail", {}):
for name in (
"destination_cooldown_seconds", "destination_daily_cap",
diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
index 1dcff84..c397f58 100644
--- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
+++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
@@ -116,6 +116,46 @@ async def login_limits(db: AsyncSession) -> dict[str, int]:
for k in LOGIN_KEYS}
+# ── Session lifetime ─────────────────────────────────────────────────────────
+#
+# `browser_idle_hours`: a browser tab signs itself out after this long with no
+# input and nothing playing (`static/idle.js`). The hub cannot measure that — it
+# hears a renewal from any open tab, attended or not — so the page does, and
+# reads the number from `/v1/hub/info`. The desktop application is exempt: it is
+# its owner's machine and signs back in with its device key.
+#
+# `refresh_idle_hours`: a refresh token unused for this long stops renewing. The
+# hub's own backstop, for a token that left the browser it was issued to.
+#
+# `max_hours`: no session renews past this since its sign-in, used or not.
+
+SESSION_KEYS = ("browser_idle_hours", "refresh_idle_hours", "max_hours")
+
+SESSION_DEFAULTS: dict[str, int] = {
+ "browser_idle_hours": 1,
+ "refresh_idle_hours": 24,
+ "max_hours": 720,
+}
+
+SESSION_BOUNDS: dict[str, tuple[int, int]] = {
+ "browser_idle_hours": (1, 168), # a week
+ "refresh_idle_hours": (1, 720), # 30 days
+ "max_hours": (1, 8_760), # a year
+}
+
+
+def clamp_session_value(key: str, value: int) -> int:
+ low, high = SESSION_BOUNDS[key]
+ return max(low, min(high, int(value)))
+
+
+async def session_limits(db: AsyncSession) -> dict[str, int]:
+ """The three session numbers, stored value or built-in default."""
+ return {k: clamp_session_value(
+ k, await get_int(db, f"session.{k}", SESSION_DEFAULTS[k]))
+ for k in SESSION_KEYS}
+
+
async def get_raw(db: AsyncSession, key: str) -> str | None:
row = await db.get(HubSetting, key)
return row.value if row else None
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
index c40d240..d5cc7a8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
@@ -17,6 +17,7 @@ export function AdminPage({ token, role }) {
// and a rejected one never looks applied.
const [mailDraft, setMailDraft] = useState(null);
const [loginDraft, setLoginDraft] = useState(null);
+ const [sessionDraft, setSessionDraft] = useState(null);
const [users, setUsers] = useState([]);
const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('');
@@ -45,6 +46,7 @@ export function AdminPage({ token, role }) {
setSettings(data);
setMailDraft({ ...data.mail });
setLoginDraft({ ...data.login });
+ setSessionDraft({ ...data.session });
} catch (e) { setError(e.message); }
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -64,6 +66,7 @@ export function AdminPage({ token, role }) {
// answer rather than left showing a number that was not stored.
setMailDraft({ ...data.mail });
setLoginDraft({ ...data.login });
+ setSessionDraft({ ...data.session });
if (patch.mail) {
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -198,6 +201,8 @@ const MAIL_FIELDS = [
const LOGIN_FIELDS = ['max_failures', 'lockout_minutes'];
+const SESSION_FIELDS = ['browser_idle_hours', 'refresh_idle_hours', 'max_hours'];
+
// Only what changed, and only what is a number: an empty field is someone
// mid-edit, not a request to set zero.
const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields
@@ -296,6 +301,35 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
</div>
`}
</div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.session_heading')}</h3>
+ <p class="settings-hint">${t('admin.session_hint')}</p>
+
+ ${sessionDraft && SESSION_FIELDS.map(key => html`
+ <div class="settings-row" key=${key}>
+ <span class="settings-label">${t('admin.session_' + key)}</span>
+ <input type="number" class="settings-number"
+ min=${(settings.session_bounds?.[key] || [0])[0]}
+ max=${(settings.session_bounds?.[key] || [0, 0])[1]}
+ value=${sessionDraft[key]}
+ disabled=${!canEditSettings || settingsSaving}
+ onInput=${e => setSessionDraft(d => ({ ...d, [key]: e.target.value }))} />
+ </div>
+ `)}
+
+ ${canEditSettings && sessionDraft && html`
+ <div class="settings-row">
+ <button class="btn" disabled=${settingsSaving}
+ onClick=${() => saveSettings({
+ session: changedNumbers(SESSION_FIELDS, sessionDraft, settings.session),
+ })}>${t('admin.session_save')}</button>
+ <button class="btn btn-secondary" disabled=${settingsSaving}
+ onClick=${() => setSessionDraft({ ...settings.session_defaults })}
+ >${t('admin.mail_reset_defaults')}</button>
+ </div>
+ `}
+ </div>
`}
`}
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.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index e72961c..7ba6f92 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -245,6 +245,22 @@ async function refreshAccessToken() {
return _refreshing;
}
+/**
+ * Revoke this session's refresh token on the hub.
+ *
+ * Fire and forget, and read synchronously: the caller clears the session on the
+ * next line, and signing out must not wait on the network or fail with it.
+ */
+function logoutOnHub() {
+ const refreshToken = _auth && _auth.refreshToken;
+ if (!refreshToken) return;
+ platform.apiFetch(HUB + '/v1/users/logout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh_token: refreshToken }),
+ }).catch(() => {});
+}
+
/** Renew before it bites, rather than after. */
async function ensureFreshToken() {
if (!_auth || !_auth.token) return null;
@@ -290,5 +306,5 @@ export {
cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes,
_storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB,
loadAuth, saveAuth, setAuth, setAuthChangeListener,
- tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch,
+ tokenLifeLeft, refreshAccessToken, ensureFreshToken, logoutOnHub, hubFetch,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/idle.js b/packages/meshbay-hub/src/meshbay_hub/static/idle.js
new file mode 100644
index 0000000..e24f758
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/idle.js
@@ -0,0 +1,77 @@
+// Signing a browser out after a stretch with nobody at it.
+//
+// The hub cannot measure this. It hears a token renewal every few hours from
+// any open tab, attended or not, and nothing at all while a film plays over
+// WebRTC. So the page decides, and the hub only says how long
+// (`browser_idle_hours` in /v1/hub/info).
+//
+// Activity is input, or any <video>/<audio> on the page that is playing: a film
+// nobody touches for two hours is somebody watching it. The last-active time
+// lives in localStorage so every tab of this browser shares it — one idle tab
+// must not sign out the tab in use — and a browser closed without signing out
+// is caught the moment it is opened again.
+//
+// Never started in the desktop application (app.js): that is its owner's own
+// machine, and it signs back in with its device key.
+
+const LAST_ACTIVE_KEY = 'mb_last_active';
+const CHECK_MS = 60000;
+// Input fires constantly; the timestamp only has to be minutes-accurate.
+const WRITE_EVERY_MS = 30000;
+const INPUT_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart'];
+
+let lastWrite = 0;
+
+function readLastActive() {
+ try {
+ const v = Number(localStorage.getItem(LAST_ACTIVE_KEY));
+ return Number.isFinite(v) && v > 0 ? v : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Record activity now. `force` skips the write throttle — a sign-in uses it. */
+function markActive(force = false) {
+ const now = Date.now();
+ if (!force && now - lastWrite < WRITE_EVERY_MS) return;
+ lastWrite = now;
+ try { localStorage.setItem(LAST_ACTIVE_KEY, String(now)); } catch { /* private mode */ }
+}
+
+function mediaPlaying() {
+ return [...document.querySelectorAll('video, audio')]
+ .some((m) => !m.paused && !m.ended);
+}
+
+/**
+ * Watch for `idleMs` without activity, then call `onIdle` once.
+ * Returns the function that stops watching.
+ */
+function startIdleWatch(idleMs, onIdle) {
+ let fired = false;
+ const onInput = () => markActive();
+ const check = () => {
+ if (fired) return;
+ if (mediaPlaying()) { markActive(); return; }
+ const last = readLastActive();
+ // No record at all is a browser that predates this, not an idle one.
+ if (last === null) { markActive(true); return; }
+ if (Date.now() - last > idleMs) { fired = true; onIdle(); }
+ };
+
+ INPUT_EVENTS.forEach((e) =>
+ window.addEventListener(e, onInput, { capture: true, passive: true }));
+ document.addEventListener('visibilitychange', check);
+ const timer = setInterval(check, CHECK_MS);
+ check();
+
+ return () => {
+ INPUT_EVENTS.forEach((e) =>
+ window.removeEventListener(e, onInput, { capture: true }));
+ document.removeEventListener('visibilitychange', check);
+ clearInterval(timer);
+ };
+}
+
+export { startIdleWatch, markActive, LAST_ACTIVE_KEY };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 065e58e..6e5afa6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -408,6 +408,10 @@ export default {
'settings.recovery_partial': 'Diese Gruppen waren nicht erreichbar. Öffnen Sie sie später oder führen Sie dies erneut aus:',
'settings.recovery_need_relogin': 'Bitte melden Sie sich ab und wieder an und versuchen Sie es erneut.',
'settings.danger': 'Konto löschen',
+ 'settings.sessions_heading': 'Sitzungen',
+ 'settings.sign_out_everywhere': 'Überall abmelden',
+ 'settings.sign_out_everywhere_hint': 'Beendet alle Sitzungen Ihres Kontos, auch diese: nützlich, wenn Sie auf einem fremden Computer angemeldet geblieben sind. Eine Desktop-Anwendung meldet sich mit ihrem Geräteschlüssel wieder an, bis Sie dieses Gerät entfernen.',
+ 'settings.sign_out_everywhere_confirm': 'Von allen Browsern und Anwendungen abmelden, auch von diesem?',
'settings.delete_hint': 'Entfernt Ihr Konto, Ihre Gruppenmitgliedschaften und Ihre '
+ 'Benachrichtigungen vom Hub und gibt Ihren Benutzernamen frei. Was auf den Nodes '
+ 'liegt, erreicht das nicht: hochgeladene Dateien bleiben dort, wo ihr Betreiber '
@@ -504,6 +508,12 @@ export default {
'admin.mail_reset_defaults': "Standardwerte wiederherstellen",
'admin.login_heading': "Anmeldung",
'admin.login_hint': "Nach so vielen falschen Passphrasen für einen Benutzernamen wird die Anmeldung mit Passphrase für die angegebene Dauer verweigert. Bereits offene Sitzungen und registrierte Geräte funktionieren weiter, und ein Zurücksetzen der Passphrase hebt die Sperre auf. 0 schaltet sie ab.",
+ 'admin.session_heading': 'Sitzungen',
+ 'admin.session_hint': 'Ein Browser meldet sich nach der ersten Stundenzahl ohne Eingabe und ohne laufende Wiedergabe ab; die Desktop-Anwendung nicht. Eine Sitzung, die so viele Stunden wie die zweite Zahl ungenutzt bleibt, wird nicht mehr verlängert, und keine dauert länger als die dritte.',
+ 'admin.session_browser_idle_hours': 'Browser-Abmeldung nach Inaktivität (Stunden)',
+ 'admin.session_refresh_idle_hours': 'Ungenutzte Sitzung läuft ab nach (Stunden)',
+ 'admin.session_max_hours': 'Maximale Sitzungsdauer (Stunden)',
+ 'admin.session_save': 'Sitzungseinstellungen speichern',
'admin.login_max_failures': "Falsche Passphrasen bis zur Sperre",
'admin.login_lockout_minutes': "Dauer der Sperre (Minuten)",
'admin.login_save': "Anmeldegrenzen speichern",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 83a39e7..7ffbc28 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -402,6 +402,10 @@ export default {
'settings.recovery_partial': 'These groups could not be reached. Open them later, or run this again:',
'settings.recovery_need_relogin': 'Please sign out and back in, then try again.',
'settings.danger': 'Delete account',
+ 'settings.sessions_heading': 'Sessions',
+ 'settings.sign_out_everywhere': 'Sign out everywhere',
+ 'settings.sign_out_everywhere_hint': 'Ends every session of your account, including this one: useful if you stayed signed in on a computer that is not yours. A desktop application signs back in with its device key until you remove that device.',
+ 'settings.sign_out_everywhere_confirm': 'Sign out of every browser and application, including this one?',
'settings.delete_hint': 'Removes your account, your group memberships and your '
+ 'notifications from the hub, and frees your username. It cannot reach what '
+ 'lives on nodes: files you uploaded stay where their operator keeps them, '
@@ -494,6 +498,12 @@ export default {
'admin.mail_reset_defaults': "Restore defaults",
'admin.login_heading': "Sign-in",
'admin.login_hint': "After this many wrong passphrases for one username, signing in with a passphrase is refused for the set duration. Sessions already open and registered devices keep working, and a passphrase reset ends the lockout. 0 turns it off.",
+ 'admin.session_heading': 'Sessions',
+ 'admin.session_hint': 'A browser signs itself out after the first number of hours with no input and nothing playing; the desktop application does not. A session unused for the second number stops renewing, and none lasts past the third.',
+ 'admin.session_browser_idle_hours': 'Browser sign-out after inactivity (hours)',
+ 'admin.session_refresh_idle_hours': 'Session expires when unused for (hours)',
+ 'admin.session_max_hours': 'Maximum session length (hours)',
+ 'admin.session_save': 'Save session settings',
'admin.login_max_failures': "Wrong passphrases before a lockout",
'admin.login_lockout_minutes': "Lockout duration (minutes)",
'admin.login_save': "Save sign-in limits",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 73757d0..e675e10 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -405,6 +405,10 @@ export default {
'settings.recovery_partial': 'No se pudo contactar con estos grupos. Ábralos más tarde o vuelva a ejecutar esto:',
'settings.recovery_need_relogin': 'Cierre sesión y vuelva a entrar, luego inténtelo de nuevo.',
'settings.danger': 'Eliminar la cuenta',
+ 'settings.sessions_heading': 'Sesiones',
+ 'settings.sign_out_everywhere': 'Cerrar sesión en todas partes',
+ 'settings.sign_out_everywhere_hint': 'Termina todas las sesiones de tu cuenta, incluida esta: útil si dejaste la sesión abierta en un ordenador que no es tuyo. Una aplicación de escritorio vuelve a entrar con su clave de dispositivo hasta que retires ese dispositivo.',
+ 'settings.sign_out_everywhere_confirm': '¿Cerrar sesión en todos los navegadores y aplicaciones, incluido este?',
'settings.delete_hint': 'Elimina su cuenta, sus pertenencias a grupos y sus '
+ 'notificaciones del hub, y libera su nombre de usuario. No alcanza lo que vive '
+ 'en los nodes: los archivos que subió se quedan donde los guarda su operador, y '
@@ -500,6 +504,12 @@ export default {
'admin.mail_reset_defaults': "Restaurar valores por defecto",
'admin.login_heading': "Inicio de sesión",
'admin.login_hint': "Tras este número de frases de contraseña incorrectas para un mismo nombre de usuario, se rechaza el inicio de sesión con frase de contraseña durante el tiempo indicado. Las sesiones ya abiertas y los dispositivos registrados siguen funcionando, y restablecer la frase de contraseña levanta el bloqueo. 0 lo desactiva.",
+ 'admin.session_heading': 'Sesiones',
+ 'admin.session_hint': 'Un navegador cierra la sesión tras el primer número de horas sin interacción ni reproducción en curso; la aplicación de escritorio no. Una sesión sin usar durante el segundo número deja de renovarse, y ninguna dura más del tercero.',
+ 'admin.session_browser_idle_hours': 'Cierre de sesión del navegador por inactividad (horas)',
+ 'admin.session_refresh_idle_hours': 'Una sesión sin usar caduca tras (horas)',
+ 'admin.session_max_hours': 'Duración máxima de una sesión (horas)',
+ 'admin.session_save': 'Guardar ajustes de sesión',
'admin.login_max_failures': "Frases incorrectas antes del bloqueo",
'admin.login_lockout_minutes': "Duración del bloqueo (minutos)",
'admin.login_save': "Guardar límites de inicio de sesión",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 35bce2c..cc0892d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -407,6 +407,10 @@ export default {
'settings.recovery_partial': 'Ces groupes n’ont pas pu être joints. Ouvrez-les plus tard, ou relancez l’opération :',
'settings.recovery_need_relogin': 'Veuillez vous déconnecter puis vous reconnecter, et réessayer.',
'settings.danger': 'Supprimer le compte',
+ 'settings.sessions_heading': 'Sessions',
+ 'settings.sign_out_everywhere': 'Se déconnecter partout',
+ 'settings.sign_out_everywhere_hint': 'Met fin à toutes les sessions de votre compte, y compris celle-ci : utile si vous êtes resté connecté sur un ordinateur qui n\'est pas le vôtre. Une application desktop se reconnecte avec sa clé d\'appareil tant que vous ne retirez pas cet appareil.',
+ 'settings.sign_out_everywhere_confirm': 'Se déconnecter de tous les navigateurs et applications, y compris celui-ci ?',
'settings.delete_hint': 'Supprime votre compte, vos adhésions aux groupes et vos '
+ 'notifications du hub, et libère votre nom d’utilisateur. Cela n’atteint pas '
+ 'ce qui se trouve sur les nodes : les fichiers que vous avez envoyés restent là '
@@ -503,6 +507,12 @@ export default {
'admin.mail_reset_defaults': "Rétablir les valeurs par défaut",
'admin.login_heading': "Connexion",
'admin.login_hint': "Après ce nombre de phrases secrètes erronées pour un même nom d'utilisateur, la connexion par phrase secrète est refusée pendant la durée indiquée. Les sessions déjà ouvertes et les appareils enregistrés continuent de fonctionner, et une réinitialisation de la phrase secrète lève le blocage. 0 le désactive.",
+ 'admin.session_heading': 'Sessions',
+ 'admin.session_hint': 'Un navigateur se déconnecte après le premier nombre d\'heures sans interaction ni lecture en cours ; l\'application desktop non. Une session inutilisée pendant le deuxième nombre n\'est plus renouvelée, et aucune ne dure au-delà du troisième.',
+ 'admin.session_browser_idle_hours': 'Déconnexion du navigateur après inactivité (heures)',
+ 'admin.session_refresh_idle_hours': 'Expiration d\'une session inutilisée (heures)',
+ 'admin.session_max_hours': 'Durée maximale d\'une session (heures)',
+ 'admin.session_save': 'Enregistrer les réglages de session',
'admin.login_max_failures': "Phrases secrètes erronées avant blocage",
'admin.login_lockout_minutes': "Durée du blocage (minutes)",
'admin.login_save': "Enregistrer les limites de connexion",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index edc0d97..7ccaff4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -407,6 +407,10 @@ export default {
'settings.recovery_partial': 'Non è stato possibile raggiungere questi gruppi. Li apra più tardi, o riesegua l’operazione:',
'settings.recovery_need_relogin': 'Esca e rientri, poi riprovi.',
'settings.danger': "Elimina l'account",
+ 'settings.sessions_heading': 'Sessioni',
+ 'settings.sign_out_everywhere': 'Esci ovunque',
+ 'settings.sign_out_everywhere_hint': 'Termina tutte le sessioni del tuo account, compresa questa: utile se sei rimasto connesso su un computer non tuo. Un\'applicazione desktop rientra con la sua chiave del dispositivo finché non rimuovi quel dispositivo.',
+ 'settings.sign_out_everywhere_confirm': 'Uscire da tutti i browser e le applicazioni, compreso questo?',
'settings.delete_hint': "Rimuove il suo account, le sue adesioni ai gruppi e le sue "
+ "notifiche dall'hub, e libera il suo nome utente. Non arriva a ciò che si trova "
+ 'sui node: i file che ha caricato restano dove il loro operatore li conserva, e '
@@ -503,6 +507,12 @@ export default {
'admin.mail_reset_defaults': "Ripristina i valori predefiniti",
'admin.login_heading': "Accesso",
'admin.login_hint': "Dopo questo numero di passphrase errate per uno stesso nome utente, l'accesso con passphrase viene rifiutato per la durata indicata. Le sessioni già aperte e i dispositivi registrati continuano a funzionare, e la reimpostazione della passphrase rimuove il blocco. 0 lo disattiva.",
+ 'admin.session_heading': 'Sessioni',
+ 'admin.session_hint': 'Un browser si disconnette dopo il primo numero di ore senza interazione né riproduzione in corso; l\'applicazione desktop no. Una sessione inutilizzata per il secondo numero non viene più rinnovata, e nessuna dura oltre il terzo.',
+ 'admin.session_browser_idle_hours': 'Disconnessione del browser dopo inattività (ore)',
+ 'admin.session_refresh_idle_hours': 'Una sessione inutilizzata scade dopo (ore)',
+ 'admin.session_max_hours': 'Durata massima di una sessione (ore)',
+ 'admin.session_save': 'Salva impostazioni sessione',
'admin.login_max_failures': "Passphrase errate prima del blocco",
'admin.login_lockout_minutes': "Durata del blocco (minuti)",
'admin.login_save': "Salva i limiti di accesso",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 8667d20..aaec812 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -402,6 +402,10 @@ export default {
'settings.recovery_partial': 'これらのグループに到達できませんでした。あとで開くか、もう一度実行してください:',
'settings.recovery_need_relogin': '一度サインアウトして再度サインインしてから、もう一度お試しください。',
'settings.danger': 'アカウントを削除',
+ 'settings.sessions_heading': 'セッション',
+ 'settings.sign_out_everywhere': 'すべての場所からサインアウト',
+ 'settings.sign_out_everywhere_hint': 'このセッションを含め、アカウントのすべてのセッションを終了します。自分のものではないコンピューターでサインインしたままにした場合に役立ちます。デスクトップアプリは、その端末を削除するまで端末キーで再びサインインします。',
+ 'settings.sign_out_everywhere_confirm': 'このブラウザーを含む、すべてのブラウザーとアプリからサインアウトしますか?',
'settings.delete_hint': 'hub から、お客様のアカウント、グループへの参加、通知を'
+ '削除し、ユーザー名を解放します。node 上にあるものには及びません。'
+ 'アップロードしたファイルは、その運営者が保管する場所に残り、'
@@ -496,6 +500,12 @@ export default {
'admin.mail_reset_defaults': "既定値に戻す",
'admin.login_heading': "サインイン",
'admin.login_hint': "1つのユーザー名に対してこの回数パスフレーズを誤ると、設定した時間のあいだパスフレーズによるサインインが拒否されます。すでに開いているセッションと登録済みの端末は引き続き使え、パスフレーズをリセットするとロックは解除されます。0 で無効になります。",
+ 'admin.session_heading': 'セッション',
+ 'admin.session_hint': 'ブラウザーは、操作も再生もないまま1つ目の時間が経過するとサインアウトします(デスクトップアプリは対象外)。2つ目の時間使われなかったセッションは更新されず、どのセッションも3つ目の時間を超えて続きません。',
+ 'admin.session_browser_idle_hours': '無操作時のブラウザーのサインアウト(時間)',
+ 'admin.session_refresh_idle_hours': '未使用セッションの有効期限(時間)',
+ 'admin.session_max_hours': 'セッションの最大時間(時間)',
+ 'admin.session_save': 'セッション設定を保存',
'admin.login_max_failures': "ロックまでの誤りの回数",
'admin.login_lockout_minutes': "ロック時間(分)",
'admin.login_save': "サインインの制限を保存",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index fc2eccb..3049cef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -408,6 +408,10 @@ export default {
'settings.recovery_partial': 'Deze groepen konden niet worden bereikt. Open ze later, of voer dit opnieuw uit:',
'settings.recovery_need_relogin': 'Meld u af en weer aan en probeer het opnieuw.',
'settings.danger': 'Account verwijderen',
+ 'settings.sessions_heading': 'Sessies',
+ 'settings.sign_out_everywhere': 'Overal afmelden',
+ 'settings.sign_out_everywhere_hint': 'Beëindigt alle sessies van je account, ook deze: handig als je aangemeld bleef op een computer die niet van jou is. Een desktoptoepassing meldt zich weer aan met haar apparaatsleutel tot je dat apparaat verwijdert.',
+ 'settings.sign_out_everywhere_confirm': 'Afmelden bij alle browsers en toepassingen, ook deze?',
'settings.delete_hint': 'Verwijdert uw account, uw groepslidmaatschappen en uw '
+ 'meldingen van de hub, en geeft uw gebruikersnaam vrij. Wat op de nodes staat, '
+ 'wordt hiermee niet bereikt: bestanden die u hebt geüpload blijven waar hun '
@@ -504,6 +508,12 @@ export default {
'admin.mail_reset_defaults': "Standaardwaarden herstellen",
'admin.login_heading': "Aanmelden",
'admin.login_hint': "Na dit aantal onjuiste wachtwoordzinnen voor één gebruikersnaam wordt aanmelden met een wachtwoordzin geweigerd gedurende de ingestelde tijd. Al geopende sessies en geregistreerde apparaten blijven werken, en het opnieuw instellen van de wachtwoordzin heft de blokkade op. 0 schakelt dit uit.",
+ 'admin.session_heading': 'Sessies',
+ 'admin.session_hint': 'Een browser meldt zich af na het eerste aantal uur zonder invoer en zonder lopende weergave; de desktoptoepassing niet. Een sessie die het tweede aantal uur ongebruikt blijft, wordt niet meer verlengd, en geen enkele duurt langer dan het derde.',
+ 'admin.session_browser_idle_hours': 'Browser afmelden na inactiviteit (uren)',
+ 'admin.session_refresh_idle_hours': 'Ongebruikte sessie verloopt na (uren)',
+ 'admin.session_max_hours': 'Maximale sessieduur (uren)',
+ 'admin.session_save': 'Sessie-instellingen opslaan',
'admin.login_max_failures': "Onjuiste wachtwoordzinnen vóór blokkade",
'admin.login_lockout_minutes': "Duur van de blokkade (minuten)",
'admin.login_save': "Aanmeldlimieten opslaan",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index af65489..8922d0a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -421,6 +421,10 @@ export default {
'settings.recovery_partial': 'Nie udało się połączyć z tymi grupami. Otwórz je później lub uruchom to ponownie:',
'settings.recovery_need_relogin': 'Wyloguj się i zaloguj ponownie, a następnie spróbuj jeszcze raz.',
'settings.danger': 'Usunięcie konta',
+ 'settings.sessions_heading': 'Sesje',
+ 'settings.sign_out_everywhere': 'Wyloguj wszędzie',
+ 'settings.sign_out_everywhere_hint': 'Kończy wszystkie sesje Twojego konta, łącznie z tą: przydatne, jeśli pozostałeś zalogowany na cudzym komputerze. Aplikacja desktopowa loguje się ponownie kluczem urządzenia, dopóki nie usuniesz tego urządzenia.',
+ 'settings.sign_out_everywhere_confirm': 'Wylogować ze wszystkich przeglądarek i aplikacji, łącznie z tą?',
'settings.delete_hint': 'Usuwa z huba konto, członkostwa w grupach i powiadomienia '
+ 'oraz zwalnia nazwę użytkownika. Nie sięga tego, co znajduje się na nodes: '
+ 'wysłane pliki pozostają tam, gdzie przechowuje je ich operator, a każdy node '
@@ -516,6 +520,12 @@ export default {
'admin.mail_reset_defaults': "Przywróć domyślne",
'admin.login_heading': "Logowanie",
'admin.login_hint': "Po tylu błędnych hasłach-frazach dla jednej nazwy użytkownika logowanie hasłem-frazą jest odrzucane przez ustawiony czas. Otwarte już sesje i zarejestrowane urządzenia działają dalej, a zresetowanie hasła-frazy znosi blokadę. 0 ją wyłącza.",
+ 'admin.session_heading': 'Sesje',
+ 'admin.session_hint': 'Przeglądarka wylogowuje się po pierwszej liczbie godzin bez interakcji i bez odtwarzania; aplikacja desktopowa nie. Sesja nieużywana przez drugą liczbę godzin przestaje być odnawiana, a żadna nie trwa dłużej niż trzecia.',
+ 'admin.session_browser_idle_hours': 'Wylogowanie przeglądarki po bezczynności (godziny)',
+ 'admin.session_refresh_idle_hours': 'Nieużywana sesja wygasa po (godziny)',
+ 'admin.session_max_hours': 'Maksymalny czas sesji (godziny)',
+ 'admin.session_save': 'Zapisz ustawienia sesji',
'admin.login_max_failures': "Błędne hasła-frazy przed blokadą",
'admin.login_lockout_minutes': "Czas blokady (minuty)",
'admin.login_save': "Zapisz limity logowania",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 65e1ec1..460a902 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -407,6 +407,10 @@ export default {
'settings.recovery_partial': 'Não foi possível alcançar estes grupos. Abra-os mais tarde, ou execute isto de novo:',
'settings.recovery_need_relogin': 'Saia e entre novamente, depois tente de novo.',
'settings.danger': 'Excluir a conta',
+ 'settings.sessions_heading': 'Sessões',
+ 'settings.sign_out_everywhere': 'Sair em todos os lugares',
+ 'settings.sign_out_everywhere_hint': 'Encerra todas as sessões da sua conta, inclusive esta: útil se você ficou conectado em um computador que não é seu. Um aplicativo desktop entra de novo com a chave do dispositivo até você remover esse dispositivo.',
+ 'settings.sign_out_everywhere_confirm': 'Sair de todos os navegadores e aplicativos, inclusive este?',
'settings.delete_hint': 'Remove sua conta, suas participações em grupos e suas '
+ 'notificações do hub, e libera seu nome de usuário. Não alcança o que está nos '
+ 'nodes: os arquivos que você enviou permanecem onde o operador deles os guarda, e '
@@ -502,6 +506,12 @@ export default {
'admin.mail_reset_defaults': "Restaurar padrões",
'admin.login_heading': "Login",
'admin.login_hint': "Após este número de frases secretas incorretas para um mesmo nome de usuário, o login com frase secreta é recusado pelo tempo definido. Sessões já abertas e dispositivos registrados continuam funcionando, e redefinir a frase secreta encerra o bloqueio. 0 desativa.",
+ 'admin.session_heading': 'Sessões',
+ 'admin.session_hint': 'Um navegador encerra a sessão após o primeiro número de horas sem interação e sem reprodução em andamento; o aplicativo desktop não. Uma sessão sem uso pelo segundo número deixa de ser renovada, e nenhuma dura além do terceiro.',
+ 'admin.session_browser_idle_hours': 'Saída do navegador após inatividade (horas)',
+ 'admin.session_refresh_idle_hours': 'Sessão sem uso expira após (horas)',
+ 'admin.session_max_hours': 'Duração máxima da sessão (horas)',
+ 'admin.session_save': 'Salvar configurações de sessão',
'admin.login_max_failures': "Frases incorretas antes do bloqueio",
'admin.login_lockout_minutes': "Duração do bloqueio (minutos)",
'admin.login_save': "Salvar limites de login",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 87257b4..b8ab4ef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -398,6 +398,10 @@ export default {
'settings.recovery_partial': '无法连接这些群组。稍后打开它们,或再次运行此操作:',
'settings.recovery_need_relogin': '请先退出登录再重新登录,然后重试。',
'settings.danger': '删除账户',
+ 'settings.sessions_heading': '会话',
+ 'settings.sign_out_everywhere': '在所有设备上退出',
+ 'settings.sign_out_everywhere_hint': '结束您账户的所有会话,包括当前会话:适用于您在他人电脑上忘记退出的情况。桌面应用会使用其设备密钥重新登录,直到您移除该设备。',
+ 'settings.sign_out_everywhere_confirm': '要退出所有浏览器和应用(包括当前这个)吗?',
'settings.delete_hint': '这会从 hub 上删除您的账户、您的群组成员身份和您的通知,'
+ '并释放您的用户名。它无法触及存放在各个 node 上的内容:您上传的文件仍留在其运营者'
+ '保管之处,每个 node 也会保留它为您固定的身份,直到其运营者取消固定。'
@@ -488,6 +492,12 @@ export default {
'admin.mail_reset_defaults': "恢复默认值",
'admin.login_heading': "登录",
'admin.login_hint': "同一用户名输错密码短语达到此次数后,将在设定时长内拒绝使用密码短语登录。已打开的会话和已注册的设备不受影响,重置密码短语即可解除锁定。设为 0 则关闭。",
+ 'admin.session_heading': '会话',
+ 'admin.session_hint': '浏览器在第一个小时数内没有操作且没有正在播放的内容时会自动退出;桌面应用不会。未使用达到第二个小时数的会话将不再续期,且任何会话都不会超过第三个小时数。',
+ 'admin.session_browser_idle_hours': '浏览器无操作后退出(小时)',
+ 'admin.session_refresh_idle_hours': '未使用的会话过期时间(小时)',
+ 'admin.session_max_hours': '会话最长时长(小时)',
+ 'admin.session_save': '保存会话设置',
'admin.login_max_failures': "锁定前允许的错误次数",
'admin.login_lockout_minutes': "锁定时长(分钟)",
'admin.login_save': "保存登录限制",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index 7e355e7..4d452e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -42,6 +42,23 @@ export function ProfilePage({ user, onLogout }) {
const [delPass, setDelPass] = useState('');
const [delError, setDelError] = useState('');
const [deleting, setDeleting] = useState(false);
+ const [revoking, setRevoking] = useState(false);
+ const [revokeError, setRevokeError] = useState('');
+
+ // Every session of this account stops renewing, this one included — the
+ // case it exists for is a browser left signed in somewhere else.
+ const signOutEverywhere = useCallback(async () => {
+ if (!confirm(t('settings.sign_out_everywhere_confirm'))) return;
+ setRevoking(true);
+ setRevokeError('');
+ try {
+ await hubFetch('/v1/users/me/sessions/revoke', { method: 'POST', token: user.token });
+ onLogout();
+ } catch (err) {
+ setRevokeError(err.message);
+ setRevoking(false);
+ }
+ }, [user, onLogout]);
const deleteAccount = useCallback(async (e) => {
e.preventDefault();
@@ -485,6 +502,15 @@ export function ProfilePage({ user, onLogout }) {
</div>
<div class="settings-section">
+ <h3 class="settings-heading">${t('settings.sessions_heading')}</h3>
+ <p class="settings-hint">${t('settings.sign_out_everywhere_hint')}</p>
+ ${revokeError && html`<p class="error-msg">${revokeError}</p>`}
+ <button class="btn-secondary" disabled=${revoking} onClick=${signOutEverywhere}>
+ ${revoking ? '…' : t('settings.sign_out_everywhere')}
+ </button>
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('settings.danger')}</h3>
<p class="settings-hint">${t('settings.delete_hint')}</p>
${delError && html`<p class="error-msg">${delError}</p>`}