diff --git a/example-apps/dashnote/src/App.tsx b/example-apps/dashnote/src/App.tsx index a751451..2f167e2 100644 --- a/example-apps/dashnote/src/App.tsx +++ b/example-apps/dashnote/src/App.tsx @@ -6,6 +6,7 @@ import { HowItWorks } from "./components/HowItWorks"; import { LoginModal } from "./components/LoginModal"; import { NotesWorkspace } from "./components/NotesWorkspace"; import { OperationResultNotice } from "./components/OperationResultNotice"; +import { SettingsPanel } from "./components/SettingsPanel"; import type { TopTab } from "./components/Tabs"; import { useSession } from "./session/useSession"; @@ -20,6 +21,11 @@ const screenCopy: Record = { subtitle: "See how the note contract, mutation helpers, and notebook UI line up with the tutorials.", }, + settings: { + title: "Settings", + subtitle: + "Manage your identity, contract, and local data for this browser.", + }, }; function App() { @@ -86,9 +92,13 @@ function App() { )} {tab === "notes" && ( - setLoginOpen(true)} /> + setLoginOpen(true)} + onOpenSettings={() => setTab("settings")} + /> )} {tab === "how-it-works" && } + {tab === "settings" && } diff --git a/example-apps/dashnote/src/components/AppShell.tsx b/example-apps/dashnote/src/components/AppShell.tsx index cfb0581..e1eee78 100644 --- a/example-apps/dashnote/src/components/AppShell.tsx +++ b/example-apps/dashnote/src/components/AppShell.tsx @@ -107,6 +107,15 @@ export function AppShell({ closeDrawer(); }} /> + { + onTabChange("settings"); + closeDrawer(); + }} + /> {status !== "authenticated" && ( { + onTabChange("settings"); + closeDrawer(); + }} /> diff --git a/example-apps/dashnote/src/components/IdentityCard.tsx b/example-apps/dashnote/src/components/IdentityCard.tsx index 82c56db..eee938d 100644 --- a/example-apps/dashnote/src/components/IdentityCard.tsx +++ b/example-apps/dashnote/src/components/IdentityCard.tsx @@ -1,5 +1,8 @@ +import { useEffect, useRef, useState } from "react"; + import type { SessionStatus } from "../session/SessionContext"; import { truncateId } from "../lib/format"; +import { useSession } from "../session/useSession"; interface IdentityCardProps { status: SessionStatus; @@ -7,6 +10,7 @@ interface IdentityCardProps { dpnsName: string | null; contractId: string | null; onLoginClick: () => void; + onOpenSettings: () => void; } function avatarGradient(seed: string | null): string { @@ -26,10 +30,36 @@ export function IdentityCard({ dpnsName, contractId, onLoginClick, + onOpenSettings, }: IdentityCardProps) { + const session = useSession(); const isAuthed = status === "authenticated"; const isBrowsing = status === "browsing"; - const isConnected = status === "readonly" || isAuthed || isBrowsing; + const isReadonly = status === "readonly"; + const isConnected = isReadonly || isAuthed || isBrowsing; + const hasIdentity = isAuthed || isBrowsing; + const [menuOpen, setMenuOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const onPointer = (event: MouseEvent | TouchEvent) => { + if (!containerRef.current) return; + if (containerRef.current.contains(event.target as Node)) return; + setMenuOpen(false); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("mousedown", onPointer); + document.addEventListener("touchstart", onPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onPointer); + document.removeEventListener("touchstart", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [menuOpen]); if (!isConnected) { return ( @@ -63,59 +93,126 @@ export function IdentityCard({ ); } + // Read-only mode has nothing to put in a menu (no identity → no Settings + // target, no Switch identity, no Log out), so the card goes straight to + // the login modal on click — matching the pre-menu behavior. + if (isReadonly) { + return ( + + ); + } + return ( - +
+ + + {isAuthed ? "Authenticated" : "Browsing (read-only)"} + +
+ + + {menuOpen && ( +
+ + + {isAuthed && ( + + )} +
+ )} + ); } diff --git a/example-apps/dashnote/src/components/LoginModal.tsx b/example-apps/dashnote/src/components/LoginModal.tsx index 3dda78d..dd9bb7f 100644 --- a/example-apps/dashnote/src/components/LoginModal.tsx +++ b/example-apps/dashnote/src/components/LoginModal.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; -import { registerContract } from "../dash/contract"; import { useWifPreview } from "../hooks/useWifPreview"; import { detectSecretShape } from "../lib/detectSecretShape"; import { errorMessage } from "../lib/logger"; @@ -21,10 +20,8 @@ export function LoginModal({ open, onClose }: LoginModalProps) { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); - const [registering, setRegistering] = useState(false); const [rememberMe, setRememberMe] = useState(true); const [useDifferentIdentity, setUseDifferentIdentity] = useState(false); - const loggedIn = session.status === "authenticated"; const showRememberedPanel = Boolean( session.rememberedIdentityId && !useDifferentIdentity, ); @@ -57,25 +54,6 @@ export function LoginModal({ open, onClose }: LoginModalProps) { session.setContractId(contractInput.trim() || null); } - async function handleRegisterContract() { - if (!session.sdk || !session.keyManager) return; - setError(null); - setRegistering(true); - try { - const contractId = await registerContract({ - sdk: session.sdk, - keyManager: session.keyManager, - log: session.log, - }); - session.setContractId(contractId); - setContractInput(contractId); - } catch (err) { - setError(errorMessage(err)); - } finally { - setRegistering(false); - } - } - async function handleLogin(event: FormEvent) { event.preventDefault(); setError(null); @@ -96,355 +74,234 @@ export function LoginModal({ open, onClose }: LoginModalProps) { } return ( - - {loggedIn ? ( -
-
-
+ +
+ {showRememberedPanel && session.rememberedIdentityId && ( +
-
- {session.identityId ?? "—"} -
+ + {session.dpnsName && ( -
+ ✓ {session.dpnsName}.dash -
+ )} + + )} + + {useDifferentIdentity && session.rememberedIdentityId && ( +

+ Signing in with a different identity. The remembered identity stays + remembered until you sign in again or forget it. +

+ )} + + + + {session.rememberedIdentityId && ( +
+ {!useDifferentIdentity && ( - {session.rememberedIdentityId && ( - - )} -
+ )} +
+ )} - + ▶ + + Advanced settings + - {showAdvanced && ( -
+ {showAdvanced && ( +
+ {!isWifInput && ( + + )} + +
- Contract ID + Contract ID (optional) setContractInput(event.target.value)} - placeholder="Paste a note contract ID or register a new one" + placeholder="Paste a Dashnote note contract ID to reuse" className="rounded-md border border-line bg-bg px-3 py-2 font-mono text-[12px] text-ink outline-none transition focus:border-accent-dim" /> -
- - -
-

- Register deploys a fresh note contract to testnet and switches - Dashnote to it immediately. -

-
- )} - - {error && ( - - {error} - - )} - -
- - -
-
- ) : ( - - {showRememberedPanel && session.rememberedIdentityId && ( - - )} - - {useDifferentIdentity && session.rememberedIdentityId && ( -

- Signing in with a different identity. The remembered identity - stays remembered until you sign in again or forget it. -

- )} - - - - {session.rememberedIdentityId && ( -
- {!useDifferentIdentity && ( - - )}
- )} +
+ )} - + {error && ( + + {error} + + )} + +

+ Your secret never leaves this browser. Only the public identity ID is + stored when this identity is remembered on this device. +

+
+ - - {showAdvanced && ( -
- {!isWifInput && ( - - )} - -
- - Contract ID (optional) - - setContractInput(event.target.value)} - placeholder="Paste a Dashnote note contract ID to reuse" - className="rounded-md border border-line bg-bg px-3 py-2 font-mono text-[12px] text-ink outline-none transition focus:border-accent-dim" - /> - -
-
- )} - - {error && ( - - {error} - - )} - -

- Your secret never leaves this browser. Only the public identity ID - is stored when this identity is remembered on this device. -

- -
- - -
- - )} +
+ ); } diff --git a/example-apps/dashnote/src/components/NoteEditor.tsx b/example-apps/dashnote/src/components/NoteEditor.tsx index 4d2298d..7a15dbc 100644 --- a/example-apps/dashnote/src/components/NoteEditor.tsx +++ b/example-apps/dashnote/src/components/NoteEditor.tsx @@ -23,6 +23,7 @@ interface NoteEditorProps { messageOversize: boolean; contractReady: boolean; error: string | null; + onOpenLogin: () => void; onOpenSettings: () => void; isReadOnly?: boolean; isDesktop: boolean; @@ -48,6 +49,7 @@ export function NoteEditor({ messageOversize, contractReady, error, + onOpenLogin, onOpenSettings, isReadOnly = false, isDesktop, @@ -115,7 +117,7 @@ export function NoteEditor({ {isReadOnly ? (
diff --git a/example-apps/dashnote/src/components/SettingsPanel.tsx b/example-apps/dashnote/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..d6116e5 --- /dev/null +++ b/example-apps/dashnote/src/components/SettingsPanel.tsx @@ -0,0 +1,239 @@ +import { useCallback, useEffect, useState } from "react"; + +import { useContractRegistration } from "../hooks/useContractRegistration"; +import { useTheme } from "../hooks/useTheme"; +import { clearCachedNotes } from "../lib/notesCache"; +import { useSession } from "../session/useSession"; +import { OperationResultNotice } from "./OperationResultNotice"; + +const NETWORK = "testnet" as const; + +interface CopyButtonProps { + value: string | null; + label: string; +} + +function CopyButton({ value, label }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const id = window.setTimeout(() => setCopied(false), 2000); + return () => window.clearTimeout(id); + }, [copied]); + + const onClick = useCallback(async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + } catch { + // Clipboard API can fail in insecure contexts; surface no error here. + } + }, [value]); + + return ( + + ); +} + +function ThemeToggleControl() { + const { theme, toggle } = useTheme(); + const isDark = theme === "dark"; + return ( + + ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +export function SettingsPanel() { + const session = useSession(); + const { + register, + registering, + error: registerError, + } = useContractRegistration(); + const [contractInput, setContractInput] = useState(session.contractId ?? ""); + const [lastSyncedContractId, setLastSyncedContractId] = useState( + session.contractId, + ); + const [cacheCleared, setCacheCleared] = useState(false); + + // Re-sync the local input whenever the upstream contract ID changes (e.g. + // a fresh registration completes). Adjusting state during render is the + // recommended pattern over a useEffect for prop-derived state. + if (session.contractId !== lastSyncedContractId) { + setLastSyncedContractId(session.contractId); + setContractInput(session.contractId ?? ""); + } + + useEffect(() => { + if (!cacheCleared) return; + const id = window.setTimeout(() => setCacheCleared(false), 2000); + return () => window.clearTimeout(id); + }, [cacheCleared]); + + const isConnected = + session.status === "authenticated" || session.status === "browsing"; + + if (!isConnected) { + return ( +
+ Sign in to view and manage your identity, contract, and device data. +
+ ); + } + + const trimmedContract = contractInput.trim(); + const canApplyContract = + trimmedContract.length > 0 && + trimmedContract !== (session.contractId ?? ""); + const canRegister = Boolean( + session.status === "authenticated" && session.sdk && session.keyManager, + ); + + return ( +
+
+
+
+
+ {session.identityId ?? "—"} +
+ +
+ {session.dpnsName && ( +
+ ✓ {session.dpnsName}.dash +
+ )} +
+
+ Network + {NETWORK} +
+
+ +
+
+
+ setContractInput(event.target.value)} + placeholder="Paste a note contract ID" + className="flex-1 rounded-md border border-line bg-bg px-3 py-2 font-mono text-[12px] text-ink outline-none transition focus:border-accent-dim" + /> + +
+
+ + +
+

+ Registering deploys a fresh note contract to testnet and switches + Dashnote to it immediately. +

+ {registerError && ( + + {registerError} + + )} +
+
+ +
+
+ +

+ Removes cached note bodies stored in this browser. Notes on Platform + are not affected; the cache rebuilds on the next refresh. +

+
+
+ +
+ +
+ + {session.rememberedIdentityId && ( +
+
+ +

+ Removes the remembered identity and clears its cached notes from + this browser. +

+
+
+ )} +
+ ); +} diff --git a/example-apps/dashnote/src/components/Tabs.tsx b/example-apps/dashnote/src/components/Tabs.tsx index 5919892..fd27085 100644 --- a/example-apps/dashnote/src/components/Tabs.tsx +++ b/example-apps/dashnote/src/components/Tabs.tsx @@ -1 +1 @@ -export type TopTab = "notes" | "how-it-works"; +export type TopTab = "notes" | "how-it-works" | "settings"; diff --git a/example-apps/dashnote/src/hooks/useContractRegistration.ts b/example-apps/dashnote/src/hooks/useContractRegistration.ts new file mode 100644 index 0000000..1d973da --- /dev/null +++ b/example-apps/dashnote/src/hooks/useContractRegistration.ts @@ -0,0 +1,49 @@ +import { useCallback, useRef, useState } from "react"; + +import { registerContract } from "../dash/contract"; +import { errorMessage } from "../lib/logger"; +import { useSession } from "../session/useSession"; + +export interface UseContractRegistrationResult { + register: () => Promise; + registering: boolean; + error: string | null; + clearError: () => void; +} + +export function useContractRegistration(): UseContractRegistrationResult { + const session = useSession(); + const [registering, setRegistering] = useState(false); + const [error, setError] = useState(null); + // Synchronous re-entrancy guard: contract publish is an irreversible + // testnet side effect, so reject a second call before React has a chance + // to commit `setRegistering(true)` and disable the button. + const inFlightRef = useRef(false); + + const register = useCallback(async () => { + if (inFlightRef.current) return null; + if (!session.sdk || !session.keyManager) return null; + inFlightRef.current = true; + setError(null); + setRegistering(true); + try { + const contractId = await registerContract({ + sdk: session.sdk, + keyManager: session.keyManager, + log: session.log, + }); + session.setContractId(contractId); + return contractId; + } catch (err) { + setError(errorMessage(err)); + return null; + } finally { + inFlightRef.current = false; + setRegistering(false); + } + }, [session]); + + const clearError = useCallback(() => setError(null), []); + + return { register, registering, error, clearError }; +} diff --git a/example-apps/dashnote/test/App.test.tsx b/example-apps/dashnote/test/App.test.tsx index 7f8ff8c..d4be5d4 100644 --- a/example-apps/dashnote/test/App.test.tsx +++ b/example-apps/dashnote/test/App.test.tsx @@ -40,7 +40,7 @@ vi.mock("../src/components/AppShell", () => ({ }: { children: ReactNode; onLoginOpen: () => void; - onTabChange: (tab: "notes" | "how-it-works") => void; + onTabChange: (tab: "notes" | "how-it-works" | "settings") => void; }) => (
+ {children}
), @@ -137,4 +140,22 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /open settings/i })); expect(screen.getByText("login:true")).toBeTruthy(); }); + + it("renders SettingsPanel when the settings tab is selected", () => { + mockUseSession.mockReturnValue( + makeSession({ + status: "authenticated", + identityId: "id-app-settings", + contractId: "contract-app-settings", + sdk: { documents: {} }, + }), + ); + + render(); + expect(screen.queryByTestId("settings-identity-block")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /settings tab/i })); + expect(screen.getByTestId("settings-identity-block")).toBeTruthy(); + expect(screen.getByText("id-app-settings")).toBeTruthy(); + }); }); diff --git a/example-apps/dashnote/test/IdentityCard.test.tsx b/example-apps/dashnote/test/IdentityCard.test.tsx index 4644ad7..e9d66eb 100644 --- a/example-apps/dashnote/test/IdentityCard.test.tsx +++ b/example-apps/dashnote/test/IdentityCard.test.tsx @@ -1,14 +1,18 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IdentityCard } from "../src/components/IdentityCard"; import type { SessionStatus } from "../src/session/SessionContext"; -afterEach(() => { - cleanup(); -}); +const { mockUseSession } = vi.hoisted(() => ({ + mockUseSession: vi.fn(), +})); + +vi.mock("../src/session/useSession", () => ({ + useSession: mockUseSession, +})); const IDENTITY_ID = "GgZekwh38XcWQTyWWWvmw6CEYFnLU7yiZFPWZEjqKHit"; // IdentityCard calls truncateId(id, 6); head=6 + ellipsis + tail=8. @@ -21,6 +25,8 @@ function renderCard(props: { identityId: string | null; dpnsName: string | null; contractId?: string | null; + onLoginClick?: () => void; + onOpenSettings?: () => void; }) { return render( , ); } +beforeEach(() => { + mockUseSession.mockReset(); + mockUseSession.mockReturnValue({ logout: vi.fn() }); +}); + +afterEach(() => { + cleanup(); +}); + describe("IdentityCard", () => { it("shows @-prefixed DPNS name as the primary line when authenticated", () => { renderCard({ @@ -87,4 +103,82 @@ describe("IdentityCard", () => { expect(screen.queryByText("@alice")).toBeNull(); expect(screen.queryByText(TRUNCATED_ID)).toBeNull(); }); + + // Regression: when the card was unified into a single menu trigger, readonly + // (connected-but-not-signed-in) silently lost its one-click path to the + // login modal — the menu offered Settings and Switch identity but no direct + // Login. The card must call onLoginClick on click and render no menu. + it("calls onLoginClick on click when readonly, without opening a menu", () => { + const onLoginClick = vi.fn(); + renderCard({ + status: "readonly", + identityId: null, + dpnsName: null, + onLoginClick, + }); + const trigger = screen.getByRole("button"); + fireEvent.click(trigger); + expect(onLoginClick).toHaveBeenCalled(); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("opens a menu when the connected card is clicked", () => { + renderCard({ + status: "authenticated", + identityId: IDENTITY_ID, + dpnsName: null, + }); + expect(screen.queryByRole("menu")).toBeNull(); + fireEvent.click(screen.getByRole("button", { expanded: false })); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("calls onOpenSettings when Settings is chosen from the menu", () => { + const onOpenSettings = vi.fn(); + renderCard({ + status: "authenticated", + identityId: IDENTITY_ID, + dpnsName: null, + onOpenSettings, + }); + fireEvent.click(screen.getByRole("button", { expanded: false })); + fireEvent.click(screen.getByRole("menuitem", { name: /settings/i })); + expect(onOpenSettings).toHaveBeenCalled(); + }); + + it("calls onLoginClick when Switch identity is chosen from the menu", () => { + const onLoginClick = vi.fn(); + renderCard({ + status: "authenticated", + identityId: IDENTITY_ID, + dpnsName: null, + onLoginClick, + }); + fireEvent.click(screen.getByRole("button", { expanded: false })); + fireEvent.click(screen.getByRole("menuitem", { name: /switch identity/i })); + expect(onLoginClick).toHaveBeenCalled(); + }); + + it("calls session.logout when Log out is chosen from the menu", () => { + const logout = vi.fn(); + mockUseSession.mockReturnValue({ logout }); + renderCard({ + status: "authenticated", + identityId: IDENTITY_ID, + dpnsName: null, + }); + fireEvent.click(screen.getByRole("button", { expanded: false })); + fireEvent.click(screen.getByRole("menuitem", { name: /log out/i })); + expect(logout).toHaveBeenCalled(); + }); + + it("hides Log out when browsing read-only", () => { + renderCard({ + status: "browsing", + identityId: IDENTITY_ID, + dpnsName: null, + }); + fireEvent.click(screen.getByRole("button", { expanded: false })); + expect(screen.queryByRole("menuitem", { name: /log out/i })).toBeNull(); + }); }); diff --git a/example-apps/dashnote/test/LoginModal.test.tsx b/example-apps/dashnote/test/LoginModal.test.tsx index a36c4ee..87d3a3a 100644 --- a/example-apps/dashnote/test/LoginModal.test.tsx +++ b/example-apps/dashnote/test/LoginModal.test.tsx @@ -12,22 +12,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LoginModal } from "../src/components/LoginModal"; -const { mockUseSession, mockRegisterContract, mockUseWifPreview } = vi.hoisted( - () => ({ - mockUseSession: vi.fn(), - mockRegisterContract: vi.fn(), - mockUseWifPreview: vi.fn(), - }), -); +const { mockUseSession, mockUseWifPreview } = vi.hoisted(() => ({ + mockUseSession: vi.fn(), + mockUseWifPreview: vi.fn(), +})); vi.mock("../src/session/useSession", () => ({ useSession: mockUseSession, })); -vi.mock("../src/dash/contract", () => ({ - registerContract: mockRegisterContract, -})); - // Mocked so LoginModal's preview-rendering branches can be exercised // independently of the hook's debounce/network/cache logic (which has its // own test file). Each test sets the return value to the state it cares @@ -74,7 +67,6 @@ function makeSession(overrides: SessionOverrides = {}) { beforeEach(() => { mockUseSession.mockReset(); - mockRegisterContract.mockReset(); mockUseWifPreview.mockReset(); mockUseWifPreview.mockReturnValue({ status: "idle" }); }); @@ -90,6 +82,26 @@ describe("LoginModal", () => { expect(container.firstChild).toBeNull(); }); + // Regression: an earlier auto-close effect dismissed the modal whenever it + // was opened while session.status === "authenticated", which broke the + // Switch-identity flow (the modal would flash and vanish). + it("stays open when opened in an authenticated session (Switch identity)", () => { + const onClose = vi.fn(); + mockUseSession.mockReturnValue( + makeSession({ + status: "authenticated", + identityId: "id-already-signed-in", + keyManager: { getAuth: vi.fn() }, + }), + ); + + render(); + + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByPlaceholderText(/mnemonic phrase/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /^login$/i })).toBeTruthy(); + }); + it("submits the mnemonic via session.login and closes on success", async () => { const login = vi.fn().mockResolvedValue(undefined); const onClose = vi.fn(); @@ -161,54 +173,6 @@ describe("LoginModal", () => { }); }); - it("shows the settings view with logout when authenticated", () => { - const logout = vi.fn(); - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-123456789012345678", - contractId: "contract-abc", - keyManager: { getAuth: vi.fn() }, - logout, - }), - ); - - render(); - - expect(screen.getByText("id-123456789012345678")).toBeTruthy(); - - fireEvent.click(screen.getByRole("button", { name: /^logout$/i })); - expect(logout).toHaveBeenCalled(); - }); - - it("applies a pasted contract ID immediately without validation", () => { - const setContractId = vi.fn(); - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - contractId: null, - keyManager: { getAuth: vi.fn() }, - setContractId, - }), - ); - - render(); - - fireEvent.click(screen.getByText(/advanced settings/i)); - fireEvent.change( - screen.getByPlaceholderText( - /paste a note contract id or register a new one/i, - ), - { - target: { value: " contract-123 " }, - }, - ); - fireEvent.click(screen.getByRole("button", { name: /use this id/i })); - - expect(setContractId).toHaveBeenCalledWith("contract-123"); - }); - it("defaults the Remember-me checkbox on when no identity is remembered", () => { mockUseSession.mockReturnValue(makeSession()); @@ -507,169 +471,6 @@ describe("LoginModal", () => { expect(within(panel).queryByText(/\.dash$/)).toBeNull(); }); - it("settings panel shows the DPNS name as a ✓ name.dash caption under the identity", () => { - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - dpnsName: "alice", - keyManager: { getAuth: vi.fn() }, - }), - ); - - render(); - - const block = screen.getByTestId("settings-identity-block"); - expect(within(block).getByText("✓ alice.dash")).toBeTruthy(); - }); - - it("settings panel omits the DPNS caption when no name is set", () => { - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - dpnsName: null, - keyManager: { getAuth: vi.fn() }, - }), - ); - - render(); - - const block = screen.getByTestId("settings-identity-block"); - expect(within(block).queryByText(/\.dash$/)).toBeNull(); - }); - - it("settings: Use a different identity link calls session.logout", () => { - const logout = vi.fn(); - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - keyManager: { getAuth: vi.fn() }, - logout, - }), - ); - - render(); - - const actions = screen.getByTestId("settings-identity-actions"); - fireEvent.click( - within(actions).getByRole("button", { - name: /use a different identity/i, - }), - ); - expect(logout).toHaveBeenCalled(); - }); - - it("settings: Forget this device link calls session.forgetIdentity when remembered", () => { - const forgetIdentity = vi.fn(); - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - rememberedIdentityId: "id-1", - keyManager: { getAuth: vi.fn() }, - forgetIdentity, - }), - ); - - render(); - - const actions = screen.getByTestId("settings-identity-actions"); - fireEvent.click( - within(actions).getByRole("button", { name: /forget this device/i }), - ); - expect(forgetIdentity).toHaveBeenCalled(); - }); - - it("settings: Forget this device link is hidden when nothing is remembered", () => { - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - rememberedIdentityId: null, - keyManager: { getAuth: vi.fn() }, - }), - ); - - render(); - - const actions = screen.getByTestId("settings-identity-actions"); - expect( - within(actions).queryByRole("button", { name: /forget this device/i }), - ).toBeNull(); - }); - - it("settings: Close button calls onClose without logging out", () => { - const onClose = vi.fn(); - const logout = vi.fn(); - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - keyManager: { getAuth: vi.fn() }, - logout, - }), - ); - - render(); - - const closeButtons = screen.getAllByRole("button", { - name: /^close$/i, - }); - const inlineClose = closeButtons.find( - (button) => button.textContent === "Close", - ); - expect(inlineClose).toBeDefined(); - fireEvent.click(inlineClose!); - expect(onClose).toHaveBeenCalled(); - expect(logout).not.toHaveBeenCalled(); - }); - - it("settings: Logout button also calls onClose", () => { - const onClose = vi.fn(); - const logout = vi.fn(); - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - keyManager: { getAuth: vi.fn() }, - logout, - }), - ); - - render(); - - fireEvent.click(screen.getByRole("button", { name: /^logout$/i })); - expect(logout).toHaveBeenCalled(); - expect(onClose).toHaveBeenCalled(); - }); - - it("registers a new contract when the Register new button is clicked", async () => { - const setContractId = vi.fn(); - mockRegisterContract.mockResolvedValue("new-contract-id"); - mockUseSession.mockReturnValue( - makeSession({ - status: "authenticated", - identityId: "id-1", - keyManager: { getAuth: vi.fn() }, - setContractId, - }), - ); - - render(); - - fireEvent.click(screen.getByText(/advanced settings/i)); - fireEvent.click(screen.getByRole("button", { name: /register new/i })); - - await waitFor(() => { - expect(mockRegisterContract).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(setContractId).toHaveBeenCalledWith("new-contract-id"); - }); - }); - it("shows the identity-index field for mnemonic input under Advanced settings", () => { mockUseSession.mockReturnValue(makeSession()); render(); diff --git a/example-apps/dashnote/test/NotesWorkspace.test.tsx b/example-apps/dashnote/test/NotesWorkspace.test.tsx index 1497f86..9e7da1b 100644 --- a/example-apps/dashnote/test/NotesWorkspace.test.tsx +++ b/example-apps/dashnote/test/NotesWorkspace.test.tsx @@ -107,13 +107,15 @@ describe("NotesWorkspace", () => { }), ); - const onOpenSettings = vi.fn(); - render(); + const onOpenLogin = vi.fn(); + render( + , + ); expect(screen.getByText(/sign in to see your notes/i)).toBeTruthy(); const loginButton = screen.getByRole("button", { name: /^log in$/i }); fireEvent.click(loginButton); - expect(onOpenSettings).toHaveBeenCalled(); + expect(onOpenLogin).toHaveBeenCalled(); expect(screen.queryByRole("button", { name: /new note/i })).toBeNull(); const bridgeLink = screen.getByRole("link", { name: /dash bridge/i }); @@ -146,7 +148,7 @@ describe("NotesWorkspace", () => { }); mockCreateNote.mockResolvedValue("note-1"); - render(); + render(); await waitFor(() => { expect(mockListMyNotes).toHaveBeenCalledTimes(1); @@ -176,7 +178,7 @@ describe("NotesWorkspace", () => { mockUseSession.mockReturnValue(makeSession()); mockListMyNotes.mockResolvedValue([]); - render(); + render(); await waitFor(() => { expect(mockListMyNotes).toHaveBeenCalledTimes(1); @@ -202,7 +204,7 @@ describe("NotesWorkspace", () => { mockUpdateNote.mockResolvedValue(undefined); mockDeleteNote.mockResolvedValue(undefined); - render(); + render(); await waitFor(() => { expect(mockGetNote).toHaveBeenCalledWith( @@ -267,7 +269,7 @@ describe("NotesWorkspace", () => { mockListMyNotes.mockResolvedValue([stale]); mockGetNote.mockResolvedValue(fresh); - render(); + render(); await waitFor(() => { expect(mockGetNote).toHaveBeenCalledWith( @@ -329,7 +331,7 @@ describe("NotesWorkspace", () => { .mockResolvedValue(remote); // post-failure refresh — chain has moved mockUpdateNote.mockRejectedValue(new Error("Identity nonce is stale")); - render(); + render(); await waitFor(() => { expect( @@ -364,7 +366,7 @@ describe("NotesWorkspace", () => { mockGetNote.mockResolvedValue(initial); mockUpdateNote.mockRejectedValue(new Error("Network unreachable")); - render(); + render(); await waitFor(() => { expect( @@ -396,7 +398,7 @@ describe("NotesWorkspace", () => { .mockRejectedValueOnce(new Error("Identity nonce is stale")) .mockResolvedValue(undefined); - render(); + render(); await waitFor(() => { expect( @@ -447,7 +449,7 @@ describe("NotesWorkspace", () => { .mockResolvedValue(saved); // post-save reload mockUpdateNote.mockResolvedValue(undefined); - render(); + render(); await waitFor(() => { expect( @@ -481,7 +483,7 @@ describe("NotesWorkspace", () => { mockUseSession.mockReturnValue(makeSession()); mockListMyNotes.mockRejectedValue(new Error("Data contract not found")); - render(); + render(); await waitFor(() => { expect(screen.getByText(/editor error/i)).toBeTruthy(); @@ -493,7 +495,9 @@ describe("NotesWorkspace", () => { mockUseSession.mockReturnValue(makeSession({ contractId: null })); const onOpenSettings = vi.fn(); - render(); + render( + , + ); expect(screen.getByText(/register or select a contract/i)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /open settings/i })); @@ -541,7 +545,7 @@ describe("NotesWorkspace", () => { mockListMyNotes.mockResolvedValue(notes); mockGetNote.mockResolvedValue(notes[0]); - render(); + render(); await waitFor(() => { expect(screen.getByText("Grocery list")).toBeTruthy(); @@ -582,7 +586,7 @@ describe("NotesWorkspace", () => { mockListMyNotes.mockResolvedValue([noteFixture]); mockGetNote.mockResolvedValue(noteFixture); - render(); + render(); await waitFor(() => { expect(mockListMyNotes).toHaveBeenCalledTimes(1); @@ -598,7 +602,7 @@ describe("NotesWorkspace", () => { mockUseSession.mockReturnValue(makeSession()); mockListMyNotes.mockResolvedValue([]); - render(); + render(); await waitFor(() => { expect(mockListMyNotes).toHaveBeenCalledTimes(1); @@ -619,7 +623,7 @@ describe("NotesWorkspace", () => { mockListMyNotes.mockResolvedValue([noteFixture]); mockGetNote.mockResolvedValue(noteFixture); - render(); + render(); await waitFor(() => { expect(screen.getByText(/phone note/i)).toBeTruthy(); @@ -641,7 +645,7 @@ describe("NotesWorkspace", () => { mockListMyNotes.mockResolvedValue([noteFixture]); mockGetNote.mockResolvedValue(noteFixture); - render(); + render(); await waitFor(() => { expect(screen.getByText(/phone note/i)).toBeTruthy(); @@ -666,7 +670,7 @@ describe("NotesWorkspace", () => { mockGetNote.mockResolvedValue(noteFixture); mockDeleteNote.mockResolvedValue(undefined); - render(); + render(); await waitFor(() => { expect(screen.getByText(/phone note/i)).toBeTruthy(); @@ -725,7 +729,7 @@ describe("NotesWorkspace", () => { ); mockGetNote.mockImplementation(() => new Promise(() => {})); - render(); + render(); // Synchronously visible from cache (no waitFor needed). expect(screen.getAllByText(/cached title/i).length).toBeGreaterThan(0); @@ -759,7 +763,7 @@ describe("NotesWorkspace", () => { ); mockGetNote.mockResolvedValue(cached); - render(); + render(); // Cached note is auto-selected on desktop and the editor is shown, but // the save button must be disabled because editsReady=false. @@ -807,7 +811,7 @@ describe("NotesWorkspace", () => { .mockResolvedValueOnce([newerFromChain]); mockGetNote.mockResolvedValue(initial); - render(); + render(); // Wait for initial load to settle so the editor reflects the cached/ // chain content with baselines set. @@ -874,7 +878,7 @@ describe("NotesWorkspace", () => { }, ]); - render(); + render(); // Cached list paints synchronously, even though sdk is null. Without the // reloadNotes-skip-when-sdk-null fix, the list would be wiped to []. @@ -900,7 +904,7 @@ describe("NotesWorkspace", () => { mockListMyNotes.mockImplementation(() => new Promise(() => {})); mockGetNote.mockImplementation(() => new Promise(() => {})); - render(); + render(); // Editor pane shows the seeded note's content from the very first paint // (no "No note selected" empty state in between). @@ -927,7 +931,7 @@ describe("NotesWorkspace", () => { ]); mockListMyNotes.mockImplementation(() => new Promise(() => {})); - render(); + render(); // Cached list paints synchronously on mobile too. expect(screen.getByText("Mobile cached")).toBeTruthy(); @@ -968,7 +972,9 @@ describe("NotesWorkspace", () => { ); mockGetNote.mockImplementation(() => new Promise(() => {})); - const { rerender } = render(); + const { rerender } = render( + , + ); // Switch to a new identity+contract while the first listMyNotes is // still pending. Re-rendering with a fresh session value triggers the @@ -977,7 +983,9 @@ describe("NotesWorkspace", () => { mockUseSession.mockReturnValue( makeSession({ identityId: "identity-2", contractId: "contract-2" }), ); - rerender(); + rerender( + , + ); // Wait until the post-rerender hydrate effect has actually issued its // own listMyNotes call. This proves the new reload token is in place @@ -1034,7 +1042,7 @@ describe("NotesWorkspace", () => { mockListMyNotes.mockRejectedValue(new Error("Network unreachable")); mockGetNote.mockRejectedValue(new Error("Network unreachable")); - render(); + render(); // Error surfaces but cached data stays visible. await waitFor(() => { diff --git a/example-apps/dashnote/test/SettingsPanel.test.tsx b/example-apps/dashnote/test/SettingsPanel.test.tsx new file mode 100644 index 0000000..a5a7414 --- /dev/null +++ b/example-apps/dashnote/test/SettingsPanel.test.tsx @@ -0,0 +1,235 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SettingsPanel } from "../src/components/SettingsPanel"; + +const { mockUseSession, mockRegisterContract, mockClearCachedNotes } = + vi.hoisted(() => ({ + mockUseSession: vi.fn(), + mockRegisterContract: vi.fn(), + mockClearCachedNotes: vi.fn(), + })); + +vi.mock("../src/session/useSession", () => ({ + useSession: mockUseSession, +})); + +vi.mock("../src/dash/contract", () => ({ + registerContract: mockRegisterContract, +})); + +vi.mock("../src/lib/notesCache", () => ({ + clearCachedNotes: mockClearCachedNotes, +})); + +interface SessionOverrides { + status?: string; + identityId?: string | null; + contractId?: string | null; + rememberedIdentityId?: string | null; + dpnsName?: string | null; + sdk?: unknown; + keyManager?: unknown; + setContractId?: ReturnType; + forgetIdentity?: ReturnType; + logout?: ReturnType; + log?: ReturnType; +} + +function makeSession(overrides: SessionOverrides = {}) { + return { + status: "authenticated", + error: null, + sdk: { documents: {} }, + keyManager: { getAuth: vi.fn() }, + identityId: "id-1234567890", + contractId: "contract-abc", + rememberedIdentityId: null, + dpnsName: null, + log: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + setContractId: vi.fn(), + enterReadOnly: vi.fn(), + viewAsRemembered: vi.fn(), + forgetIdentity: vi.fn(), + ...overrides, + }; +} + +beforeEach(() => { + mockUseSession.mockReset(); + mockRegisterContract.mockReset(); + mockClearCachedNotes.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("SettingsPanel", () => { + it("renders the identity ID and DPNS caption when authenticated", () => { + mockUseSession.mockReturnValue( + makeSession({ identityId: "id-abc", dpnsName: "alice" }), + ); + render(); + const block = screen.getByTestId("settings-identity-block"); + expect(within(block).getByText("id-abc")).toBeTruthy(); + expect(within(block).getByText("✓ alice.dash")).toBeTruthy(); + }); + + it("omits the DPNS caption when no name is set", () => { + mockUseSession.mockReturnValue( + makeSession({ identityId: "id-abc", dpnsName: null }), + ); + render(); + const block = screen.getByTestId("settings-identity-block"); + expect(within(block).queryByText(/\.dash$/)).toBeNull(); + }); + + it("renders the network indicator as testnet", () => { + mockUseSession.mockReturnValue(makeSession()); + render(); + expect(screen.getByText("testnet")).toBeTruthy(); + }); + + it("shows an empty state when not signed in or browsing", () => { + mockUseSession.mockReturnValue( + makeSession({ status: "readonly", identityId: null, keyManager: null }), + ); + render(); + expect(screen.getByText(/sign in to view/i)).toBeTruthy(); + expect(screen.queryByTestId("settings-identity-block")).toBeNull(); + }); + + it("hides the danger zone when nothing is remembered", () => { + mockUseSession.mockReturnValue(makeSession({ rememberedIdentityId: null })); + render(); + expect( + screen.queryByRole("button", { name: /forget this device/i }), + ).toBeNull(); + }); + + it("calls session.forgetIdentity when Forget this device is clicked", () => { + const forgetIdentity = vi.fn(); + mockUseSession.mockReturnValue( + makeSession({ + rememberedIdentityId: "id-1234567890", + forgetIdentity, + }), + ); + render(); + fireEvent.click( + screen.getByRole("button", { name: /forget this device/i }), + ); + expect(forgetIdentity).toHaveBeenCalled(); + }); + + it("copies the identity ID to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + mockUseSession.mockReturnValue(makeSession({ identityId: "id-xyz" })); + render(); + fireEvent.click(screen.getByRole("button", { name: /copy identity id/i })); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("id-xyz"); + }); + }); + + it("applies a pasted contract ID via session.setContractId", () => { + const setContractId = vi.fn(); + mockUseSession.mockReturnValue( + makeSession({ contractId: "old", setContractId }), + ); + render(); + const input = screen.getByPlaceholderText(/paste a note contract id/i); + fireEvent.change(input, { target: { value: " new-contract " } }); + fireEvent.click(screen.getByRole("button", { name: /use this id/i })); + expect(setContractId).toHaveBeenCalledWith("new-contract"); + }); + + it("registers a fresh contract with the session sdk, keyManager, and log", async () => { + const setContractId = vi.fn(); + const sdk = { documents: {}, marker: "sdk" }; + const keyManager = { getAuth: vi.fn(), marker: "km" }; + const log = vi.fn(); + mockRegisterContract.mockResolvedValue("brand-new-id"); + mockUseSession.mockReturnValue( + makeSession({ setContractId, sdk, keyManager, log }), + ); + render(); + fireEvent.click( + screen.getByRole("button", { name: /register a fresh contract/i }), + ); + await waitFor(() => { + expect(mockRegisterContract).toHaveBeenCalledWith({ + sdk, + keyManager, + log, + }); + }); + await waitFor(() => { + expect(setContractId).toHaveBeenCalledWith("brand-new-id"); + }); + }); + + it("rejects concurrent register clicks before React disables the button", async () => { + // Hold the first call open so a second invocation can race past + // setRegistering(true). The ref guard inside the hook must short-circuit + // the second call so the SDK only sees one publish. + let resolveFirst: ((value: string) => void) | undefined; + mockRegisterContract.mockImplementation( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + mockUseSession.mockReturnValue(makeSession({ setContractId: vi.fn() })); + render(); + const button = screen.getByRole("button", { + name: /register a fresh contract/i, + }); + fireEvent.click(button); + fireEvent.click(button); + expect(mockRegisterContract).toHaveBeenCalledTimes(1); + resolveFirst?.("only-id"); + await waitFor(() => { + expect(mockRegisterContract).toHaveBeenCalledTimes(1); + }); + }); + + it("surfaces a registration failure without switching contracts", async () => { + const setContractId = vi.fn(); + mockRegisterContract.mockRejectedValue(new Error("Network down")); + mockUseSession.mockReturnValue(makeSession({ setContractId })); + render(); + fireEvent.click( + screen.getByRole("button", { name: /register a fresh contract/i }), + ); + expect(await screen.findByText("Network down")).toBeTruthy(); + expect(setContractId).not.toHaveBeenCalled(); + }); + + it("invokes clearCachedNotes with the current identity ID", () => { + mockUseSession.mockReturnValue(makeSession({ identityId: "id-cache" })); + render(); + fireEvent.click( + screen.getByRole("button", { + name: /clear local cache for this device/i, + }), + ); + expect(mockClearCachedNotes).toHaveBeenCalledWith("id-cache"); + }); +});