Skip to content

Commit 8a2fd11

Browse files
Caldisclaude
andcommitted
feat(wall): keep the admin unlock until explicitly locked
The admin token lived in sessionStorage, so closing the tab silently relocked admin mode and the maintainer had to re-enter the token every session. Store it in localStorage instead: the unlock now survives reloads, closed tabs and restarts until lockAdmin() clears it. The Worker remains the sole authority validating the token on every request; a bonus is the cross-tab `storage` listener in useWallAdmin now actually fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 24f6943 commit 8a2fd11

2 files changed

Lines changed: 11 additions & 10 deletions

File tree

‎website/app/hooks/useWallAdmin.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ import {
88
verifyAdmin,
99
} from "@/app/services/wall";
1010

11-
// Shared admin (panel moderation) state for the wall. sessionStorage is the source
11+
// Shared admin (panel moderation) state for the wall. localStorage is the source
1212
// of truth (written in services/wall.ts); this hook mirrors it into React and
1313
// keeps the sibling WallHeader (which unlocks) and WallClient (which shows the
1414
// delete affordances) in sync via a window event. Lighter than a Context
1515
// provider — which page.tsx can't host, being a server component that owns the
1616
// static `metadata` required by the static export.
1717
export function useWallAdmin() {
1818
// Start false on both server and first client render to avoid an SSR/hydration
19-
// mismatch; the real value is read from sessionStorage after mount.
19+
// mismatch; the real value is read from localStorage after mount.
2020
const [admin, setAdmin] = useState(false);
2121

2222
useEffect(() => {

‎website/app/services/wall.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -227,19 +227,20 @@ export function getOwner(): string {
227227

228228
// --- Admin (panel moderation) -----------------------------------------------
229229
// A single shared secret unlocks panel moderation (delete ANY note). It lives in
230-
// sessionStorage — cleared when the tab closes, safer than localStorage for a
231-
// privileged credential — and rides on the x-wall-admin header. The Worker is the
232-
// only authority: without a valid token every admin request is rejected, so the
233-
// client-side "admin mode" is purely a UI affordance, not a security boundary.
230+
// localStorage — the maintainer asked for a non-expiring unlock, so it survives
231+
// reloads, closed tabs and restarts until explicitly locked — and rides on the
232+
// x-wall-admin header. The Worker is the only authority: without a valid token
233+
// every admin request is rejected, so the client-side "admin mode" is purely a
234+
// UI affordance, not a security boundary.
234235
const ADMIN_TOKEN_KEY = "wall_admin";
235236
// Same-tab listeners can't use the native `storage` event (it only fires in OTHER
236237
// tabs), so unlock/lock dispatch this so the current tab's hook re-renders too.
237238
export const WALL_ADMIN_EVENT = "wall-admin-change";
238239

239240
export function getAdminToken(): string {
240-
if (typeof sessionStorage === "undefined") return "";
241+
if (typeof localStorage === "undefined") return "";
241242
try {
242-
return sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? "";
243+
return localStorage.getItem(ADMIN_TOKEN_KEY) ?? "";
243244
} catch {
244245
return "";
245246
}
@@ -251,8 +252,8 @@ export function isAdminUnlocked(): boolean {
251252

252253
function setAdminToken(token: string): void {
253254
try {
254-
if (token) sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
255-
else sessionStorage.removeItem(ADMIN_TOKEN_KEY);
255+
if (token) localStorage.setItem(ADMIN_TOKEN_KEY, token);
256+
else localStorage.removeItem(ADMIN_TOKEN_KEY);
256257
} catch {
257258
// private mode / quota — admin mode just won't persist; ignore.
258259
}

0 commit comments

Comments
 (0)