From 51a4b0ad85cc92cfebcd582bc4c0d1084043aeaa Mon Sep 17 00:00:00 2001 From: Fadeedev Date: Sun, 18 Oct 2026 14:24:07 +0100 Subject: [PATCH] fix: nav focus-visible, wallet nonce replay, error server logs, theme docs --- docs/WALLET_AUTH_REPLAY.md | 22 +++++++ src/App.tsx | 11 ++-- src/components/nav/MobileNav.tsx | 36 ++++++++++++ src/components/nav/PrimaryNav.tsx | 27 +++++++++ src/components/nav/SecondaryNav.tsx | 37 ++++++++++++ src/components/nav/__tests__/navFocus.test.ts | 16 ++++++ src/components/nav/navFocus.ts | 9 +++ src/components/ui/ErrorBoundary.tsx | 5 +- .../__tests__/useWalletAuth.replay.test.ts | 20 +++++++ src/features/auth/hooks/useWalletAuth.ts | 16 ++---- src/lib/__tests__/error-tracking.test.ts | 31 ++++++++++ src/lib/__tests__/theme-persistence.test.ts | 17 ++++++ src/lib/analytics.ts | 16 +++++- src/lib/error-tracking.ts | 57 +++++++++++++++++++ 14 files changed, 300 insertions(+), 20 deletions(-) create mode 100644 docs/WALLET_AUTH_REPLAY.md create mode 100644 src/components/nav/MobileNav.tsx create mode 100644 src/components/nav/PrimaryNav.tsx create mode 100644 src/components/nav/SecondaryNav.tsx create mode 100644 src/components/nav/__tests__/navFocus.test.ts create mode 100644 src/components/nav/navFocus.ts create mode 100644 src/features/auth/hooks/__tests__/useWalletAuth.replay.test.ts create mode 100644 src/lib/__tests__/error-tracking.test.ts create mode 100644 src/lib/__tests__/theme-persistence.test.ts create mode 100644 src/lib/error-tracking.ts diff --git a/docs/WALLET_AUTH_REPLAY.md b/docs/WALLET_AUTH_REPLAY.md new file mode 100644 index 00000000..0f300fc9 --- /dev/null +++ b/docs/WALLET_AUTH_REPLAY.md @@ -0,0 +1,22 @@ +# Wallet authentication replay protection (#934) + +## Change + +`useWalletAuth` **no longer** synthesises a client-side nonce when +`authApi.requestWalletNonce` fails. A local string such as +`lumora-auth-nonce--` is not bound to server state and +can be re-signed or replayed outside the backend’s single-use nonce table. + +## Model + +1. Client requests a **server-issued** challenge/nonce for the public key. +2. User signs that challenge in the wallet. +3. Backend verifies the signature **and** that the nonce is unused/unexpired, + then issues a session. +4. On-chain Stellar operations remain protected by account **sequence numbers** + independently of this login challenge. + +## Failure mode + +If the nonce endpoint is unavailable, wallet login fails closed with an error +instead of weakening replay protection. diff --git a/src/App.tsx b/src/App.tsx index c5655d2f..f272b337 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,8 +30,9 @@ import { ArtistWithdrawal } from '@/features/portfolio' import { PaymentResultScreen } from '@/features/payments' import { useUiStore } from '@/stores' +/** Shared nav link styles including WCAG 2.4.7 focus-visible ring (#939). */ const NAV_LINK_CLASSES = - 'text-caption font-semibold text-muted transition-colors hover:text-foreground' + 'text-caption font-semibold text-muted transition-colors hover:text-foreground rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background' /** Tone is conveyed by glyph as well as colour (#843). */ const TOAST_GLYPHS: Record<'error' | 'success' | 'info', string> = { @@ -117,7 +118,7 @@ function AppShell() {
Lumora @@ -209,9 +210,9 @@ function AppShell() {

Lumora — transparent, borderless crowdfunding and commissions on Stellar.

- Artists - Wallets - Stellar Network ↗ + Artists + Wallets + Stellar Network ↗
diff --git a/src/components/nav/MobileNav.tsx b/src/components/nav/MobileNav.tsx new file mode 100644 index 00000000..4b6f37ac --- /dev/null +++ b/src/components/nav/MobileNav.tsx @@ -0,0 +1,36 @@ +import { Link } from 'react-router-dom' +import { NAV_LINK_BASE } from './navFocus' + +export interface MobileNavProps { + open: boolean + items: { to: string; label: string }[] + onNavigate?: () => void +} + +/** + * Mobile disclosure nav — same focus-visible treatment as desktop (#939). + */ +export function MobileNav({ open, items, onNavigate }: MobileNavProps) { + if (!open) return null + return ( + + ) +} diff --git a/src/components/nav/PrimaryNav.tsx b/src/components/nav/PrimaryNav.tsx new file mode 100644 index 00000000..02a5bce8 --- /dev/null +++ b/src/components/nav/PrimaryNav.tsx @@ -0,0 +1,27 @@ +import { Link } from 'react-router-dom' +import { NAV_LINK_BASE } from './navFocus' + +export interface NavItem { + to: string + label: string +} + +const DEFAULT_ITEMS: NavItem[] = [ + { to: '/artists/elena_art', label: 'Featured Artist' }, + { to: '/artists/stellar_nova', label: 'Explore Creators' }, +] + +/** + * Desktop primary navigation with visible keyboard focus indicators (#939). + */ +export function PrimaryNav({ items = DEFAULT_ITEMS }: { items?: NavItem[] }) { + return ( + + ) +} diff --git a/src/components/nav/SecondaryNav.tsx b/src/components/nav/SecondaryNav.tsx new file mode 100644 index 00000000..f2292f26 --- /dev/null +++ b/src/components/nav/SecondaryNav.tsx @@ -0,0 +1,37 @@ +import { Link } from 'react-router-dom' +import { NAV_FOCUS_CLASSES } from './navFocus' + +/** + * Secondary / footer-style nav links with focus indicators (#939). + */ +export function SecondaryNav({ + items, +}: { + items: { to: string; label: string; external?: boolean }[] +}) { + return ( + + ) +} diff --git a/src/components/nav/__tests__/navFocus.test.ts b/src/components/nav/__tests__/navFocus.test.ts new file mode 100644 index 00000000..7e354c06 --- /dev/null +++ b/src/components/nav/__tests__/navFocus.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { NAV_FOCUS_CLASSES, NAV_LINK_BASE } from '../navFocus' + +describe('nav focus styles (#939)', () => { + it('includes focus-visible ring classes for WCAG 2.4.7', () => { + expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:ring-2/) + expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:ring-primary/) + expect(NAV_LINK_BASE).toContain(NAV_FOCUS_CLASSES) + }) + + it('does not use outline:none without a ring replacement', () => { + // outline-none is OK only when paired with ring + expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:outline-none/) + expect(NAV_FOCUS_CLASSES).toMatch(/focus-visible:ring-/) + }) +}) diff --git a/src/components/nav/navFocus.ts b/src/components/nav/navFocus.ts new file mode 100644 index 00000000..48e110a8 --- /dev/null +++ b/src/components/nav/navFocus.ts @@ -0,0 +1,9 @@ +/** + * Shared focus-visible styles for navigation (#939 / WCAG 2.4.7). + * Uses the primary ring token (contrast ≥ 3:1 against backgrounds). + */ +export const NAV_FOCUS_CLASSES = + 'rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background' + +export const NAV_LINK_BASE = + `text-caption font-semibold text-muted transition-colors hover:text-foreground ${NAV_FOCUS_CLASSES}` diff --git a/src/components/ui/ErrorBoundary.tsx b/src/components/ui/ErrorBoundary.tsx index de176430..679bbf5f 100644 --- a/src/components/ui/ErrorBoundary.tsx +++ b/src/components/ui/ErrorBoundary.tsx @@ -16,8 +16,9 @@ interface ErrorBoundaryState { } /** - * Global error boundary: catches unexpected render errors and shows a branded - * recovery screen instead of a blank page. + * Global error boundary: catches unexpected render errors, reports them to + * server monitoring via `reportError` (#936), and shows a branded recovery + * screen (friendly copy only — no stack traces in the UI). */ export default class ErrorBoundary extends Component { state: ErrorBoundaryState = { error: null, attempt: 0 } diff --git a/src/features/auth/hooks/__tests__/useWalletAuth.replay.test.ts b/src/features/auth/hooks/__tests__/useWalletAuth.replay.test.ts new file mode 100644 index 00000000..91230da0 --- /dev/null +++ b/src/features/auth/hooks/__tests__/useWalletAuth.replay.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Static regression guard for #934 — client-side nonce fallback must never return. + * Replay protection depends on server-issued nonces (and Stellar sequence numbers + * for on-chain ops); a local `lumora-auth-nonce-…` string is not a secure nonce. + */ +describe('useWalletAuth replay protection (#934)', () => { + it('does not contain a client-side nonce fallback', () => { + const src = readFileSync( + join(__dirname, '../useWalletAuth.ts'), + 'utf8', + ) + expect(src).not.toMatch(/fallbackNonce/) + expect(src).not.toMatch(/lumora-auth-nonce-/) + expect(src).toMatch(/requestWalletNonce/) + }) +}) diff --git a/src/features/auth/hooks/useWalletAuth.ts b/src/features/auth/hooks/useWalletAuth.ts index c9288910..965e99b1 100644 --- a/src/features/auth/hooks/useWalletAuth.ts +++ b/src/features/auth/hooks/useWalletAuth.ts @@ -64,18 +64,10 @@ export function useWalletAuth(): UseWalletAuth { abortControllerRef.current = abortController try { - // Step 1: Request challenge nonce from backend - let nonceData - try { - nonceData = await authApi.requestWalletNonce(connection.publicKey) - } catch (nonceError) { - // If the backend endpoint is not reachable or returns a fallback format - const fallbackNonce = `lumora-auth-nonce-${connection.publicKey.slice(0, 8)}-${Date.now()}` - nonceData = { - nonce: fallbackNonce, - challenge: `Sign this message to authenticate with Lumora:\nNonce: ${fallbackNonce}\nTimestamp: ${new Date().toISOString()}`, - expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), - } + // Step 1: Request a server-issued challenge nonce. + // Never synthesise a client-side fallback — that disables replay protection + // and lets captured signatures be reused (#934). + const nonceData = await authApi.requestWalletNonce(connection.publicKey) } if (abortController.signal.aborted) return null diff --git a/src/lib/__tests__/error-tracking.test.ts b/src/lib/__tests__/error-tracking.test.ts new file mode 100644 index 00000000..291da2e7 --- /dev/null +++ b/src/lib/__tests__/error-tracking.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { logErrorToServer, setErrorTrackingEndpoint } from '../error-tracking' + +describe('logErrorToServer (#936)', () => { + afterEach(() => { + vi.unstubAllGlobals() + setErrorTrackingEndpoint('/api/monitoring/errors') + }) + + it('POSTs message, stack, componentStack, and timestamp', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + setErrorTrackingEndpoint('/test/errors') + + const err = new Error('boom') + await logErrorToServer(err, { + componentStack: 'at Foo', + source: 'error_boundary', + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/test/errors') + expect(init.method).toBe('POST') + const body = JSON.parse(String(init.body)) + expect(body.message).toBe('boom') + expect(body.componentStack).toBe('at Foo') + expect(body.timestamp).toBeTruthy() + expect(body.source).toBe('error_boundary') + }) +}) diff --git a/src/lib/__tests__/theme-persistence.test.ts b/src/lib/__tests__/theme-persistence.test.ts new file mode 100644 index 00000000..2b64ca52 --- /dev/null +++ b/src/lib/__tests__/theme-persistence.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { getStoredTheme, persistTheme, THEME_STORAGE_KEY } from '../theme' + +describe('theme persistence (#938)', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('persists and restores the theme preference', () => { + expect(getStoredTheme()).toBeNull() + persistTheme('dark') + expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark') + expect(getStoredTheme()).toBe('dark') + persistTheme('light') + expect(getStoredTheme()).toBe('light') + }) +}) diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 1259f92b..fef7c16d 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,5 +1,7 @@ export interface ErrorReportInfo { componentStack: string | null + /** When true, skip the server monitoring POST (tests). */ + skipServer?: boolean } export type ErrorReporter = (error: Error, info: ErrorReportInfo) => void @@ -14,7 +16,7 @@ export function setErrorReporter(reporter: ErrorReporter | null): void { errorReporter = reporter } -/** Logs a render error to the console and forwards it to the registered reporter. */ +/** Logs a render error to the console, server monitoring (#936), and optional reporter. */ export function reportError(error: Error, info: ErrorReportInfo): void { if (info.componentStack) { console.error('[ErrorBoundary] render error:', error, info.componentStack) @@ -22,6 +24,18 @@ export function reportError(error: Error, info: ErrorReportInfo): void { console.error('[ErrorBoundary] render error:', error) } + if (!info.skipServer) { + // Dynamic import avoided for sync componentDidCatch — use fire-and-forget. + void import('./error-tracking') + .then(({ logErrorToServer }) => + logErrorToServer(error, { + componentStack: info.componentStack, + source: 'error_boundary', + }), + ) + .catch(() => {}) + } + if (!errorReporter) return try { errorReporter(error, info) diff --git a/src/lib/error-tracking.ts b/src/lib/error-tracking.ts new file mode 100644 index 00000000..fb11ac21 --- /dev/null +++ b/src/lib/error-tracking.ts @@ -0,0 +1,57 @@ +/** + * Server-side error monitoring (#936). + * Posts structured error payloads to the monitoring endpoint. + * User-facing UI must never display these internals. + */ + +export interface ServerErrorPayload { + message: string + name: string + stack?: string + componentStack: string | null + timestamp: string + url?: string + userAgent?: string + source: 'error_boundary' | 'global_handler' | 'manual' +} + +const DEFAULT_ENDPOINT = '/api/monitoring/errors' + +let endpoint = DEFAULT_ENDPOINT + +/** Override the monitoring endpoint (e.g. in tests). */ +export function setErrorTrackingEndpoint(url: string): void { + endpoint = url +} + +/** + * Fire-and-forget log of an error to the backend monitoring service. + * Never throws to callers. + */ +export async function logErrorToServer( + error: Error, + info: { componentStack?: string | null; source?: ServerErrorPayload['source'] } = {}, +): Promise { + const payload: ServerErrorPayload = { + message: error.message, + name: error.name, + stack: error.stack, + componentStack: info.componentStack ?? null, + timestamp: new Date().toISOString(), + url: typeof window !== 'undefined' ? window.location.href : undefined, + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + source: info.source ?? 'manual', + } + + try { + await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + // keepalive so the request can outlive a page unload + keepalive: true, + }) + } catch { + // Monitoring must not break the app. + } +}