From 66f35bd86954a2c3ae83ec29c307d8481d95b0e4 Mon Sep 17 00:00:00 2001 From: Evelyn Lawrence Date: Mon, 27 Jul 2026 11:51:46 +0000 Subject: [PATCH] feat: add global toast system, site footer, and generated-wallet runbook Closes #183, #185, #284 - ToastProvider context + useToast hook with success/error/warning/info variants - SiteFooter component with project links, MIT license, and Stellar tagline - Operational runbook for generated wallet support requests --- ...agnose-generated-wallet-support-request.md | 35 +++++++ frontend/app/layout.tsx | 11 ++- frontend/components/page-shell.tsx | 2 + frontend/components/site-footer.stories.tsx | 13 +++ frontend/components/site-footer.test.tsx | 56 +++++++++++ frontend/components/site-footer.tsx | 55 +++++++++++ .../components/toast-notification.stories.tsx | 4 + frontend/components/toast-notification.tsx | 6 +- .../components/toast-provider.stories.tsx | 32 +++++++ frontend/components/toast-provider.test.tsx | 96 +++++++++++++++++++ frontend/components/toast-provider.tsx | 63 ++++++++++++ 11 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 bridgelet-product-audit/runbooks/diagnose-generated-wallet-support-request.md create mode 100644 frontend/components/site-footer.stories.tsx create mode 100644 frontend/components/site-footer.test.tsx create mode 100644 frontend/components/site-footer.tsx create mode 100644 frontend/components/toast-provider.stories.tsx create mode 100644 frontend/components/toast-provider.test.tsx create mode 100644 frontend/components/toast-provider.tsx diff --git a/bridgelet-product-audit/runbooks/diagnose-generated-wallet-support-request.md b/bridgelet-product-audit/runbooks/diagnose-generated-wallet-support-request.md new file mode 100644 index 0000000..62eebd6 --- /dev/null +++ b/bridgelet-product-audit/runbooks/diagnose-generated-wallet-support-request.md @@ -0,0 +1,35 @@ +# Diagnose: Generated Wallet Support Request + +> **Status:** Placeholder — not yet actionable +> **Created:** 2026-07-27 +> **Part of:** bridgelet-product-audit knowledge-base initiative + +## Summary + +This runbook covers diagnosis and response for a future support request related to the `generated` wallet type — a Stellar keypair created client-side by `generateNewWallet()` in `frontend/lib/wallet.ts`. + +## Current State + +`generateNewWallet()` is **currently unused in the UI**. It exists in the codebase as an exported utility but is not wired into any user-facing flow. Therefore, **no real incidents of this type have occurred**, and this runbook has no real incidents to describe at this time. + +## Open Questions + +The following questions must be answered before this runbook can be meaningfully completed: + +| # | Question | Why it matters | +|---|----------|----------------| +| 1 | **Storage location** — If `generateNewWallet()` is wired into the UI, where will the generated secret key be stored (if at all)? Browser memory? `localStorage`? Encrypted export? | Determines the attack surface and recovery path. | +| 2 | **Recovery mechanism** — If the user loses access to the generated key (tab closed, device lost), is there any recovery path? Is the key backed up server-side? | Without recovery, support requests will require fund return or re-issuance. | +| 3 | **Transmission** — Is the secret key ever transmitted off-device? If so, over what channel and with what protections? | Directly impacts whether a leaked key is a user-side or system-side incident. | +| 4 | **User communication** — How is the user instructed to handle the secret key? Is there an onboarding step, a warning, a copy-to-clipboard flow? | Determines whether a support request is likely user error or a product gap. | + +## Recommended Next Steps + +1. **Revisit this runbook** as a required step when wiring `generateNewWallet()` into the UI for the first time. +2. Complete the open questions table above during the design phase of that work. +3. Add incident response steps (triage, containment, user communication) once the flow is live. + +## References + +- `frontend/lib/wallet.ts` — `generateNewWallet()` implementation (T-15 in security model) +- `docs/security-model.mdx` — Threat T-15: "Private key leak from generated wallet" diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b1ebfa2..8cdebc6 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -5,6 +5,7 @@ import { DevToolbar } from '@/components/dev-toolbar'; import { MockProvider } from '@/components/mock-provider'; import { ServiceWorkerRegister } from '@/components/service-worker-register'; import { ThemeProvider } from '@/components/theme-provider'; +import { ToastProvider } from '@/components/toast-provider'; export const metadata: Metadata = { title: 'Bridgelet Payments', @@ -44,10 +45,12 @@ export default function RootLayout({ children }: RootLayoutProps) { - - {children} - {isDev && } - {isDev && } + + + {children} + {isDev && } + {isDev && } + diff --git a/frontend/components/page-shell.tsx b/frontend/components/page-shell.tsx index e074445..bb4ee23 100644 --- a/frontend/components/page-shell.tsx +++ b/frontend/components/page-shell.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import Logo from './logo'; +import { SiteFooter } from './site-footer'; import { ThemeToggle } from './theme-toggle'; type PageShellProps = { @@ -39,6 +40,7 @@ export function PageShell({ title, description, children, footer }: PageShellPro {footer} + ); } diff --git a/frontend/components/site-footer.stories.tsx b/frontend/components/site-footer.stories.tsx new file mode 100644 index 0000000..304ff38 --- /dev/null +++ b/frontend/components/site-footer.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { SiteFooter } from './site-footer'; + +const meta: Meta = { + title: 'Components/SiteFooter', + component: SiteFooter, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/components/site-footer.test.tsx b/frontend/components/site-footer.test.tsx new file mode 100644 index 0000000..c8e3065 --- /dev/null +++ b/frontend/components/site-footer.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { SiteFooter } from './site-footer'; + +describe('SiteFooter', () => { + it('renders a footer landmark', () => { + render(); + expect(screen.getByRole('contentinfo')).toBeInTheDocument(); + }); + + it('renders the project name', () => { + render(); + expect(screen.getByText('bridgelet')).toBeInTheDocument(); + }); + + it('renders the MIT license note', () => { + render(); + expect(screen.getByText(/released under the mit license/i)).toBeInTheDocument(); + }); + + it('renders the Stellar ecosystem tagline', () => { + render(); + expect(screen.getByText(/built for the stellar ecosystem/i)).toBeInTheDocument(); + }); + + it('renders a GitHub link pointing to the repo', () => { + render(); + const link = screen.getByRole('link', { name: /github/i }); + expect(link).toHaveAttribute('href', 'https://github.com/bridgelet-org/bridgelet'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('renders a Docs link', () => { + render(); + expect(screen.getByRole('link', { name: /docs/i })).toHaveAttribute('href', '/docs'); + }); + + it('renders a Security Policy link', () => { + render(); + const link = screen.getByRole('link', { name: /security policy/i }); + expect(link).toHaveAttribute( + 'href', + 'https://github.com/bridgelet-org/bridgelet/blob/main/SECURITY.md', + ); + }); + + it('renders a Contributing link', () => { + render(); + const link = screen.getByRole('link', { name: /contributing/i }); + expect(link).toHaveAttribute( + 'href', + 'https://github.com/bridgelet-org/bridgelet/blob/main/CONTRIBUTING.md', + ); + }); +}); diff --git a/frontend/components/site-footer.tsx b/frontend/components/site-footer.tsx new file mode 100644 index 0000000..771c8d0 --- /dev/null +++ b/frontend/components/site-footer.tsx @@ -0,0 +1,55 @@ +import Link from 'next/link'; + +const REPO_URL = 'https://github.com/bridgelet-org/bridgelet'; + +export function SiteFooter() { + return ( + + ); +} diff --git a/frontend/components/toast-notification.stories.tsx b/frontend/components/toast-notification.stories.tsx index 0e9ce36..42ff6ed 100644 --- a/frontend/components/toast-notification.stories.tsx +++ b/frontend/components/toast-notification.stories.tsx @@ -22,3 +22,7 @@ export const Error: Story = { export const Info: Story = { args: { message: 'Your claim link will expire in 24 hours.', variant: 'info' }, }; + +export const Warning: Story = { + args: { message: 'Your session will expire in 5 minutes.', variant: 'warning' }, +}; diff --git a/frontend/components/toast-notification.tsx b/frontend/components/toast-notification.tsx index 7184d04..c93a006 100644 --- a/frontend/components/toast-notification.tsx +++ b/frontend/components/toast-notification.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; -export type ToastVariant = 'success' | 'error' | 'info'; +export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; type ToastNotificationProps = { message: string; @@ -15,19 +15,21 @@ type ToastNotificationProps = { const VARIANT_STYLES: Record = { success: 'bg-emerald-50 border-emerald-200 text-emerald-900', error: 'bg-red-50 border-red-200 text-red-900', + warning: 'bg-amber-50 border-amber-200 text-amber-900', info: 'bg-blue-50 border-blue-200 text-blue-900', }; const ICONS: Record = { success: '✓', error: '✕', + warning: '!', info: 'i', }; export function ToastNotification({ message, variant = 'info', - duration = 4000, + duration = 5000, onDismiss, }: ToastNotificationProps) { const [visible, setVisible] = useState(true); diff --git a/frontend/components/toast-provider.stories.tsx b/frontend/components/toast-provider.stories.tsx new file mode 100644 index 0000000..95eaaba --- /dev/null +++ b/frontend/components/toast-provider.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { ToastProvider, useToast } from './toast-provider'; +import { ToastNotification } from './toast-notification'; + +function DemoButtons() { + const { showToast } = useToast(); + return ( +
+ + + + +
+ ); +} + +const meta: Meta = { + title: 'Components/ToastProvider', + component: ToastProvider, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + ), +}; diff --git a/frontend/components/toast-provider.test.tsx b/frontend/components/toast-provider.test.tsx new file mode 100644 index 0000000..321e6ba --- /dev/null +++ b/frontend/components/toast-provider.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/vitest'; +import { ToastProvider, useToast } from './toast-provider'; + +function ToastTrigger() { + const { showToast } = useToast(); + return ( +
+ + + + +
+ ); +} + +describe('ToastProvider', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + it('renders children', () => { + render( + +
Child content
+
, + ); + expect(screen.getByText('Child content')).toBeInTheDocument(); + }); + + it('throws when useToast is used outside provider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + function Bad() { + useToast(); + return null; + } + expect(() => render()).toThrow('useToast must be used within a ToastProvider'); + spy.mockRestore(); + }); + + it('shows a toast when showToast is called', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: /show success/i })); + expect(screen.getByRole('alert')).toHaveTextContent('Success message'); + }); + + it('shows the correct variant styling', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: /show error/i })); + const alert = screen.getByRole('alert'); + expect(alert.className).toContain('bg-red-50'); + }); + + it('dismisses a toast manually', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: /show info/i })); + expect(screen.getByRole('alert')).toHaveTextContent('Info message'); + + await user.click(screen.getByRole('button', { name: /dismiss notification/i })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('auto-dismisses after 5 seconds', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: /show success/i })); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/components/toast-provider.tsx b/frontend/components/toast-provider.tsx new file mode 100644 index 0000000..c7baa6d --- /dev/null +++ b/frontend/components/toast-provider.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { ToastNotification, type ToastVariant } from './toast-notification'; + +type Toast = { + id: string; + message: string; + variant: ToastVariant; +}; + +type ToastContextValue = { + showToast: (message: string, variant?: ToastVariant) => void; +}; + +const ToastContext = createContext(null); + +let nextId = 0; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const showToast = useCallback( + (message: string, variant: ToastVariant = 'info') => { + const id = `toast-${++nextId}`; + setToasts((prev) => [...prev, { id, message, variant }]); + }, + [], + ); + + const value = useMemo(() => ({ showToast }), [showToast]); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ removeToast(toast.id)} + /> +
+ ))} +
+
+ ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within a ToastProvider'); + return ctx; +}