From 204776ee31fdf87dfc3006ebd8e92b30d8bfcb25 Mon Sep 17 00:00:00 2001 From: b-sw <45360754+b-sw@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:49:10 +0100 Subject: [PATCH] feat: lock the browser after 1 hour of inactivity Add "After 1 hour of inactivity" as a lock setting and make it the default for users with no saved choice. Activity in any tab keeps every tab unlocked. The first input after a long absence locks, because timers stop while a device sleeps. An idle locked tab stops reconnecting its device-flow stream until someone returns, and the unlock dialog says why the browser locked. Also raise the mobile header above the page content, so the user menu is no longer hidden under the editor on phones. Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/browser-lock.spec.ts | 183 ++++++++++++++++++ apps/web/src/lib/keys/activity.svelte.ts | 79 ++++++++ apps/web/src/lib/keys/lock-sync.ts | 10 + .../lib/keys/ui/UnlockBrowserDialog.svelte | 10 +- .../src/lib/projects/ui/ProjectPage.svelte | 5 +- apps/web/src/lib/stores/auth.svelte.ts | 7 +- apps/web/src/lib/stores/key-state.svelte.ts | 14 ++ apps/web/src/lib/stores/key.svelte.ts | 10 +- apps/web/src/routes/+layout.svelte | 23 ++- backend/src/user/core/entities/user.entity.ts | 2 +- .../src/user/core/entities/user.serializer.ts | 4 +- .../user/core/enum/browser-lock-on.enum.ts | 1 + backend/test/user/reads.spec.ts | 2 +- packages/backend-sdk/openapi.json | 2 + packages/backend-sdk/src/openapi.generated.ts | 4 +- 15 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/lib/keys/activity.svelte.ts diff --git a/apps/web/e2e/browser-lock.spec.ts b/apps/web/e2e/browser-lock.spec.ts index e216bf99..34e7c3a6 100644 --- a/apps/web/e2e/browser-lock.spec.ts +++ b/apps/web/e2e/browser-lock.spec.ts @@ -483,6 +483,189 @@ test("the passphrase setting can lock the browser whenever the tab loses focus", await expect.poll(() => hasStoredMasterKey(page)).toBe(false); }); +const IDLE_USER = { ...user, lockBrowserOn: "inactivity" }; +const ONE_HOUR_MS = 60 * 60 * 1000; + +async function openUnlockedApp(page: Page) { + await page.goto(`${APP}/app/project`, { waitUntil: "domcontentloaded" }); + await expect( + page.getByRole("heading", { name: /Name your first/i }), + ).toBeVisible(); +} + +function unlockDialog(page: Page) { + return page.getByRole("dialog", { name: "Unlock this browser" }); +} + +async function setLastActive(page: Page, msAgo: number) { + await page.evaluate((ago) => { + localStorage.setItem("cryptly:last-active-at", String(Date.now() - ago)); + }, msAgo); +} + +test("an hour without activity locks the browser and wipes local keys", async ({ + page, +}) => { + await page.clock.install(); + await mockApi(page, { user: IDLE_USER }); + await seedBrowserState(page); + await openUnlockedApp(page); + + await page.clock.runFor(ONE_HOUR_MS + 60 * 1000); + + await expect(unlockDialog(page)).toBeVisible(); + await expect.poll(() => hasStoredMasterKey(page)).toBe(false); +}); + +test("activity in any tab keeps every tab unlocked", async ({ browser }) => { + const context = await browser.newContext(); + await context.clock.install(); + const idleTab = await context.newPage(); + await mockApi(idleTab, { user: IDLE_USER }); + await seedBrowserState(idleTab); + await openUnlockedApp(idleTab); + const activeTab = await context.newPage(); + await mockApi(activeTab, { user: IDLE_USER }); + await openUnlockedApp(activeTab); + + await context.clock.runFor(50 * 60 * 1000); + await activeTab.mouse.move(200, 200); + await context.clock.runFor(50 * 60 * 1000); + + await expect(unlockDialog(idleTab)).toHaveCount(0); + expect(await hasStoredMasterKey(idleTab)).toBe(true); + await context.close(); +}); + +test("the first input after a long absence locks instead of renewing the session", async ({ + page, +}) => { + await page.clock.install(); + await mockApi(page, { user: IDLE_USER }); + await seedBrowserState(page); + await openUnlockedApp(page); + await page.clock.fastForward(10 * 1000); + + await setLastActive(page, 2 * ONE_HOUR_MS); + await page.mouse.move(300, 300); + + await expect(unlockDialog(page)).toBeVisible(); + await expect(unlockDialog(page)).toContainText( + "locked after 1 hour of inactivity", + ); + await expect.poll(() => hasStoredMasterKey(page)).toBe(false); +}); + +test("reloading after a long absence still locks", async ({ page }) => { + await mockApi(page, { user: IDLE_USER, userDelayMs: 500 }); + await seedBrowserState(page); + await openUnlockedApp(page); + + await setLastActive(page, 2 * ONE_HOUR_MS); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.mouse.move(300, 300); + + await expect(unlockDialog(page)).toBeVisible(); + await expect(unlockDialog(page)).toContainText( + "locked after 1 hour of inactivity", + ); + await expect.poll(() => hasStoredMasterKey(page)).toBe(false); +}); + +test("unlocking after an idle lock stays unlocked, the setting can be chosen, and a later lock from another tab does not blame inactivity", async ({ + page, +}) => { + const passphrase = "correct horse battery staple"; + const fixtures = await createUnlockedFixtures(page, passphrase); + let currentUser = { ...fixtures.user, lockBrowserOn: "tab-close" }; + await mockApi(page, { user: currentUser, projects: [fixtures.project] }); + await page.route("**/users/me", async (route) => { + if (route.request().method() === "PATCH") { + currentUser = { ...currentUser, ...route.request().postDataJSON() }; + } + await route.fulfill({ json: currentUser }); + }); + await seedAuthOnly(page); + await page.goto(`${APP}/app/project/project-eval`, { + waitUntil: "domcontentloaded", + }); + const unlock = async () => { + await page.getByRole("textbox", { name: "Passphrase" }).fill(passphrase); + await page.getByRole("button", { name: "Unlock" }).click(); + await expect(unlockDialog(page)).toHaveCount(0); + }; + await unlock(); + + await page.getByRole("button", { name: /Eval User/ }).click(); + const inactivity = page.getByRole("menuitemradio", { + name: "After 1 hour of inactivity", + }); + await inactivity.click(); + await expect(inactivity).toHaveAttribute("aria-checked", "true"); + expect(currentUser.lockBrowserOn).toBe("inactivity"); + + await setLastActive(page, 2 * ONE_HOUR_MS); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(unlockDialog(page)).toBeVisible(); + + await unlock(); + await expect(unlockDialog(page)).toHaveCount(0); + await expect.poll(() => hasStoredMasterKey(page)).toBe(true); + + await page.evaluate((key) => { + window.dispatchEvent( + new StorageEvent("storage", { key, newValue: String(Date.now()) }), + ); + }, LOCKED_AT_STORAGE_KEY); + await expect(unlockDialog(page)).toBeVisible(); + await expect(unlockDialog(page)).not.toContainText("inactivity"); +}); + +test("the user menu is fully visible on a phone", async ({ page }) => { + const fixtures = await createUnlockedFixtures(page, "correct horse"); + await page.setViewportSize({ width: 390, height: 844 }); + await mockApi(page, { user: fixtures.user, projects: [fixtures.project] }); + await seedAuthOnly(page); + await page.goto(`${APP}/app/project/project-eval`, { + waitUntil: "domcontentloaded", + }); + await page.getByRole("textbox", { name: "Passphrase" }).fill("correct horse"); + await page.getByRole("button", { name: "Unlock" }).click(); + await expect(unlockDialog(page)).toHaveCount(0); + + await page.getByRole("button", { name: "User menu" }).click(); + for (const option of await page.getByRole("menuitemradio").all()) { + await option.click({ trial: true, timeout: 5000 }); + } + await expect(page.getByRole("menuitemradio")).toHaveCount(3); +}); + +test("an idle locked tab stops reconnecting until someone returns", async ({ + page, +}) => { + await page.clock.install(); + await mockApi(page, { user: IDLE_USER }); + await seedBrowserState(page); + let requesterStreamRequests = 0; + page.on("request", (request) => { + if (request.url().includes("device-flow/messages?role=requester")) { + requesterStreamRequests += 1; + } + }); + await openUnlockedApp(page); + await page.clock.runFor(ONE_HOUR_MS + 60 * 1000); + await expect(unlockDialog(page)).toBeVisible(); + + const requestsWhenIdle = requesterStreamRequests; + await page.clock.runFor(10 * 60 * 1000); + expect(requesterStreamRequests).toBe(requestsWhenIdle); + + await page.mouse.move(300, 300); + await expect + .poll(() => requesterStreamRequests) + .toBeGreaterThan(requestsWhenIdle); +}); + test("unsaved edits survive tab switches and project switches go through the guard", async ({ page, }) => { diff --git a/apps/web/src/lib/keys/activity.svelte.ts b/apps/web/src/lib/keys/activity.svelte.ts new file mode 100644 index 00000000..8921ec3f --- /dev/null +++ b/apps/web/src/lib/keys/activity.svelte.ts @@ -0,0 +1,79 @@ +import { browser } from "$app/environment"; + +const IDLE_LIMIT_MS = 60 * 60 * 1000; +const CHECK_INTERVAL_MS = 30 * 1000; +const RECORD_THROTTLE_MS = 5 * 1000; +const LAST_ACTIVE_STORAGE_KEY = "cryptly:last-active-at"; +const ACTIVITY_EVENTS = [ + "pointerdown", + "pointermove", + "keydown", + "wheel", + "scroll", + "focus", +]; + +export const activity = $state({ idle: false }); + +let onIdle: (() => void) | null = null; +let lastHandledAt = 0; + +function readLastActive(): number | null { + const value = Number(localStorage.getItem(LAST_ACTIVE_STORAGE_KEY)); + return value > 0 ? value : null; +} + +function checkIdle() { + const lastActive = readLastActive(); + const idle = lastActive !== null && Date.now() - lastActive >= IDLE_LIMIT_MS; + activity.idle = idle; + if (idle) onIdle?.(); +} + +export function recordActivity() { + if (!browser) return; + localStorage.setItem(LAST_ACTIVE_STORAGE_KEY, String(Date.now())); + activity.idle = false; +} + +function handleActivity() { + const now = Date.now(); + if (now - lastHandledAt < RECORD_THROTTLE_MS) return; + lastHandledAt = now; + // Timers stop while a device sleeps, so the input that ends an absence must still lock. + checkIdle(); + recordActivity(); +} + +function handleVisibilityChange() { + if (document.visibilityState === "visible") handleActivity(); +} + +export function trackActivity() { + if (!browser) return; + if (readLastActive() === null) recordActivity(); + checkIdle(); + + const interval = setInterval(checkIdle, CHECK_INTERVAL_MS); + const options = { capture: true, passive: true }; + for (const event of ACTIVITY_EVENTS) { + window.addEventListener(event, handleActivity, options); + } + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + clearInterval(interval); + for (const event of ACTIVITY_EVENTS) { + window.removeEventListener(event, handleActivity, options); + } + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; +} + +export function whenIdle(listener: () => void) { + onIdle = listener; + checkIdle(); + return () => { + onIdle = null; + }; +} diff --git a/apps/web/src/lib/keys/lock-sync.ts b/apps/web/src/lib/keys/lock-sync.ts index 1964520c..0efbefde 100644 --- a/apps/web/src/lib/keys/lock-sync.ts +++ b/apps/web/src/lib/keys/lock-sync.ts @@ -1,9 +1,11 @@ import { browser } from "$app/environment"; import { keystore } from "$lib/auth/keystore"; +import { whenIdle } from "$lib/keys/activity.svelte"; import { broadcastKeyLock, KEY_LOCKED_AT_STORAGE_KEY, markKeyLocked, + noteLockedByInactivity, } from "$lib/stores/key-state.svelte"; let lockPromise: Promise | null = null; @@ -44,6 +46,14 @@ export function followKeyLocks() { return () => window.removeEventListener("storage", handleStorage); } +export function lockOnInactivity() { + if (!browser) return; + return whenIdle(() => { + noteLockedByInactivity(); + lockEveryTab(); + }); +} + /** Locks every tab as soon as this one loses focus. */ export function lockOnFocusLoss() { if (!browser) return; diff --git a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte index 35a6281f..d66c1b9e 100644 --- a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte +++ b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte @@ -15,8 +15,10 @@ deviceFlowRequester, requestUnlockFromDevice, } from "$lib/device-flow/device-flow-requester.svelte"; + import { activity } from "$lib/keys/activity.svelte"; import { auth, logout } from "$lib/stores/auth.svelte"; import { hydrateMasterKey, keyAuth, unlock } from "$lib/stores/key.svelte"; + import { wasLockedByInactivity } from "$lib/stores/key-state.svelte"; let passphrase = $state(""); let showPassphrase = $state(false); @@ -43,6 +45,8 @@ ), ); + const lockedByInactivity = $derived(open && wasLockedByInactivity()); + $effect(() => { if (open) return; passphrase = ""; @@ -61,8 +65,9 @@ } }); + const wantsApprovers = $derived(open && !activity.idle); $effect(() => { - if (!open) { + if (!wantsApprovers) { detachDeviceFlowRequester(); return; } @@ -118,6 +123,9 @@ Unlock this browser

+ {#if lockedByInactivity} + Cryptly locked after 1 hour of inactivity. + {/if} Enter your account passphrase.