diff --git a/components/profile-modal.html b/components/profile-modal.html index 198f88d8..310a20e8 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -373,10 +373,31 @@ Admin (Moderation) + + + + + Lock this device + + + Lock this device + Logout diff --git a/js/app.js b/js/app.js index 9799f1b7..ab9d6dbd 100644 --- a/js/app.js +++ b/js/app.js @@ -96,6 +96,7 @@ import { import { getActiveSigner, onActiveSignerChanged, + logoutSigner, } from "./nostrClientRegistry.js"; import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js"; import { @@ -2220,6 +2221,76 @@ class Application { return { ok: true, unlocked: true }; } + // True when the active (or given) account currently has a cached "keep + // unlocked" key — i.e. there is something to lock (TODO #51). + isSessionKeptUnlocked(pubkey) { + const normalized = + this.normalizeHexPubkey(pubkey) || + this.normalizeHexPubkey(this.pubkey) || + ""; + if (!normalized || typeof nostrClient?.hasCachedUnlockedKey !== "function") { + return false; + } + try { + return nostrClient.hasCachedUnlockedKey(normalized); + } catch (error) { + return false; + } + } + + // "Lock now": re-lock this device immediately AND forget the cached key so a + // future reload re-prompts. Counterpart to the opt-in "keep unlocked" (TODO + // #51). Locks everything the signer unlocks — the in-memory signer, storage, + // and the decrypted NWC cache — so the account is fully re-locked in one action. + lockKeptUnlockedSession(pubkey) { + const normalized = + this.normalizeHexPubkey(pubkey) || + this.normalizeHexPubkey(this.pubkey) || + ""; + if (!normalized) { + return { ok: false, reason: "no-pubkey" }; + } + + // 1. Forget the cached key (both tiers) → future reloads re-prompt for the PIN. + try { + if (typeof nostrClient.forgetUnlockedSigner === "function") { + nostrClient.forgetUnlockedSigner(normalized); + } + } catch (error) { + devLogger.warn("[Application] Failed to forget cached unlock on lock:", error); + } + + // 2. Remove the in-memory nsec signer → the CURRENT session re-locks now. + try { + logoutSigner(normalized); + } catch (error) { + devLogger.warn("[Application] Failed to drop the active signer on lock:", error); + } + + // 3. Lock storage (drops the in-memory master key). + try { + if (typeof storageService.lock === "function") { + storageService.lock(normalized); + } + } catch (error) { + devLogger.warn("[Application] Failed to lock storage on lock:", error); + } + + // 4. Drop the decrypted NWC cache (re-hydrates from the signer next time). + try { + if (typeof this.nwcSettingsService?.clearCache === "function") { + this.nwcSettingsService.clearCache(); + } + } catch (error) { + devLogger.warn("[Application] Failed to drop the NWC cache on lock:", error); + } + + this.showSuccess?.( + "Locked on this device. You'll re-enter your PIN next time it's needed.", + ); + return { ok: true }; + } + resetPermissionPromptState() { this.permissionPromptShownForSession = false; this.permissionPromptVisible = false; diff --git a/js/nostr/client.js b/js/nostr/client.js index 202dc564..4b75b729 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2792,6 +2792,15 @@ export class NostrClient { } } + // True if a "keep unlocked" key is cached for this account (session or disk). + hasCachedUnlockedKey(targetPubkey) { + try { + return Boolean(readUnlockedKey(targetPubkey)); + } catch (error) { + return false; + } + } + // Forget any cached "keep unlocked" key for an account (both tiers). forgetUnlockedSigner(targetPubkey) { try { diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 206b30ab..fa83a3ec 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -577,6 +577,10 @@ export default class ApplicationBootstrap { // re-unlock it instead of dead-ending with a blanket error (TODO #57). ensureEncryptionCapableSigner: (options = {}) => app.ensureEncryptionCapableSigner(options), + // "Keep unlocked" lock controls (TODO #51). + isSessionKeptUnlocked: (pubkey) => app.isSessionKeptUnlocked(pubkey), + lockKeptUnlockedSession: (pubkey) => + app.lockKeptUnlockedSession(pubkey), hashtagPreferences: app.hashtagPreferences, getHashtagPreferences: () => app.getHashtagPreferences(), describeHashtagPreferencesError: (error, fallbackMessage) => diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index da27d4f4..051b759a 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -360,6 +360,8 @@ export class ProfileModalController { this.closeButton = null; this.logoutButton = null; this.mobileLogoutButton = null; + this.lockNowButton = null; + this.mobileLockNowButton = null; this.channelLink = null; this.addAccountButton = null; this.navButtons = { @@ -657,6 +659,9 @@ export class ProfileModalController { this.logoutButton = document.getElementById("profileLogoutBtn") || null; this.mobileLogoutButton = document.getElementById("profileMobileLogoutBtn") || null; + this.lockNowButton = document.getElementById("profileLockNowBtn") || null; + this.mobileLockNowButton = + document.getElementById("profileMobileLockNowBtn") || null; this.channelLink = document.getElementById("profileChannelLink") || null; this.addAccountButton = document.getElementById("profileAddAccountBtn") || null; @@ -1291,6 +1296,25 @@ export class ProfileModalController { } } + // Show the "Lock this device" control only when there is a cached "keep + // unlocked" key to forget (an nsec account currently kept unlocked). TODO #51. + updateLockNowVisibility() { + const check = this.services?.isSessionKeptUnlocked; + let unlocked = false; + if (typeof check === "function") { + try { + unlocked = Boolean(check(this.getActivePubkey())); + } catch (error) { + unlocked = false; + } + } + for (const button of [this.lockNowButton, this.mobileLockNowButton]) { + if (button instanceof HTMLElement) { + button.classList.toggle("hidden", !unlocked); + } + } + } + setMessagesUnreadIndicator(visible) { if (!(this.dmController.profileMessagesUnreadDot instanceof HTMLElement)) { return; @@ -1672,6 +1696,25 @@ export class ProfileModalController { }); } + const handleLockNow = () => { + const lock = this.services?.lockKeptUnlockedSession; + if (typeof lock !== "function") { + return; + } + try { + lock(this.getActivePubkey()); + } catch (error) { + devLogger.warn("[profileModal] Lock this device failed:", error); + } + this.updateLockNowVisibility(); + }; + if (this.lockNowButton instanceof HTMLElement) { + this.lockNowButton.addEventListener("click", handleLockNow); + } + if (this.mobileLockNowButton instanceof HTMLElement) { + this.mobileLockNowButton.addEventListener("click", handleLockNow); + } + if (this.channelLink instanceof HTMLElement) { this.channelLink.addEventListener("click", (event) => { event.preventDefault(); @@ -2365,6 +2408,7 @@ export class ProfileModalController { * Triggers a batch fetch for any missing profile metadata. */ renderSavedProfiles() { + this.updateLockNowVisibility(); const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey()); const entriesNeedingFetch = new Set(); const savedProfiles = this.getSavedProfiles(); @@ -3237,6 +3281,7 @@ export class ProfileModalController { } selectPane(name = "account", options = {}) { + this.updateLockNowVisibility(); const { keepMenuView = false } = options && typeof options === "object" ? options : {}; const normalized = typeof name === "string" ? name.toLowerCase() : "account"; diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index 19a00871..49f0aa24 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -1562,3 +1562,86 @@ test('hashtag persist: an ok signer gate lets publish proceed', async (t) => { }); assert.equal(publishCalls.length, 1, 'publish ran once after the gate passed'); }); + +// "Lock this device" control (TODO #51): only shown when there is a cached +// "keep unlocked" key to forget, and clicking it locks the active account. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-lock-now-control +// given: "a profile modal with mocked isSessionKeptUnlocked + lockKeptUnlockedSession" +// when: "updateLockNowVisibility runs / the lock button is clicked" +// then: "the button shows only when unlocked; clicking locks the active pubkey" +// observable_outcomes: +// - "kept-unlocked -> button not hidden; not-unlocked -> hidden" +// - "click -> lockKeptUnlockedSession(activePubkey) called once" +// determinism_controls: +// - "mocked services; JSDOM; no network" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +test('Lock this device: shown only when the session is kept unlocked', async (t) => { + let unlocked = true; + const controller = createController({ + services: { + isSessionKeptUnlocked: () => unlocked, + lockKeptUnlockedSession: () => ({ ok: true }), + }, + }); + await controller.load(); + t.after(() => { + try { + controller.hide({ silent: true }); + } catch {} + resetRuntimeFlags(); + }); + controller.setActivePubkey('a'.repeat(64)); + + controller.updateLockNowVisibility(); + assert.ok( + controller.lockNowButton instanceof dom.window.HTMLElement, + 'the lock button exists in the modal', + ); + assert.equal( + controller.lockNowButton.classList.contains('hidden'), + false, + 'shown when the session is kept unlocked', + ); + + unlocked = false; + controller.updateLockNowVisibility(); + assert.equal( + controller.lockNowButton.classList.contains('hidden'), + true, + 'hidden when nothing is cached to lock', + ); +}); + +test('Lock this device: clicking locks the active account', async (t) => { + const lockCalls = []; + const controller = createController({ + services: { + isSessionKeptUnlocked: () => true, + lockKeptUnlockedSession: (pubkey) => { + lockCalls.push(pubkey); + return { ok: true }; + }, + }, + }); + await controller.load(); + t.after(() => { + try { + controller.hide({ silent: true }); + } catch {} + resetRuntimeFlags(); + }); + controller.setActivePubkey('a'.repeat(64)); + controller.updateLockNowVisibility(); + + controller.lockNowButton.click(); + assert.equal(lockCalls.length, 1, 'lock ran once'); + assert.equal(lockCalls[0], controller.getActivePubkey(), 'locked the active account'); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index cf71d605..0358e8b4 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -1608,11 +1608,19 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that. Storage shows Unlocked + the wallet/NWC connection is live without re-entry; close the tab + reopen → PIN again. Check the box → close/reopen the browser → still no PIN and storage/NWC still ready; Log out or clear site data → PIN again. -- [ ] **Follow-up (optional):** a visible "Lock now / forget on this device" control in - the profile menu (today the cache is cleared only on logout / clear-site-data), and - consider surfacing whether the current session is persistently remembered. When it - lands, "Lock now" should also `storageService.lock(pubkey)` + drop the NWC cache so - the inherited unlocks re-lock together. +- [x] **"Lock this device" control — DONE 2026-07-01.** A footer button in the profile + modal (desktop + mobile), shown only when the active account has a cached + "keep unlocked" key to forget (`app.isSessionKeptUnlocked`). Clicking it runs + `app.lockKeptUnlockedSession(pubkey)`, which re-locks everything the signer + unlocks in one action: forgets the cached key (both tiers → future reloads + re-prompt), drops the in-memory signer (`logoutSigner` → the current session + re-locks now), `storageService.lock(pubkey)`, and `nwcSettingsService.clearCache()`. + Visibility refreshes on `renderSavedProfiles` + `selectPane`. Tests: + 2 in `tests/profile-modal-controller.test.mjs` (visibility gate + click locks the + active pubkey). Lint + build green. +- [ ] **Follow-up (optional):** surface whether the current session is *persistently* + remembered (disk vs session tier) in the UI, and consider a "keep unlocked" toggle + in the storage/wallet panes for users who set it up there rather than via a prompt. ### 52. After editing a video, the video grids don't refresh to show the update (BUG) - [ ] Editing a video leaves the grids showing the pre-edit version until a manual