mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[auth] Add "Lock this device" control to re-lock a kept-unlocked session (#51)
The opt-in "keep unlocked" cache had no user-facing way to undo it mid-session
(it cleared only on logout / clear-site-data). Add a "Lock this device" button to
the profile modal footer (desktop + mobile), shown only when the active account
has a cached key to forget (app.isSessionKeptUnlocked -> client.hasCachedUnlockedKey).
Clicking it runs app.lockKeptUnlockedSession(pubkey), which re-locks in one action
everything the signer unlocks:
- forgets the cached key (both tiers) so future reloads re-prompt for the PIN,
- drops the in-memory signer (logoutSigner) so the CURRENT session re-locks now
(the next signing action prompts again via ensureEncryptionCapableSigner),
- storageService.lock(pubkey), and
- nwcSettingsService.clearCache() (re-hydrates from the signer next time).
Visibility refreshes on renderSavedProfiles + selectPane. Tests: 2 in
tests/profile-modal-controller.test.mjs (shown only when kept-unlocked; click locks
the active pubkey). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
b0a2eeaf72
commit
cebd3dc1e7
7 files changed
+255
-6
No files matched your search
@@ -373,10 +373,31 @@
|
||||
</svg>
|
||||
Admin (Moderation)
|
||||
</button>
|
||||
<button
|
||||
id="profileMobileLockNowBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden hidden"
|
||||
title="Forget your unlocked key on this device and re-lock storage + wallet now"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
Lock this device
|
||||
</button>
|
||||
<button
|
||||
id="profileMobileLogoutBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden"
|
||||
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-2 lg:hidden"
|
||||
data-variant="critical"
|
||||
>
|
||||
<svg
|
||||
@@ -1943,6 +1964,14 @@
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="hidden lg:flex items-center justify-end gap-3 border-t border-border/60 p-4">
|
||||
<button
|
||||
id="profileLockNowBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring hidden"
|
||||
title="Forget your unlocked key on this device and re-lock storage + wallet now"
|
||||
>
|
||||
Lock this device
|
||||
</button>
|
||||
<button id="profileLogoutBtn" class="btn focus-ring" data-variant="danger">
|
||||
Logout
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user