From 963afa33a664aba72c930723607e5777fc6fcd8f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 25 Jun 2026 22:58:29 +0200 Subject: [PATCH] fix(messages): gate on server key state, not just local unlock The Messages UI trusted only the per-device IndexedDB key, so if the server's key record was gone (deleted, DB-restored, admin-reset) while a stale private key lingered in the browser, the app looked 'unlocked' but no one could be messaged and no setup was offered. Add useKeyState (server 'configured' AND local unlock): show setup when the server has no key (setup overwrites the stale local key), unlock when it has one this device hasn't opened, ready otherwise. ChatThread is now self-contained. Also fix the header title on the Messages and Notifications pages (was falling through to 'Channel manager'). --- frontend/src/components/ChatDock.tsx | 8 +++----- frontend/src/components/ChatThread.tsx | 14 ++++++------- frontend/src/components/Header.tsx | 6 +++++- frontend/src/components/Messages.tsx | 28 +++++++++++--------------- frontend/src/lib/messaging.ts | 15 ++++++++++++++ 5 files changed, 42 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index ba24db7..12fff57 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, ChevronUp, X } from "lucide-react"; import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock"; -import { useUnlocked } from "../lib/messaging"; import { onMessage } from "../lib/messagesSocket"; import * as e2ee from "../lib/e2ee"; import Avatar from "./Avatar"; @@ -15,7 +14,6 @@ import ChatThread from "./ChatThread"; export default function ChatDock({ meId }: { meId: number }) { const qc = useQueryClient(); const chats = useDockChats(); - const unlocked = useUnlocked(); // Restore the per-device unlocked key and the persisted dock windows once, app-wide. useEffect(() => { @@ -45,13 +43,13 @@ export default function ChatDock({ meId }: { meId: number }) { return (
{chats.map((c) => ( - + ))}
); } -function ChatWindow({ chat, meId, unlocked }: { chat: DockChat; meId: number; unlocked: boolean }) { +function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) { const { t } = useTranslation(); const [flashing, setFlashing] = useState(false); @@ -95,7 +93,7 @@ function ChatWindow({ chat, meId, unlocked }: { chat: DockChat; meId: number; un {!chat.minimized && (
- +
)} diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index bd373e7..138eddb 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -5,7 +5,7 @@ import { Send } from "lucide-react"; import { api, type Message, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { relativeTime } from "../lib/format"; -import { partnerPub } from "../lib/messaging"; +import { partnerPub, useKeyState } from "../lib/messaging"; import * as e2ee from "../lib/e2ee"; import KeyGate from "./KeyGate"; @@ -13,24 +13,24 @@ const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket // The message list + composer for one conversation, shared by the full Messages page and the // floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding -// chrome (back button / minimize / close) is the caller's. +// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts +// setup/unlock (server-truth) for a user conversation until messaging is ready. export default function ChatThread({ meId, partnerId, isSystem, - unlocked, }: { meId: number; partnerId: number; isSystem: boolean; - unlocked: boolean; }) { const { t } = useTranslation(); const qc = useQueryClient(); const [draft, setDraft] = useState(""); const [plain, setPlain] = useState>({}); const bottomRef = useRef(null); - const needsKey = !isSystem && !unlocked; + const ready = useKeyState() === "ready"; + const needsKey = !isSystem && !ready; const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>( ["thread", partnerId], @@ -40,7 +40,7 @@ export default function ChatThread({ const items = q.data?.items ?? []; useEffect(() => { - if (isSystem || !unlocked) return; + if (isSystem || !ready) return; let cancelled = false; (async () => { const out: Record = {}; @@ -59,7 +59,7 @@ export default function ChatThread({ return () => { cancelled = true; }; - }, [items, unlocked, isSystem, partnerId, t]); + }, [items, ready, isSystem, partnerId, t]); // Opening / refreshing the thread marks incoming messages read, so clear the badges. useEffect(() => { diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index b0f1b4b..a7a1c26 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -79,7 +79,11 @@ export default function Header({ ? t("header.configuration") : page === "users" ? t("header.users") - : t("header.channelManager")} + : page === "notifications" + ? t("inbox.navLabel") + : page === "messages" + ? t("messages.navLabel") + : t("header.channelManager")} )} diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 9a10680..231d672 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -5,7 +5,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { api, type Conversation, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { relativeTime } from "../lib/format"; -import { partnerPub, SYSTEM_ID, useUnlocked } from "../lib/messaging"; +import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging"; import { openChat } from "../lib/chatDock"; import * as e2ee from "../lib/e2ee"; import Avatar from "./Avatar"; @@ -18,7 +18,6 @@ type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string export default function Messages({ meId }: { meId: number }) { const [view, setView] = useState("list"); - const unlocked = useUnlocked(); if (typeof view === "object") { return ( @@ -28,7 +27,6 @@ export default function Messages({ meId }: { meId: number }) { isSystem={!!view.system} seedName={view.name} seedAvatar={view.avatar} - unlocked={unlocked} onBack={() => setView("list")} /> ); @@ -39,7 +37,6 @@ export default function Messages({ meId }: { meId: number }) { return ( setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })} onNew={() => setView("new")} /> @@ -48,22 +45,22 @@ export default function Messages({ meId }: { meId: number }) { function ConversationList({ meId, - unlocked, onOpen, onNew, }: { meId: number; - unlocked: boolean; onOpen: (c: Conversation) => void; onNew: () => void; }) { const { t } = useTranslation(); + const keyState = useKeyState(); + const ready = keyState === "ready"; const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS }); const items = q.data?.items ?? []; const [previews, setPreviews] = useState>({}); useEffect(() => { - if (!unlocked) return; + if (!ready) return; let cancelled = false; (async () => { const out: Record = {}; @@ -83,12 +80,12 @@ function ConversationList({ return () => { cancelled = true; }; - }, [items, unlocked]); + }, [items, ready]); function previewText(c: Conversation): string { const m = c.last_message; if (m.kind === "system") return m.body ?? ""; - if (!unlocked) return "🔒 " + t("messages.encrypted"); + if (!ready) return "🔒 " + t("messages.encrypted"); return previews[c.partner.id] ?? "…"; } @@ -96,7 +93,7 @@ function ConversationList({
{t("messages.conversations")}
- {unlocked && ( + {ready && (
- {!unlocked && } + {(keyState === "setup" || keyState === "unlock") && } {q.isLoading && !q.data ? (
{t("common.loading")}
) : items.length === 0 ? ( @@ -134,7 +131,7 @@ function ConversationList({
- {!isSystem && unlocked && ( + {!isSystem && ready && ( )} - + ); } diff --git a/frontend/src/lib/messaging.ts b/frontend/src/lib/messaging.ts index 4824115..42e9bc7 100644 --- a/frontend/src/lib/messaging.ts +++ b/frontend/src/lib/messaging.ts @@ -1,5 +1,6 @@ // Shared messaging helpers used by both the Messages page and the floating chat dock. import { useSyncExternalStore } from "react"; +import { useQuery } from "@tanstack/react-query"; import { api } from "./api"; import { isUnlocked, subscribeUnlock } from "./e2ee"; @@ -19,3 +20,17 @@ export async function partnerPub(partnerId: number): Promise { export function useUnlocked(): boolean { return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked); } + +// The SERVER is the source of truth for whether messaging is set up. Combining it with the +// local unlock state gives the real gate: "setup" if the server has no key (even if a stale +// private key lingers in this browser — setup overwrites it), "unlock" if the server has a key +// but this device hasn't unlocked it, else "ready". +export type KeyState = "loading" | "setup" | "unlock" | "ready"; +export function useKeyState(): KeyState { + const unlocked = useUnlocked(); + const q = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 }); + if (q.isLoading && !q.data) return "loading"; + if (!(q.data?.configured ?? false)) return "setup"; + if (!unlocked) return "unlock"; + return "ready"; +}