diff --git a/.gitignore b/.gitignore index 81cd2eef5..daa145939 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ test-config.yml dist/ build/ lib/ +# The GGA example app keeps decoupled source modules under src/lib/ (not a +# compiled-output dir), so it must not be swept up by the `lib/` rule above. +!apps/examples/grid-global-accounts-example-app/src/lib/ # Vim swap files *.swp @@ -126,3 +129,6 @@ stats.html # Dev proxy cookies (contains ALB session tokens) .dev-proxy-cookies + +# Playwright MCP logs +.playwright-mcp/ diff --git a/.yarnrc.yml b/.yarnrc.yml index 614d67f60..13ab38360 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,10 +1,10 @@ compressionLevel: mixed -enableGlobalCache: false +enableGlobalCache: true nodeLinker: node-modules -npmMinimalAgeGate: 1440 +npmMinimalAgeGate: 4320 # 3 days npmPreapprovedPackages: - "@lightsparkdev/*" diff --git a/apps/examples/grid-global-accounts-example-app/.gitignore b/apps/examples/grid-global-accounts-example-app/.gitignore new file mode 100644 index 000000000..a144fe599 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/.gitignore @@ -0,0 +1,2 @@ +# Verification screenshots — captured locally, not tracked. +.screenshots/ diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html new file mode 100644 index 000000000..f65f09a18 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -0,0 +1,12 @@ + + + + + + Grid Global Accounts + + +
+ + + diff --git a/apps/examples/grid-global-accounts-example-app/package.json b/apps/examples/grid-global-accounts-example-app/package.json new file mode 100644 index 000000000..0349edf13 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/package.json @@ -0,0 +1,30 @@ +{ + "name": "@lightsparkdev/grid-global-accounts-example-app", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "start": "vite", + "preview": "vite preview", + "test": "vitest run" + }, + "devDependencies": { + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^5.6.2", + "vite": "^8.0.14", + "vitest": "^4.1.7" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@lightsparkdev/origin": "*", + "@turnkey/api-key-stamper": "^0.6.5", + "@turnkey/crypto": "^2.8.14", + "@turnkey/encoding": "^0.6.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + } +} diff --git a/apps/examples/grid-global-accounts-example-app/src/App.tsx b/apps/examples/grid-global-accounts-example-app/src/App.tsx new file mode 100644 index 000000000..1b491ca9e --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/App.tsx @@ -0,0 +1,19 @@ +import { Shell } from "./components/Shell"; +import { AppStateProvider, useAppState } from "./state/store"; +import { CustomerView } from "./views/customer/CustomerView"; +import { PlatformView } from "./views/platform/PlatformView"; + +export function App() { + return ( + + + + + + ); +} + +function Router() { + const { persona } = useAppState(); + return persona === "platform" ? : ; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/__tests__/session.test.ts b/apps/examples/grid-global-accounts-example-app/src/__tests__/session.test.ts new file mode 100644 index 000000000..b6e3f4b7a --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/__tests__/session.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + clearActiveSession, + getAccountId, + getSessionId, + getSessionModel, + hasSessionSigningKey, + resolveSessionKeys, + setAccountId, + setActiveSessionAccount, + setSessionId, + setSessionKeysFromTek, +} from "../session"; + +// Reset to logged-out between tests so module-level context state can't leak. +afterEach(() => { + setActiveSessionAccount(null); +}); + +describe("per-customer session isolation", () => { + it("keeps signing keys, model, and account id independent per account key", () => { + // No active context → logged-out, getters return empty/null. + setActiveSessionAccount(null); + expect(hasSessionSigningKey()).toBe(false); + expect(resolveSessionKeys()).toBeNull(); + expect(getAccountId()).toBe(""); + expect(getSessionModel()).toBe("none"); + + // Customer A signs in (OTP-TEK model) under its own account key. + setActiveSessionAccount("InternalAccount:A"); + setAccountId("InternalAccount:A"); + setSessionKeysFromTek({ publicKey: "pubA", privateKey: "privA" }); + expect(hasSessionSigningKey()).toBe(true); + expect(getSessionModel()).toBe("otp-tek"); + expect(resolveSessionKeys()).toEqual({ + apiPublicKey: "pubA", + apiPrivateKey: "privA", + }); + expect(getAccountId()).toBe("InternalAccount:A"); + + // Switch to a fresh customer B: it inherits NOTHING from A. + setActiveSessionAccount("InternalAccount:B"); + expect(hasSessionSigningKey()).toBe(false); + expect(resolveSessionKeys()).toBeNull(); + expect(getSessionModel()).toBe("none"); + expect(getAccountId()).toBe(""); + + // B establishes its own session with different keys. + setAccountId("InternalAccount:B"); + setSessionKeysFromTek({ publicKey: "pubB", privateKey: "privB" }); + expect(resolveSessionKeys()).toEqual({ + apiPublicKey: "pubB", + apiPrivateKey: "privB", + }); + expect(getAccountId()).toBe("InternalAccount:B"); + + // Switching back to A restores A's cached session, untouched by B. + setActiveSessionAccount("InternalAccount:A"); + expect(hasSessionSigningKey()).toBe(true); + expect(getSessionModel()).toBe("otp-tek"); + expect(resolveSessionKeys()).toEqual({ + apiPublicKey: "pubA", + apiPrivateKey: "privA", + }); + expect(getAccountId()).toBe("InternalAccount:A"); + }); + + it("treats null as logged-out with no active context", () => { + setActiveSessionAccount("InternalAccount:A"); + setSessionKeysFromTek({ publicKey: "pubA", privateKey: "privA" }); + expect(hasSessionSigningKey()).toBe(true); + + setActiveSessionAccount(null); + expect(hasSessionSigningKey()).toBe(false); + expect(resolveSessionKeys()).toBeNull(); + expect(getAccountId()).toBe(""); + + // Mutators no-op while logged-out; later re-activation still has A cached. + setSessionKeysFromTek({ publicKey: "pubX", privateKey: "privX" }); + setActiveSessionAccount("InternalAccount:A"); + expect(resolveSessionKeys()).toEqual({ + apiPublicKey: "pubA", + apiPrivateKey: "privA", + }); + }); + + it("clearActiveSession wipes the active context's signing key without touching others", () => { + // Customer A signs in under its own account key. + setActiveSessionAccount("InternalAccount:clearA"); + setAccountId("InternalAccount:clearA"); + setSessionId("session-A"); + setSessionKeysFromTek({ publicKey: "pubA", privateKey: "privA" }); + + // Customer B signs in under a different key. + setActiveSessionAccount("InternalAccount:clearB"); + setAccountId("InternalAccount:clearB"); + setSessionId("session-B"); + setSessionKeysFromTek({ publicKey: "pubB", privateKey: "privB" }); + + // Back on A, clearing wipes A's signing key/model/session id but keeps the + // account id so the slot still belongs to A (logged out, can re-auth). + setActiveSessionAccount("InternalAccount:clearA"); + expect(hasSessionSigningKey()).toBe(true); + clearActiveSession(); + expect(hasSessionSigningKey()).toBe(false); + expect(resolveSessionKeys()).toBeNull(); + expect(getSessionModel()).toBe("none"); + expect(getSessionId()).toBe(""); + expect(getAccountId()).toBe("InternalAccount:clearA"); + + // B is untouched by clearing A. + setActiveSessionAccount("InternalAccount:clearB"); + expect(hasSessionSigningKey()).toBe(true); + expect(getSessionModel()).toBe("otp-tek"); + expect(getSessionId()).toBe("session-B"); + expect(resolveSessionKeys()).toEqual({ + apiPublicKey: "pubB", + apiPrivateKey: "privB", + }); + }); + + it("clearActiveSession is a no-op when logged out", () => { + setActiveSessionAccount(null); + expect(() => clearActiveSession()).not.toThrow(); + expect(hasSessionSigningKey()).toBe(false); + }); + + it("preserves first-account-wins for setAccountId within a fresh context", () => { + // A distinct key so the context is fresh (contexts persist across tests). + setActiveSessionAccount("InternalAccount:C"); + setAccountId("first"); + setAccountId("second"); + expect(getAccountId()).toBe("first"); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/__tests__/webauthn-options.test.ts b/apps/examples/grid-global-accounts-example-app/src/__tests__/webauthn-options.test.ts new file mode 100644 index 000000000..4da827c3a --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/__tests__/webauthn-options.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAllowCredentials, + buildAssertionOptions, + buildCreationOptions, + bytesToB64Url, + SECURITY_KEY_TRANSPORTS, +} from "../webauthn"; + +const RP = "localhost"; + +describe("buildCreationOptions — forces a cross-platform security key", () => { + const challenge = new Uint8Array([1, 2, 3]); + const userId = new Uint8Array([9, 9]); + const opts = buildCreationOptions("My key", RP, challenge, userId); + + it("requests a cross-platform (roaming) authenticator, not the platform one", () => { + expect(opts.authenticatorSelection?.authenticatorAttachment).toBe( + "cross-platform", + ); + }); + + it("uses security-key-friendly resident-key + UV settings", () => { + expect(opts.authenticatorSelection?.residentKey).toBe("discouraged"); + expect(opts.authenticatorSelection?.requireResidentKey).toBe(false); + expect(opts.authenticatorSelection?.userVerification).toBe("preferred"); + }); + + it("offers ES256 (-7) in pubKeyCredParams", () => { + expect(opts.pubKeyCredParams).toContainEqual({ + type: "public-key", + alg: -7, + }); + }); + + it("sets the rp id and passes the challenge/user through", () => { + expect(opts.rp.id).toBe(RP); + expect(opts.challenge).toBe(challenge); + expect(opts.user.id).toBe(userId); + }); +}); + +describe("buildAllowCredentials — targets the security key over USB/NFC", () => { + // A valid base64url credential id (decodes cleanly via atob). + const idA = bytesToB64Url(new Uint8Array([10, 20, 30])); + const idB = bytesToB64Url(new Uint8Array([40, 50, 60])); + + it("includes every registered id with usb/nfc transports", () => { + const out = buildAllowCredentials([idA, idB]); + expect(out).toHaveLength(2); + for (const d of out) { + expect(d.type).toBe("public-key"); + expect(d.transports).toEqual(SECURITY_KEY_TRANSPORTS); + expect(d.transports).toEqual(["usb", "nfc"]); + } + }); + + it("drops blank and duplicate ids", () => { + const out = buildAllowCredentials([idA, "", " ", idA]); + expect(out).toHaveLength(1); + }); + + it("returns [] when no ids are known (discoverable-credential fallback)", () => { + expect(buildAllowCredentials([])).toEqual([]); + }); +}); + +describe("buildAssertionOptions", () => { + const challenge = new Uint8Array([7]); + const id = bytesToB64Url(new Uint8Array([1, 2, 3, 4])); + + it("wires the rp id, challenge, UV and the allowCredentials", () => { + const opts = buildAssertionOptions(challenge, [id], RP); + expect(opts.rpId).toBe(RP); + expect(opts.challenge).toBe(challenge); + expect(opts.userVerification).toBe("preferred"); + expect(opts.allowCredentials).toHaveLength(1); + expect(opts.allowCredentials?.[0].transports).toEqual(["usb", "nfc"]); + }); + + it("yields an empty allowCredentials when no ids are known", () => { + const opts = buildAssertionOptions(challenge, [], RP); + expect(opts.allowCredentials).toEqual([]); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/api-client.ts b/apps/examples/grid-global-accounts-example-app/src/api-client.ts new file mode 100644 index 000000000..40e550d11 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/api-client.ts @@ -0,0 +1,112 @@ +// HTTP client + auth header + mode resolution. +// +// DOM-free: the platform credentials (client id / secret) and the active mode +// are passed in via an `ApiAuth` value instead of being read out of input +// elements, so the same client works from React, tests, or any caller that can +// supply the credentials it already holds. + +import { API_BASE, type Mode } from "./config"; + +export interface ApiAuth { + clientId: string; + clientSecret: string; + mode: Mode; +} + +// Fail a stalled request instead of spinning forever — a guided op that hangs +// server-side surfaces as a clear timeout rather than an indefinite wait. +const REQUEST_TIMEOUT_MS = 30_000; + +export function resolveMode(value: string | undefined): Mode { + return value === "production" ? "production" : "sandbox"; +} + +function authHeader(auth: ApiAuth): string { + return "Basic " + btoa(`${auth.clientId.trim()}:${auth.clientSecret.trim()}`); +} + +async function timedFetch(path: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(API_BASE + path, { ...init, signal: controller.signal }); + } catch (err) { + if (controller.signal.aborted) + throw new Error( + `Request to ${path} timed out after ${REQUEST_TIMEOUT_MS / 1000}s.`, + ); + throw err; + } finally { + clearTimeout(timer); + } +} + +export async function apiPost( + auth: ApiAuth, + path: string, + body: Record | undefined, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await timedFetch(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader(auth), + ...extraHeaders, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + +export async function apiDelete( + auth: ApiAuth, + path: string, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await timedFetch(path, { + method: "DELETE", + headers: { + Authorization: authHeader(auth), + ...extraHeaders, + }, + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + +export async function apiPatch( + auth: ApiAuth, + path: string, + body: Record, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await timedFetch(path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: authHeader(auth), + ...extraHeaders, + }, + body: JSON.stringify(body), + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + +export async function apiGet(auth: ApiAuth, path: string): Promise { + const res = await timedFetch(path, { + headers: { Authorization: authHeader(auth) }, + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return data; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/components/ContextChip.tsx b/apps/examples/grid-global-accounts-example-app/src/components/ContextChip.tsx new file mode 100644 index 000000000..5af4d2fab --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/ContextChip.tsx @@ -0,0 +1,171 @@ +import styled from "@emotion/styled"; + +import { useAppState } from "../state/store"; + +/** + * The active-context indicator in the Shell header. Normally a quiet chip + * naming the customer the app is acting as (or "No customer" before one is + * picked). When debug mode is on it expands to reveal the actual identifiers + * the plumbing runs on — customer id, account id (if provisioned), and session + * id (if signed in) — inline as monospace key/value pairs. + * + * Nothing identifying is shown in the normal (non-debug) state, keeping the + * polished persona views free of raw IDs. + */ +export function ContextChip() { + const { activeCustomer, session, debugOn } = useAppState(); + + const name = activeCustomer?.name || activeCustomer?.email || null; + const sessionId = extractSessionId(session); + + if (!activeCustomer) { + // Nothing to anchor to before a customer is active — stay out of the way. + if (!debugOn) return null; + return ( + + + No customer + + ); + } + + return ( + + + {name} + + {debugOn && ( + + + cust + {activeCustomer.id} + + {activeCustomer.accountId && ( + + acct + {activeCustomer.accountId} + + )} + {sessionId && ( + + sess + {sessionId} + + )} + + )} + + ); +} + +/** + * The session is held as `unknown` (the concrete bundle shape is owned by the + * reused login flows). Best-effort dig for a likely identifier so debug mode + * can surface *something* without coupling to one provider's shape; falls back + * to null when there's nothing id-like to show. + */ +function extractSessionId(session: unknown): string | null { + if (!session || typeof session !== "object") return null; + const s = session as Record; + const candidates = [ + s.id, + s.sessionId, + s.session_id, + s.credentialId, + s.credential_id, + (s.session as Record | undefined)?.id, + ]; + for (const c of candidates) { + if (typeof c === "string" && c.length > 0) return c; + } + return null; +} + +const Chip = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); + min-width: 0; + max-width: 360px; + padding: var(--spacing-3xs, 4px) var(--spacing-xs, 8px); + border-radius: var(--corner-radius-md, 8px); + background: var(--surface-secondary, #f0f0ee); + border: var(--stroke-xs, 0.5px) solid + var(--border-primary, rgba(38, 38, 35, 0.1)); + + &[data-debug] { + /* Fixed dark "console" surface (matches DebugDrawer's PANEL_BG), not the + * mode-flipping --surface-inverse — its light text would vanish on the + * near-white --surface-inverse in dark mode. */ + background: #16161a; + border-color: rgba(255, 255, 255, 0.14); + } + + @media (width <= 760px) { + display: none; + } +`; + +const Dot = styled.span` + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-tertiary, #8a8a8a); + + &[data-on="true"] { + background: #3dd68c; + box-shadow: 0 0 0 3px rgba(61, 214, 140, 0.22); + } +`; + +const Name = styled.span` + flex: 0 0 auto; + font-size: var(--font-size-xs, 12px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &[data-debug] { + color: rgba(255, 255, 255, 0.92); + } +`; + +const Ids = styled.span` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); + padding-left: var(--spacing-2xs, 6px); + margin-left: var(--spacing-3xs, 4px); + border-left: var(--stroke-xs, 0.5px) solid rgba(255, 255, 255, 0.16); + min-width: 0; +`; + +const Id = styled.span` + display: inline-flex; + align-items: baseline; + gap: var(--spacing-3xs, 4px); + min-width: 0; +`; + +const K = styled.span` + flex: 0 0 auto; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-2xs, 10px); + text-transform: uppercase; + letter-spacing: 0.4px; + color: rgba(255, 255, 255, 0.45); +`; + +const V = styled.span` + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-2xs, 10px); + color: #b8e8ff; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/components/DebugDrawer.tsx b/apps/examples/grid-global-accounts-example-app/src/components/DebugDrawer.tsx new file mode 100644 index 000000000..d2a0b4368 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/DebugDrawer.tsx @@ -0,0 +1,338 @@ +import styled from "@emotion/styled"; +import { Badge, Collapsible } from "@lightsparkdev/origin"; +import { useState, type ComponentProps } from "react"; + +import type { LogEntry } from "../lib/reporter"; +import { useAppState } from "../state/store"; + +type BadgeVariant = ComponentProps["variant"]; + +/** Map each log level to an Origin badge variant + short tag. */ +const LEVEL: Record = + { + info: { variant: "gray", tag: "INFO" }, + error: { variant: "red", tag: "ERR" }, + request: { variant: "blue", tag: "REQ" }, + response: { variant: "green", tag: "RES" }, + }; + +/** + * The debug surface's main instrument: a docked panel pinned to the bottom of + * the viewport that streams the structured `store.log` — one row per entry with + * a level badge, label, timestamp, and an expandable raw `detail` JSON. Rendered + * only when debug mode is on, across both personas (wired into the Shell), and + * non-modal so the app stays fully usable behind it. Collapsible to a slim + * header bar so it can be parked out of the way. + */ +export function DebugDrawer() { + const { debugOn, log } = useAppState(); + const [open, setOpen] = useState(true); + + if (!debugOn) return null; + + // Newest first — the reporter appends, so reverse a shallow copy. + const entries = [...log].reverse(); + + return ( + + setOpen((v) => !v)} aria-expanded={open}> + + + Debug console + {log.length} + + {open ? "▾" : "▴"} + + + {open && ( + + {entries.length === 0 ? ( + + No events yet. Connect, create a customer, or sign in — every + request and response lands here. + + ) : ( + + {entries.map((entry) => ( + + ))} + + )} + + )} + + ); +} + +function LogRow({ entry }: { entry: LogEntry }) { + const level = LEVEL[entry.level]; + const hasDetail = entry.detail !== undefined && entry.detail !== null; + + return ( + + + + {level.tag} + + {entry.label} + + + + {hasDetail && ( + + + detail + + +
+              {stringify(entry.detail)}
+            
+
+
+ )} +
+ ); +} + +function stringify(value: unknown): string { + if (value === undefined) return "undefined"; + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function formatTime(ts: number): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + const ms = String(d.getMilliseconds()).padStart(3, "0"); + return `${hh}:${mm}:${ss}.${ms}`; +} + +const PANEL_BG = "#16161a"; +const PANEL_BORDER = "rgba(255, 255, 255, 0.1)"; +const PANEL_TEXT = "rgba(255, 255, 255, 0.92)"; +const PANEL_DIM = "rgba(255, 255, 255, 0.45)"; + +const Dock = styled.aside` + position: fixed; + inset: auto 0 0 0; + z-index: 40; + display: flex; + flex-direction: column; + background: ${PANEL_BG}; + color: ${PANEL_TEXT}; + border-top: var(--stroke-sm, 1px) solid ${PANEL_BORDER}; + box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.28); + font-family: var(--font-family-mono, ui-monospace, monospace); + + /* A hairline of accent at the very top edge, like a live wire. */ + &::before { + content: ""; + position: absolute; + top: -1px; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + var(--surface-blue-strong, #0072db) 30%, + #3dd68c 70%, + transparent + ); + opacity: 0.7; + } +`; + +const Bar = styled.button` + all: unset; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm, 12px); + padding: var(--spacing-xs, 8px) var(--spacing-lg, 20px); + cursor: pointer; + user-select: none; + + &:hover { + background: rgba(255, 255, 255, 0.04); + } + &:focus-visible { + outline: 2px solid var(--surface-blue-strong, #0072db); + outline-offset: -2px; + } +`; + +const BarLeft = styled.span` + display: inline-flex; + align-items: center; + gap: var(--spacing-xs, 8px); +`; + +const Pulse = styled.span` + width: 7px; + height: 7px; + border-radius: 50%; + background: #3dd68c; + box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.6); + animation: dbg-pulse 2.4s ease-out infinite; + + @keyframes dbg-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.5); + } + 70% { + box-shadow: 0 0 0 6px rgba(61, 214, 140, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(61, 214, 140, 0); + } + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +`; + +const BarTitle = styled.span` + font-size: var(--font-size-xs, 12px); + font-weight: var(--font-weight-semibold, 600); + letter-spacing: 0.6px; + text-transform: uppercase; +`; + +const Count = styled.span` + font-size: var(--font-size-2xs, 10px); + color: ${PANEL_DIM}; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + padding: 1px var(--spacing-2xs, 6px); + font-variant-numeric: tabular-nums; +`; + +const BarRight = styled.span` + font-size: var(--font-size-sm, 13px); + color: ${PANEL_DIM}; +`; + +const Body = styled.div` + max-height: min(42vh, 380px); + overflow: auto; + border-top: var(--stroke-xs, 0.5px) solid ${PANEL_BORDER}; +`; + +const Empty = styled.div` + padding: var(--spacing-md, 16px) var(--spacing-lg, 20px); + font-size: var(--font-size-xs, 12px); + color: ${PANEL_DIM}; + line-height: 1.5; +`; + +const List = styled.ol` + list-style: none; + margin: 0; + padding: 0; +`; + +const Row = styled.li` + padding: var(--spacing-xs, 8px) var(--spacing-lg, 20px); + border-top: var(--stroke-xs, 0.5px) solid rgba(255, 255, 255, 0.06); + + &:first-of-type { + border-top: none; + } +`; + +const RowHead = styled.div` + display: flex; + align-items: center; + gap: var(--spacing-sm, 12px); +`; + +const LevelBadge = styled(Badge)` + flex: 0 0 auto; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-variant-numeric: tabular-nums; + letter-spacing: 0.5px; +`; + +const RowLabel = styled.span` + flex: 1; + min-width: 0; + font-size: var(--font-size-xs, 12px); + color: ${PANEL_TEXT}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Time = styled.span` + flex: 0 0 auto; + font-size: var(--font-size-2xs, 10px); + color: ${PANEL_DIM}; + font-variant-numeric: tabular-nums; +`; + +const DetailTrigger = styled(Collapsible.Trigger)` + display: inline-flex; + align-items: center; + gap: var(--spacing-3xs, 4px); + width: auto; + margin-top: var(--spacing-3xs, 4px); + margin-left: 52px; /* align under the label, clear of the badge */ + padding: 0; + background: transparent; + border: none; + cursor: pointer; + color: ${PANEL_DIM}; + + &:hover { + color: ${PANEL_TEXT}; + } + &:hover span { + text-decoration: none; + } + + [class*="icon"] svg { + width: 14px; + height: 14px; + } + [class*="icon"] { + width: auto; + height: auto; + color: currentColor; + } +`; + +const DetailTriggerLabel = styled.span` + font-size: var(--font-size-2xs, 10px); + text-transform: uppercase; + letter-spacing: 0.6px; + flex: 0 0 auto; +`; + +const Pre = styled.pre` + margin: var(--spacing-3xs, 4px) 0 var(--spacing-2xs, 6px) 52px; + padding: var(--spacing-sm, 12px); + background: rgba(0, 0, 0, 0.4); + border: var(--stroke-xs, 0.5px) solid ${PANEL_BORDER}; + border-radius: var(--corner-radius-md, 8px); + color: #b8e8ff; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-2xs, 10px); + line-height: 1.55; + overflow: auto; + max-height: 240px; + white-space: pre; + tab-size: 2; + + code { + font-family: inherit; + } +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/components/DebugToggle.tsx b/apps/examples/grid-global-accounts-example-app/src/components/DebugToggle.tsx new file mode 100644 index 000000000..22c05493e --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/DebugToggle.tsx @@ -0,0 +1,49 @@ +import styled from "@emotion/styled"; +import { Switch } from "@lightsparkdev/origin"; + +import { useAppState } from "../state/store"; + +/** + * Top-bar control that flips the app between the two polished personas + * (off) and the dev-tools view that surfaces the request/response log and + * raw IDs (on). Defaults to off — the store seeds `debugOn = false`. + */ +export function DebugToggle() { + const { debugOn, toggleDebug } = useAppState(); + + return ( + + + + + ); +} + +const Root = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-xs, 8px); +`; + +const Label = styled.label` + font-size: var(--font-size-xs, 12px); + font-weight: var(--font-weight-medium, 500); + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text-tertiary, #8a8a8a); + cursor: pointer; + transition: color 120ms ease; + user-select: none; + + &[data-active="true"] { + color: var(--text-primary, #1a1a1a); + } +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/components/DismissibleAlert.tsx b/apps/examples/grid-global-accounts-example-app/src/components/DismissibleAlert.tsx new file mode 100644 index 000000000..7c680d6ef --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/DismissibleAlert.tsx @@ -0,0 +1,58 @@ +import styled from "@emotion/styled"; +import { Alert } from "@lightsparkdev/origin"; +import type { ComponentProps } from "react"; + +type DismissibleAlertProps = ComponentProps & { + /** Called when the user clicks the close (✕) button. */ + onClose: () => void; +}; + +/** + * An Origin with a close button. Origin's Alert has no dismiss + * affordance, so we overlay one at the top-right and reserve room for it so a + * long description doesn't run underneath. + */ +export function DismissibleAlert({ + onClose, + ...alertProps +}: DismissibleAlertProps) { + return ( + + + + ✕ + + + ); +} + +const Wrap = styled.div` + position: relative; + /* Higher specificity than Origin's .root class, so it wins reliably. */ + & [role="alert"] { + padding-right: 44px; + } +`; + +const CloseButton = styled.button` + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + border-radius: var(--corner-radius-sm, 6px); + color: var(--text-tertiary, #8a8a8a); + font-size: 13px; + line-height: 1; + cursor: pointer; + &:hover { + color: var(--text-primary, #1a1a1a); + background: var(--surface-hover, rgba(0, 0, 0, 0.04)); + } +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/components/PersonaSwitcher.tsx b/apps/examples/grid-global-accounts-example-app/src/components/PersonaSwitcher.tsx new file mode 100644 index 000000000..0e3bd09d0 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/PersonaSwitcher.tsx @@ -0,0 +1,40 @@ +import styled from "@emotion/styled"; +import { Tabs } from "@lightsparkdev/origin"; + +import { useAppState, type Persona } from "../state/store"; + +const PERSONAS: { value: Persona; label: string }[] = [ + { value: "platform", label: "Platform" }, + { value: "customer", label: "Customer" }, +]; + +/** + * Segmented control that toggles between the Platform and Customer views. + * Only one persona is on screen at a time; this drives `persona` in the + * app store. Built on Origin's Tabs so we get the sliding indicator + a + * roving-tabindex keyboard model for free. + */ +export function PersonaSwitcher() { + const { persona, setPersona } = useAppState(); + + return ( + + setPersona(value as Persona)} + > + + {PERSONAS.map(({ value, label }) => ( + + {label} + + ))} + + + + ); +} + +const Root = styled.div` + display: inline-flex; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/components/RawExpander.tsx b/apps/examples/grid-global-accounts-example-app/src/components/RawExpander.tsx new file mode 100644 index 000000000..e346573f3 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/RawExpander.tsx @@ -0,0 +1,146 @@ +import styled from "@emotion/styled"; +import { Collapsible } from "@lightsparkdev/origin"; + +import { useAppState } from "../state/store"; + +export interface RawExpanderProps { + /** Raw payload to pretty-print. Anything JSON-serializable (objects, arrays). */ + value: unknown; + /** Trigger label. Defaults to "Raw response". */ + label?: string; +} + +/** + * A reusable disclosure that reveals a raw JSON blob — but only when debug mode + * is on. Off by default and renders nothing when debug is off, so it can be + * dropped next to a polished value without leaking the plumbing into the + * happy-path UI. Collapsed by default when shown. + * + * Used to attach the real API payload behind a value the user already sees + * formatted (e.g. a balance, a connection summary), so the demo can show "here's + * the pretty number, and here's exactly what the API returned". + */ +export function RawExpander({ + value, + label = "Raw response", +}: RawExpanderProps) { + const { debugOn } = useAppState(); + if (!debugOn) return null; + + const json = stringify(value); + + return ( + + + + + + {label} + + + +
+            {json}
+          
+
+
+
+ ); +} + +/** Pretty-print, falling back gracefully on cyclic / non-serializable input. */ +function stringify(value: unknown): string { + if (value === undefined) return "undefined"; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +const Root = styled.div` + margin-top: var(--spacing-sm, 12px); +`; + +const Trigger = styled(Collapsible.Trigger)` + /* Override the default Collapsible trigger to a compact, monospace "dev" tag + so it reads as instrumentation rather than primary content. The base + component already rotates its chevron on open via .root[data-open]. */ + display: inline-flex; + align-items: center; + gap: var(--spacing-3xs, 4px); + width: auto; + padding: var(--spacing-3xs, 4px) var(--spacing-xs, 8px); + background: var(--surface-secondary, #f0f0ee); + border: var(--stroke-xs, 0.5px) solid + var(--border-primary, rgba(38, 38, 35, 0.1)); + border-radius: var(--corner-radius-sm, 6px); + cursor: pointer; + color: var(--text-secondary, #555); + + &:hover { + background: var(--surface-tertiary, #c1c0b8); + color: var(--text-primary, #1a1a1a); + } + + /* Suppress the base trigger's underline-on-hover; this reads as a tag. */ + span { + flex: 0 0 auto; + } + &:hover span { + text-decoration: none; + } + + /* Shrink the oversized 24px chevron to fit the compact tag. */ + [class*="icon"] svg { + width: 14px; + height: 14px; + } + [class*="icon"] { + width: auto; + height: auto; + } +`; + +const TriggerInner = styled.span` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); +`; + +const Spark = styled.span` + width: 5px; + height: 5px; + border-radius: 1px; + background: var(--surface-blue-strong, #0072db); + transform: rotate(45deg); +`; + +const TriggerLabel = styled.span` + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-2xs, 10px); + font-weight: var(--font-weight-medium, 500); + letter-spacing: 0.4px; + text-transform: uppercase; +`; + +const Pre = styled.pre` + margin: var(--spacing-2xs, 6px) 0 0; + padding: var(--spacing-sm, 12px); + /* Fixed dark "terminal" surface, not the mode-flipping --surface-inverse: + * the light-green text would vanish on its near-white value in dark mode. */ + background: #16161a; + color: #d6f7c2; + border-radius: var(--corner-radius-md, 8px); + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-2xs, 10px); + line-height: 1.55; + overflow: auto; + max-height: 280px; + white-space: pre; + tab-size: 2; + + code { + font-family: inherit; + } +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/components/Shell.tsx b/apps/examples/grid-global-accounts-example-app/src/components/Shell.tsx new file mode 100644 index 000000000..944ae9748 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/Shell.tsx @@ -0,0 +1,137 @@ +import styled from "@emotion/styled"; +import { Badge, Logo } from "@lightsparkdev/origin"; +import { type ReactNode } from "react"; + +import { useAppState } from "../state/store"; +import { ContextChip } from "./ContextChip"; +import { DebugDrawer } from "./DebugDrawer"; +import { DebugToggle } from "./DebugToggle"; +import { PersonaSwitcher } from "./PersonaSwitcher"; + +/** + * App frame: a sticky top bar (brand · persona switcher · debug toggle) over + * a centered content column. View routing happens in `App`; the Shell only + * owns the chrome so each persona view can stay focused on its own content. + */ +export function Shell({ children }: { children: ReactNode }) { + const { persona, debugOn } = useAppState(); + + return ( + + + + + + Global Accounts + + + + + + + + + + {persona === "platform" ? "Platform" : "Customer"} + + + + + + + {children} + + + {/* Docked dev console — renders only when debugOn, spans both personas. */} + + + ); +} + +const Page = styled.div` + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--surface-base, #f5f5f7); + color: var(--text-primary, #1a1a1a); + font-family: var(--font-family-sans); +`; + +const TopBar = styled.header` + position: sticky; + top: 0; + z-index: 10; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--spacing-md, 16px); + padding: var(--spacing-sm, 12px) var(--spacing-lg, 24px); + background: var(--surface-primary, #fff); + border-bottom: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + backdrop-filter: saturate(180%) blur(8px); +`; + +const Brand = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-xs, 8px); + min-width: 0; +`; + +const BrandDivider = styled.span` + width: var(--stroke-xs, 1px); + height: 18px; + background: var(--border-primary, #e6e6e9); +`; + +const BrandLabel = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-secondary, #555); + white-space: nowrap; + letter-spacing: -0.1px; +`; + +const SwitcherSlot = styled.div` + display: flex; + justify-content: center; +`; + +const Controls = styled.div` + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-md, 16px); +`; + +const PersonaBadge = styled(Badge)` + /* Hidden on narrow widths; the switcher already names the persona. */ + @media (width <= 640px) { + display: none; + } +`; + +const Content = styled.main` + flex: 1; + display: flex; + justify-content: center; + padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px) + var(--spacing-4xl, 64px); + + /* Reserve room for the docked debug console so it never hides content; the + console's body scrolls internally, so the collapsed-bar clearance is enough. */ + &[data-debug] { + padding-bottom: var(--spacing-9xl, 96px); + } +`; + +const Column = styled.div` + width: 100%; + max-width: 920px; + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/components/StatusBanner.tsx b/apps/examples/grid-global-accounts-example-app/src/components/StatusBanner.tsx new file mode 100644 index 000000000..f8e883b89 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/components/StatusBanner.tsx @@ -0,0 +1,19 @@ +import { useAppState } from "../state/store"; +import { DismissibleAlert } from "./DismissibleAlert"; + +/** + * The transient, app-wide status line fed by `reporter.status(...)` (e.g. + * "Payment executed.", errors), shown at the top of each persona view and + * dismissable — clicking ✕ clears it via `clearStatus`. + */ +export function StatusBanner() { + const { status, clearStatus } = useAppState(); + if (!status) return null; + return ( + + ); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/config.ts b/apps/examples/grid-global-accounts-example-app/src/config.ts new file mode 100644 index 000000000..9a6a620c5 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/config.ts @@ -0,0 +1,46 @@ +// Grid Global Accounts — Example App: shared config + constants. + +export type Mode = "sandbox" | "production"; +export type CredType = "email_otp" | "oauth" | "passkey"; + +// Sandbox magic signature injected into signed-retry headers and the execute +// signature. In production these are wrong — a real stamp must be supplied. +export const SANDBOX_SIG = "sandbox-valid-signature"; + +// All requests proxy through Vite at `/api` and forward to the configured Grid +// backend. Credentials are entered manually in the UI — never embedded. +export const API_BASE = "/api"; + +// Turnkey API stamp scheme — must match what `@turnkey/api-key-stamper` emits. +export const TURNKEY_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; + +// `localStorage` key for the persisted mode so a reload keeps the chosen mode +// instead of silently reverting to sandbox. +export const MODE_STORAGE_KEY = "gga-example-app-mode"; + +// ----- Sandbox magic values ----- +// +// The single source of truth for every fake "looks-real" field. Keyed by input +// element id → the magic value the sandbox backend accepts. In sandbox mode +// these are seeded into the fields (and the field gets a "magic" pill) by +// `mode.ts`; in production mode the same fields are hidden so nothing fake is +// ever on screen. This replaces the scattered `value="sandbox-..."` attributes +// that made fake data indistinguishable from real values. +export const SANDBOX_MAGIC: Record = { + // EMAIL_OTP — sandbox always accepts the fixed code. + "email_otp-v3-code": "000000", + // OAUTH — magic OIDC tokens (verify input + JWT-shaped create/add identities). + "oauth-verify-oidc": "sandbox-valid-oidc-token", + "oauth-create-oidc": + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiJzYW5kYm94LXVzZXItMSJ9.sig", + "oauth-add-oidc": + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwic3ViIjoic2FuZGJveC11c2VyLTIifQ.sig", + // PASSKEY — magic attestation (create) + assertion (verify) blobs. + "passkey-create-challenge": "c2FuZGJveC1jaGFsbGVuZ2U", + "passkey-create-cred-id-raw": "c2FuZGJveC1jcmVkLWlk", + "passkey-create-client-data-json": "c2FuZGJveC1jbGllbnREYXRhSlNPTg", + "passkey-create-attestation-object": "c2FuZGJveC1hdHRlc3RhdGlvbk9iamVjdA", + "passkey-verify-signature": "sandbox-valid-passkey-signature", + "passkey-verify-auth-data": "c2FuZGJveC1hdXRoLWRhdGE", + "passkey-verify-client-data-json": "c2FuZGJveC1jbGllbnQtZGF0YQ", +}; diff --git a/apps/examples/grid-global-accounts-example-app/src/declarations.d.ts b/apps/examples/grid-global-accounts-example-app/src/declarations.d.ts new file mode 100644 index 000000000..85b884cbc --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/declarations.d.ts @@ -0,0 +1,15 @@ +// tsc resolves @lightsparkdev/origin's component imports against its source +// files (the package's `main` points at src/index.ts), so when we compile +// this app it walks into Origin's *.module.scss imports. Origin ships its +// own scss shim under its src/declarations.d.ts, but TypeScript only picks +// up .d.ts files inside the current compilation root — we need our own. + +declare module "*.module.scss" { + const classes: { readonly [key: string]: string }; + export default classes; +} + +declare module "*.module.css" { + const classes: { readonly [key: string]: string }; + export default classes; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-external-account.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-external-account.test.ts new file mode 100644 index 000000000..58acbd138 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-external-account.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import type { ApiAuth } from "../../api-client"; +import { + createCustomerExternalAccount, + listCustomerExternalAccounts, +} from "../money"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", +}; + +// Mock at the api-client boundary so no real API is hit. +vi.mock("../../api-client", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), +})); +import { apiGet, apiPost } from "../../api-client"; +const mockGet = vi.mocked(apiGet); +const mockPost = vi.mocked(apiPost); + +beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); +}); + +describe("createCustomerExternalAccount", () => { + it("POSTs /customers/external-accounts with customerId + USD bank body", async () => { + const { reporter } = createCollectingReporter(); + mockPost.mockResolvedValueOnce({ + status: 200, + data: { id: "ExternalAccount:ext1" }, + }); + + const id = await createCustomerExternalAccount(reporter, auth, { + customerId: "Customer:c1", + accountNumber: "000123456789", + routingNumber: "021000021", + beneficiaryName: "Ada Lovelace", + }); + + expect(id).toBe("ExternalAccount:ext1"); + expect(mockPost).toHaveBeenCalledTimes(1); + const [, path, body] = mockPost.mock.calls[0]; + expect(path).toBe("/customers/external-accounts"); + const sent = body as Record; + expect(sent.customerId).toBe("Customer:c1"); + expect(sent.currency).toBe("USD"); + const info = sent.accountInfo as Record; + expect(info.accountType).toBe("USD_ACCOUNT"); + expect(info.accountNumber).toBe("000123456789"); + expect(info.routingNumber).toBe("021000021"); + expect((info.beneficiary as Record).fullName).toBe( + "Ada Lovelace", + ); + }); + + it("trims inputs and requires a customer + bank fields", async () => { + const { reporter } = createCollectingReporter(); + await expect( + createCustomerExternalAccount(reporter, auth, { + customerId: " ", + accountNumber: "000123456789", + routingNumber: "021000021", + }), + ).rejects.toThrow(/customer/i); + await expect( + createCustomerExternalAccount(reporter, auth, { + customerId: "Customer:c1", + accountNumber: " ", + routingNumber: "021000021", + }), + ).rejects.toThrow(/account number/i); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("throws when the create response has no id", async () => { + const { reporter } = createCollectingReporter(); + mockPost.mockResolvedValueOnce({ status: 200, data: {} }); + await expect( + createCustomerExternalAccount(reporter, auth, { + customerId: "Customer:c1", + accountNumber: "000123456789", + routingNumber: "021000021", + }), + ).rejects.toThrow(/no id/i); + }); +}); + +describe("listCustomerExternalAccounts", () => { + it("GETs /customers/external-accounts with customerId + currency and parses labels", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ + data: [ + { + id: "ExternalAccount:ext1", + currency: "USD", + accountInfo: { + accountType: "USD_ACCOUNT", + accountNumber: "123456789", + }, + }, + { + id: "ExternalAccount:ext2", + currency: "USD", + accountInfo: { + accountType: "USD_ACCOUNT", + accountNumber: "987654321", + }, + }, + ], + hasMore: false, + }); + + const rows = await listCustomerExternalAccounts( + reporter, + auth, + "Customer:c1", + "USD", + ); + + expect(mockGet).toHaveBeenCalledTimes(1); + const [, path] = mockGet.mock.calls[0]; + expect(path).toBe( + "/customers/external-accounts?customerId=Customer%3Ac1¤cy=USD", + ); + expect(rows).toEqual([ + { id: "ExternalAccount:ext1", label: "USD •••6789" }, + { id: "ExternalAccount:ext2", label: "USD •••4321" }, + ]); + }); + + it("omits the currency query param when not supplied", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ data: [], hasMore: false }); + + await listCustomerExternalAccounts(reporter, auth, "Customer:c1"); + + const [, path] = mockGet.mock.calls[0]; + expect(path).toBe("/customers/external-accounts?customerId=Customer%3Ac1"); + }); + + it("skips rows without an id and tolerates a null/empty response", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ + data: [ + { currency: "USD" }, + { id: "ExternalAccount:ok", currency: "USD" }, + ], + hasMore: false, + }); + + const rows = await listCustomerExternalAccounts( + reporter, + auth, + "Customer:c1", + ); + expect(rows).toEqual([{ id: "ExternalAccount:ok", label: "USD" }]); + + mockGet.mockResolvedValueOnce(null); + const empty = await listCustomerExternalAccounts( + reporter, + auth, + "Customer:c1", + ); + expect(empty).toEqual([]); + }); + + it("requires a customer id", async () => { + const { reporter } = createCollectingReporter(); + await expect( + listCustomerExternalAccounts(reporter, auth, " "), + ).rejects.toThrow(/customer/i); + expect(mockGet).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-internal-accounts.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-internal-accounts.test.ts new file mode 100644 index 000000000..2df61de2b --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/customer-internal-accounts.test.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import type { ApiAuth } from "../../api-client"; +import { + groupCustomerWallets, + listAllInternalAccounts, + parseInternalAccount, + type ParsedInternalAccount, +} from "../customer"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", +}; + +// Mock at the api-client boundary so no real API is hit. +vi.mock("../../api-client", () => ({ + apiGet: vi.fn(), +})); +import { apiGet } from "../../api-client"; +const mockGet = vi.mocked(apiGet); + +// Reset call history + queued resolutions between tests so the multi-page tests +// (which assert exact call counts) don't see earlier tests' calls or leftovers. +beforeEach(() => { + mockGet.mockReset(); +}); + +/** Convenience builder for a parsed account in grouping tests. */ +function acct( + over: Partial & { customerId: string }, +): ParsedInternalAccount { + return { + id: `InternalAccount:${Math.random().toString(36).slice(2)}`, + type: "INTERNAL_FIAT", + status: "ACTIVE", + amount: 0, + currency: { code: "USD", decimals: 2 }, + ...over, + }; +} + +describe("parseInternalAccount", () => { + it("maps id, customerId, type, status, and balance", () => { + const row = { + id: "InternalAccount:1", + customerId: "Customer:abc", + type: "EMBEDDED_WALLET", + status: "ACTIVE", + balance: { amount: 123456, currency: { code: "USDB", decimals: 6 } }, + }; + expect(parseInternalAccount(row)).toEqual({ + id: "InternalAccount:1", + customerId: "Customer:abc", + type: "EMBEDDED_WALLET", + status: "ACTIVE", + amount: 123456, + currency: { code: "USDB", decimals: 6 }, + }); + }); + + it("treats a missing customerId as platform-owned (empty string)", () => { + const out = parseInternalAccount({ + id: "InternalAccount:2", + type: "INTERNAL_FIAT", + balance: { amount: 1, currency: { code: "USD" } }, + }); + expect(out?.customerId).toBe(""); + }); + + it("defaults a missing amount to 0 and currency to {}", () => { + expect(parseInternalAccount({ id: "InternalAccount:3" })).toEqual({ + id: "InternalAccount:3", + customerId: "", + type: "", + status: "", + amount: 0, + currency: {}, + }); + }); + + it("returns null for rows without a usable id", () => { + expect(parseInternalAccount({ balance: { amount: 1 } })).toBeNull(); + expect(parseInternalAccount(null)).toBeNull(); + expect(parseInternalAccount("nope")).toBeNull(); + }); +}); + +describe("groupCustomerWallets", () => { + it("groups by customerId into one wallet row each", () => { + const out = groupCustomerWallets([ + acct({ customerId: "Customer:1", type: "EMBEDDED_WALLET", amount: 100 }), + acct({ customerId: "Customer:2", type: "EMBEDDED_WALLET", amount: 200 }), + ]); + expect(out.map((w) => w.customerId).sort()).toEqual([ + "Customer:1", + "Customer:2", + ]); + }); + + it("drops platform-owned accounts (empty customerId)", () => { + const out = groupCustomerWallets([ + acct({ customerId: "", type: "INTERNAL_FIAT", amount: 999 }), + acct({ customerId: "Customer:1", type: "EMBEDDED_WALLET", amount: 50 }), + ]); + expect(out).toHaveLength(1); + expect(out[0].customerId).toBe("Customer:1"); + }); + + it("keeps a USDB account as a customer wallet even without the embedded type", () => { + const out = groupCustomerWallets([ + acct({ + customerId: "Customer:1", + type: "INTERNAL_CRYPTO", + currency: { code: "USDB", decimals: 6 }, + amount: 7, + }), + ]); + expect(out).toHaveLength(1); + expect(out[0].amount).toBe(7); + }); + + it("omits a customer with no wallet account (only non-USDB fiat)", () => { + const out = groupCustomerWallets([ + acct({ + customerId: "Customer:1", + type: "INTERNAL_FIAT", + currency: { code: "EUR", decimals: 2 }, + }), + ]); + expect(out).toEqual([]); + }); + + it("picks the embedded-wallet account when a customer has several candidates", () => { + const out = groupCustomerWallets([ + acct({ + id: "InternalAccount:usdb", + customerId: "Customer:1", + type: "INTERNAL_CRYPTO", + currency: { code: "USDB", decimals: 6 }, + amount: 1, + }), + acct({ + id: "InternalAccount:wallet", + customerId: "Customer:1", + type: "EMBEDDED_WALLET", + currency: { code: "USDB", decimals: 6 }, + amount: 500, + }), + ]); + expect(out).toHaveLength(1); + expect(out[0].accountId).toBe("InternalAccount:wallet"); + expect(out[0].amount).toBe(500); + }); + + it("projects the wallet's accountId, currency, and amount onto the row", () => { + const out = groupCustomerWallets([ + acct({ + id: "InternalAccount:w", + customerId: "Customer:9", + type: "EMBEDDED_WALLET", + currency: { code: "USDB", decimals: 6 }, + amount: 4242, + }), + ]); + expect(out[0]).toEqual({ + customerId: "Customer:9", + accountId: "InternalAccount:w", + currency: { code: "USDB", decimals: 6 }, + amount: 4242, + }); + }); +}); + +describe("listAllInternalAccounts (api-client boundary)", () => { + it("fetches a single page with no customerId filter and parses accounts", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ + data: [ + { + id: "InternalAccount:1", + customerId: "Customer:1", + type: "EMBEDDED_WALLET", + status: "ACTIVE", + balance: { amount: 10, currency: { code: "USDB", decimals: 6 } }, + }, + ], + hasMore: false, + }); + + const out = await listAllInternalAccounts(reporter, auth); + + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledWith( + auth, + "/customers/internal-accounts?limit=100", + ); + expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:1"]); + expect(out.truncated).toBe(false); + }); + + it("follows hasMore/nextCursor across pages and concatenates accounts", async () => { + const { reporter } = createCollectingReporter(); + mockGet + .mockResolvedValueOnce({ + data: [{ id: "InternalAccount:1", customerId: "Customer:1" }], + hasMore: true, + nextCursor: "cursor-2", + }) + .mockResolvedValueOnce({ + data: [{ id: "InternalAccount:2", customerId: "Customer:2" }], + hasMore: false, + }); + + const out = await listAllInternalAccounts(reporter, auth); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(mockGet).toHaveBeenNthCalledWith( + 1, + auth, + "/customers/internal-accounts?limit=100", + ); + expect(mockGet).toHaveBeenNthCalledWith( + 2, + auth, + "/customers/internal-accounts?limit=100&cursor=cursor-2", + ); + expect(out.accounts.map((a) => a.id)).toEqual([ + "InternalAccount:1", + "InternalAccount:2", + ]); + expect(out.truncated).toBe(false); + }); + + it("stops at the page cap and flags truncation when the API keeps reporting more", async () => { + const { reporter } = createCollectingReporter(); + // Always claim there's another page, so the cap (10) is what stops us. + mockGet.mockResolvedValue({ + data: [{ id: "InternalAccount:x", customerId: "Customer:x" }], + hasMore: true, + nextCursor: "next", + }); + + const out = await listAllInternalAccounts(reporter, auth); + + expect(mockGet).toHaveBeenCalledTimes(10); + expect(out.truncated).toBe(true); + }); + + it("tolerates a bare array (no envelope)", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce([ + { id: "InternalAccount:1", customerId: "Customer:1" }, + ]); + const out = await listAllInternalAccounts(reporter, auth); + expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:1"]); + }); + + it("returns [] for an empty payload", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ data: [] }); + const out = await listAllInternalAccounts(reporter, auth); + expect(out.accounts).toEqual([]); + expect(out.truncated).toBe(false); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/export-wallet.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/export-wallet.test.ts new file mode 100644 index 000000000..c9d997f7b --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/export-wallet.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import type { ApiAuth } from "../../api-client"; +import { exportWallet } from "../manage"; + +// Mock the api-client boundary so no real API is hit. `apiPost` is called twice +// by the guided flow: the 202 issue leg, then the signed retry leg. +vi.mock("../../api-client", () => ({ + apiPost: vi.fn(), + apiGet: vi.fn(), + apiDelete: vi.fn(), +})); +import { apiPost } from "../../api-client"; +const mockPost = vi.mocked(apiPost); + +// Production signs the retry with a live session stamp; stub it so the test can +// reach the decrypt path without standing up a real session. +vi.mock("../../turnkey", () => ({ + turnkeyStamp: vi.fn().mockResolvedValue("stamped-sig"), +})); + +// Mock the crypto: a fixed keypair, and decrypt helpers that yield a known +// mnemonic, so the test asserts the wiring (bundle → decrypt → mnemonic) +// without any real enclave material. +const decryptExportBundle = vi.fn(); +const hpkeDecrypt = vi.fn(); +vi.mock("@turnkey/crypto", () => ({ + generateP256KeyPair: () => ({ + privateKey: "priv-hex", + publicKey: "pub-hex", + publicKeyUncompressed: "04-uncompressed-hex", + }), + decryptExportBundle: (...args: unknown[]) => decryptExportBundle(...args), + hpkeDecrypt: (...args: unknown[]) => hpkeDecrypt(...args), +})); + +const MNEMONIC = "legal winner thank year wave sausage worth useful legal"; + +// Build an export bundle whose `data` blob hex-decodes to the signed-data JSON +// (encappedPublic / ciphertext / organizationId), matching the real shape. +function makeBundle(data: { + encappedPublic: string; + ciphertext: string; + organizationId: string; +}): string { + const hex = Array.from(new TextEncoder().encode(JSON.stringify(data))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return JSON.stringify({ + version: "v1.0.0", + data: hex, + dataSignature: "", + enclaveQuorumPublic: "", + }); +} + +function queueGuidedExport(bundle: string) { + mockPost + // Issue leg → 202 challenge. + .mockResolvedValueOnce({ + status: 202, + data: { requestId: "Request:abc", payloadToSign: "payload" }, + }) + // Signed retry → 200 with the sealed bundle. + .mockResolvedValueOnce({ + status: 200, + data: { id: "InternalAccount:1", encryptedWalletCredentials: bundle }, + }); +} + +beforeEach(() => { + mockPost.mockReset(); + decryptExportBundle.mockReset(); + hpkeDecrypt.mockReset(); +}); + +describe("exportWallet", () => { + it("sandbox: HPKE-decrypts the bundle and returns the mnemonic", async () => { + const { reporter } = createCollectingReporter(); + const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", + }; + const bundle = makeBundle({ + encappedPublic: "0a0b", + ciphertext: "0c0d", + organizationId: "org-123", + }); + queueGuidedExport(bundle); + hpkeDecrypt.mockReturnValue(new TextEncoder().encode(MNEMONIC)); + + const out = await exportWallet(reporter, auth, "InternalAccount:1"); + + // Sandbox bypasses enclave attestation: hpkeDecrypt, not decryptExportBundle. + expect(decryptExportBundle).not.toHaveBeenCalled(); + expect(hpkeDecrypt).toHaveBeenCalledWith( + expect.objectContaining({ receiverPriv: "priv-hex" }), + ); + expect(out.mnemonic).toBe(MNEMONIC); + // Raw guided result is preserved for the debug log. + expect(out.retried).toMatchObject({ encryptedWalletCredentials: bundle }); + }); + + it("production: verifies via decryptExportBundle with the org id from the bundle", async () => { + const { reporter } = createCollectingReporter(); + const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "production", + }; + const bundle = makeBundle({ + encappedPublic: "0a0b", + ciphertext: "0c0d", + organizationId: "org-xyz", + }); + queueGuidedExport(bundle); + decryptExportBundle.mockResolvedValue(MNEMONIC); + + const out = await exportWallet(reporter, auth, "InternalAccount:1"); + + expect(hpkeDecrypt).not.toHaveBeenCalled(); + expect(decryptExportBundle).toHaveBeenCalledWith({ + exportBundle: bundle, + embeddedKey: "priv-hex", + organizationId: "org-xyz", + returnMnemonic: true, + }); + expect(out.mnemonic).toBe(MNEMONIC); + }); + + it("throws when the export response has no sealed bundle", async () => { + const { reporter } = createCollectingReporter(); + const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", + }; + mockPost + .mockResolvedValueOnce({ + status: 202, + data: { requestId: "Request:abc", payloadToSign: "payload" }, + }) + .mockResolvedValueOnce({ + status: 200, + data: { id: "InternalAccount:1" }, + }); + + await expect( + exportWallet(reporter, auth, "InternalAccount:1"), + ).rejects.toThrow(/encryptedWalletCredentials/); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fetch-balance.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fetch-balance.test.ts new file mode 100644 index 000000000..2f79719bd --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fetch-balance.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ApiAuth } from "../../api-client"; +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import { formatMoney } from "../../lib/format-money"; +import { fetchBalance, mapBalanceRow } from "../customer"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", +}; + +// Mock at the api-client boundary so no real API is hit. +vi.mock("../../api-client", () => ({ + apiGet: vi.fn(), +})); +import { apiGet } from "../../api-client"; +const mockGet = vi.mocked(apiGet); + +describe("mapBalanceRow", () => { + it("pulls amount + currency from `balance` (not the top level)", () => { + // The Grid internal-account shape: the currency object (with `decimals`) + // lives INSIDE `balance`, not at the top level. + const row = { + id: "InternalAccount:abc", + balance: { amount: 3_000_000, currency: { code: "USDB", decimals: 6 } }, + }; + expect(mapBalanceRow(row)).toEqual({ + id: "InternalAccount:abc", + currency: { code: "USDB", decimals: 6 }, + balance: 3_000_000, + }); + }); + + it("renders 3 USDB (3,000,000 minor, 6 decimals) as 3, not 30,000", () => { + const mapped = mapBalanceRow({ + id: "InternalAccount:abc", + balance: { amount: 3_000_000, currency: { code: "USDB", decimals: 6 } }, + }); + const out = formatMoney(mapped.balance, mapped.currency); + expect(out).toBe("3.000000 USDB"); + expect(out).not.toContain("30,000"); + }); + + it("tolerates a bare-number balance (no currency block)", () => { + expect(mapBalanceRow({ id: "InternalAccount:x", balance: 4200 })).toEqual({ + id: "InternalAccount:x", + currency: undefined, + balance: 4200, + }); + }); + + it("defaults a missing/odd balance to 0", () => { + expect(mapBalanceRow({ id: "InternalAccount:y" })).toEqual({ + id: "InternalAccount:y", + currency: undefined, + balance: 0, + }); + expect( + mapBalanceRow({ id: "InternalAccount:z", balance: { currency: {} } }), + ).toEqual({ id: "InternalAccount:z", currency: {}, balance: 0 }); + }); +}); + +describe("fetchBalance (api-client boundary)", () => { + it("maps each account row's amount + currency from `balance`", async () => { + const { reporter } = createCollectingReporter(); + const raw = { + data: [ + { + id: "InternalAccount:1", + balance: { + amount: 3_000_000, + currency: { code: "USDB", decimals: 6 }, + }, + }, + ], + }; + mockGet.mockResolvedValueOnce(raw); + + const { rows } = await fetchBalance(reporter, auth, "Customer:c1"); + + expect(mockGet).toHaveBeenCalledWith( + auth, + "/customers/internal-accounts?customerId=Customer%3Ac1", + ); + expect(rows).toEqual([ + { + id: "InternalAccount:1", + currency: { code: "USDB", decimals: 6 }, + balance: 3_000_000, + }, + ]); + expect(formatMoney(rows[0].balance, rows[0].currency)).toBe( + "3.000000 USDB", + ); + }); + + it("throws when the customer id is blank", async () => { + const { reporter } = createCollectingReporter(); + await expect(fetchBalance(reporter, auth, " ")).rejects.toThrow( + "Customer ID is required.", + ); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fund-customer.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fund-customer.test.ts new file mode 100644 index 000000000..110e77833 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/fund-customer.test.ts @@ -0,0 +1,319 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import type { ApiAuth } from "../../api-client"; +import { + fundCustomerFromPlatform, + pollTransaction, + type Sleep, +} from "../money"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", +}; + +// Mock at the api-client boundary so no real API is hit. +vi.mock("../../api-client", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), +})); +import { apiGet, apiPost } from "../../api-client"; +const mockGet = vi.mocked(apiGet); +const mockPost = vi.mocked(apiPost); + +// A sleep that never actually waits — keeps the poll loop synchronous in tests. +const noSleep: Sleep = () => Promise.resolve(); + +beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); +}); + +describe("fundCustomerFromPlatform — request shaping", () => { + it("builds the RECEIVING-locked quote body with platform source + customer destination", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) // quote + .mockResolvedValueOnce({ + status: 200, + data: { transactionId: "Transaction:t1" }, + }); // execute + mockGet.mockResolvedValueOnce({ + id: "Transaction:t1", + status: "COMPLETED", + }); + + await fundCustomerFromPlatform( + reporter, + auth, + { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "InternalAccount:cust", + amountMinor: 2500, + }, + { poll: { sleep: noSleep } }, + ); + + // First POST is the quote with the exact reference shape. + expect(mockPost).toHaveBeenNthCalledWith(1, auth, "/quotes", { + source: { sourceType: "ACCOUNT", accountId: "InternalAccount:fund" }, + destination: { + destinationType: "ACCOUNT", + accountId: "InternalAccount:cust", + }, + lockedCurrencySide: "RECEIVING", + lockedCurrencyAmount: 2500, + }); + }); + + it("executes with an EMPTY body and NO Grid-Wallet-Signature header", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) + .mockResolvedValueOnce({ + status: 200, + data: { transactionId: "Transaction:t1" }, + }); + mockGet.mockResolvedValueOnce({ status: "COMPLETED" }); + + await fundCustomerFromPlatform( + reporter, + auth, + { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "InternalAccount:cust", + amountMinor: 100, + }, + { poll: { sleep: noSleep } }, + ); + + // Second POST is the execute: path includes the quote id, body is {} and + // there is NO fourth (extraHeaders) argument — i.e. no signature header. + const executeCall = mockPost.mock.calls[1]; + expect(executeCall[1]).toBe("/quotes/Quote%3Aq1/execute"); + expect(executeCall[2]).toEqual({}); + expect(executeCall[3]).toBeUndefined(); + }); + + it("trims ids and rejects a non-positive amount before calling the API", async () => { + const { reporter } = createCollectingReporter(); + await expect( + fundCustomerFromPlatform(reporter, auth, { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "InternalAccount:cust", + amountMinor: 0, + }), + ).rejects.toThrow(/amount/i); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("requires a funding account and a customer account", async () => { + const { reporter } = createCollectingReporter(); + await expect( + fundCustomerFromPlatform(reporter, auth, { + fundingAccountId: " ", + destinationAccountId: "InternalAccount:cust", + amountMinor: 100, + }), + ).rejects.toThrow(/funding account/i); + await expect( + fundCustomerFromPlatform(reporter, auth, { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "", + amountMinor: 100, + }), + ).rejects.toThrow(/internal account/i); + expect(mockPost).not.toHaveBeenCalled(); + }); +}); + +describe("fundCustomerFromPlatform — orchestration result", () => { + it("returns quoteId, transactionId, and the terminal COMPLETED status", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q9" } }) + .mockResolvedValueOnce({ + status: 200, + data: { transactionId: "Transaction:t9" }, + }); + mockGet.mockResolvedValueOnce({ + id: "Transaction:t9", + status: "COMPLETED", + }); + + const out = await fundCustomerFromPlatform( + reporter, + auth, + { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "InternalAccount:cust", + amountMinor: 500, + }, + { poll: { sleep: noSleep } }, + ); + + expect(out.quoteId).toBe("Quote:q9"); + expect(out.transactionId).toBe("Transaction:t9"); + expect(out.status).toBe("COMPLETED"); + }); + + it("surfaces a FAILED terminal status without throwing", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) + .mockResolvedValueOnce({ + status: 200, + data: { transactionId: "Transaction:t1" }, + }); + mockGet.mockResolvedValueOnce({ status: "FAILED" }); + + const out = await fundCustomerFromPlatform( + reporter, + auth, + { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "InternalAccount:cust", + amountMinor: 500, + }, + { poll: { sleep: noSleep } }, + ); + + expect(out.status).toBe("FAILED"); + }); + + it("throws when execute returns no transactionId", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) + .mockResolvedValueOnce({ status: 200, data: {} }); + + await expect( + fundCustomerFromPlatform( + reporter, + auth, + { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "InternalAccount:cust", + amountMinor: 500, + }, + { poll: { sleep: noSleep } }, + ), + ).rejects.toThrow(/transactionId/i); + }); +}); + +describe("fundCustomerFromPlatform — onStage sequence", () => { + const baseParams = { + fundingAccountId: "InternalAccount:fund", + destinationAccountId: "InternalAccount:cust", + amountMinor: 500, + }; + + it("fires quoting → executing → processing → completed on a COMPLETED txn", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) + .mockResolvedValueOnce({ + status: 200, + data: { transactionId: "Transaction:t1" }, + }); + mockGet.mockResolvedValueOnce({ status: "COMPLETED" }); + + const stages: string[] = []; + await fundCustomerFromPlatform(reporter, auth, baseParams, { + poll: { sleep: noSleep }, + onStage: (s) => stages.push(s), + }); + + expect(stages).toEqual(["quoting", "executing", "processing", "completed"]); + }); + + it("ends with failed on a FAILED txn", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) + .mockResolvedValueOnce({ + status: 200, + data: { transactionId: "Transaction:t1" }, + }); + mockGet.mockResolvedValueOnce({ status: "FAILED" }); + + const stages: string[] = []; + await fundCustomerFromPlatform(reporter, auth, baseParams, { + poll: { sleep: noSleep }, + onStage: (s) => stages.push(s), + }); + + expect(stages).toEqual(["quoting", "executing", "processing", "failed"]); + }); + + it("stays at processing (no terminal stage) when the poll times out", async () => { + const { reporter } = createCollectingReporter(); + mockPost + .mockResolvedValueOnce({ status: 200, data: { id: "Quote:q1" } }) + .mockResolvedValueOnce({ + status: 200, + data: { transactionId: "Transaction:t1" }, + }); + mockGet.mockResolvedValue({ status: "PROCESSING" }); + + const stages: string[] = []; + await fundCustomerFromPlatform(reporter, auth, baseParams, { + poll: { sleep: noSleep, intervalMs: 10, timeoutMs: 25 }, + onStage: (s) => stages.push(s), + }); + + expect(stages).toEqual(["quoting", "executing", "processing"]); + }); +}); + +describe("pollTransaction", () => { + it("polls GET /transactions/{id} and resolves on the first COMPLETED", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ status: "COMPLETED" }); + + const out = await pollTransaction(reporter, auth, "Transaction:abc", { + sleep: noSleep, + }); + + expect(mockGet).toHaveBeenCalledWith( + auth, + "/transactions/Transaction%3Aabc", + ); + expect(out.status).toBe("COMPLETED"); + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it("keeps polling through PENDING/PROCESSING until a terminal status", async () => { + const { reporter } = createCollectingReporter(); + mockGet + .mockResolvedValueOnce({ status: "PENDING" }) + .mockResolvedValueOnce({ status: "PROCESSING" }) + .mockResolvedValueOnce({ status: "COMPLETED" }); + + const out = await pollTransaction(reporter, auth, "Transaction:abc", { + sleep: noSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(mockGet).toHaveBeenCalledTimes(3); + expect(out.status).toBe("COMPLETED"); + }); + + it("returns the last-seen status when the timeout elapses (non-terminal)", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValue({ status: "PROCESSING" }); + + const out = await pollTransaction(reporter, auth, "Transaction:abc", { + sleep: noSleep, + intervalMs: 10, + timeoutMs: 25, + }); + + // Polls at t=0, 10, 20 (next would exceed 25), then gives up. + expect(out.status).toBe("PROCESSING"); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/login-decision.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/login-decision.test.ts new file mode 100644 index 000000000..0395c89fb --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/login-decision.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import type { ApiAuth } from "../../api-client"; +import { + decideLogin, + existingCredentialFor, + parseCredentials, +} from "../login-decision"; +import { signInEmailOtp, type EmailOtpSignInDeps } from "../email-otp"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "production", +}; + +describe("parseCredentials", () => { + it("unwraps the { data: [...] } envelope the API returns", () => { + const raw = { data: [{ id: "c1", type: "EMAIL_OTP" }] }; + expect(parseCredentials(raw)).toEqual([{ id: "c1", type: "EMAIL_OTP" }]); + }); + + it("tolerates a bare array", () => { + const raw = [{ id: "c1", type: "OAUTH" }]; + expect(parseCredentials(raw)).toEqual([{ id: "c1", type: "OAUTH" }]); + }); + + it("returns [] for a missing/empty payload (loading state)", () => { + expect(parseCredentials(null)).toEqual([]); + expect(parseCredentials(undefined)).toEqual([]); + expect(parseCredentials({})).toEqual([]); + }); +}); + +describe("existingCredentialFor", () => { + const creds = [ + { id: "otp-1", type: "EMAIL_OTP" }, + { id: "oauth-1", type: "OAUTH" }, + ]; + + it("maps each method to the matching credential type", () => { + expect(existingCredentialFor(creds, "email_otp")?.id).toBe("otp-1"); + expect(existingCredentialFor(creds, "oauth")?.id).toBe("oauth-1"); + expect(existingCredentialFor(creds, "passkey")).toBeUndefined(); + }); + + it("ignores credentials without a usable id", () => { + const broken = [{ id: "", type: "EMAIL_OTP" }]; + expect(existingCredentialFor(broken, "email_otp")).toBeUndefined(); + }); +}); + +describe("decideLogin", () => { + it("authenticates with the existing credential id when one exists", () => { + const creds = [{ id: "otp-1", type: "EMAIL_OTP" }]; + expect(decideLogin(creds, "email_otp")).toEqual({ + action: "authenticate", + credId: "otp-1", + }); + }); + + it("creates when no credential of that method exists", () => { + const creds = [{ id: "oauth-1", type: "OAUTH" }]; + expect(decideLogin(creds, "email_otp")).toEqual({ action: "create" }); + expect(decideLogin([], "email_otp")).toEqual({ action: "create" }); + }); +}); + +describe("signInEmailOtp (create-vs-authenticate routing)", () => { + it("authenticates with the existing credential id and does NOT create", async () => { + const { reporter } = createCollectingReporter(); + const create = vi.fn(); + const login = vi + .fn() + .mockResolvedValue({ leg1: {}, session: { id: "sess-1" } }); + const deps = { create, login } as unknown as EmailOtpSignInDeps; + + const session = await signInEmailOtp( + reporter, + auth, + "acct-1", + "000000", + "existing-otp-cred", + deps, + ); + + expect(create).not.toHaveBeenCalled(); + expect(login).toHaveBeenCalledWith( + reporter, + auth, + "existing-otp-cred", + "000000", + ); + expect(session).toEqual({ id: "sess-1" }); + }); + + it("creates a credential first when none exists, then logs in with the new id", async () => { + const { reporter } = createCollectingReporter(); + const create = vi.fn().mockResolvedValue({ id: "new-otp-cred" }); + const login = vi + .fn() + .mockResolvedValue({ leg1: {}, session: { id: "sess-2" } }); + const deps = { create, login } as unknown as EmailOtpSignInDeps; + + const session = await signInEmailOtp( + reporter, + auth, + "acct-1", + "000000", + null, + deps, + ); + + expect(create).toHaveBeenCalledOnce(); + expect(create).toHaveBeenCalledWith(reporter, auth, "acct-1"); + expect(login).toHaveBeenCalledWith( + reporter, + auth, + "new-otp-cred", + "000000", + ); + expect(session).toEqual({ id: "sess-2" }); + }); + + it("throws if create returns no id (does not silently log in)", async () => { + const { reporter } = createCollectingReporter(); + const create = vi.fn().mockResolvedValue({}); + const login = vi.fn(); + const deps = { create, login } as unknown as EmailOtpSignInDeps; + + await expect( + signInEmailOtp(reporter, auth, "acct-1", "000000", null, deps), + ).rejects.toThrow(/no id/i); + expect(login).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/otp-step.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/otp-step.test.ts new file mode 100644 index 000000000..fe864303a --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/otp-step.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import type { ApiAuth } from "../../api-client"; +import { sendOtpChallenge, verifyOtpStep, type OtpStepDeps } from "../otp-step"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", +}; + +function makeDeps() { + const requestChallenge = vi.fn().mockResolvedValue("bundle-from-challenge"); + const runVerify = vi + .fn() + .mockResolvedValue({ leg1: {}, session: { id: "sess-1" } }); + const deps = { requestChallenge, runVerify } as unknown as OtpStepDeps; + return { requestChallenge, runVerify, deps }; +} + +describe("EMAIL_OTP two-step sign-in (challenge decoupled from verify)", () => { + it("sendOtpChallenge fires the challenge exactly once and returns the bundle", async () => { + const { reporter } = createCollectingReporter(); + const { requestChallenge, runVerify, deps } = makeDeps(); + + const next = await sendOtpChallenge(reporter, auth, "otp-cred", deps); + + expect(requestChallenge).toHaveBeenCalledOnce(); + expect(requestChallenge).toHaveBeenCalledWith(reporter, auth, "otp-cred"); + expect(next).toEqual({ + status: "awaiting_code", + targetBundle: "bundle-from-challenge", + }); + // Sending the challenge must not verify anything. + expect(runVerify).not.toHaveBeenCalled(); + }); + + it("verifyOtpStep never issues a challenge — it only runs verify against the cached bundle", async () => { + const { reporter } = createCollectingReporter(); + const { requestChallenge, runVerify, deps } = makeDeps(); + + const session = await verifyOtpStep( + reporter, + auth, + "otp-cred", + "bundle-from-challenge", + "000000", + deps, + ); + + expect(requestChallenge).not.toHaveBeenCalled(); + expect(runVerify).toHaveBeenCalledOnce(); + expect(runVerify).toHaveBeenCalledWith( + reporter, + auth, + "otp-cred", + "bundle-from-challenge", + "000000", + ); + expect(session).toEqual({ id: "sess-1" }); + }); + + it("a full send → verify only sends ONE OTP; a verify retry sends none", async () => { + const { reporter } = createCollectingReporter(); + const { requestChallenge, runVerify, deps } = makeDeps(); + + // Step 1: explicit Send. + const step = await sendOtpChallenge(reporter, auth, "otp-cred", deps); + // Step 2: verify with the bundle from step 1 — a first attempt that fails… + runVerify.mockRejectedValueOnce(new Error("bad code")); + await expect( + verifyOtpStep( + reporter, + auth, + "otp-cred", + step.targetBundle, + "wrong", + deps, + ), + ).rejects.toThrow(/bad code/); + // …then a retry with the SAME bundle succeeds — still no extra challenge. + const session = await verifyOtpStep( + reporter, + auth, + "otp-cred", + step.targetBundle, + "000000", + deps, + ); + + // Exactly one OTP was sent across the whole interaction. + expect(requestChallenge).toHaveBeenCalledOnce(); + expect(runVerify).toHaveBeenCalledTimes(2); + expect(session).toEqual({ id: "sess-1" }); + }); + + it("verifyOtpStep refuses to verify without a challenge bundle", async () => { + const { reporter } = createCollectingReporter(); + const { requestChallenge, runVerify, deps } = makeDeps(); + + await expect( + verifyOtpStep(reporter, auth, "otp-cred", "", "000000", deps), + ).rejects.toThrow(/send the code first/i); + expect(requestChallenge).not.toHaveBeenCalled(); + expect(runVerify).not.toHaveBeenCalled(); + }); + + it("verifyOtpStep requires a code (does not verify an empty OTP)", async () => { + const { reporter } = createCollectingReporter(); + const { runVerify, deps } = makeDeps(); + + await expect( + verifyOtpStep(reporter, auth, "otp-cred", "bundle", " ", deps), + ).rejects.toThrow(/one-time code/i); + expect(runVerify).not.toHaveBeenCalled(); + }); + + it("Resend is just another explicit challenge — one call per click", async () => { + const { reporter } = createCollectingReporter(); + const { requestChallenge, deps } = makeDeps(); + + await sendOtpChallenge(reporter, auth, "otp-cred", deps); // initial Send + await sendOtpChallenge(reporter, auth, "otp-cred", deps); // Resend click + + expect(requestChallenge).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/platform-funding-accounts.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/platform-funding-accounts.test.ts new file mode 100644 index 000000000..e5b403a64 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/platform-funding-accounts.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ApiAuth } from "../../api-client"; +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import { + listPlatformFundingAccounts, + parsePlatformFundingAccount, +} from "../customer"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", +}; + +// Mock at the api-client boundary so no real API is hit. +vi.mock("../../api-client", () => ({ + apiGet: vi.fn(), +})); +import { apiGet } from "../../api-client"; +const mockGet = vi.mocked(apiGet); + +describe("parsePlatformFundingAccount", () => { + it("maps an account to {id, amount, currency}", () => { + const row = { + id: "InternalAccount:abc", + balance: { amount: 100_000, currency: { code: "USD", decimals: 2 } }, + }; + expect(parsePlatformFundingAccount(row)).toEqual({ + id: "InternalAccount:abc", + amount: 100_000, + currency: { code: "USD", decimals: 2 }, + }); + }); + + it("defaults a missing amount to 0 and currency to {}", () => { + expect(parsePlatformFundingAccount({ id: "InternalAccount:x" })).toEqual({ + id: "InternalAccount:x", + amount: 0, + currency: {}, + }); + }); + + it("returns null for rows without a usable id", () => { + expect(parsePlatformFundingAccount({ balance: { amount: 1 } })).toBeNull(); + expect(parsePlatformFundingAccount(null)).toBeNull(); + expect(parsePlatformFundingAccount("nope")).toBeNull(); + }); +}); + +describe("listPlatformFundingAccounts (api-client boundary)", () => { + it("queries the platform's own internal accounts and parses the envelope", async () => { + const { reporter } = createCollectingReporter(); + const raw = { + data: [ + { + id: "InternalAccount:1", + balance: { amount: 5000, currency: { code: "USD", decimals: 2 } }, + }, + { + id: "InternalAccount:2", + balance: { amount: 0, currency: { code: "EUR", decimals: 2 } }, + }, + ], + }; + mockGet.mockResolvedValueOnce(raw); + + const out = await listPlatformFundingAccounts(reporter, auth); + + expect(mockGet).toHaveBeenCalledWith(auth, "/platform/internal-accounts"); + expect(out.accounts.map((a) => a.id)).toEqual([ + "InternalAccount:1", + "InternalAccount:2", + ]); + expect(out.raw).toBe(raw); + }); + + it("returns [] for an empty pool so the picker can render an empty state", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ data: [] }); + const out = await listPlatformFundingAccounts(reporter, auth); + expect(out.accounts).toEqual([]); + }); + + it("tolerates a bare array (no envelope)", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce([{ id: "InternalAccount:9" }]); + const out = await listPlatformFundingAccounts(reporter, auth); + expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:9"]); + }); + + it("drops rows without an id", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ + data: [{ id: "InternalAccount:1" }, { balance: { amount: 1 } }], + }); + const out = await listPlatformFundingAccounts(reporter, auth); + expect(out.accounts.map((a) => a.id)).toEqual(["InternalAccount:1"]); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/transactions.test.ts b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/transactions.test.ts new file mode 100644 index 000000000..aadc490c7 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/__tests__/transactions.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ApiAuth } from "../../api-client"; +import { createCollectingReporter } from "../../lib/collecting-reporter"; +import { listTransactions } from "../transactions"; + +const auth: ApiAuth = { + clientId: "id", + clientSecret: "secret", + mode: "sandbox", +}; + +// Mock at the api-client boundary so no real API is hit. +vi.mock("../../api-client", () => ({ + apiGet: vi.fn(), +})); +import { apiGet } from "../../api-client"; +const mockGet = vi.mocked(apiGet); + +beforeEach(() => { + mockGet.mockReset(); +}); + +/** The single path argument passed to `apiGet`, split into base + params. */ +function calledPath(): { path: string; params: URLSearchParams } { + const path = mockGet.mock.calls[0][1]; + const query = path.split("?")[1] ?? ""; + return { path, params: new URLSearchParams(query) }; +} + +describe("listTransactions query string", () => { + it("always sends customerId, limit (default 20), and sortOrder=desc", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ data: [], hasMore: false }); + + await listTransactions(reporter, auth, { customerId: "Customer:c1" }); + + const { path, params } = calledPath(); + expect(path.startsWith("/transactions?")).toBe(true); + expect(params.get("customerId")).toBe("Customer:c1"); + expect(params.get("limit")).toBe("20"); + expect(params.get("sortOrder")).toBe("desc"); + }); + + it("honors an explicit limit", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({ data: [], hasMore: false }); + + await listTransactions(reporter, auth, { + customerId: "Customer:c1", + limit: 50, + }); + + expect(calledPath().params.get("limit")).toBe("50"); + }); + + it("omits `type` for ALL and includes it otherwise", async () => { + const { reporter } = createCollectingReporter(); + + mockGet.mockResolvedValueOnce({ data: [], hasMore: false }); + await listTransactions(reporter, auth, { + customerId: "Customer:c1", + type: "ALL", + }); + expect(calledPath().params.has("type")).toBe(false); + + mockGet.mockReset(); + mockGet.mockResolvedValueOnce({ data: [], hasMore: false }); + await listTransactions(reporter, auth, { + customerId: "Customer:c1", + type: "INCOMING", + }); + expect(calledPath().params.get("type")).toBe("INCOMING"); + }); + + it("includes `cursor` only when provided", async () => { + const { reporter } = createCollectingReporter(); + + mockGet.mockResolvedValueOnce({ data: [], hasMore: false }); + await listTransactions(reporter, auth, { + customerId: "Customer:c1", + cursor: null, + }); + expect(calledPath().params.has("cursor")).toBe(false); + + mockGet.mockReset(); + mockGet.mockResolvedValueOnce({ data: [], hasMore: false }); + await listTransactions(reporter, auth, { + customerId: "Customer:c1", + cursor: "cursor-uuid", + }); + expect(calledPath().params.get("cursor")).toBe("cursor-uuid"); + }); +}); + +describe("listTransactions response mapping", () => { + it("maps the camelCase envelope to a TransactionPage", async () => { + const { reporter } = createCollectingReporter(); + const raw = { + data: [ + { + id: "Transaction:1", + type: "OUTGOING", + status: "COMPLETED", + sentAmount: { amount: 1250, currency: { code: "USD", decimals: 2 } }, + destination: { destinationType: "UMA_ADDRESS", umaAddress: "$bob@x" }, + }, + { + id: "Transaction:2", + type: "INCOMING", + status: "PENDING", + receivedAmount: { + amount: 3_000_000, + currency: { code: "USDB", decimals: 6 }, + }, + source: { sourceType: "ACCOUNT", accountId: "InternalAccount:9" }, + }, + ], + hasMore: true, + nextCursor: "next-uuid", + totalCount: 42, + }; + mockGet.mockResolvedValueOnce(raw); + + const page = await listTransactions(reporter, auth, { + customerId: "Customer:c1", + }); + + expect(page.data).toEqual(raw.data); + expect(page.hasMore).toBe(true); + expect(page.nextCursor).toBe("next-uuid"); + expect(page.totalCount).toBe(42); + }); + + it("coerces missing data/hasMore/nextCursor/totalCount", async () => { + const { reporter } = createCollectingReporter(); + mockGet.mockResolvedValueOnce({}); + + const page = await listTransactions(reporter, auth, { + customerId: "Customer:c1", + }); + + expect(page.data).toEqual([]); + expect(page.hasMore).toBe(false); + expect(page.nextCursor).toBeNull(); + expect(page.totalCount).toBe(0); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/context.ts b/apps/examples/grid-global-accounts-example-app/src/flows/context.ts new file mode 100644 index 000000000..dc76f6cf3 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/context.ts @@ -0,0 +1,47 @@ +// Cross-flow wallet context: account / credential / session ids shared between +// the per-credential-type tabs and the money + manage flows. +// +// State + chip rendering live in `session.ts`; these are the require-or-throw +// guards the flows use, plus thin re-exports of the setters so the chip stays +// in sync. + +import { + getAccountId, + getCredentialId, + getSessionId, + setAccountId, + setCredentialId, + setSessionId, +} from "../session"; + +// Thin re-exports of the session setters so flows keep their familiar names. +// Note: `setCtxAccount`/`setAccountId` is first-call-wins by design (see +// session.ts) — the account id is established once and shared across tabs. +export const setCtxAccount = setAccountId; +export const setCtxCredential = setCredentialId; +export const setCtxSession = setSessionId; + +export function requireAccountId(): string { + const id = getAccountId(); + if (!id) + throw new Error( + "Internal Account ID is required — run Create Customer first.", + ); + return id; +} + +export function requireCredentialId(): string { + const id = getCredentialId(); + if (!id) + throw new Error( + "Credential ID is required — run Create for this type first.", + ); + return id; +} + +export function requireSessionId(): string { + const id = getSessionId(); + if (!id) + throw new Error("Session ID is required — run Verify for this type first."); + return id; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts b/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts new file mode 100644 index 000000000..9050e1a22 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/customer.ts @@ -0,0 +1,438 @@ +// Shared setup: create customer, platform config (OTP + branding), balance. +// +// DOM-free operation functions: each takes the platform `auth`, the form values +// it needs, and a `Reporter` to emit request/response log events through, then +// returns its result. The React layer collects the inputs and renders. + +import { apiGet, apiPatch, apiPost, type ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; +import { setCtxAccount } from "./context"; + +export interface CreateCustomerParams { + platformCustomerId?: string; + fullName?: string; + email?: string; +} + +export interface CreateCustomerResult { + customer: unknown; + accounts: unknown; + customerId: string; + accountId: string | null; +} + +// ----- Create customer ----- + +export async function createCustomer( + reporter: Reporter, + auth: ApiAuth, + params: CreateCustomerParams, +): Promise { + const platformCustomerId = + params.platformCustomerId?.trim() || `test-${Date.now()}`; + const fullName = params.fullName?.trim() || "Test User"; + const email = params.email?.trim(); + const body: Record = { + customerType: "BUSINESS", + platformCustomerId, + region: "US", + currencies: ["USDB"], + businessInfo: { + legalName: fullName, + taxId: "12-3456789", + incorporatedOn: "2020-01-01", + }, + }; + if (email) body.email = email; + reporter.log({ level: "request", label: "POST /customers", detail: body }); + const { data: customer } = await apiPost(auth, "/customers", body); + reporter.log({ + level: "response", + label: "Create Customer", + detail: customer, + }); + const customerId = (customer as Record).id as string; + + const accounts = (await apiGet( + auth, + `/customers/internal-accounts?customerId=${customerId}¤cy=USDB`, + )) as { data: Array<{ id: string }> }; + reporter.log({ + level: "response", + label: "Internal Accounts", + detail: accounts, + }); + + let accountId: string | null = null; + if (accounts.data && accounts.data.length > 0) { + accountId = accounts.data[0].id; + setCtxAccount(accountId); + } + return { customer, accounts, customerId, accountId }; +} + +// ----- All customer internal accounts (one fetch → every customer) ----- + +/** A single internal account projected to the fields the platform table needs. */ +export interface ParsedInternalAccount { + /** LSID, e.g. `InternalAccount:`. */ + id: string; + /** Owning customer's LSID. Empty string means platform-owned. */ + customerId: string; + /** `INTERNAL_FIAT` / `INTERNAL_CRYPTO` / `EMBEDDED_WALLET`, when present. */ + type: string; + /** `ACTIVE` / `PENDING` / `CLOSED` / `FROZEN`, when present. */ + status: string; + /** Balance in minor units (per `currency.decimals`), per `CurrencyAmount.amount`. */ + amount: number; + /** Currency metadata: `{ code, name, symbol, decimals }` (any may be absent). */ + currency: Record; +} + +/** One customer's wallet row, derived from grouping internal accounts by owner. */ +export interface CustomerWallet { + /** Owning customer's LSID. */ + customerId: string; + /** The wallet account's LSID (act-as / fund destination). */ + accountId: string; + /** Currency metadata for the wallet balance. */ + currency: Record; + /** Wallet balance in minor units. */ + amount: number; +} + +export interface ListAllInternalAccountsResult { + accounts: ParsedInternalAccount[]; + /** True if pagination was capped before the API ran out of pages. */ + truncated: boolean; +} + +// Stop after this many pages / accounts so a misbehaving or huge tenant can't +// spin forever; the caller is told (via `truncated`) rather than silently cut. +const MAX_PAGES = 10; +const PAGE_LIMIT = 100; +const MAX_ACCOUNTS = MAX_PAGES * PAGE_LIMIT; + +/** Project a single internal-account row to the fields the table groups on. */ +export function parseInternalAccount( + row: unknown, +): ParsedInternalAccount | null { + if (!row || typeof row !== "object") return null; + const a = row as Record; + const id = typeof a.id === "string" ? a.id : ""; + if (!id) return null; + + const balance = a.balance as Record | undefined; + const amount = + balance && typeof balance.amount === "number" ? balance.amount : 0; + const currency = + balance && balance.currency && typeof balance.currency === "object" + ? (balance.currency as Record) + : {}; + return { + id, + customerId: typeof a.customerId === "string" ? a.customerId : "", + type: typeof a.type === "string" ? a.type : "", + status: typeof a.status === "string" ? a.status : "", + amount, + currency, + }; +} + +/** + * Page `GET /customers/internal-accounts` (no `customerId` — the param is an + * optional filter, see `GridListCustomerInternalAccountsRequestArgs.customer_id` + * in `list_customer_internal_accounts.py`) to return EVERY customer account in + * one sweep. The handler reports `hasMore` / `nextCursor`; we follow the cursor + * until exhausted, capped at `MAX_ACCOUNTS` so we never loop unbounded — if the + * cap is hit we set `truncated` and log it rather than silently dropping pages. + */ +export async function listAllInternalAccounts( + reporter: Reporter, + auth: ApiAuth, +): Promise { + const accounts: ParsedInternalAccount[] = []; + let cursor: string | null = null; + let truncated = false; + + for (let page = 0; page < MAX_PAGES; page++) { + const query = cursor + ? `/customers/internal-accounts?limit=${PAGE_LIMIT}&cursor=${encodeURIComponent( + cursor, + )}` + : `/customers/internal-accounts?limit=${PAGE_LIMIT}`; + const raw = await apiGet(auth, query); + reporter.log({ + level: "response", + label: "GET /customers/internal-accounts", + detail: raw, + }); + + const env = (raw && typeof raw === "object" ? raw : {}) as Record< + string, + unknown + >; + const rows = Array.isArray(raw) + ? raw + : Array.isArray(env.data) + ? env.data + : []; + for (const row of rows) { + const parsed = parseInternalAccount(row); + if (parsed) accounts.push(parsed); + } + + const hasMore = env.hasMore === true; + cursor = typeof env.nextCursor === "string" ? env.nextCursor : null; + if (!hasMore || !cursor) break; + if (page === MAX_PAGES - 1) truncated = true; + } + + if (truncated) { + reporter.log({ + level: "response", + label: "Internal accounts truncated", + detail: { cappedAt: MAX_ACCOUNTS, returned: accounts.length }, + }); + } + return { accounts, truncated }; +} + +/** True for a customer's spendable wallet account (USDB or embedded-wallet). */ +function isCustomerWalletAccount(a: ParsedInternalAccount): boolean { + return ( + a.type === "EMBEDDED_WALLET" || + String((a.currency as { code?: unknown }).code ?? "").toUpperCase() === + "USDB" + ); +} + +/** + * Group internal accounts into one wallet row per customer. Platform-owned + * accounts (empty `customerId`) are dropped, leaving only customer wallets. Of a + * customer's accounts we keep the spendable wallet — the `EMBEDDED_WALLET` / + * USDB account — preferring an explicit `EMBEDDED_WALLET` when several qualify. + * Customers with no wallet account yet are omitted (no row to show a balance on). + */ +export function groupCustomerWallets( + accounts: ParsedInternalAccount[], +): CustomerWallet[] { + const byCustomer = new Map(); + for (const a of accounts) { + if (!a.customerId) continue; // platform-owned + if (!isCustomerWalletAccount(a)) continue; + const existing = byCustomer.get(a.customerId); + // Prefer an explicit embedded wallet when a customer has several candidates. + if ( + !existing || + (existing.type !== "EMBEDDED_WALLET" && a.type === "EMBEDDED_WALLET") + ) { + byCustomer.set(a.customerId, a); + } + } + return [...byCustomer.values()].map((a) => ({ + customerId: a.customerId, + accountId: a.id, + currency: a.currency, + amount: a.amount, + })); +} + +// ----- Platform funding accounts ----- + +/** A platform-owned internal account projected to the funding-picker fields. */ +export interface PlatformFundingAccount { + /** LSID, e.g. `InternalAccount:` — used as the funding `source`. */ + id: string; + /** Balance in minor units (cents / satoshis), per `CurrencyAmount.amount`. */ + amount: number; + /** Currency metadata: `{ code, name, symbol, decimals }` (any may be absent). */ + currency: Record; +} + +/** + * Project a single `GET /platform/internal-accounts` row (an `InternalAccount`, + * see `gen_internal_account_from_entity`) to the funding-picker shape: the LSID + * plus its `balance` (`{ amount, currency }`). Rows without an `id` are dropped. + */ +export function parsePlatformFundingAccount( + row: unknown, +): PlatformFundingAccount | null { + if (!row || typeof row !== "object") return null; + const a = row as Record; + const id = typeof a.id === "string" ? a.id : ""; + if (!id) return null; + + const balance = a.balance as Record | undefined; + const amount = + balance && typeof balance.amount === "number" ? balance.amount : 0; + const currency = + balance && balance.currency && typeof balance.currency === "object" + ? (balance.currency as Record) + : {}; + return { id, amount, currency }; +} + +/** + * List the platform's own (non-customer) internal accounts — the funding pool — + * via `GET /platform/internal-accounts`, which scopes to accounts owned by the + * authenticated platform itself (`is_customers=False` in + * `get_internal_accounts_query`). Unwraps the `{ data: [...] }` envelope + * (`PlatformInternalAccountListResponse`); tolerates a missing/empty payload by + * returning [] so the picker can render an empty state. + */ +export async function listPlatformFundingAccounts( + reporter: Reporter, + auth: ApiAuth, +): Promise<{ accounts: PlatformFundingAccount[]; raw: unknown }> { + const raw = await apiGet(auth, "/platform/internal-accounts"); + reporter.log({ + level: "response", + label: "GET /platform/internal-accounts", + detail: raw, + }); + + let rows: unknown[] = []; + if (Array.isArray(raw)) { + rows = raw; + } else if (raw && typeof raw === "object") { + const data = (raw as Record).data; + if (Array.isArray(data)) rows = data; + } + + const accounts = rows + .map(parsePlatformFundingAccount) + .filter((a): a is PlatformFundingAccount => a !== null); + return { accounts, raw }; +} + +// ----- Fetch balance ----- + +/** A wallet balance row: account id, minor-unit amount, and currency block. */ +export interface BalanceRow { + id: unknown; + /** Currency metadata `{ code, name, symbol, decimals }` — drives formatting. */ + currency: unknown; + /** Amount in minor units (per `currency.decimals`), as returned by the API. */ + balance: number; +} + +export interface FetchBalanceResult { + /** Projected rows the wallet UI renders. */ + rows: BalanceRow[]; + /** The unmodified API response, for the debug raw-payload expander. */ + raw: unknown; +} + +/** + * Map one `GET /customers/internal-accounts` row to a wallet balance row. The + * account's `balance` is a `CurrencyAmount` — `{ amount, currency }` where + * `amount` is minor units and `currency` is `{ code, name, symbol, decimals }`. + * So `currency` comes from `balance.currency` (NOT the top level) and `balance` + * is the minor-unit `balance.amount`. Tolerates the fallback where `balance` is + * already a bare number (then no currency block is present). + */ +export function mapBalanceRow(row: Record): BalanceRow { + const balance = row.balance; + if (typeof balance === "number") { + return { id: row.id, currency: undefined, balance }; + } + if (balance && typeof balance === "object") { + const b = balance as Record; + return { + id: row.id, + currency: b.currency, + balance: typeof b.amount === "number" ? b.amount : 0, + }; + } + return { id: row.id, currency: undefined, balance: 0 }; +} + +export async function fetchBalance( + reporter: Reporter, + auth: ApiAuth, + customerId: string, +): Promise { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID is required."); + const data = (await apiGet( + auth, + `/customers/internal-accounts?customerId=${encodeURIComponent(id)}`, + )) as { data: Array> }; + reporter.log({ level: "response", label: "Fetch Balance", detail: data }); + const rows = data.data?.map(mapBalanceRow) ?? []; + return { rows, raw: data }; +} + +// ----- Platform config (OTP + branding) ----- + +export interface PlatformConfigForm { + appName?: string; + otpLength?: number; + alphanumeric?: boolean; + expirationSeconds?: number; + sendFromEmailAddress?: string; + sendFromEmailSenderName?: string; + replyToEmailAddress?: string; + logoUrl?: string; +} + +// GET the platform config and project its embedded-wallet block into the form +// shape the React layer renders. +export async function loadPlatformConfig( + reporter: Reporter, + auth: ApiAuth, +): Promise { + const cfg = await apiGet(auth, "/config"); + reporter.log({ level: "response", label: "GET /config", detail: cfg }); + const ewc = (cfg as { embeddedWalletConfig?: Record }) + ?.embeddedWalletConfig; + const form: PlatformConfigForm = {}; + if (!ewc) return form; + if (typeof ewc.appName === "string") form.appName = ewc.appName; + if (typeof ewc.otpLength === "number") form.otpLength = ewc.otpLength; + if (typeof ewc.alphanumeric === "boolean") + form.alphanumeric = ewc.alphanumeric; + if (typeof ewc.expirationSeconds === "number") + form.expirationSeconds = ewc.expirationSeconds; + if (typeof ewc.sendFromEmailAddress === "string") + form.sendFromEmailAddress = ewc.sendFromEmailAddress; + if (typeof ewc.sendFromEmailSenderName === "string") + form.sendFromEmailSenderName = ewc.sendFromEmailSenderName; + if (typeof ewc.replyToEmailAddress === "string") + form.replyToEmailAddress = ewc.replyToEmailAddress; + if (typeof ewc.logoUrl === "string") form.logoUrl = ewc.logoUrl; + return form; +} + +// PATCH the platform config with only the fields the caller actually set, so we +// send a real partial (mirrors the original "only non-empty fields" behaviour). +export async function savePlatformConfig( + reporter: Reporter, + auth: ApiAuth, + form: PlatformConfigForm, +): Promise { + const ewc: Record = {}; + if (form.appName?.trim()) ewc.appName = form.appName.trim(); + if (typeof form.otpLength === "number" && !Number.isNaN(form.otpLength)) + ewc.otpLength = form.otpLength; + if (typeof form.alphanumeric === "boolean") + ewc.alphanumeric = form.alphanumeric; + if ( + typeof form.expirationSeconds === "number" && + !Number.isNaN(form.expirationSeconds) + ) + ewc.expirationSeconds = form.expirationSeconds; + if (form.sendFromEmailAddress?.trim()) + ewc.sendFromEmailAddress = form.sendFromEmailAddress.trim(); + if (form.sendFromEmailSenderName?.trim()) + ewc.sendFromEmailSenderName = form.sendFromEmailSenderName.trim(); + if (form.replyToEmailAddress?.trim()) + ewc.replyToEmailAddress = form.replyToEmailAddress.trim(); + if (form.logoUrl?.trim()) ewc.logoUrl = form.logoUrl.trim(); + const body = { embeddedWalletConfig: ewc }; + reporter.log({ level: "request", label: "PATCH /config", detail: body }); + const { data } = await apiPatch(auth, "/config", body); + reporter.log({ level: "response", label: "PATCH /config", detail: data }); + return data; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts new file mode 100644 index 000000000..c6b7dec1e --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/email-otp.ts @@ -0,0 +1,223 @@ +// EMAIL_OTP lifecycle: create, secure-OTP challenge/verify, rechallenge, add. +// +// DOM-free operation functions. The secure-OTP code never leaves the client in +// plaintext; the TEK private key stays client-side (no encryptedSessionSigningKey +// is returned). Each function takes the platform `auth`, the values it needs, +// and a `Reporter`, and returns its result. + +import { generateP256KeyPair } from "@turnkey/crypto"; + +import { SANDBOX_SIG } from "../config"; +import { apiPost, type ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; +import { buildWalletSignature, sealOtpBundle } from "../turnkey"; +import { setSessionKeysFromTek } from "../session"; +import { setCtxCredential, setCtxSession } from "./context"; + +// ----- Create credential ----- + +export async function createEmailOtpCredential( + reporter: Reporter, + auth: ApiAuth, + accountId: string, +): Promise { + const { data } = await apiPost(auth, "/auth/credentials", { + type: "EMAIL_OTP", + accountId, + }); + reporter.log({ level: "response", label: "EMAIL_OTP Create", detail: data }); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return data; +} + +// ----- Secure OTP ----- +// +// /challenge issues the INIT_OTP and returns the enclave's target bundle; verify +// HPKE-seals the entered code under it, runs /verify first leg (202 + +// payloadToSign), signs the token with the TEK, then runs /verify retry (200 +// session). + +// Request a challenge for `credId` and return the enclave target bundle. +export async function requestV3Challenge( + reporter: Reporter, + auth: ApiAuth, + credId: string, +): Promise { + const { data: challengeData } = await apiPost( + auth, + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + {}, + ); + reporter.log({ + level: "response", + label: "V3 Challenge", + detail: challengeData, + }); + const targetBundle = (challengeData as Record) + .otpEncryptionTargetBundle as string | undefined; + if (!targetBundle) + throw new Error( + "Challenge response missing otpEncryptionTargetBundle — is the local " + + "backend running the secure-OTP branch?", + ); + return targetBundle; +} + +export interface V3VerifyResult { + leg1: unknown; + session: unknown; +} + +// Run the two verify legs against `targetBundle` with the entered `otp`, caching +// the TEK as the session signing key on success. +export async function runV3Verify( + reporter: Reporter, + auth: ApiAuth, + credId: string, + targetBundle: string, + otp: string, +): Promise { + // Generate a TEK and HPKE-seal the entered OTP under the challenge bundle. + const tek = generateP256KeyPair(); + const encryptedOtpBundle = sealOtpBundle(targetBundle, tek.publicKey, otp); + + // First leg → expect 202 with payloadToSign (verificationToken) + requestId. + const leg1 = await apiPost( + auth, + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "EMAIL_OTP", encryptedOtpBundle }, + ); + const l1 = (leg1.data ?? {}) as Record; + reporter.log({ + level: "response", + label: "V3 Verify leg 1 (expect 202)", + detail: { status: leg1.status, ...l1 }, + }); + const payloadToSign = l1.payloadToSign as string | undefined; + const requestId = l1.requestId as string | undefined; + if (leg1.status !== 202 || !payloadToSign || !requestId) + throw new Error(`Unexpected first-leg response: ${JSON.stringify(leg1)}`); + + // Sign the verificationToken with the TEK private key. + const signature = await buildWalletSignature( + tek.publicKey, + tek.privateKey, + payloadToSign, + ); + + // Retry with the signature → expect 200 AuthSession. + const leg2 = await apiPost( + auth, + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "EMAIL_OTP", encryptedOtpBundle }, + { "Grid-Wallet-Signature": signature, "Request-Id": requestId }, + ); + const session = (leg2.data ?? {}) as Record; + reporter.log({ + level: "response", + label: "V3 Verify leg 2 (expect 200 session)", + detail: { status: leg2.status, ...session }, + }); + if (session.id) setCtxSession(session.id as string); + // The TEK is now the session's API key (OTP_LOGIN registered it). Cache it as + // the active session signing key so later signed retries (add passkey, quote + // execute, etc.) can stamp with this session via turnkeyStamp(). + // MIGRATION (P6): this OTP-TEK caching is the model passkey/oauth login + // converge on once the login-family knob is ON — see oauth.ts/passkey.ts. + if (leg2.status === 200) setSessionKeysFromTek(tek); + return { leg1: leg1.data, session: leg2.data }; +} + +// Guided log in: /challenge → verify legs → cache TEK as session. +export async function loginEmailOtp( + reporter: Reporter, + auth: ApiAuth, + credId: string, + otp: string, +): Promise { + const targetBundle = await requestV3Challenge(reporter, auth, credId); + if (!otp.trim()) throw new Error("OTP code is required."); + return runV3Verify(reporter, auth, credId, targetBundle, otp.trim()); +} + +// ----- Sign-in entry point (create-vs-authenticate) ----- +// +// The fix for EMAIL_OTP_CREDENTIAL_ALREADY_EXISTS: only create a credential when +// the caller doesn't already have one. If `existingCredId` is provided, we +// authenticate against it directly (challenge → verify) and never POST +// /auth/credentials; otherwise we run the original create + verify ceremony. +// +// The create/login functions are injected so the decision is unit-testable at +// the flow boundary without exercising real Turnkey. +export interface EmailOtpSignInDeps { + create: typeof createEmailOtpCredential; + login: typeof loginEmailOtp; +} + +const defaultEmailOtpSignInDeps: EmailOtpSignInDeps = { + create: createEmailOtpCredential, + login: loginEmailOtp, +}; + +export async function signInEmailOtp( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + otp: string, + existingCredId: string | null, + deps: EmailOtpSignInDeps = defaultEmailOtpSignInDeps, +): Promise { + let credId = existingCredId; + if (!credId) { + // No existing EMAIL_OTP credential — run the create leg first. + const cred = await deps.create(reporter, auth, accountId); + credId = (cred as { id?: string }).id ?? null; + if (!credId) throw new Error("Create credential returned no id."); + } + const result = await deps.login(reporter, auth, credId, otp); + return result.session; +} + +// ----- Add an additional EMAIL_OTP credential (issue → signed retry) ----- + +export async function addEmailOtpIssue( + reporter: Reporter, + auth: ApiAuth, + accountId: string, +): Promise<{ data: unknown; requestId: string | undefined }> { + const { data } = await apiPost(auth, "/auth/credentials", { + type: "EMAIL_OTP", + accountId, + }); + reporter.log({ + level: "response", + label: "EMAIL_OTP Add (issue)", + detail: data, + }); + const d = data as Record; + const requestId = typeof d.requestId === "string" ? d.requestId : undefined; + return { data, requestId }; +} + +export async function addEmailOtpRetry( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + requestId: string, +): Promise { + if (!requestId.trim()) + throw new Error("Request-Id is required — run the issue step first."); + const { data } = await apiPost( + auth, + "/auth/credentials", + { type: "EMAIL_OTP", accountId }, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId.trim() }, + ); + reporter.log({ + level: "response", + label: "EMAIL_OTP Add (retry)", + detail: data, + }); + return data; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/login-decision.ts b/apps/examples/grid-global-accounts-example-app/src/flows/login-decision.ts new file mode 100644 index 000000000..7aac67986 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/login-decision.ts @@ -0,0 +1,104 @@ +// Login decision logic: given a wallet's existing credentials, decide whether a +// sign-in for a given method should AUTHENTICATE with an existing credential or +// CREATE a new one first. +// +// This is the fix for the production EMAIL_OTP bug where Login unconditionally +// ran the create-a-credential ceremony on every sign-in: once a credential of +// that type exists, POST /auth/credentials 400s with +// EMAIL_OTP_CREDENTIAL_ALREADY_EXISTS. The rule is simple — if a credential of +// the chosen method already exists, skip create and authenticate with its id; +// otherwise run the existing create+verify ceremony. +// +// Pure + DOM-free so it can be unit-tested at the flow boundary without touching +// real Turnkey. + +/** The three sign-in methods the wallet supports. */ +export type Method = "email_otp" | "oauth" | "passkey"; + +/** The credential `type` strings the Grid API returns for each method. */ +const TYPE_FOR_METHOD: Record = { + email_otp: "EMAIL_OTP", + oauth: "OAUTH", + passkey: "PASSKEY", +}; + +/** Reverse of `TYPE_FOR_METHOD`: the credential's API `type` → its `Method`. */ +const METHOD_FOR_TYPE: Record = { + EMAIL_OTP: "email_otp", + OAUTH: "oauth", + PASSKEY: "passkey", +}; + +/** + * The `Method` a credential authenticates with, derived from its API `type`, or + * undefined for an unrecognised type. The new login screen iterates the FULL + * credential list (a wallet can hold multiple passkeys / oauth identities), so + * each row maps its own credential to a method rather than collapsing the list + * to one-per-type. + */ +export function methodForCredential( + credential: ExistingCredential, +): Method | undefined { + return credential.type ? METHOD_FOR_TYPE[credential.type] : undefined; +} + +/** True when the wallet already has at least one EMAIL_OTP credential. A wallet + * may hold only one, so "Add Email OTP" is offered only when this is false. */ +export function hasEmailOtpCredential( + credentials: ExistingCredential[], +): boolean { + return Boolean(existingCredentialFor(credentials, "email_otp")); +} + +/** A credential as returned by GET /auth/credentials. */ +export interface ExistingCredential { + id: string; + type?: string; + nickname?: string; + status?: string; +} + +/** The decision for a single method: authenticate with an existing credential + * (skip create), or create a new one first. */ +export type LoginDecision = + | { action: "authenticate"; credId: string } + | { action: "create" }; + +/** + * Pull the credentials array out of a `listCredentials` response. The API wraps + * the list as `{ data: Credential[] }`; we tolerate a bare array or a missing + * payload too so callers don't have to special-case the empty/loading state. + */ +export function parseCredentials(raw: unknown): ExistingCredential[] { + if (Array.isArray(raw)) return raw as ExistingCredential[]; + const data = (raw as { data?: unknown })?.data; + return Array.isArray(data) ? (data as ExistingCredential[]) : []; +} + +/** + * The first existing credential for `method`, or undefined if the wallet has + * none of that type. Credentials without a usable `id` are ignored (they can't + * be authenticated against). + */ +export function existingCredentialFor( + credentials: ExistingCredential[], + method: Method, +): ExistingCredential | undefined { + const wanted = TYPE_FOR_METHOD[method]; + return credentials.find((c) => c.type === wanted && Boolean(c.id)); +} + +/** + * Decide whether signing in with `method` should authenticate against an + * existing credential or create one first. This is the single source of truth + * the Login UI and tests share. + */ +export function decideLogin( + credentials: ExistingCredential[], + method: Method, +): LoginDecision { + const existing = existingCredentialFor(credentials, method); + return existing + ? { action: "authenticate", credId: existing.id } + : { action: "create" }; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts b/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts new file mode 100644 index 000000000..6f003a312 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/manage.ts @@ -0,0 +1,240 @@ +// Manage flows: delete credential / session / export (guided issue → sign → +// retry), plus list credentials / sessions. +// +// DOM-free operation functions. The endpoints are identical across credential +// types, so these take the ids + platform `auth` directly. Each guided action +// owns the whole issue → sign → retry chain in one call, pulling the signature +// from the live session in production and from `SANDBOX_SIG` in sandbox; the +// separate issue/retry functions remain for inspecting the 202 between legs. + +import { + decryptExportBundle, + generateP256KeyPair, + hpkeDecrypt, +} from "@turnkey/crypto"; + +import { SANDBOX_SIG } from "../config"; +import { apiDelete, apiGet, apiPost, type ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; +import { turnkeyStamp } from "../turnkey"; + +// A 202-issuing leg: hit the issue endpoint and return its response data, from +// which the guided runner pulls `requestId` + `payloadToSign`. +type IssueLeg = () => Promise; +// A signed-retry leg: re-hit the endpoint with the resolved signature headers. +type RetryLeg = ( + headers: Record, +) => Promise<{ status: number; data: unknown }>; + +// Resolve the Grid-Wallet-Signature for a guided signed retry: the sandbox +// magic value, or a real session stamp over the 202's payloadToSign in +// production. Throws a clear error if production lacks a payload/session. +async function guidedSignature( + auth: ApiAuth, + payloadToSign: string | undefined, +): Promise { + if (auth.mode !== "production") return SANDBOX_SIG; + if (!payloadToSign) + throw new Error( + "No payloadToSign in the 202 challenge — cannot stamp this retry.", + ); + return turnkeyStamp(payloadToSign); +} + +function payloadFrom(data: unknown): string | undefined { + const v = (data as Record)?.payloadToSign; + return typeof v === "string" ? v : undefined; +} +function requestIdFrom(data: unknown): string | undefined { + const v = (data as Record)?.requestId; + return typeof v === "string" ? v : undefined; +} + +export interface GuidedRetryResult { + issued: unknown; + retried: unknown; +} + +// Run a guided issue → sign → retry chain: issue the 202, derive the signature +// (session stamp in production, magic value in sandbox), then forward the +// signed retry. +async function runGuidedRetry( + reporter: Reporter, + auth: ApiAuth, + label: string, + issue: IssueLeg, + retry: RetryLeg, +): Promise { + const issued = await issue(); + reporter.log({ + level: "response", + label: `${label} (issue)`, + detail: issued, + }); + const requestId = requestIdFrom(issued); + if (!requestId) + throw new Error(`No requestId in the ${label} 202 challenge.`); + const signature = await guidedSignature(auth, payloadFrom(issued)); + const { data } = await retry({ + "Grid-Wallet-Signature": signature, + "Request-Id": requestId, + }); + reporter.log({ level: "response", label: `${label} (retry)`, detail: data }); + return { issued, retried: data }; +} + +// ----- Delete credential ----- + +export function deleteCredential( + reporter: Reporter, + auth: ApiAuth, + credId: string, +): Promise { + const path = `/auth/credentials/${encodeURIComponent(credId)}`; + return runGuidedRetry( + reporter, + auth, + "Delete Credential", + () => apiDelete(auth, path).then((r) => r.data), + (headers) => apiDelete(auth, path, headers), + ); +} + +// ----- Delete session ----- + +export function deleteSession( + reporter: Reporter, + auth: ApiAuth, + sessionId: string, +): Promise { + const path = `/auth/sessions/${encodeURIComponent(sessionId)}`; + return runGuidedRetry( + reporter, + auth, + "Delete Session", + () => apiDelete(auth, path).then((r) => r.data), + (headers) => apiDelete(auth, path, headers), + ); +} + +// ----- Wallet export ----- + +// The export bundle is a signed enclave envelope; its `data` field hex-decodes +// to JSON carrying the HPKE encapsulated key, ciphertext, and the wallet's +// Turnkey sub-org id — so the org id `decryptExportBundle` checks comes from the +// bundle itself, not from session state. +interface ExportBundleData { + encappedPublic: string; + ciphertext: string; + organizationId: string; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function parseBundleData(exportBundle: string): ExportBundleData { + const { data } = JSON.parse(exportBundle) as { data: string }; + return JSON.parse( + new TextDecoder().decode(hexToBytes(data)), + ) as ExportBundleData; +} + +// Pull the recovered mnemonic out of a sealed export bundle. In production the +// bundle is signed by the enclave, so `decryptExportBundle` verifies that +// signature before HPKE-decrypting. The sandbox backend returns an unsigned +// bundle (empty `dataSignature`/`enclaveQuorumPublic`), which that verification +// can't pass — so there we HPKE-decrypt the bundle directly, the same crypto +// minus the attestation check. +async function recoverMnemonic( + auth: ApiAuth, + exportBundle: string, + privateKey: string, +): Promise { + if (auth.mode === "production") { + const { organizationId } = parseBundleData(exportBundle); + return decryptExportBundle({ + exportBundle, + embeddedKey: privateKey, + organizationId, + returnMnemonic: true, + }); + } + const { encappedPublic, ciphertext } = parseBundleData(exportBundle); + const decrypted = hpkeDecrypt({ + ciphertextBuf: hexToBytes(ciphertext), + encappedKeyBuf: hexToBytes(encappedPublic), + receiverPriv: privateKey, + }); + return new TextDecoder().decode(decrypted); +} + +function exportBundleFrom(retried: unknown): string { + const v = (retried as Record)?.encryptedWalletCredentials; + if (typeof v !== "string" || !v) + throw new Error("Export response missing encryptedWalletCredentials."); + return v; +} + +export interface ExportWalletResult extends GuidedRetryResult { + mnemonic: string; +} + +// Run the guided export, then decrypt the sealed bundle with the matching +// private key (kept client-side, never sent) to recover the wallet mnemonic. +export async function exportWallet( + reporter: Reporter, + auth: ApiAuth, + accountId: string, +): Promise { + const path = `/internal-accounts/${encodeURIComponent(accountId)}/export`; + // The enclave encrypts the exported mnemonic to this client key; the matching + // private key stays here and decrypts the returned bundle. + const keyPair = generateP256KeyPair(); + const body = { clientPublicKey: keyPair.publicKeyUncompressed }; + const result = await runGuidedRetry( + reporter, + auth, + "Wallet Export", + () => apiPost(auth, path, body).then((r) => r.data), + (headers) => apiPost(auth, path, body, headers), + ); + const mnemonic = await recoverMnemonic( + auth, + exportBundleFrom(result.retried), + keyPair.privateKey, + ); + return { ...result, mnemonic }; +} + +// ----- List ----- + +export async function listCredentials( + reporter: Reporter, + auth: ApiAuth, + accountId: string, +): Promise { + const data = await apiGet( + auth, + `/auth/credentials?accountId=${encodeURIComponent(accountId)}`, + ); + reporter.log({ level: "response", label: "List Credentials", detail: data }); + return data; +} + +export async function listSessions( + reporter: Reporter, + auth: ApiAuth, + accountId: string, +): Promise { + const data = await apiGet( + auth, + `/auth/sessions?accountId=${encodeURIComponent(accountId)}`, + ); + reporter.log({ level: "response", label: "List Sessions", detail: data }); + return data; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/money.ts b/apps/examples/grid-global-accounts-example-app/src/flows/money.ts new file mode 100644 index 000000000..2c8441669 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/money.ts @@ -0,0 +1,498 @@ +// Money movement: external account, quote, sign payload, execute. +// +// DOM-free operation functions. The React layer collects the form values +// (account type + fields, amounts) and renders results; this module builds the +// request bodies, talks to Grid + Turnkey, and emits log events through the +// injected `Reporter`. + +import { SANDBOX_SIG, type Mode } from "../config"; +import { apiGet, apiPost, type ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; +import { turnkeyStamp } from "../turnkey"; + +export interface BankExternalAccount { + kind: "bank"; + accountNumber: string; + routingNumber: string; + beneficiaryName?: string; +} + +export type ExternalAccountParams = BankExternalAccount; + +export interface CreateExternalAccountResult { + data: unknown; + externalAccountId: string | undefined; +} + +export async function createExternalAccount( + reporter: Reporter, + auth: ApiAuth, + params: ExternalAccountParams, +): Promise { + const accountNumber = params.accountNumber.trim(); + const routingNumber = params.routingNumber.trim(); + const fullName = params.beneficiaryName?.trim() || "Sandbox Test User"; + if (!accountNumber || !routingNumber) + throw new Error("Account number and routing number are required."); + const body: Record = { + currency: "USD", + accountInfo: { + accountType: "USD_ACCOUNT", + countries: ["US"], + paymentRails: ["ACH", "WIRE", "RTP", "FEDNOW"], + accountNumber, + routingNumber, + beneficiary: { + beneficiaryType: "INDIVIDUAL", + fullName, + birthDate: "1990-01-15", + nationality: "US", + address: { + line1: "100 Test St", + city: "SF", + postalCode: "94102", + country: "US", + }, + }, + }, + }; + const { data } = await apiPost(auth, "/platform/external-accounts", body); + reporter.log({ + level: "response", + label: "Create External Account", + detail: data, + }); + const d = data as Record; + const externalAccountId = typeof d.id === "string" ? d.id : undefined; + return { data, externalAccountId }; +} + +// ----- Customer-owned external accounts (offramp destination) ----- +// +// A customer offramp quote (embedded wallet → external account) requires the +// destination to be owned by that customer, created via +// `POST /customers/external-accounts` with a `customerId`. A platform-owned +// external account (`POST /platform/external-accounts`) does not belong to the +// customer and is rejected with `sparkcore_to_account_id does not belong to the +// specified user`. + +export interface CreateCustomerExternalAccountParams { + /** The customer LSID the external account is created for. */ + customerId: string; + accountNumber: string; + routingNumber: string; + beneficiaryName?: string; +} + +/** Build the USD/ACH `accountInfo` body shared by platform + customer creates. */ +function usdBankAccountInfo( + accountNumber: string, + routingNumber: string, + fullName: string, +): Record { + return { + accountType: "USD_ACCOUNT", + countries: ["US"], + paymentRails: ["ACH", "WIRE", "RTP", "FEDNOW"], + accountNumber, + routingNumber, + beneficiary: { + beneficiaryType: "INDIVIDUAL", + fullName, + birthDate: "1990-01-15", + nationality: "US", + address: { + line1: "100 Test St", + city: "SF", + postalCode: "94102", + country: "US", + }, + }, + }; +} + +/** + * Create a customer-owned USD bank external account + * (`POST /customers/external-accounts`). Sends `customerId` + `currency: "USD"` + * + the USD/ACH `accountInfo`, and returns the new external account id. + */ +export async function createCustomerExternalAccount( + reporter: Reporter, + auth: ApiAuth, + params: CreateCustomerExternalAccountParams, +): Promise { + const customerId = params.customerId.trim(); + const accountNumber = params.accountNumber.trim(); + const routingNumber = params.routingNumber.trim(); + const fullName = params.beneficiaryName?.trim() || "Sandbox Test User"; + if (!customerId) throw new Error("A customer is required."); + if (!accountNumber || !routingNumber) + throw new Error("Account number and routing number are required."); + const body: Record = { + customerId, + currency: "USD", + accountInfo: usdBankAccountInfo(accountNumber, routingNumber, fullName), + }; + const { data } = await apiPost(auth, "/customers/external-accounts", body); + reporter.log({ + level: "response", + label: "Create Customer External Account", + detail: data, + }); + const id = (data as Record)?.id; + if (typeof id !== "string" || !id) + throw new Error("External account create returned no id."); + return id; +} + +/** A customer external account, flattened to the bits the picker renders. */ +export interface CustomerExternalAccount { + id: string; + /** Human label, e.g. `USD •••6789`. */ + label: string; +} + +/** + * List a customer's external accounts + * (`GET /customers/external-accounts?customerId=...`), optionally filtered by + * currency. Returns each account's id and a human label (currency + last-4 of + * the bank account number when present). + */ +export async function listCustomerExternalAccounts( + reporter: Reporter, + auth: ApiAuth, + customerId: string, + currency?: string, +): Promise { + const id = customerId.trim(); + if (!id) throw new Error("A customer is required."); + const query = new URLSearchParams({ customerId: id }); + if (currency) query.set("currency", currency); + const data = await apiGet(auth, `/customers/external-accounts?${query}`); + reporter.log({ + level: "response", + label: "List Customer External Accounts", + detail: data, + }); + const rows = ((data as Record | null)?.data ?? []) as Array< + Record + >; + const accounts: CustomerExternalAccount[] = []; + for (const row of rows) { + if (typeof row.id !== "string" || !row.id) continue; + accounts.push({ id: row.id, label: externalAccountLabel(row) }); + } + return accounts; +} + +/** Build a `USD •••6789`-style label from an external account response item. */ +function externalAccountLabel(row: Record): string { + const currency = typeof row.currency === "string" ? row.currency : ""; + const info = row.accountInfo as Record | undefined; + const number = + info && typeof info.accountNumber === "string" ? info.accountNumber : ""; + const last4 = number ? `•••${number.slice(-4)}` : ""; + return [currency, last4].filter(Boolean).join(" ") || (row.id as string); +} + +export interface CreateQuoteParams { + sourceAccountId: string; + destinationAccountId: string; + lockedCurrencySide: string; + lockedCurrencyAmount: number; + mode: Mode; +} + +export interface CreateQuoteResult { + data: unknown; + quoteId: string | undefined; + /** payloadToSign from the EMBEDDED_WALLET payment instruction, if present. */ + payloadToSign: string | undefined; + /** Pre-filled signature: the magic value in sandbox, blank in production. */ + signature: string; +} + +export async function createQuote( + reporter: Reporter, + auth: ApiAuth, + params: CreateQuoteParams, +): Promise { + const destinationAccountId = params.destinationAccountId.trim(); + if (!destinationAccountId || !params.lockedCurrencyAmount) + throw new Error("Destination external account and amount are required."); + const { data } = await apiPost(auth, "/quotes", { + source: { sourceType: "ACCOUNT", accountId: params.sourceAccountId }, + destination: { + destinationType: "ACCOUNT", + accountId: destinationAccountId, + }, + lockedCurrencySide: params.lockedCurrencySide, + lockedCurrencyAmount: params.lockedCurrencyAmount, + }); + reporter.log({ level: "response", label: "Create Quote", detail: data }); + const d = data as Record; + const quoteId = typeof d.id === "string" ? d.id : undefined; + + // Extract `payloadToSign` from the EMBEDDED_WALLET payment instruction + // (find by accountType match). + let payloadToSign: string | undefined; + const instructions = (d.paymentInstructions ?? []) as Array< + Record + >; + for (const inst of instructions) { + const info = inst.accountOrWalletInfo as + | Record + | undefined; + if (info && info.accountType === "EMBEDDED_WALLET" && info.payloadToSign) { + payloadToSign = info.payloadToSign as string; + break; + } + } + + // In sandbox mode, pre-fill the magic signature so the user can Execute + // immediately. In production, leave blank — `signPayload` decrypts the + // session bundle and stamps the payload. + const signature = params.mode === "sandbox" ? SANDBOX_SIG : ""; + return { data, quoteId, payloadToSign, signature }; +} + +export interface SignPayloadResult { + signature: string; + message: string; +} + +export async function signPayload( + mode: Mode, + payloadToSign: string, +): Promise { + if (mode === "sandbox") { + return { + signature: SANDBOX_SIG, + message: "Mode: sandbox — filled magic signature.", + }; + } + const payload = payloadToSign.trim(); + if (!payload) + throw new Error( + "payloadToSign is empty — run Create Quote first or paste it manually.", + ); + const stamp = await turnkeyStamp(payload); + return { signature: stamp, message: `Stamped (${stamp.length} chars).` }; +} + +export async function executeQuote( + reporter: Reporter, + auth: ApiAuth, + quoteId: string, + signature: string, +): Promise { + const id = quoteId.trim(); + const sig = signature.trim(); + if (!id || !sig) + throw new Error("Quote ID and Grid-Wallet-Signature are required."); + const { data } = await apiPost( + auth, + `/quotes/${encodeURIComponent(id)}/execute`, + {}, + { "Grid-Wallet-Signature": sig }, + ); + reporter.log({ level: "response", label: "Execute Quote", detail: data }); + return data; +} + +// ----- Platform-funded transfer (no wallet signature) ----- +// +// Mirrors the proven platform→customer flow in +// `sparkcore/sparkcore/grid/__itests__/test_token_fund_in_live.py` +// (`_gen_create_and_execute_quote`, lines 476-507, and +// `_gen_poll_transaction_status`, lines 373-393): +// POST /quotes { source: ACCOUNT, destination: ACCOUNT, lockedCurrencySide: +// "SENDING", lockedCurrencyAmount } → { id } +// POST /quotes/{id}/execute {} (EMPTY body, NO Grid-Wallet-Signature — the +// platform's Basic-auth token authorizes spending its own source account) +// poll GET /transactions/{transactionId} until status ∈ {COMPLETED, FAILED}. +// Unlike the customer-signed `executeQuote`, the platform funds its own customer +// so there is no embedded-wallet payload to sign. + +/** Terminal + happy-path statuses a transaction can reach. */ +const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED"]); + +export interface FundCustomerParams { + /** The platform's funded source internal account LSID. */ + fundingAccountId: string; + /** The customer's destination internal account LSID. */ + destinationAccountId: string; + /** Amount to send, in minor units (cents / micro-units / sats per currency). */ + amountMinor: number; +} + +/** + * Coarse stages the fund flow passes through, surfaced to the UI for a staged + * progress indicator. `quoting` → `executing` → `processing` are the in-flight + * steps; `completed` / `failed` are terminal. PROCESSING is the only real + * backend signal, so the bar advances approximately between steps. + */ +export type FundStage = + | "quoting" + | "executing" + | "processing" + | "completed" + | "failed"; + +export interface FundCustomerResult { + quoteId: string; + transactionId: string; + /** The terminal transaction `status` (COMPLETED / FAILED), or the last seen. */ + status: string; + /** The full transaction payload from the final `GET /transactions/{id}`. */ + transaction: unknown; +} + +/** + * Inject the wait between polls so tests don't sleep on real timers. Production + * callers leave it defaulted to a real `setTimeout`-backed delay. + */ +export type Sleep = (ms: number) => Promise; +const realSleep: Sleep = (ms) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export interface PollTransactionOptions { + /** Total time budget before giving up and returning the last seen txn. */ + timeoutMs?: number; + /** Delay between polls. */ + intervalMs?: number; + /** Injected sleep (tests pass a no-op / fake-timer-driven one). */ + sleep?: Sleep; +} + +/** + * Poll `GET /transactions/{id}` until `status` is terminal (COMPLETED / FAILED) + * or the timeout elapses, then return the last-seen transaction. Mirrors + * `_gen_poll_transaction_status` in the reference itest. + */ +export async function pollTransaction( + reporter: Reporter, + auth: ApiAuth, + transactionId: string, + opts: PollTransactionOptions = {}, +): Promise<{ status: string; transaction: unknown }> { + const id = transactionId.trim(); + if (!id) throw new Error("Transaction ID is required to poll."); + const timeoutMs = opts.timeoutMs ?? 30_000; + const intervalMs = opts.intervalMs ?? 1_000; + const sleep = opts.sleep ?? realSleep; + + let elapsed = 0; + let txn: unknown = null; + let status = ""; + // Poll at least once even if timeoutMs is 0. + do { + txn = await apiGet(auth, `/transactions/${encodeURIComponent(id)}`); + reporter.log({ + level: "response", + label: "GET /transactions", + detail: txn, + }); + const s = (txn as Record | null)?.status; + status = typeof s === "string" ? s : ""; + if (TERMINAL_STATUSES.has(status)) return { status, transaction: txn }; + if (elapsed + intervalMs >= timeoutMs) break; + await sleep(intervalMs); + elapsed += intervalMs; + } while (elapsed < timeoutMs); + + return { status, transaction: txn }; +} + +/** + * Options for `fundCustomerFromPlatform`: poll tuning + a staged-progress hook. + */ +export interface FundCustomerOptions { + /** Poll tuning (timeout / interval / injected sleep). */ + poll?: PollTransactionOptions; + /** + * Stage callback for a staged UI indicator. Invoked with `quoting` before the + * quote, `executing` before execute, `processing` before the poll, then the + * terminal `completed` / `failed` (or left at `processing` if the poll times + * out before a terminal status). + */ + onStage?: (stage: FundStage) => void; +} + +/** + * Fund a customer from the platform's own funded internal account: + * quote (RECEIVING-locked) → execute (empty body, platform Basic auth, no + * signature) → poll the transaction to a terminal status. Returns the quote id, + * transaction id, and final status. The caller refreshes the customer's balance + * and surfaces the status. DOM-free: takes a `Reporter`, `auth`, and params. + */ +export async function fundCustomerFromPlatform( + reporter: Reporter, + auth: ApiAuth, + params: FundCustomerParams, + opts: FundCustomerOptions = {}, +): Promise { + const onStage = opts.onStage ?? (() => {}); + const fundingAccountId = params.fundingAccountId.trim(); + const destinationAccountId = params.destinationAccountId.trim(); + if (!fundingAccountId) + throw new Error("A platform funding account is required."); + if (!destinationAccountId) + throw new Error("The customer has no internal account to fund."); + if (!params.amountMinor || params.amountMinor <= 0) + throw new Error("Enter an amount to fund."); + + // 1) Quote: platform source → customer destination. The amount is in the + // customer's (receiving) currency, so lock RECEIVING and let the quote derive + // the source amount. + onStage("quoting"); + const quoteBody = { + source: { sourceType: "ACCOUNT", accountId: fundingAccountId }, + destination: { + destinationType: "ACCOUNT", + accountId: destinationAccountId, + }, + lockedCurrencySide: "RECEIVING", + lockedCurrencyAmount: params.amountMinor, + }; + reporter.log({ level: "request", label: "POST /quotes", detail: quoteBody }); + const { data: quoteData } = await apiPost(auth, "/quotes", quoteBody); + reporter.log({ level: "response", label: "Create Quote", detail: quoteData }); + const quoteId = (quoteData as Record)?.id; + if (typeof quoteId !== "string" || !quoteId) + throw new Error("Quote creation returned no id."); + + // 2) Execute: EMPTY body, NO Grid-Wallet-Signature. Platform Basic auth + // authorizes spending its own source account. + onStage("executing"); + reporter.log({ + level: "request", + label: "POST /quotes/{id}/execute", + detail: {}, + }); + const { data: execData } = await apiPost( + auth, + `/quotes/${encodeURIComponent(quoteId)}/execute`, + {}, + ); + reporter.log({ level: "response", label: "Execute Quote", detail: execData }); + const transactionId = (execData as Record)?.transactionId; + if (typeof transactionId !== "string" || !transactionId) + throw new Error("Execute returned no transactionId."); + + // 3) Poll the transaction to a terminal status. + onStage("processing"); + const { status, transaction } = await pollTransaction( + reporter, + auth, + transactionId, + opts.poll, + ); + + // Terminal stage: COMPLETED / FAILED flip to that stage; a poll timeout leaves + // the indicator at `processing` (the balance may still settle). + if (status === "COMPLETED") onStage("completed"); + else if (status === "FAILED") onStage("failed"); + + return { quoteId, transactionId, status, transaction }; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts new file mode 100644 index 000000000..8f85a0351 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/oauth.ts @@ -0,0 +1,144 @@ +// OAUTH lifecycle: guided login, create, verify (→ session), add. +// +// DOM-free operation functions: the React layer supplies the OIDC token + client +// public key and renders the returned data; this module only talks to Grid and +// emits log events through the injected `Reporter`. + +import { SANDBOX_SIG } from "../config"; +import { apiPost, type ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; +import { generateClientKeyPair } from "../turnkey"; +import { rememberEncryptedSessionSigningKey } from "../session"; +import { setCtxCredential, setCtxSession } from "./context"; + +// Run /verify with the OIDC token + client public key, caching the session +// bundle on success. Shared by the guided login and the manual verify path. +export async function runOauthVerify( + reporter: Reporter, + auth: ApiAuth, + credId: string, + oidc: string, + pubkey: string, +): Promise { + if (!oidc.trim()) throw new Error("OIDC token is required."); + if (!pubkey.trim()) throw new Error("Client public key is required."); + const { data } = await apiPost( + auth, + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "OAUTH", oidcToken: oidc.trim(), clientPublicKey: pubkey.trim() }, + ); + reporter.log({ level: "response", label: "OAUTH Verify", detail: data }); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + // MIGRATION (P6): OAUTH login moves to OAUTH_LOGIN; the knob-ON response + // drops `encryptedSessionSigningKey`, so this becomes the OTP-style + // `setSessionKeysFromTek(clientKeyPair)` path. The shape-detection in + // `rememberEncryptedSessionSigningKey` already no-ops when the field is + // absent — flip this one call once the P3 wire shape settles. + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return data; +} + +export interface OauthLoginResult { + data: unknown; + /** The freshly generated client public key that was sent to /verify. */ + clientPublicKey: string; +} + +// Guided log in: gen client key → /verify with OIDC token + clientPublicKey → +// remember the session bundle. +export async function loginOauth( + reporter: Reporter, + auth: ApiAuth, + credId: string, + oidc: string, +): Promise { + if (!oidc.trim()) throw new Error("OIDC token is required."); + const kp = generateClientKeyPair(); + const data = await runOauthVerify( + reporter, + auth, + credId, + oidc, + kp.publicKeyUncompressed, + ); + return { data, clientPublicKey: kp.publicKeyUncompressed }; +} + +// ----- Sign-in entry point (create-vs-authenticate) ----- +// +// Mirror of EMAIL_OTP's signIn: only create an OAUTH credential when the wallet +// doesn't already have one (a second create would 400 with +// OAUTH_CREDENTIAL_ALREADY_EXISTS). When `existingCredId` is provided we verify +// directly against it; otherwise we run the original create + verify ceremony. +export async function signInOauth( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + oidc: string, + existingCredId: string | null, +): Promise { + let credId = existingCredId; + if (!credId) { + const cred = await createOauthCredential(reporter, auth, accountId, oidc); + credId = (cred as { id?: string }).id ?? null; + if (!credId) throw new Error("Create credential returned no id."); + } + const { data } = await loginOauth(reporter, auth, credId, oidc); + return data; +} + +export async function createOauthCredential( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + oidc: string, +): Promise { + if (!oidc.trim()) throw new Error("OIDC token is required."); + const { data } = await apiPost(auth, "/auth/credentials", { + type: "OAUTH", + accountId, + oidcToken: oidc.trim(), + }); + reporter.log({ level: "response", label: "OAUTH Create", detail: data }); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return data; +} + +export async function addOauthIssue( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + oidc: string, +): Promise<{ data: unknown; requestId: string | undefined }> { + if (!oidc.trim()) throw new Error("OIDC token is required."); + const { data } = await apiPost(auth, "/auth/credentials", { + type: "OAUTH", + accountId, + oidcToken: oidc.trim(), + }); + reporter.log({ level: "response", label: "OAUTH Add (issue)", detail: data }); + const d = data as Record; + const requestId = typeof d.requestId === "string" ? d.requestId : undefined; + return { data, requestId }; +} + +export async function addOauthRetry( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + oidc: string, + requestId: string, +): Promise { + if (!requestId.trim()) + throw new Error("Request-Id is required — run the issue step first."); + const { data } = await apiPost( + auth, + "/auth/credentials", + { type: "OAUTH", accountId, oidcToken: oidc.trim() }, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId.trim() }, + ); + reporter.log({ level: "response", label: "OAUTH Add (retry)", detail: data }); + return data; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/otp-step.ts b/apps/examples/grid-global-accounts-example-app/src/flows/otp-step.ts new file mode 100644 index 000000000..d2825c862 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/otp-step.ts @@ -0,0 +1,102 @@ +// Two-step EMAIL_OTP sign-in orchestration (pure + DOM-free). +// +// The production bug this fixes: the old login coupled the OTP *challenge* (which +// SENDS the email) to *verify* in a single call, so every sign-in attempt — and +// every retry — fired a fresh OTP, invalidating the prior code and tripping the +// rate limit. Here the challenge and verify are two distinct, explicit steps: +// +// step "idle" → user must click Send to fire requestV3Challenge ONCE. +// step "awaiting_code" → the challenge bundle is held; the user enters the code +// and clicks Verify, which runs runV3Verify against the +// *cached* bundle. Verify NEVER issues a challenge. +// "Resend" is an explicit re-challenge from the awaiting_code step. +// +// The challenge is injected (`requestChallenge`) and the verify is injected +// (`runVerify`) so the two-step guarantee is unit-testable at the flow boundary +// without exercising real Turnkey: a test can assert the challenge dependency is +// called exactly once per send and that verify is reachable only after a +// challenge, carrying the bundle the challenge produced. + +import type { ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; +import { + requestV3Challenge, + runV3Verify, + type V3VerifyResult, +} from "./email-otp"; + +/** Where a single credential's OTP sign-in currently is. */ +export type OtpStep = + | { status: "idle" } + | { status: "challenging" } + | { status: "awaiting_code"; targetBundle: string } + | { status: "verifying"; targetBundle: string }; + +/** The challenge leg: send the OTP for `credId`, returning the enclave bundle. */ +export type ChallengeFn = ( + reporter: Reporter, + auth: ApiAuth, + credId: string, +) => Promise; + +/** The verify leg: run the two verify legs against an already-issued bundle. */ +export type VerifyFn = ( + reporter: Reporter, + auth: ApiAuth, + credId: string, + targetBundle: string, + otp: string, +) => Promise; + +export interface OtpStepDeps { + requestChallenge: ChallengeFn; + runVerify: VerifyFn; +} + +const defaultOtpStepDeps: OtpStepDeps = { + requestChallenge: requestV3Challenge, + runVerify: runV3Verify, +}; + +/** + * Fire the challenge for `credId` exactly once and return the bundle-bearing + * next step. This is the ONLY path that calls the challenge dependency, so the + * OTP email is sent only when a caller explicitly invokes this (Send / Resend) — + * never on render and never from `verify`. + */ +export async function sendOtpChallenge( + reporter: Reporter, + auth: ApiAuth, + credId: string, + deps: OtpStepDeps = defaultOtpStepDeps, +): Promise> { + const targetBundle = await deps.requestChallenge(reporter, auth, credId); + return { status: "awaiting_code", targetBundle }; +} + +/** + * Verify the entered `otp` against the bundle the challenge already produced. + * Requires a `targetBundle` from a prior `sendOtpChallenge`; it deliberately + * does NOT call the challenge dependency, so a verify (or a failed verify retry) + * can never send a fresh OTP. Returns the auth session on success. + */ +export async function verifyOtpStep( + reporter: Reporter, + auth: ApiAuth, + credId: string, + targetBundle: string, + otp: string, + deps: OtpStepDeps = defaultOtpStepDeps, +): Promise { + if (!targetBundle) + throw new Error("Send the code first — no challenge bundle to verify."); + if (!otp.trim()) throw new Error("Enter the one-time code."); + const { session } = await deps.runVerify( + reporter, + auth, + credId, + targetBundle, + otp.trim(), + ); + return session; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts new file mode 100644 index 000000000..fe3c1be9c --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/passkey.ts @@ -0,0 +1,370 @@ +// PASSKEY lifecycle: create (real registration), challenge, verify (assertion), +// add (signed retry, session-stamped in production). +// +// DOM-free operation functions. Attestation / assertion material is passed in +// as plain values (the React layer captures it from the real WebAuthn ceremony +// in production, or the seeded magic values in sandbox); this module talks to +// Grid + Turnkey and emits log events through the injected `Reporter`. + +import { SANDBOX_SIG, type Mode } from "../config"; +import { apiPost, type ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; +import { generateClientKeyPair, turnkeyStamp } from "../turnkey"; +import { rememberEncryptedSessionSigningKey } from "../session"; +import { rememberRawCredentialId } from "../passkey-store"; +import { + createRealPasskey, + signWithPasskey, + type RealAssertion, +} from "../webauthn"; +import { setCtxCredential, setCtxSession } from "./context"; + +export interface PasskeyAttestation { + challenge: string; + credentialId: string; + clientDataJson: string; + attestationObject: string; +} + +export interface PasskeyAssertion { + credentialId: string; + clientDataJson: string; + authenticatorData: string; + signature: string; +} + +// ----- Create / Add a credential (attestation) ----- + +function buildCredentialBody( + accountId: string, + nickname: string, + attestation: PasskeyAttestation, +): Record { + return { + type: "PASSKEY", + accountId, + nickname, + challenge: attestation.challenge, + attestation: { + credentialId: attestation.credentialId, + clientDataJson: attestation.clientDataJson, + attestationObject: attestation.attestationObject, + }, + }; +} + +export async function createPasskeyCredential( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + nickname: string, + attestation: PasskeyAttestation, +): Promise { + const body = buildCredentialBody(accountId, nickname, attestation); + const { data } = await apiPost(auth, "/auth/credentials", body); + reporter.log({ level: "response", label: "PASSKEY Create", detail: data }); + const d = data as Record; + if (d.id) { + setCtxCredential(d.id as string); + // Map the new Grid credential id → the raw WebAuthn credential id so a later + // sign-in can target this security key via allowCredentials. + rememberRawCredentialId(d.id as string, attestation.credentialId); + } + return data; +} + +// Drive a real WebAuthn registration on a roaming security key (YubiKey) and +// return the attestation the Create / Add flows send. +export async function registerRealPasskey( + reporter: Reporter, + nickname: string, + rpId?: string, +): Promise { + const att = await createRealPasskey(nickname, rpId); + reporter.log({ + level: "info", + label: "Passkey Registered (real)", + detail: att, + }); + return att; +} + +// ----- Session challenge + verify ----- + +export interface PasskeyChallengeResult { + data: unknown; + requestId: string | undefined; + challenge: string; +} + +export async function requestPasskeyChallenge( + reporter: Reporter, + auth: ApiAuth, + credId: string, + clientPublicKey: string, +): Promise { + if (!clientPublicKey.trim()) + throw new Error("Client public key is required — generate one first."); + const { data } = await apiPost( + auth, + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + { clientPublicKey: clientPublicKey.trim() }, + ); + reporter.log({ level: "response", label: "PASSKEY Challenge", detail: data }); + const d = data as Record; + const requestId = typeof d.requestId === "string" ? d.requestId : undefined; + const challenge = typeof d.challenge === "string" ? d.challenge : ""; + return { data, requestId, challenge }; +} + +// Run /verify with the supplied assertion, caching the session bundle on +// success. Shared by guided login + the manual verify path. +export async function runPasskeyVerify( + reporter: Reporter, + auth: ApiAuth, + credId: string, + clientPublicKey: string, + assertion: PasskeyAssertion, + requestId: string | undefined, +): Promise { + const body = { + type: "PASSKEY", + clientPublicKey: clientPublicKey.trim(), + assertion: { + credentialId: assertion.credentialId.trim(), + clientDataJson: assertion.clientDataJson.trim(), + authenticatorData: assertion.authenticatorData.trim(), + signature: assertion.signature.trim(), + }, + }; + const headers: Record = {}; + if (requestId) headers["Request-Id"] = requestId; + const { data } = await apiPost( + auth, + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + body, + headers, + ); + reporter.log({ level: "response", label: "PASSKEY Verify", detail: data }); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + // MIGRATION (P6): PASSKEY login moves to STAMP_LOGIN; the knob-ON response + // drops `encryptedSessionSigningKey`, so this becomes the OTP-style + // `setSessionKeysFromTek(clientKeyPair)` path. The shape-detection in + // `rememberEncryptedSessionSigningKey` already no-ops when the field is + // absent — flip this one call once the P2 wire shape settles. + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return data; +} + +export interface PasskeyLoginParams { + credId: string; + mode: Mode; + /** + * Raw WebAuthn credential id(s) for the assertion's allowCredentials + * (production). Pass every registered passkey's raw id so the security key + * can satisfy the assertion; empty/omitted falls back to a discoverable + * credential on the key. + */ + credentialIds?: string[]; + rpId?: string; + /** Sandbox-seeded assertion fields, used when not running a real ceremony. */ + sandboxAssertion?: PasskeyAssertion; +} + +export interface PasskeyLoginResult { + data: unknown; + clientPublicKey: string; + assertion: PasskeyAssertion; +} + +// Drive a real WebAuthn assertion against the issued challenge, targeting the +// security key via the supplied raw credential id(s). +export async function signRealPasskey( + reporter: Reporter, + challenge: string, + credentialIds: string[], + rpId?: string, +): Promise { + const assertion = await signWithPasskey(challenge, credentialIds, rpId); + reporter.log({ + level: "info", + label: "Passkey Signed (real)", + detail: assertion, + }); + return assertion; +} + +// Guided log in: gen client key → /challenge → assertion (a real security-key +// ceremony in production, the seeded sandbox assertion otherwise) → /verify. +export async function loginPasskey( + reporter: Reporter, + auth: ApiAuth, + params: PasskeyLoginParams, +): Promise { + const kp = generateClientKeyPair(); + const { requestId, challenge } = await requestPasskeyChallenge( + reporter, + auth, + params.credId, + kp.publicKeyUncompressed, + ); + + let assertion: PasskeyAssertion; + if (params.mode === "production") { + const real = await signRealPasskey( + reporter, + challenge, + params.credentialIds ?? [], + params.rpId, + ); + assertion = { + credentialId: real.credentialId, + clientDataJson: real.clientDataJson, + authenticatorData: real.authenticatorData, + signature: real.signature, + }; + } else { + if (!params.sandboxAssertion) + throw new Error("Sandbox assertion fields are required."); + assertion = params.sandboxAssertion; + } + + const data = await runPasskeyVerify( + reporter, + auth, + params.credId, + kp.publicKeyUncompressed, + assertion, + requestId, + ); + return { data, clientPublicKey: kp.publicKeyUncompressed, assertion }; +} + +// ----- Sign-in entry point (create-vs-authenticate) ----- +// +// Mirror of EMAIL_OTP's signIn: only register (create) a PASSKEY credential when +// the wallet doesn't already have one. When `existingCredId` is provided we run +// the challenge → assertion → verify ceremony directly against it (no create); +// otherwise we register a new passkey first, then verify it into a session. +// +// `register` produces the attestation for the create leg (a real Touch ID +// ceremony in production, the seeded magic attestation in sandbox). It is only +// invoked when there is no existing credential, so callers don't pay for a +// registration prompt on an authenticate-with-existing sign-in. +export interface PasskeySignInParams { + accountId: string; + nickname: string; + existingCredId: string | null; + /** Login params reused for the verify ceremony (mode, rpId, sandbox seed, and + * the raw `credentialIds` for the assertion). `credId` is filled in by + * signInPasskey once the credential to use is known. */ + loginParams: Omit; + /** Produce the attestation for the create leg. Only called when registering a + * new credential (no existing one). */ + register: () => Promise; +} + +export async function signInPasskey( + reporter: Reporter, + auth: ApiAuth, + params: PasskeySignInParams, +): Promise { + let credId = params.existingCredId; + // When we register a fresh passkey, its raw WebAuthn credential id is only + // known here — feed it into the assertion's allowCredentials so the verify + // leg targets the security key we just created on. + let freshRawId: string | undefined; + if (!credId) { + const attestation = await params.register(); + freshRawId = attestation.credentialId; + const cred = await createPasskeyCredential( + reporter, + auth, + params.accountId, + params.nickname, + attestation, + ); + credId = (cred as { id?: string }).id ?? null; + if (!credId) throw new Error("Create credential returned no id."); + } + const credentialIds = [ + ...(params.loginParams.credentialIds ?? []), + ...(freshRawId ? [freshRawId] : []), + ]; + const { data } = await loginPasskey(reporter, auth, { + ...params.loginParams, + credentialIds, + credId, + }); + return data; +} + +// ----- Add an additional passkey (issue → signed retry) ----- + +export interface PasskeyAddIssueResult { + data: unknown; + requestId: string | undefined; + payloadToSign: string | undefined; +} + +export async function addPasskeyIssue( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + nickname: string, + attestation: PasskeyAttestation, +): Promise { + const body = buildCredentialBody(accountId, nickname, attestation); + const { data } = await apiPost(auth, "/auth/credentials", body); + reporter.log({ + level: "response", + label: "PASSKEY Add (issue)", + detail: data, + }); + const d = data as Record; + const requestId = typeof d.requestId === "string" ? d.requestId : undefined; + const payloadToSign = + typeof d.payloadToSign === "string" ? d.payloadToSign : undefined; + return { data, requestId, payloadToSign }; +} + +export async function addPasskeyRetry( + reporter: Reporter, + auth: ApiAuth, + accountId: string, + nickname: string, + attestation: PasskeyAttestation, + requestId: string, + payloadToSign: string | undefined, +): Promise { + if (!requestId.trim()) + throw new Error("Request-Id is required — run the issue step first."); + // Sandbox accepts the magic value, but real Turnkey requires the + // CREATE_AUTHENTICATORS payload to be stamped by an authorized credential — + // the active session's signing key. Establish a session (e.g. OTP login or + // passkey verify) first so the session signing key is available. + let signature = SANDBOX_SIG; + if (auth.mode === "production") { + if (!payloadToSign) + throw new Error("Missing payloadToSign — run the issue step first."); + signature = await turnkeyStamp(payloadToSign); + } + const { data } = await apiPost( + auth, + "/auth/credentials", + buildCredentialBody(accountId, nickname, attestation), + { "Grid-Wallet-Signature": signature, "Request-Id": requestId.trim() }, + ); + reporter.log({ + level: "response", + label: "PASSKEY Add (retry)", + detail: data, + }); + // Map the added Grid credential id → its raw WebAuthn credential id so a later + // sign-in can target this additional security key via allowCredentials. + const addedId = (data as Record)?.id; + if (typeof addedId === "string" && addedId) + rememberRawCredentialId(addedId, attestation.credentialId); + return data; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/flows/transactions.ts b/apps/examples/grid-global-accounts-example-app/src/flows/transactions.ts new file mode 100644 index 000000000..fa2ba8aef --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/flows/transactions.ts @@ -0,0 +1,99 @@ +// List a customer's real, server-persisted transactions (onramps / offramps / +// payments) from the Grid API. +// +// DOM-free: takes the platform `auth`, the query params, and a `Reporter` to +// emit a response log event through, then returns a normalized page. The React +// layer owns paging state + rendering. + +import { apiGet, type ApiAuth } from "../api-client"; +import type { Reporter } from "../lib/reporter"; + +/** A money amount: `amount` in minor units + a `currency` block. */ +export interface CurrencyAmount { + amount?: number; + /** `{ code, name, symbol, decimals }` — drives money formatting. */ + currency?: unknown; +} + +/** + * A permissive transaction shape covering the fields the UI renders. The Grid + * response is a OneOf of incoming/outgoing transactions; rather than model both + * exactly, we keep every field optional and read what we need defensively. + * Outgoing carries `sentAmount`, incoming carries `receivedAmount`. + */ +export interface Transaction { + id?: string; + /** "INCOMING" | "OUTGOING". */ + type?: string; + status?: string; + /** Outgoing amount (sender's currency). */ + sentAmount?: CurrencyAmount; + /** Incoming amount (recipient's currency). */ + receivedAmount?: CurrencyAmount; + /** Generic fallback amount, if a row ever uses a plain `amount` block. */ + amount?: CurrencyAmount; + /** OneOf: `{ sourceType, accountId | umaAddress, ... }`. */ + source?: unknown; + /** OneOf: `{ destinationType, accountId | umaAddress, ... }`. */ + destination?: unknown; + createdAt?: string; + description?: string; + [key: string]: unknown; +} + +/** One normalized page of transactions. */ +export interface TransactionPage { + data: Transaction[]; + hasMore: boolean; + nextCursor: string | null; + totalCount: number; +} + +/** Direction filter; "ALL" omits the `type` query param entirely. */ +export type TransactionTypeFilter = "ALL" | "INCOMING" | "OUTGOING"; + +const DEFAULT_LIMIT = 20; + +export interface ListTransactionsParams { + customerId: string; + limit?: number; + cursor?: string | null; + type?: TransactionTypeFilter; +} + +/** + * GET `/transactions` scoped to a customer, newest first. Always sends + * `customerId`, `limit` (default 20), and `sortOrder=desc`; adds `cursor` when + * paging and `type` only when filtering to a single direction. Normalizes the + * `{ data, hasMore, nextCursor, totalCount }` envelope (camelCase, verified + * against the generated `TransactionListResponse`), coercing a missing `data` + * to `[]`, `hasMore` to false, and `nextCursor` to null. + */ +export async function listTransactions( + reporter: Reporter, + auth: ApiAuth, + params: ListTransactionsParams, +): Promise { + const limit = params.limit ?? DEFAULT_LIMIT; + const query = new URLSearchParams({ + customerId: params.customerId, + limit: String(limit), + sortOrder: "desc", + }); + if (params.cursor) query.set("cursor", params.cursor); + if (params.type && params.type !== "ALL") query.set("type", params.type); + + const detail = await apiGet(auth, `/transactions?${query.toString()}`); + reporter.log({ level: "response", label: "List Transactions", detail }); + + const env = (detail && typeof detail === "object" ? detail : {}) as Record< + string, + unknown + >; + return { + data: Array.isArray(env.data) ? (env.data as Transaction[]) : [], + hasMore: env.hasMore === true, + nextCursor: typeof env.nextCursor === "string" ? env.nextCursor : null, + totalCount: typeof env.totalCount === "number" ? env.totalCount : 0, + }; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/format-money.test.ts b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/format-money.test.ts new file mode 100644 index 000000000..a5ae4ce8d --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/format-money.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { currencyCode, formatMoney } from "../format-money"; + +describe("formatMoney", () => { + it("formats USD minor units (2 decimals) with the code", () => { + expect(formatMoney(123456, { code: "USD", decimals: 2 })).toBe( + "1,234.56 USD", + ); + }); + + it("honors a non-cent decimals count (USDB at 6)", () => { + expect(formatMoney(5_000_000, { code: "USDB", decimals: 6 })).toBe( + "5.000000 USDB", + ); + }); + + it("renders 3 USDB (3,000,000 minor, 6 decimals) as 3, not 30,000", () => { + const out = formatMoney(3_000_000, { code: "USDB", decimals: 6 }); + expect(out).toBe("3.000000 USDB"); + expect(out).not.toContain("30,000"); + }); + + it("falls back to 2 decimals when the currency omits decimals", () => { + expect(formatMoney(100, { code: "USD" })).toBe("1.00 USD"); + }); + + it("accepts a bare currency-code string", () => { + expect(formatMoney(100, "USD")).toBe("1.00 USD"); + }); + + it("omits the code when none is present", () => { + expect(formatMoney(100, {})).toBe("1.00"); + }); +}); + +describe("currencyCode", () => { + it("reads code from a Currency object", () => { + expect(currencyCode({ code: "USDB", decimals: 6 })).toBe("USDB"); + }); + + it("accepts a bare string and tolerates missing data", () => { + expect(currencyCode("BTC")).toBe("BTC"); + expect(currencyCode({})).toBe(""); + expect(currencyCode(null)).toBe(""); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/reporter.test.ts b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/reporter.test.ts new file mode 100644 index 000000000..e8f3fdd59 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/lib/__tests__/reporter.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { createCollectingReporter } from "../collecting-reporter"; +import type { LogEntry } from "../reporter"; + +describe("createCollectingReporter", () => { + it("records log entries in the order they were reported", () => { + const collector = createCollectingReporter(); + + collector.reporter.log({ level: "request", label: "POST /customers" }); + collector.reporter.log({ level: "response", label: "201 Created" }); + collector.reporter.log({ level: "info", label: "done" }); + + expect(collector.entries.map((e) => e.label)).toEqual([ + "POST /customers", + "201 Created", + "done", + ]); + expect(collector.entries.map((e) => e.level)).toEqual([ + "request", + "response", + "info", + ]); + }); + + it("assigns a unique id and a numeric timestamp to each entry", () => { + const collector = createCollectingReporter(); + const before = Date.now(); + + collector.reporter.log({ level: "info", label: "a" }); + collector.reporter.log({ level: "info", label: "b" }); + + const after = Date.now(); + const [first, second] = collector.entries; + + expect(typeof first.id).toBe("string"); + expect(first.id).not.toEqual(""); + expect(first.id).not.toEqual(second.id); + + expect(typeof first.ts).toBe("number"); + expect(first.ts).toBeGreaterThanOrEqual(before); + expect(first.ts).toBeLessThanOrEqual(after); + }); + + it("preserves the level, label, and raw detail payload", () => { + const collector = createCollectingReporter(); + const detail = { id: "wallet-123", nested: { code: 202 } }; + + collector.reporter.log({ level: "response", label: "verify", detail }); + + const entry: LogEntry = collector.entries[0]; + expect(entry.level).toBe("response"); + expect(entry.label).toBe("verify"); + expect(entry.detail).toEqual(detail); + }); + + it("surfaces the latest status message and kind", () => { + const collector = createCollectingReporter(); + + expect(collector.lastStatus).toBeNull(); + + collector.reporter.status("Creating customer..."); + expect(collector.lastStatus).toEqual({ + message: "Creating customer...", + kind: "info", + }); + + collector.reporter.status("Failed", "error"); + expect(collector.lastStatus).toEqual({ message: "Failed", kind: "error" }); + }); + + it("defaults the status kind to info when omitted", () => { + const collector = createCollectingReporter(); + + collector.reporter.status("hello"); + + expect(collector.lastStatus?.kind).toBe("info"); + }); +}); diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/collecting-reporter.ts b/apps/examples/grid-global-accounts-example-app/src/lib/collecting-reporter.ts new file mode 100644 index 000000000..484fee6f8 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/lib/collecting-reporter.ts @@ -0,0 +1,41 @@ +// A `Reporter` that simply collects what it's told, for tests and any non-React +// consumer. The React store has its own state-backed reporter; this one keeps +// the recorded entries + latest status in plain arrays/fields you can assert on. + +import type { LogEntry, Reporter } from "./reporter"; + +export type StatusKind = "info" | "error" | "success"; + +export interface ReportedStatus { + message: string; + kind: StatusKind; +} + +export interface CollectingReporter { + reporter: Reporter; + entries: LogEntry[]; + lastStatus: ReportedStatus | null; +} + +let counter = 0; + +function nextId(): string { + counter += 1; + return `log-${Date.now().toString(36)}-${counter}`; +} + +export function createCollectingReporter(): CollectingReporter { + const collector: CollectingReporter = { + entries: [], + lastStatus: null, + reporter: { + log(entry) { + collector.entries.push({ id: nextId(), ts: Date.now(), ...entry }); + }, + status(message, kind = "info") { + collector.lastStatus = { message, kind }; + }, + }, + }; + return collector; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/format-money.ts b/apps/examples/grid-global-accounts-example-app/src/lib/format-money.ts new file mode 100644 index 000000000..af426a871 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/lib/format-money.ts @@ -0,0 +1,49 @@ +// Money formatting shared by the platform customers table and the customer +// wallet view, so a balance reads identically on both sides. +// +// Grid balances are a `CurrencyAmount`: `amount` in MINOR units plus a +// `currency` block (`{ code, name, symbol, decimals }`, any field optional). +// The number of minor-unit decimals comes from `currency.decimals` when the +// API provides it (USD/USDB = 2, BTC = 8, …); we fall back to 2 only when it's +// absent rather than assuming every currency is cents. + +const DEFAULT_DECIMALS = 2; + +/** Pull a three-letter (or ticker) code out of a Currency block. */ +export function currencyCode(currency: unknown): string { + if (currency && typeof currency === "object") { + const c = currency as Record; + if (typeof c.code === "string" && c.code) return c.code; + } + if (typeof currency === "string") return currency; + return ""; +} + +/** + * Minor-unit decimal count from a Currency block (USD/USDB = 2, BTC = 8, …), + * defaulting to 2 when absent. Shared so amount→minor conversions in the money + * flows match how balances are formatted. + */ +export function currencyDecimals(currency: unknown): number { + if (currency && typeof currency === "object") { + const c = currency as Record; + if (typeof c.decimals === "number" && c.decimals >= 0) return c.decimals; + } + return DEFAULT_DECIMALS; +} + +/** + * Format a minor-unit amount + Currency block as a major-unit string with the + * code appended, e.g. `1,234.56 USD`. The fraction-digit count follows + * `currency.decimals` so non-cent currencies (e.g. BTC at 8) render correctly. + */ +export function formatMoney(amount: number, currency: unknown): string { + const code = currencyCode(currency); + const decimals = currencyDecimals(currency); + const major = amount / 10 ** decimals; + const formatted = major.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + return code ? `${formatted} ${code}` : formatted; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/lib/reporter.ts b/apps/examples/grid-global-accounts-example-app/src/lib/reporter.ts new file mode 100644 index 000000000..132eb7d9c --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/lib/reporter.ts @@ -0,0 +1,19 @@ +// The output sink the integration logic talks to instead of the DOM. +// +// Every lib/flow module that used to call `ui.addLog(...)` / `ui.showStatus(...)` +// now takes a `Reporter` and emits structured `LogEntry`s + status messages +// through it. The renderer (React store, a collecting test double, etc.) owns +// what to do with them — keeping the integration logic DOM-free and reusable. + +export type LogEntry = { + id: string; + ts: number; + level: "info" | "error" | "request" | "response"; + label: string; + detail?: unknown; // raw payload / IDs / JSON, shown only in debug mode +}; + +export interface Reporter { + log(entry: Omit): void; + status(message: string, kind?: "info" | "error" | "success"): void; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/main.tsx b/apps/examples/grid-global-accounts-example-app/src/main.tsx new file mode 100644 index 000000000..e0c91be04 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import "@lightsparkdev/origin/styles.css"; +// App-level dark-mode contrast overrides. MUST come after Origin's styles so +// our token re-definitions win by source order. See theme-overrides.css. +import "./theme-overrides.css"; + +import { App } from "./App"; + +const container = document.getElementById("root"); +if (!container) throw new Error("#root not found"); +createRoot(container).render( + + + , +); diff --git a/apps/examples/grid-global-accounts-example-app/src/mode.ts b/apps/examples/grid-global-accounts-example-app/src/mode.ts new file mode 100644 index 000000000..a18abce10 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/mode.ts @@ -0,0 +1,40 @@ +// Sandbox / production mode: the chosen mode is persisted to localStorage and +// drives magic-value seeding + field visibility. +// +// - production: every magic-value field is hidden (nothing fake on screen); +// real-ceremony (Touch ID) buttons are shown. Values come from real +// ceremonies or guided flows. +// - sandbox: magic-value fields are shown, seeded from `SANDBOX_MAGIC`; +// real-ceremony buttons are hidden. +// +// DOM-free: this module owns the *mode value* (persistence + magic-value +// lookups) only. The React layer decides what to show/seed based on the mode it +// reads here — no input elements are touched. + +import { MODE_STORAGE_KEY, SANDBOX_MAGIC, type Mode } from "./config"; + +export function readPersistedMode(): Mode { + try { + return localStorage.getItem(MODE_STORAGE_KEY) === "production" + ? "production" + : "sandbox"; + } catch { + return "sandbox"; + } +} + +export function persistMode(mode: Mode): void { + try { + localStorage.setItem(MODE_STORAGE_KEY, mode); + } catch { + // localStorage unavailable (private mode etc.) — non-fatal, mode just + // won't survive a reload. + } +} + +// The sandbox magic value seeded into a given field, if any. In sandbox mode +// the React layer pre-fills empty magic fields with this; in production these +// fields are hidden so nothing fake is ever submitted. +export function sandboxMagicFor(fieldId: string): string | undefined { + return SANDBOX_MAGIC[fieldId]; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/passkey-store.ts b/apps/examples/grid-global-accounts-example-app/src/passkey-store.ts new file mode 100644 index 000000000..3465c6a3f --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/passkey-store.ts @@ -0,0 +1,75 @@ +// Local map of Grid credential id → raw WebAuthn credential id (base64url). +// +// WebAuthn assertions target a specific security key via `allowCredentials`, +// which needs the RAW credential id the authenticator returned at +// registration. The Grid credential id (from POST /auth/credentials) is a +// DIFFERENT, server-side id and can't be used there. The raw id is only +// available at the moment of `navigator.credentials.create()`, so we persist it +// here keyed by the Grid id when a passkey is registered, and look it up at +// sign-in to drive the assertion against the YubiKey. +// +// LIMITATION: a passkey registered before this store existed (or on another +// device / browser) has no entry — `getRawCredentialId` returns null and the +// assertion falls back to an empty allowCredentials, letting the security key +// present a discoverable credential instead. + +const STORAGE_KEY = "grid-example-passkey-raw-ids"; + +type IdMap = Record; + +function read(): IdMap { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" ? (parsed as IdMap) : {}; + } catch { + return {}; + } +} + +function write(map: IdMap): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + // localStorage unavailable (private mode etc.) — non-fatal; the assertion + // just falls back to a discoverable credential. + } +} + +/** Remember the raw WebAuthn credential id for a freshly-registered passkey. */ +export function rememberRawCredentialId( + gridCredentialId: string, + rawCredentialId: string, +): void { + const grid = gridCredentialId?.trim(); + const raw = rawCredentialId?.trim(); + if (!grid || !raw) return; + const map = read(); + map[grid] = raw; + write(map); +} + +/** The raw WebAuthn credential id for a Grid credential id, or null if unknown. */ +export function getRawCredentialId(gridCredentialId: string): string | null { + const grid = gridCredentialId?.trim(); + if (!grid) return null; + return read()[grid] ?? null; +} + +/** Every raw WebAuthn credential id we've stored (for the given Grid ids, or + * all of them). Used to populate `allowCredentials` with all the wallet's + * passkeys so any registered security key can satisfy the assertion. */ +export function getRawCredentialIds(gridCredentialIds?: string[]): string[] { + const map = read(); + const grids = + gridCredentialIds && gridCredentialIds.length > 0 + ? gridCredentialIds + : Object.keys(map); + const out: string[] = []; + for (const grid of grids) { + const raw = map[grid?.trim()]; + if (raw && !out.includes(raw)) out.push(raw); + } + return out; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/session.ts b/apps/examples/grid-global-accounts-example-app/src/session.ts new file mode 100644 index 000000000..0f6696049 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/session.ts @@ -0,0 +1,243 @@ +// Session state — the ONE place that holds the client keypair / encrypted +// session-signing-key bundle / TEK, plus the account / credential / session +// ids. DOM-free: it holds this state in module variables and notifies +// subscribers on change (the React layer reads it via the getters and renders +// the session chip; gated flows re-evaluate their disabled state). The two +// session models still funnel through here: +// - "Verify-bundle": a client keypair + an `encryptedSessionSigningKey` +// bundle Grid returns on passkey/oauth Verify, HPKE-decrypted on demand. +// - "OTP-TEK": the TEK private key *is* the session key (OTP login, and — +// post-migration — passkey/oauth too); cached directly, no bundle. +// +// Sessions are PER-CUSTOMER, keyed by the customer's wallet account id (the +// natural session domain). A `Map` holds one context +// per customer and `activeKey` points at the active customer's context, so +// acting as customer A then B never lets B inherit A's keys. All exported +// getters/setters operate on the *active* context; `setActiveSessionAccount` +// switches which context is active (creating an empty one for a new key, or +// `null` for logged-out / no active customer). + +import { decryptCredentialBundle, getPublicKey } from "@turnkey/crypto"; + +export interface ClientKeyPair { + privateKey: string; // hex + publicKey: string; // hex, compressed + publicKeyUncompressed: string; // hex, 130 chars (0x04 prefix) +} + +export interface SessionKeys { + apiPublicKey: string; // hex, compressed P-256 + apiPrivateKey: string; // hex +} + +// Which model established the current signing key, for the chip badge. +export type SessionModel = "none" | "otp-tek" | "verify-bundle"; + +// All the per-customer session material, grouped so each account key owns an +// independent copy. +interface SessionContext { + clientKeyPair: ClientKeyPair | null; + lastEncryptedSessionSigningKey: string | null; + cachedSessionKeys: SessionKeys | null; + model: SessionModel; + accountId: string; + credentialId: string; + sessionId: string; +} + +function emptyContext(): SessionContext { + return { + clientKeyPair: null, + lastEncryptedSessionSigningKey: null, + cachedSessionKeys: null, + model: "none", + accountId: "", + credentialId: "", + sessionId: "", + }; +} + +const contexts = new Map(); +let activeKey: string | null = null; + +// The active context, or null when no customer is active (logged-out). Getters +// fall back to an empty context's defaults; mutators no-op when null. +function active(): SessionContext | null { + if (activeKey === null) return null; + return contexts.get(activeKey) ?? null; +} + +// ----- Active-context switching ----- +// +// Point the session at a customer's account. A new key gets a fresh empty +// context (logged-out); `null` means no active customer (logged-out, no +// context). Notifies subscribers since the visible session changes. +export function setActiveSessionAccount(accountId: string | null): void { + if (accountId === null) { + activeKey = null; + notify(); + return; + } + if (!contexts.has(accountId)) contexts.set(accountId, emptyContext()); + activeKey = accountId; + notify(); +} + +// Wipe the active context's signing material (keys / model / ids) so +// `hasSessionSigningKey()` is false and `getSessionModel()` is "none", while +// keeping the account id so the slot still belongs to this customer. Used when +// the session this client holds is revoked: the customer stays selected but is +// logged out locally and can re-authenticate. No-op when logged out. +export function clearActiveSession(): void { + const ctx = active(); + if (!ctx) return; + ctx.clientKeyPair = null; + ctx.lastEncryptedSessionSigningKey = null; + ctx.cachedSessionKeys = null; + ctx.model = "none"; + ctx.credentialId = ""; + ctx.sessionId = ""; + notify(); +} + +// ----- Client keypair (Verify-bundle model) ----- + +export function setClientKeyPair(kp: ClientKeyPair): void { + const ctx = active(); + if (!ctx) return; + ctx.clientKeyPair = kp; + // A fresh client key invalidates any session decrypted under the old one. + ctx.cachedSessionKeys = null; + ctx.lastEncryptedSessionSigningKey = null; + ctx.model = "none"; + notify(); +} + +export function getClientKeyPair(): ClientKeyPair | null { + return active()?.clientKeyPair ?? null; +} + +export function rememberEncryptedSessionSigningKey(value: unknown): void { + // MIGRATION (P6): once the login-family knob is ON, passkey/oauth Verify drop + // `encryptedSessionSigningKey` and behave like OTP (the client key is the + // session key). Shape-detection on field-presence already no-ops here when + // the field is absent, so both knob states work unchanged. + const ctx = active(); + if (!ctx) return; + if (typeof value === "string" && value) { + ctx.lastEncryptedSessionSigningKey = value; + ctx.cachedSessionKeys = null; + ctx.model = "verify-bundle"; + notify(); + } +} + +// ----- OTP-TEK model ----- +// +// There is no encryptedSessionSigningKey bundle — the TEK private key *is* the +// session's API key once login registers it. Cache it directly so +// `turnkeyStamp` can authorize later signed retries without the Verify-style +// clientKeyPair + bundle. +export function setSessionKeysFromTek(tek: { + publicKey: string; + privateKey: string; +}): void { + const ctx = active(); + if (!ctx) return; + ctx.cachedSessionKeys = { + apiPublicKey: tek.publicKey, + apiPrivateKey: tek.privateKey, + }; + ctx.model = "otp-tek"; + notify(); +} + +// Resolve the session signing keys, decrypting the Verify bundle on demand. +// Returns null (rather than throwing) when no session is established yet — the +// caller decides how to surface that. Crypto callers use this; UI gates use +// `hasSessionSigningKey()`. +export function resolveSessionKeys(): SessionKeys | null { + const ctx = active(); + if (!ctx) return null; + if (ctx.cachedSessionKeys) return ctx.cachedSessionKeys; + if (!ctx.clientKeyPair || !ctx.lastEncryptedSessionSigningKey) return null; + const apiPrivateKey = decryptCredentialBundle( + ctx.lastEncryptedSessionSigningKey, + ctx.clientKeyPair.privateKey, + ); + const apiPublicKeyBytes = getPublicKey(apiPrivateKey, /*isCompressed*/ true); + const apiPublicKey = Array.from(apiPublicKeyBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + ctx.cachedSessionKeys = { apiPublicKey, apiPrivateKey }; + return ctx.cachedSessionKeys; +} + +// True once a signing key is available *or* derivable (cached TEK, or a client +// keypair + bundle). This is the gate the UI uses to enable/disable buttons. +export function hasSessionSigningKey(): boolean { + const ctx = active(); + if (!ctx) return false; + if (ctx.cachedSessionKeys) return true; + return Boolean(ctx.clientKeyPair && ctx.lastEncryptedSessionSigningKey); +} + +export function getSessionModel(): SessionModel { + return active()?.model ?? "none"; +} + +// ----- Change subscribers ----- +// +// Buttons / views that need a live session (e.g. "Add passkey" retry in +// production) subscribe here so they can re-evaluate their disabled-with-tooltip +// state whenever the session changes — surfacing the requirement instead of +// throwing on click. Subscribers are notified on any active-context change AND +// on an active-key switch. + +type SessionListener = () => void; +const listeners: SessionListener[] = []; + +export function onSessionChange(listener: SessionListener): void { + listeners.push(listener); + listener(); // run once so the initial state is applied +} + +function notify(): void { + for (const listener of listeners) listener(); +} + +// ----- Cross-flow ids (account / credential / session) ----- +// +// Held per-context; the React layer reads them via the getters to render the +// context chip and re-fires gates through `notify()` when they change. + +export function setAccountId(id: string): void { + const ctx = active(); + if (!ctx) return; + // Match the original behaviour: only adopt the first account id seen, so a + // later flow doesn't clobber the customer's primary account. + if (!ctx.accountId) ctx.accountId = id; + notify(); +} +export function setCredentialId(id: string): void { + const ctx = active(); + if (!ctx) return; + ctx.credentialId = id; + notify(); +} +export function setSessionId(id: string): void { + const ctx = active(); + if (!ctx) return; + ctx.sessionId = id; + notify(); +} + +export function getAccountId(): string { + return (active()?.accountId ?? "").trim(); +} +export function getCredentialId(): string { + return (active()?.credentialId ?? "").trim(); +} +export function getSessionId(): string { + return (active()?.sessionId ?? "").trim(); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/state/store.tsx b/apps/examples/grid-global-accounts-example-app/src/state/store.tsx new file mode 100644 index 000000000..95a10b0d6 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/state/store.tsx @@ -0,0 +1,267 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import type { ApiAuth } from "../api-client"; +import type { LogEntry, Reporter } from "../lib/reporter"; +import { clearActiveSession, setActiveSessionAccount } from "../session"; + +/** Which side of the integration is currently on screen. */ +export type Persona = "platform" | "customer"; + +/** A customer the platform has created and can "act as". */ +export interface ActiveCustomer { + id: string; + name: string; + /** + * Optional display email. Only set when a flow has it (e.g. create); the + * customers table identifies rows by `id`, not email, so act-as omits it. + */ + email?: string; + /** + * The customer's spendable wallet internal account id. The customers table + * carries it straight from the grouped internal-accounts fetch so act-as can + * scope the Customer view without an extra request. May be null if no account + * was provisioned yet. + */ + accountId?: string | null; + /** Coarse lifecycle state surfaced in the customers table. */ + status?: string; + /** Wallet/account state surfaced in the customers table. */ + walletState?: string; +} + +export type StatusKind = "info" | "error" | "success"; + +/** The latest status message surfaced by the reporter. */ +export interface StatusState { + message: string; + kind: StatusKind; +} + +export interface AppState { + persona: Persona; + setPersona: (persona: Persona) => void; + + /** + * The platform API credentials + mode entered in the Platform config panel. + * Null until the operator connects. The decoupled flows take an `ApiAuth` + * argument, so this is the gate that unlocks every platform operation. + */ + platformAuth: ApiAuth | null; + setPlatformAuth: (auth: ApiAuth | null) => void; + + /** + * The platform's funded source internal account LSID (`InternalAccount:`) + * the operator pastes in the config panel. Used as the `source` when funding a + * customer from the platform (quote → execute → poll). Not a secret, but lives + * alongside `platformAuth` as connect config. Empty until set. + */ + platformFundingAccountId: string; + setPlatformFundingAccountId: (id: string) => void; + + /** + * The platform's customers, derived once connected from a single grouped + * `GET /customers/internal-accounts` fetch (one row per customer). `setCustomers` + * replaces the list after a fetch; `addCustomer` optimistically prepends a + * just-created customer ahead of the refetch so it shows immediately. De-duped + * by id. + */ + customers: ActiveCustomer[]; + addCustomer: (customer: ActiveCustomer) => void; + setCustomers: (customers: ActiveCustomer[]) => void; + + activeCustomer: ActiveCustomer | null; + setActiveCustomer: (customer: ActiveCustomer | null) => void; + + /** + * Held session material for the *active customer* once logged in. Per-customer: + * cached by the customer's account id, so switching away and back restores the + * cached session without re-authenticating, while a fresh customer reads null + * (logged-out). Typed as `unknown` — the concrete session shape is owned by the + * reused `session`/`turnkey` logic, whose crypto context is kept in lockstep via + * `setActiveSessionAccount` on every customer switch. + */ + session: unknown | null; + setSession: (session: unknown | null) => void; + /** + * Sign the active customer out locally: wipe their crypto signing context + * (`clearActiveSession`) and clear their session slot, so `CustomerView` + * falls back to `` and they can re-authenticate. No-op when no + * customer is active. + */ + signOut: () => void; + + debugOn: boolean; + toggleDebug: () => void; + + /** Structured log the debug drawer renders (newest entries appended last). */ + log: LogEntry[]; + /** Latest status message, or null before anything has been reported. */ + status: StatusState | null; + /** Clear the current app-wide status (e.g. when the user dismisses it). */ + clearStatus: () => void; + /** + * The sink the reused integration logic (`lib/*`, `flows/*`) emits through. + * Appends to `log` and records the latest `status` as React state. + */ + reporter: Reporter; +} + +const AppStateContext = createContext(null); + +let logCounter = 0; +function nextLogId(): string { + logCounter += 1; + return `log-${Date.now().toString(36)}-${logCounter}`; +} + +export function AppStateProvider({ children }: { children: ReactNode }) { + const [persona, setPersona] = useState("platform"); + const [platformAuth, setPlatformAuth] = useState(null); + const [platformFundingAccountId, setPlatformFundingAccountId] = + useState(""); + const [customers, setCustomersState] = useState([]); + const [activeCustomer, setActiveCustomerState] = + useState(null); + // Per-customer session cache, keyed by wallet account id (the session domain). + // A customer with no accountId has no session slot. `setActiveCustomer` syncs + // the crypto context (`setActiveSessionAccount`) and surfaces this slot. + const [sessions, setSessions] = useState>({}); + const activeKey = activeCustomer?.accountId ?? null; + const session = activeKey ? sessions[activeKey] ?? null : null; + const [debugOn, setDebugOn] = useState(false); + const [log, setLog] = useState([]); + const [status, setStatus] = useState(null); + + // De-dupe by id and keep newest first so a re-created customer doesn't double + // up in the table. Stable identity so effects that depend on it don't re-fire. + const addCustomer = useCallback( + (customer: ActiveCustomer) => + setCustomersState((prev) => [ + customer, + ...prev.filter((c) => c.id !== customer.id), + ]), + [], + ); + + // De-dupe on replace too, so an optimistic add already present in the fetched + // page doesn't double up. Stable identity (only closes over a setter). + const setCustomers = useCallback((next: ActiveCustomer[]) => { + const seen = new Set(); + setCustomersState( + next.filter((c) => (seen.has(c.id) ? false : (seen.add(c.id), true))), + ); + }, []); + + // Switching the active customer must move the crypto session context with it: + // point `session.ts` at this customer's account (or null → logged-out) so + // signing material follows the customer, never leaks across them. The cached + // session slot is then surfaced by the derived `session` above. Stable + // identity (only closes over a setter) — it's used in effects. + const setActiveCustomer = useCallback((customer: ActiveCustomer | null) => { + setActiveSessionAccount(customer?.accountId ?? null); + setActiveCustomerState(customer); + }, []); + + // Write the active customer's session slot. Keyed by account id so each + // customer's session is independent and survives switching away and back. + // No active account id → nothing to key on, so this is a no-op. + const setSession = useCallback( + (next: unknown | null) => { + if (!activeKey) return; + setSessions((prev) => ({ ...prev, [activeKey]: next })); + }, + [activeKey], + ); + + // Local sign-out: wipe the active customer's signing material AND clear their + // session slot so the view returns to Login. Used by the WalletHome "Sign out" + // button and by Settings when the session this client uses is revoked. + const signOut = useCallback(() => { + if (!activeKey) return; + clearActiveSession(); + setSessions((prev) => ({ ...prev, [activeKey]: null })); + }, [activeKey]); + + // The reporter identity is stable across renders so it can be threaded into + // long-lived flow closures without re-subscribing; the setters it closes over + // are stable too (React guarantees `setX` identity). + const reporterRef = useRef({ + log(entry) { + setLog((prev) => [ + ...prev, + { id: nextLogId(), ts: Date.now(), ...entry }, + ]); + }, + status(message, kind = "info") { + setStatus({ message, kind }); + }, + }); + + const toggleDebug = useCallback(() => setDebugOn((prev) => !prev), []); + const clearStatus = useCallback(() => setStatus(null), []); + + const value = useMemo( + () => ({ + persona, + setPersona, + platformAuth, + setPlatformAuth, + platformFundingAccountId, + setPlatformFundingAccountId, + customers, + addCustomer, + setCustomers, + activeCustomer, + setActiveCustomer, + session, + setSession, + signOut, + debugOn, + toggleDebug, + log, + status, + clearStatus, + reporter: reporterRef.current, + }), + [ + persona, + platformAuth, + platformFundingAccountId, + customers, + addCustomer, + setCustomers, + activeCustomer, + setActiveCustomer, + session, + setSession, + signOut, + debugOn, + toggleDebug, + log, + status, + clearStatus, + ], + ); + + return ( + + {children} + + ); +} + +export function useAppState(): AppState { + const ctx = useContext(AppStateContext); + if (!ctx) { + throw new Error("useAppState must be used within an "); + } + return ctx; +} diff --git a/apps/examples/grid-global-accounts-example-app/src/theme-overrides.css b/apps/examples/grid-global-accounts-example-app/src/theme-overrides.css new file mode 100644 index 000000000..ed7544fb0 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/theme-overrides.css @@ -0,0 +1,67 @@ +/* + * App-level dark-mode contrast overrides. + * + * Origin's dark-mode palette sets several "soft surface + soft text" color + * pairs to two near-identical dark tones, which fails WCAG AA (>=4.5:1) for + * normal-weight body text. Most visibly, the critical Alert renders dark-red + * text (--text-critical #ef0d0d) on a dark-red surface (--surface-red #cc0909) + * — a 1.31:1 ratio, i.e. effectively invisible. + * + * We fix this at the APP level only (never in @lightsparkdev/origin, which is + * shared by every other app) by re-defining the offending design tokens, and + * we do it ONLY in dark mode, mirroring the exact selectors Origin uses so we + * don't touch light mode at all: + * - [data-theme="dark"], .dark (explicit dark theme) + * - @media (prefers-color-scheme: dark) :root:not([data-theme="light"]) + * + * This must be imported in src/main.tsx AFTER "@lightsparkdev/origin/styles.css" + * so these declarations win by source order at equal specificity. + * + * Overriding the tokens (rather than the hashed CSS-module classnames) keeps + * the fix robust against Origin's build-time class hashing and automatically + * covers every consumer of the token (critical Alert title/description, the + * red Badge, etc.). + */ + +/* The token values applied in dark mode. Kept in one place and referenced from + * both selector blocks below so the two stay in sync. */ + +/* Explicit dark theme (data-theme="dark" or a .dark container). */ +[data-theme="dark"], +.dark { + /* Critical Alert: dark red-tinted surface + light red text. + * surface #4c0303 (red-950) vs text #ffc4c4 (red-200) = 10.55:1 (AA/AAA). + * Keeps the red-on-dark aesthetic; just makes the text legible. */ + --surface-red: #4c0303; + --text-critical: #ffc4c4; + /* The critical icon shares the surface; lift it to match the text so the + * leading "!" icon is visible too (was #ef0d0d on #4c0303 = 2.6:1). */ + --icon-critical: #ffc4c4; + /* Border on the same dark surface (was #ef0d0d, barely visible). */ + --border-critical: #ff6161; + + /* Badge "red" reads text-red (#ff9a9a) on --surface-red. With the darker + * surface above this is now #ff9a9a on #4c0303 = 7.82:1 (was 2.86:1). */ + + /* Badge "sky" (used live in the Login mode badge): text #00b3e0 on + * surface #067198 was 2.23:1. Darken the soft surface to restore contrast. + * surface #022f3f vs text #00b3e0 = 5.0:1. */ + --surface-sky: #022f3f; + + /* Badge "pink": text #ff99e5 on surface #cf00a7 was 2.6:1. Darken surface. + * surface #3d0031 vs text #ff99e5 = 7.4:1. */ + --surface-pink: #3d0031; +} + +/* System dark mode, when the app hasn't pinned an explicit theme. Mirrors the + * exact selector Origin uses for its system-dark token block. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --surface-red: #4c0303; + --text-critical: #ffc4c4; + --icon-critical: #ffc4c4; + --border-critical: #ff6161; + --surface-sky: #022f3f; + --surface-pink: #3d0031; + } +} diff --git a/apps/examples/grid-global-accounts-example-app/src/turnkey.ts b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts new file mode 100644 index 000000000..ef9cb0645 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/turnkey.ts @@ -0,0 +1,125 @@ +// Turnkey crypto: P-256 keygen, HPKE seal, wallet signature, and the X-Stamp +// builder. Session-key *state* lives in `session.ts`; this module only does the +// crypto and reads/writes that state through it. + +import { + formatHpkeBuf, + generateP256KeyPair, + hpkeEncrypt, +} from "@turnkey/crypto"; +import { signWithApiKey } from "@turnkey/api-key-stamper"; + +import { TURNKEY_STAMP_SCHEME } from "./config"; +import { + type ClientKeyPair, + resolveSessionKeys, + setClientKeyPair, +} from "./session"; + +// Generate the client-side P-256 keypair (Verify-bundle model). The +// uncompressed public key (130 hex chars, 0x04-prefixed) goes to Grid as +// `clientPublicKey` on Verify; the private key stays client-side to +// HPKE-decrypt the `encryptedSessionSigningKey` Grid hands back. Stored in +// `session.ts` so a session decrypted under one keypair stays valid across tabs. +export function generateClientKeyPair(): ClientKeyPair { + const kp = generateP256KeyPair(); + const clientKeyPair: ClientKeyPair = { + privateKey: kp.privateKey, + publicKey: kp.publicKey, + publicKeyUncompressed: kp.publicKeyUncompressed, + }; + setClientKeyPair(clientKeyPair); + return clientKeyPair; +} + +export async function turnkeyStamp(payload: string): Promise { + const keys = resolveSessionKeys(); + if (!keys) + throw new Error( + "No session signing key — log in (Verify) first to establish a session.", + ); + const { apiPublicKey, apiPrivateKey } = keys; + // `signWithApiKey` returns the hex DER signature; the X-Stamp header + // value is base64url(JSON({publicKey, scheme, signature})) with that + // hex signature embedded as-is. Mirrors what `@turnkey/api-key-stamper` + // produces internally; replicated here so we can fill the field on the + // test UI rather than going through the stamper's `stamp(payload)` shape + // (which returns `{stampHeaderName, stampHeaderValue}`). + const signature = await signWithApiKey({ + content: payload, + publicKey: apiPublicKey, + privateKey: apiPrivateKey, + }); + const stamp = { + publicKey: apiPublicKey, + scheme: TURNKEY_STAMP_SCHEME, + signature, + }; + const json = JSON.stringify(stamp); + // base64url(json) — no padding. + return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +// ----- V3 secure OTP client crypto ----- +// +// HPKE-seal {clientPublicKey, otpCodeAttempt} under the enclave's +// `otpEncryptionTargetBundle`. That bundle is a signed enclave envelope — +// {version, data, dataSignature, enclaveQuorumPublic} — where `data` is a +// hex-encoded JSON blob carrying the enclave's uncompressed HPKE target key as +// `targetPublic`. We pull `targetPublic` out, HPKE-encrypt under it, and emit +// Turnkey's `formatHpkeBuf` wire shape {"encappedPublic","ciphertext"} — exactly +// what `@turnkey/crypto`'s `encryptPrivateKeyToBundle` produces for the +// analogous key-import flow. (A production client would also verify +// `dataSignature` against `enclaveQuorumPublic`; skipped here because the bundle +// originates from our own backend in this test app.) +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +export function sealOtpBundle( + targetBundle: string, + clientPublicKeyHex: string, + otpCode: string, +): string { + const parsed = JSON.parse(targetBundle) as { data: string }; + const signedData = JSON.parse( + new TextDecoder().decode(hexToBytes(parsed.data)), + ) as { targetPublic: string }; + const targetKeyBuf = hexToBytes(signedData.targetPublic); // 65-byte uncompressed + const plainTextBuf = new TextEncoder().encode( + // The enclave expects snake_case {otp_code, public_key} — NOT the + // {clientPublicKey, otpCodeAttempt} shown in Turnkey's docs sequence + // diagram. Matches @turnkey/crypto's encryptOtpCodeToBundle. + JSON.stringify({ otp_code: otpCode, public_key: clientPublicKeyHex }), + ); + const encryptedBuf = hpkeEncrypt({ plainTextBuf, targetKeyBuf }); // compressed_enc[33] || ciphertext + return formatHpkeBuf(encryptedBuf); // {"encappedPublic","ciphertext"} +} + +// Build the `Grid-Wallet-Signature` stamp over the verificationToken using a +// specific keypair (the V3 TEK), not the session key — base64url(JSON({ +// publicKey, scheme, signature})), the shape `parse_api_key_stamp` expects. +export async function buildWalletSignature( + publicKeyHex: string, + privateKeyHex: string, + payload: string, +): Promise { + const signature = await signWithApiKey({ + content: payload, + publicKey: publicKeyHex, + privateKey: privateKeyHex, + }); + const stamp = { + publicKey: publicKeyHex, + scheme: TURNKEY_STAMP_SCHEME, + signature, + }; + return btoa(JSON.stringify(stamp)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Activity.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Activity.tsx new file mode 100644 index 000000000..a9e6beb40 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Activity.tsx @@ -0,0 +1,210 @@ +import styled from "@emotion/styled"; +import { Badge, Card } from "@lightsparkdev/origin"; + +import { useAppState } from "../../state/store"; +import type { LogEntry } from "../../lib/reporter"; + +// Labels emitted by the flows that read as customer-facing wallet activity. +// (The full request/response log lives behind the Debug drawer in Task 5; this +// is the curated, human-readable slice.) +const ACTIVITY_LABELS = new Set([ + "Create Quote", + "Execute Quote", + "Create External Account", + "V3 Verify leg 2 (expect 200 session)", + "OAUTH Verify", + "PASSKEY Verify", + "Wallet Export", + "Wallet Export (retry)", + "Delete Credential (retry)", + "Delete Session (retry)", +]); + +// Friendly title + tone per raw flow label. +const TITLES: Record< + string, + { title: string; tone: "blue" | "green" | "gray" | "yellow" } +> = { + "Create Quote": { title: "Quote created", tone: "gray" }, + "Execute Quote": { title: "Payment executed", tone: "green" }, + "Create External Account": { title: "External account added", tone: "blue" }, + "V3 Verify leg 2 (expect 200 session)": { + title: "Signed in (Email OTP)", + tone: "blue", + }, + "OAUTH Verify": { title: "Signed in (OAuth)", tone: "blue" }, + "PASSKEY Verify": { title: "Signed in (Passkey)", tone: "blue" }, + "Wallet Export": { title: "Wallet export started", tone: "yellow" }, + "Wallet Export (retry)": { title: "Wallet exported", tone: "yellow" }, + "Delete Credential (retry)": { title: "Credential removed", tone: "gray" }, + "Delete Session (retry)": { title: "Session revoked", tone: "gray" }, +}; + +/** + * Activity feed — a curated, human-readable slice of the reporter log scoped to + * customer-facing wallet events (funding, payments, sign-ins, exports). Newest + * first. `compact` trims it to the latest few for the wallet overview. + */ +export function Activity({ compact = false }: { compact?: boolean }) { + const { log } = useAppState(); + const events = selectActivity(log); + const shown = compact ? events.slice(0, 4) : events; + + if (shown.length === 0) { + const empty = ( + + No activity yet + + Funding, payments, and account changes appear here. + + + ); + return compact ? ( + empty + ) : ( + + {empty} + + ); + } + + const list = ( + + {shown.map((e) => { + const meta = TITLES[e.label] ?? { + title: e.label, + tone: "gray" as const, + }; + return ( + + + + {meta.title} + + + {e.level} + {formatTime(e.ts)} + + + ); + })} + + ); + + if (compact) return list; + return ( + + + + Activity + + Wallet events from this session, newest first. + + + + {list} + + ); +} + +function selectActivity(log: LogEntry[]): LogEntry[] { + return log + .filter((e) => ACTIVITY_LABELS.has(e.label)) + .slice() + .reverse(); +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +const List = styled.div` + display: flex; + flex-direction: column; +`; + +const Row = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md, 16px); + padding: var(--spacing-sm, 12px) var(--spacing-md, 16px); + border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + + &:first-of-type { + border-top: none; + } +`; + +const RowLeft = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + min-width: 0; +`; + +const Dot = styled.span` + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-tertiary, #8a8a8a); + + &[data-tone="green"] { + background: var(--brand-green, #16a34a); + } + &[data-tone="blue"] { + background: var(--brand-blue, #2563eb); + } + &[data-tone="yellow"] { + background: var(--brand-yellow, #d97706); + } +`; + +const RowTitle = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const RowRight = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); +`; + +const When = styled.span` + font-size: var(--font-size-xs, 11px); + color: var(--text-tertiary, #8a8a8a); + font-variant-numeric: tabular-nums; + white-space: nowrap; +`; + +const Empty = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-2xs, 6px); + text-align: center; + padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px); +`; + +const EmptyTitle = styled.div` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #1a1a1a); +`; + +const EmptyBody = styled.div` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); + max-width: 320px; + line-height: 1.45; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/CustomerView.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/CustomerView.tsx new file mode 100644 index 000000000..0527ee9e9 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/CustomerView.tsx @@ -0,0 +1,86 @@ +import styled from "@emotion/styled"; +import { Alert, Badge, Button } from "@lightsparkdev/origin"; + +import { StatusBanner } from "../../components/StatusBanner"; +import { useAppState } from "../../state/store"; +import { Login } from "./Login"; +import { WalletHome } from "./WalletHome"; + +/** + * Customer view — the consumer-wallet side of a Grid integration, scoped to the + * `activeCustomer` the platform is acting as. Authenticates with `platformAuth` + * and runs the decoupled flow operations; every action is a real ceremony, + * nothing auto-signed. + * + * Routing inside the view is session-gated: no `session` → the login screen; + * a `session` → the wallet home (balance, fund, pay, activity) with Settings. + * If no customer is active (e.g. someone flipped the persona switch directly), + * we prompt them back to the Platform view to pick one. + */ +export function CustomerView() { + const { activeCustomer, session, setPersona } = useAppState(); + + if (!activeCustomer) { + return ( + + + + + ); + } + + return ( + + + + Acting as + + {activeCustomer.name || activeCustomer.email} + {activeCustomer.email && activeCustomer.name && ( + {activeCustomer.email} + )} + + + + + {session ? : } + + ); +} + +const Stack = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; + +const Empty = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-md, 16px); +`; + +const ActingAs = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-xs, 8px); + flex-wrap: wrap; +`; + +const ActingName = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #1a1a1a); +`; + +const ActingEmail = styled.span` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Fund.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Fund.tsx new file mode 100644 index 000000000..4d87373b2 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Fund.tsx @@ -0,0 +1,224 @@ +import styled from "@emotion/styled"; +import { Badge, Button, Card, Field, Input } from "@lightsparkdev/origin"; +import { useState } from "react"; + +import { + createExternalAccount, + createQuote, + executeQuote, + signPayload, + type ExternalAccountParams, +} from "../../flows/money"; +import { currencyCode, currencyDecimals } from "../../lib/format-money"; +import { useAppState } from "../../state/store"; +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import type { AccountBalance } from "./WalletHome"; + +/** + * Fund — money-in. Registers an external US bank source account on the platform, + * then quotes a transfer from that external source into the active customer's + * internal account, signs the embedded-wallet payload, and executes. Sandbox + * pre-fills the magic signature so Execute runs immediately; production stamps + * with the live session key. + */ +export function Fund({ + accounts, + onDone, +}: { + accounts: AccountBalance[]; + onDone: () => void; +}) { + const { activeCustomer, platformAuth, reporter } = useAppState(); + const mode = platformAuth?.mode ?? "sandbox"; + + const destinationAccountId = + activeCustomer?.accountId ?? (accounts[0] ? String(accounts[0].id) : ""); + + // The destination is the customer's own account. Convert the entered major + // amount to minor units using THAT account's currency decimals (USDB = 6), + // not a hardcoded *100, so non-cent currencies fund the right amount. + const destinationAccount = + accounts.find((a) => String(a.id) === destinationAccountId) ?? accounts[0]; + const destinationDecimals = currencyDecimals(destinationAccount?.currency); + + const [accountNumber, setAccountNumber] = useState(""); + const [routingNumber, setRoutingNumber] = useState(""); + const [amount, setAmount] = useState(""); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(null); + + async function submit() { + if (!platformAuth) return; + setBusy(true); + setError(null); + setDone(null); + try { + if (!destinationAccountId) + throw new Error("No destination account for this customer."); + const amountMinor = Math.round( + parseFloat(amount || "0") * 10 ** destinationDecimals, + ); + if (!amountMinor) throw new Error("Enter an amount to fund."); + + // 1) Register the external bank source account. + const params: ExternalAccountParams = { + kind: "bank", + accountNumber, + routingNumber, + beneficiaryName: activeCustomer?.name, + }; + const { externalAccountId } = await createExternalAccount( + reporter, + platformAuth, + params, + ); + if (!externalAccountId) + throw new Error("External account create returned no id."); + + // 2) Quote external -> customer's internal account, 3) sign, 4) execute. + const quote = await createQuote(reporter, platformAuth, { + sourceAccountId: externalAccountId, + destinationAccountId, + // Money-in: the entered amount is the customer's (receiving) amount. + lockedCurrencySide: "RECEIVING", + lockedCurrencyAmount: amountMinor, + mode, + }); + if (!quote.quoteId) throw new Error("Quote returned no id."); + let signature = quote.signature; + if (!signature) { + const signed = await signPayload(mode, quote.payloadToSign ?? ""); + signature = signed.signature; + } + await executeQuote(reporter, platformAuth, quote.quoteId, signature); + setDone(`Funded ${amount} into ${destinationAccountId}.`); + reporter.status("Funding complete.", "success"); + onDone(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Funding failed.", "error"); + } finally { + setBusy(false); + } + } + + const currency = currencyCode(accounts[0]?.currency); + + return ( + + + + + + Money in + + + Fund wallet + + Pull funds from an external account into this wallet. We register + the source, quote the transfer, then sign & execute. + + + + + +
{ + e.preventDefault(); + void submit(); + }} + > + {error && ( + setError(null)} + /> + )} + {done && ( + setDone(null)} + /> + )} + + + + Account number + setAccountNumber(e.target.value)} + /> + + + Routing number + setRoutingNumber(e.target.value)} + /> + + + + + Amount{currency ? ` (${currency})` : ""} + setAmount(e.target.value)} + /> + + Credited to {destinationAccountId || "—"}. + + + + + +
+
+ ); +} + +const EyebrowRow = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); + margin-bottom: var(--spacing-2xs, 6px); +`; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); +`; + +const Row2 = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-md, 16px); + + @media (width <= 560px) { + grid-template-columns: 1fr; + } +`; + +const Mono = styled.code` + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 0.92em; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Login.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Login.tsx new file mode 100644 index 000000000..54102f408 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Login.tsx @@ -0,0 +1,967 @@ +import styled from "@emotion/styled"; +import { + Alert, + Badge, + Button, + Card, + Field, + Input, +} from "@lightsparkdev/origin"; +import { useCallback, useEffect, useState } from "react"; + +import { sandboxMagicFor } from "../../mode"; +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import { useAppState } from "../../state/store"; +import { addEmailOtpIssue, addEmailOtpRetry } from "../../flows/email-otp"; +import { addOauthIssue, addOauthRetry, signInOauth } from "../../flows/oauth"; +import { + addPasskeyIssue, + addPasskeyRetry, + registerRealPasskey, + signInPasskey, + type PasskeyAttestation, +} from "../../flows/passkey"; +import { listCredentials } from "../../flows/manage"; +import { getRawCredentialId, getRawCredentialIds } from "../../passkey-store"; +import { sendOtpChallenge, verifyOtpStep } from "../../flows/otp-step"; +import { + hasEmailOtpCredential, + methodForCredential, + parseCredentials, + type ExistingCredential, + type Method, +} from "../../flows/login-decision"; + +const METHOD_LABEL: Record = { + email_otp: "Email OTP", + oauth: "OAuth", + passkey: "Security key", +}; + +const METHOD_BADGE: Record = { + email_otp: "blue", + oauth: "purple", + passkey: "green", +}; + +/** What the login view knows about an account's existing credentials. */ +type CredentialsState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; credentials: ExistingCredential[] }; + +/** + * Customer login screen (logged-out state). It is organized around the wallet's + * EXISTING credentials rather than method tabs: + * + * 1. "Your sign-in methods" lists EVERY credential the wallet has (a wallet can + * hold multiple passkeys / oauth identities — we render the full list, never + * collapsing to one-per-type). Each row authenticates against THAT row's + * credential id. + * 2. "Add a sign-in method" runs the create + verify ceremony for a brand-new + * method. Passkey/OAuth are always addable (multiple allowed); Email OTP is + * offered only when the wallet has none yet (single). + * + * The production EMAIL_OTP bug is fixed by the per-row sign-in being a TWO-STEP + * action: clicking "Sign in" on an Email-OTP row fires `requestV3Challenge` ONCE + * (the only thing that sends the OTP email), then reveals a code input. Verify + * runs `runV3Verify` against the bundle the challenge produced — it never issues + * a challenge, so a failed verify / retry can't re-send (and invalidate) the + * code. The challenge fires only on an explicit Send / Resend click, never on + * render. See `flows/otp-step.ts` for the pure two-step orchestration. + * + * On a verified session it calls `setSession(...)`, flipping `CustomerView` to + * the wallet home. In sandbox the magic values are pre-filled (but never + * auto-submitted); in production the user supplies real material. + */ +export function Login() { + const { activeCustomer, platformAuth, reporter } = useAppState(); + + // Login needs both an account to attach the credential to and platform auth + // to talk to Grid. Both come from the platform side ("Act as" + Connect). + const accountId = activeCustomer?.accountId ?? null; + + const [creds, setCreds] = useState({ status: "loading" }); + + const refreshCredentials = useCallback(async () => { + if (!platformAuth || !accountId) { + setCreds({ status: "ready", credentials: [] }); + return; + } + setCreds({ status: "loading" }); + try { + const raw = await listCredentials(reporter, platformAuth, accountId); + setCreds({ status: "ready", credentials: parseCredentials(raw) }); + } catch (err) { + setCreds({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }, [platformAuth, accountId, reporter]); + + // Single-shot per (account, auth): refreshCredentials' deps are stable, so this + // fires once per account rather than looping. + useEffect(() => { + void refreshCredentials(); + }, [refreshCredentials]); + + if (!platformAuth) { + return ( + + + + + + ); + } + + const credentials = + creds.status === "ready" ? creds.credentials : ([] as ExistingCredential[]); + const canAddEmailOtp = + creds.status === "ready" && !hasEmailOtpCredential(credentials); + const empty = creds.status === "ready" && credentials.length === 0; + + return ( + + + + + + Sign in + + + {platformAuth.mode} + + + Access your wallet + + Sign in with a method already on this wallet, or add a new one. + Every ceremony runs for real — nothing is auto-signed. + + + + + + {!accountId && ( + + + + )} + + {creds.status === "error" && ( + + + + )} + + {/* 1. Existing credentials → per-credential sign-in. */} + {!empty && ( +
+ + Your sign-in methods + + Authenticate with a credential already registered on this + wallet. + + + + {creds.status === "loading" && ( + Loading sign-in methods… + )} + + {creds.status === "ready" && credentials.length > 0 && ( + + {credentials.map((cred, i) => ( + + ))} + + )} +
+ )} + + {/* 4. Empty state: only the add section shows. */} + {empty && ( + + + + )} + + {/* 3. Add a sign-in method. */} + +
+
+ ); +} + +/** Shared "run an action + surface error/busy + set session on success" hook. */ +function useLoginAction() { + const { reporter, platformAuth, setSession, activeCustomer } = useAppState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run(action: () => Promise) { + if (!platformAuth) return; + setBusy(true); + setError(null); + try { + const session = await action(); + setSession(session ?? { loggedIn: true }); + reporter.status( + `Signed in as ${activeCustomer?.name ?? "customer"}.`, + "success", + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Sign in failed.", "error"); + } finally { + setBusy(false); + } + } + + return { busy, error, setError, run, reporter, platformAuth }; +} + +/** One existing-credential row: a labeled header plus the method-specific + * authenticate UI. Renders for EVERY credential the wallet returns. */ +function CredentialRow({ + accountId, + credential, + onChanged, +}: { + accountId: string | null; + credential: ExistingCredential; + onChanged: () => Promise | void; +}) { + const { debugOn } = useAppState(); + const method = methodForCredential(credential); + + return ( + + + + + {method ? METHOD_LABEL[method] : credential.type ?? "Credential"} + + + + {credential.nickname || + (method ? METHOD_LABEL[method] : "Credential")} + + {debugOn && credential.id && ( + {credential.id} + )} + + + {credential.status && ( + + {credential.status} + + )} + + + + {method === "email_otp" && ( + + )} + {method === "oauth" && ( + + )} + {method === "passkey" && ( + + )} + {!method && ( + + Unrecognized credential type — no sign-in handler available. + + )} + + + ); +} + +/** Holds the two-step OTP state for a single credential row. `targetBundle` is + * null until the explicit Send fires the challenge. */ +type OtpUi = + | { phase: "idle" } + | { phase: "awaiting_code"; targetBundle: string }; + +function EmailOtpSignIn({ + credential, + onChanged, +}: { + credential: ExistingCredential; + onChanged: () => Promise | void; +}) { + const { busy, error, setError, run, reporter, platformAuth } = + useLoginAction(); + const sandbox = platformAuth?.mode !== "production"; + const [ui, setUi] = useState({ phase: "idle" }); + const [sending, setSending] = useState(false); + const [otp, setOtp] = useState(""); + + // Step 1 — explicit Send / Resend: this is the ONLY thing that fires the + // challenge (and thus sends the OTP email). It never verifies. + async function send() { + if (!platformAuth) return; + setSending(true); + setError(null); + try { + const next = await sendOtpChallenge( + reporter, + platformAuth, + credential.id, + ); + setUi({ phase: "awaiting_code", targetBundle: next.targetBundle }); + // Sandbox may pre-fill the magic code AFTER the challenge — but never + // auto-submit; the user still clicks Verify. + if (sandbox) + setOtp((prev) => prev || (sandboxMagicFor("email_otp-v3-code") ?? "")); + reporter.status("One-time code sent.", "info"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + reporter.status("Couldn't send the code.", "error"); + } finally { + setSending(false); + } + } + + // Step 2 — verify against the bundle the challenge produced. Never challenges. + async function verify() { + if (ui.phase !== "awaiting_code") return; + await run(async () => { + const session = await verifyOtpStep( + reporter, + platformAuth!, + credential.id, + ui.targetBundle, + otp, + ); + await onChanged(); + return session; + }); + } + + return ( +
{ + e.preventDefault(); + if (ui.phase === "awaiting_code") void verify(); + else void send(); + }} + > + {error && ( + setError(null)} + /> + )} + + We email a one-time code, then verify it inside the enclave — the code + never leaves the device in plaintext. + + + {ui.phase === "idle" && ( + + )} + + {ui.phase === "awaiting_code" && ( + <> + + One-time code + setOtp(e.target.value)} + /> + {sandbox && ( + + Sandbox accepts the magic code 000000. + + )} + + + + + + + )} + + ); +} + +function OauthSignIn({ + accountId, + credential, + onChanged, +}: { + accountId: string | null; + credential: ExistingCredential; + onChanged: () => Promise | void; +}) { + const { busy, error, setError, run, reporter, platformAuth } = + useLoginAction(); + const sandbox = platformAuth?.mode !== "production"; + const [oidc, setOidc] = useState( + sandbox ? sandboxMagicFor("oauth-verify-oidc") ?? "" : "", + ); + + async function submit() { + await run(async () => { + if (!accountId) throw new Error("No wallet account to sign in to."); + // Authenticate against THIS credential id (existingCredId set → no create). + const data = await signInOauth( + reporter, + platformAuth!, + accountId, + oidc, + credential.id, + ); + await onChanged(); + return data; + }); + } + + return ( +
{ + e.preventDefault(); + void submit(); + }} + > + {error && ( + setError(null)} + /> + )} + + Paste the provider's OpenID Connect ID token; Grid verifies it against + this credential and issues a session. + + + OIDC ID token + setOidc(e.target.value)} + /> + {sandbox && ( + + Pre-filled with a sandbox magic token. + + )} + + + + ); +} + +function PasskeySignIn({ + accountId, + credential, + onChanged, +}: { + accountId: string | null; + credential: ExistingCredential; + onChanged: () => Promise | void; +}) { + const { busy, error, setError, run, reporter, platformAuth } = + useLoginAction(); + const mode = platformAuth?.mode ?? "sandbox"; + const sandbox = mode !== "production"; + const [rpId, setRpId] = useState(""); + + // Raw WebAuthn credential id for THIS passkey, if we registered it on this + // browser (the Grid credential id is NOT the WebAuthn id). When unknown we + // fall back to every raw id we have stored, so a wallet with several security + // keys can still match one; if we have none, the assertion uses an empty + // allowCredentials and lets the key present a discoverable credential. + const ownRawId = getRawCredentialId(credential.id); + const knownRawIds = ownRawId ? [ownRawId] : getRawCredentialIds(); + const rawIdKnown = !sandbox && knownRawIds.length > 0; + + async function submit() { + await run(async () => { + if (!accountId) throw new Error("No wallet account to sign in to."); + // Authenticate against THIS existing passkey via the WebAuthn assertion + // (security key in prod, seeded assertion in sandbox). existingCredId set + // → `register` is never invoked. + const data = await signInPasskey(reporter, platformAuth!, { + accountId, + nickname: credential.nickname || "Security key", + existingCredId: credential.id, + register: () => + Promise.reject( + new Error("Passkey already exists — registration not needed."), + ), + loginParams: { + mode, + // Target the security key by its raw WebAuthn id(s) over USB/NFC. + credentialIds: sandbox + ? [sandboxMagicFor("passkey-create-cred-id-raw") ?? ""] + : knownRawIds, + rpId: rpId || undefined, + sandboxAssertion: sandbox + ? { + credentialId: + sandboxMagicFor("passkey-create-cred-id-raw") ?? "", + clientDataJson: + sandboxMagicFor("passkey-verify-client-data-json") ?? "", + authenticatorData: + sandboxMagicFor("passkey-verify-auth-data") ?? "", + signature: sandboxMagicFor("passkey-verify-signature") ?? "", + } + : undefined, + }, + }); + await onChanged(); + return data; + }); + } + + return ( +
{ + e.preventDefault(); + void submit(); + }} + > + {error && ( + setError(null)} + /> + )} + + {sandbox + ? "Sandbox uses a seeded assertion — no authenticator prompt." + : "Insert your security key (YubiKey) and sign the session challenge. We target the key over USB/NFC, not the platform passkey."} + + {!sandbox && !rawIdKnown && ( + + )} + {!sandbox && ( + + Relying-party ID + setRpId(e.target.value)} + /> + + Defaults to the page hostname; must match the sub-org's RP ID. + + + )} + + + ); +} + +/** + * "Add a sign-in method" section. Passkey + OAuth are always available (a wallet + * may hold several). Email OTP is offered only when the wallet has none yet. + * Each add runs the real create + verify ceremony via the existing add flows + * (issue 202 → signed retry); afterwards the credentials list refreshes so the + * new method appears with its own per-credential sign-in (Email OTP still goes + * through the explicit challenge → verify steps once added). + */ +function AddMethods({ + accountId, + canAddEmailOtp, + loading, + onChanged, +}: { + accountId: string | null; + canAddEmailOtp: boolean; + loading: boolean; + onChanged: () => Promise | void; +}) { + const { reporter, platformAuth } = useAppState(); + const mode = platformAuth?.mode ?? "sandbox"; + const sandbox = mode !== "production"; + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [note, setNote] = useState(null); + + async function add(key: string, action: () => Promise, ok: string) { + if (!platformAuth || !accountId) { + setError("No wallet account to add a credential to."); + return; + } + setBusy(key); + setError(null); + setNote(null); + try { + await action(); + setNote(ok); + reporter.status(ok, "success"); + await onChanged(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Couldn't add that method.", "error"); + } finally { + setBusy(null); + } + } + + function sandboxPasskeyAttestation(): PasskeyAttestation { + return { + challenge: sandboxMagicFor("passkey-create-challenge") ?? "", + credentialId: sandboxMagicFor("passkey-create-cred-id-raw") ?? "", + clientDataJson: sandboxMagicFor("passkey-create-client-data-json") ?? "", + attestationObject: + sandboxMagicFor("passkey-create-attestation-object") ?? "", + }; + } + + async function addPasskey() { + await add( + "add-passkey", + async () => { + const nickname = "Security key (YubiKey)"; + const attestation = + mode === "production" + ? await registerRealPasskey(reporter, nickname) + : sandboxPasskeyAttestation(); + const { requestId, payloadToSign } = await addPasskeyIssue( + reporter, + platformAuth!, + accountId!, + nickname, + attestation, + ); + if (!requestId) + throw new Error("Add security key: no requestId in the 202."); + return addPasskeyRetry( + reporter, + platformAuth!, + accountId!, + nickname, + attestation, + requestId, + payloadToSign, + ); + }, + "Security key added.", + ); + } + + async function addOauth() { + await add( + "add-oauth", + async () => { + const oidc = sandboxMagicFor("oauth-add-oidc") ?? ""; + const { requestId } = await addOauthIssue( + reporter, + platformAuth!, + accountId!, + oidc, + ); + if (!requestId) throw new Error("Add OAuth: no requestId in the 202."); + return addOauthRetry( + reporter, + platformAuth!, + accountId!, + oidc, + requestId, + ); + }, + "OAuth credential added.", + ); + } + + async function addEmailOtp() { + await add( + "add-email-otp", + async () => { + const { requestId } = await addEmailOtpIssue( + reporter, + platformAuth!, + accountId!, + ); + if (!requestId) + throw new Error("Add Email OTP: no requestId in the 202."); + return addEmailOtpRetry(reporter, platformAuth!, accountId!, requestId); + }, + "Email OTP method added — sign in with it above.", + ); + } + + const disabled = !accountId || loading; + + return ( +
+ + Add a sign-in method + + Register another way to unlock this wallet. Adding a security key + prompts for a roaming hardware key (YubiKey) over USB/NFC, not the + platform passkey. Security keys and OAuth identities can be added more + than once. + {sandbox && " Sandbox uses seeded ceremony material."} + + + + {error && ( + setError(null)} + /> + )} + {note && ( + setNote(null)} + /> + )} + + + + + {canAddEmailOtp && ( + + )} + +
+ ); +} + +const EyebrowRow = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); + margin-bottom: var(--spacing-2xs, 6px); +`; + +const Note = styled.div` + margin-bottom: var(--spacing-md, 16px); +`; + +const Section = styled.section` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); + + & + & { + margin-top: var(--spacing-xl, 32px); + padding-top: var(--spacing-lg, 24px); + border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + } +`; + +const SectionHead = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-3xs, 4px); +`; + +const SectionTitle = styled.h3` + margin: 0; + font-size: var(--font-size-base, 15px); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #1a1a1a); +`; + +const SectionHint = styled.p` + margin: 0; + font-size: var(--font-size-sm, 13px); + color: var(--text-secondary, #555); + line-height: 1.5; +`; + +const CredList = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); +`; + +const Row = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); + padding: var(--spacing-md, 16px); + border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + border-radius: var(--corner-radius-lg, 12px); + background: var(--surface-base, #fff); +`; + +const RowHead = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm, 12px); +`; + +const RowHeadLeft = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + min-width: 0; +`; + +const RowText = styled.div` + display: flex; + flex-direction: column; + min-width: 0; +`; + +const RowName = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); +`; + +const RowId = styled.span` + font-size: var(--font-size-xs, 11px); + color: var(--text-tertiary, #8a8a8a); + font-variant-numeric: tabular-nums; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RowBody = styled.div` + display: flex; + flex-direction: column; +`; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); +`; + +const Actions = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + flex-wrap: wrap; +`; + +const AddRow = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + flex-wrap: wrap; +`; + +const Lede = styled.p` + margin: 0; + font-size: var(--font-size-sm, 13px); + color: var(--text-secondary, #555); + line-height: 1.5; +`; + +const MutedRow = styled.div` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); +`; + +const Mono = styled.code` + font-family: var(--font-family-mono, ui-monospace, monospace); + background: var(--surface-base, #f5f5f7); + border-radius: var(--corner-radius-sm, 6px); + padding: 1px 5px; + font-size: 0.92em; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Pay.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Pay.tsx new file mode 100644 index 000000000..bcf1df1bf --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Pay.tsx @@ -0,0 +1,534 @@ +import styled from "@emotion/styled"; +import { + Badge, + Button, + Card, + Field, + Input, + Select, +} from "@lightsparkdev/origin"; +import { useCallback, useEffect, useState } from "react"; + +import { + createCustomerExternalAccount, + createQuote, + executeQuote, + listCustomerExternalAccounts, + signPayload, + type CreateQuoteResult, + type CustomerExternalAccount, +} from "../../flows/money"; +import { currencyCode, currencyDecimals } from "../../lib/format-money"; +import { useAppState } from "../../state/store"; +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import type { AccountBalance } from "./WalletHome"; + +interface PendingQuote { + quote: CreateQuoteResult; + amount: string; + destinationLabel: string; +} + +/** + * Pay — money-out (offramp). The destination is a *customer-owned* USD external + * account, so the embedded wallet → external account quote passes ownership + * checks. On entry we list the customer's USD external accounts and let them + * pick one; "Add USD bank account" registers a new one and selects it. Then: + * amount → quote (SENDING-locked) → review → sign the embedded-wallet payload → + * execute. Signature is the magic value in sandbox, a live session stamp in + * production. + */ +export function Pay({ + accounts, + onDone, +}: { + accounts: AccountBalance[]; + onDone: () => void; +}) { + const { activeCustomer, platformAuth, reporter } = useAppState(); + const mode = platformAuth?.mode ?? "sandbox"; + const customerId = activeCustomer?.id ?? ""; + + const sourceAccountId = + activeCustomer?.accountId ?? (accounts[0] ? String(accounts[0].id) : ""); + + // The source is the customer's own wallet account. Convert the entered major + // amount to minor units using THAT account's currency decimals (USDB = 6), + // not a hardcoded *100, so non-cent currencies send the right amount. + const sourceAccount = + accounts.find((a) => String(a.id) === sourceAccountId) ?? accounts[0]; + const sourceDecimals = currencyDecimals(sourceAccount?.currency); + + // Existing customer USD external accounts + the selected destination id. + const [externalAccounts, setExternalAccounts] = useState< + CustomerExternalAccount[] + >([]); + const [selectedId, setSelectedId] = useState(""); + const [listing, setListing] = useState(false); + + // "Add USD bank account" form (secondary path), collapsed by default. + const [adding, setAdding] = useState(false); + const [accountNumber, setAccountNumber] = useState(""); + const [routingNumber, setRoutingNumber] = useState(""); + + const [amount, setAmount] = useState(""); + + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(null); + + // Load the customer's USD external accounts and preselect the first one. + const refreshExternal = useCallback(async () => { + if (!platformAuth || !customerId) return; + setListing(true); + try { + const rows = await listCustomerExternalAccounts( + reporter, + platformAuth, + customerId, + "USD", + ); + setExternalAccounts(rows); + setSelectedId((prev) => + rows.some((r) => r.id === prev) ? prev : rows[0]?.id ?? "", + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setListing(false); + } + }, [platformAuth, customerId, reporter]); + + useEffect(() => { + void refreshExternal(); + }, [refreshExternal]); + + async function addBank() { + if (!platformAuth) return; + setBusy(true); + setError(null); + setDone(null); + try { + const id = await createCustomerExternalAccount(reporter, platformAuth, { + customerId, + accountNumber, + routingNumber, + beneficiaryName: activeCustomer?.name, + }); + await refreshExternal(); + setSelectedId(id); + setAdding(false); + setAccountNumber(""); + setRoutingNumber(""); + reporter.status("Bank account added.", "success"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Couldn't add bank account.", "error"); + } finally { + setBusy(false); + } + } + + async function getQuote() { + if (!platformAuth) return; + setBusy(true); + setError(null); + setDone(null); + try { + if (!sourceAccountId) + throw new Error("No source account for this customer."); + if (!selectedId) + throw new Error("Select a destination bank account first."); + const amountMinor = Math.round( + parseFloat(amount || "0") * 10 ** sourceDecimals, + ); + if (!amountMinor) throw new Error("Enter an amount to send."); + + const quote = await createQuote(reporter, platformAuth, { + sourceAccountId, + // Customer-owned external account — passes the ownership check. + destinationAccountId: selectedId, + // Money-out: the entered amount is what leaves the wallet (sending). + lockedCurrencySide: "SENDING", + lockedCurrencyAmount: amountMinor, + mode, + }); + if (!quote.quoteId) throw new Error("Quote returned no id."); + const destinationLabel = + externalAccounts.find((a) => a.id === selectedId)?.label ?? selectedId; + setPending({ quote, amount, destinationLabel }); + reporter.status("Quote ready — review and confirm.", "info"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Quote failed.", "error"); + } finally { + setBusy(false); + } + } + + async function confirm() { + if (!platformAuth || !pending?.quote.quoteId) return; + setBusy(true); + setError(null); + try { + let signature = pending.quote.signature; + if (!signature) { + const signed = await signPayload( + mode, + pending.quote.payloadToSign ?? "", + ); + signature = signed.signature; + } + await executeQuote( + reporter, + platformAuth, + pending.quote.quoteId, + signature, + ); + setDone(`Sent ${pending.amount} to ${pending.destinationLabel}.`); + setPending(null); + setAmount(""); + reporter.status("Payment executed.", "success"); + onDone(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Payment failed.", "error"); + } finally { + setBusy(false); + } + } + + const currency = currencyCode(accounts[0]?.currency); + const value = selectedId || null; + + return ( + + + + + + Money out + + + Send a payment + + Quote a transfer from this wallet to a USD bank account, then + confirm to sign & execute. + + + + + + {pending ? ( + + {error && ( + setError(null)} + /> + )} + + + Amount + + {pending.amount} {currency} + + + + To + {pending.destinationLabel} + + + From + {sourceAccountId} + + + Quote + + {pending.quote.quoteId} + + + + Signature + + {mode === "sandbox" ? "Magic (sandbox)" : "Session-stamped"} + + + + + + + + + ) : ( +
{ + e.preventDefault(); + void getQuote(); + }} + > + {error && ( + setError(null)} + /> + )} + {done && ( + setDone(null)} + /> + )} + + + Destination (USD bank account) + {externalAccounts.length > 0 ? ( + + setSelectedId(typeof next === "string" ? next : "") + } + > + + + {(selected) => + externalAccounts.find((a) => a.id === selected) + ?.label ?? selected + } + + + + + + + + {externalAccounts.map((acct) => ( + + + {acct.label} + + ))} + + + + + + ) : ( + + {listing + ? "Loading bank accounts…" + : "No saved bank accounts yet — add one below."} + + )} + + + {adding ? ( + + + Add USD bank account + + + Account number + setAccountNumber(e.target.value)} + /> + + + Routing number + setRoutingNumber(e.target.value)} + /> + + + + + + + + + ) : ( + { + setError(null); + setAdding(true); + }} + > + + Add USD bank account + + )} + + + + Amount{currency ? ` (${currency})` : ""} + + setAmount(e.target.value)} + /> + + + + + )} +
+
+ ); +} + +const EyebrowRow = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); + margin-bottom: var(--spacing-2xs, 6px); +`; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); +`; + +const Row2 = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-md, 16px); + + @media (width <= 560px) { + grid-template-columns: 1fr; + } +`; + +const AddCard = styled.div` + border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + border-radius: var(--corner-radius-lg, 12px); + padding: var(--spacing-md, 16px); + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); +`; + +const AddLink = styled.button` + align-self: flex-start; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-link, #6c4cf6); + + &:hover { + text-decoration: underline; + } +`; + +const Review = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); +`; + +const QuoteCard = styled.div` + border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + border-radius: var(--corner-radius-lg, 12px); + background: var(--surface-base, #f5f5f7); + padding: var(--spacing-md, 16px); + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 12px); +`; + +const QuoteRow = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--spacing-md, 16px); +`; + +const QuoteLabel = styled.span` + font-size: var(--font-size-xs, 12px); + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary, #8a8a8a); +`; + +const QuoteValue = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); + font-variant-numeric: tabular-nums; +`; + +const QuoteMono = styled.span` + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-xs, 12px); + color: var(--text-secondary, #555); + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Actions = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Settings.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Settings.tsx new file mode 100644 index 000000000..ae0ef92a2 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Settings.tsx @@ -0,0 +1,546 @@ +import styled from "@emotion/styled"; +import { Alert, Badge, Button, Card } from "@lightsparkdev/origin"; +import { useCallback, useEffect, useState } from "react"; + +import { sandboxMagicFor } from "../../mode"; +import { addOauthIssue, addOauthRetry } from "../../flows/oauth"; +import { + addPasskeyIssue, + addPasskeyRetry, + registerRealPasskey, + type PasskeyAttestation, +} from "../../flows/passkey"; +import { + deleteCredential, + deleteSession, + exportWallet, + listCredentials, + listSessions, +} from "../../flows/manage"; +import { getSessionId } from "../../session"; +import { useAppState } from "../../state/store"; +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import type { AccountBalance } from "./WalletHome"; + +interface Credential { + id: string; + type?: string; + nickname?: string; + status?: string; +} +interface Session { + id: string; + status?: string; + expiresAt?: string; +} + +/** + * Settings — manage the active customer's wallet credentials & sessions, plus + * wallet export. Every action runs the real guided flow in `flows/manage` + * (issue 202 -> sign -> retry) or the credential add flows; in sandbox the + * magic signature/attestation is used, in production a live session stamp. + */ +export function Settings({ accounts }: { accounts: AccountBalance[] }) { + const { activeCustomer, platformAuth, reporter, signOut } = useAppState(); + const mode = platformAuth?.mode ?? "sandbox"; + const accountId = + activeCustomer?.accountId ?? (accounts[0] ? String(accounts[0].id) : ""); + + const [credentials, setCredentials] = useState([]); + const [sessions, setSessions] = useState([]); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [note, setNote] = useState(null); + const [mnemonic, setMnemonic] = useState(null); + const [mnemonicRevealed, setMnemonicRevealed] = useState(false); + const [copied, setCopied] = useState(false); + + const refresh = useCallback(async () => { + if (!platformAuth || !accountId) return; + try { + const creds = (await listCredentials( + reporter, + platformAuth, + accountId, + )) as { + data?: Credential[]; + }; + setCredentials(creds.data ?? []); + const sess = (await listSessions(reporter, platformAuth, accountId)) as { + data?: Session[]; + }; + setSessions(sess.data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [platformAuth, accountId, reporter]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function run( + key: string, + action: () => Promise, + ok: string, + onSuccess?: () => void, + ) { + if (!platformAuth) return; + setBusy(key); + setError(null); + setNote(null); + try { + await action(); + setNote(ok); + reporter.status(ok, "success"); + onSuccess?.(); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Action failed.", "error"); + } finally { + setBusy(null); + } + } + + // After a session is revoked server-side, decide whether it's the session + // THIS client is signing with: compare the revoked row id to the active + // session id (`getSessionId`). If it matches, the local signing key is now + // dead, so sign out locally — `CustomerView` falls back to and the + // customer can re-authenticate. A different session leaves ours intact. + function onSessionRevoked(revokedId: string) { + // Compare on the uuid tail so "Session:" and a bare "" still match. + const tail = (id: string) => id.split(":").pop() ?? id; + const current = getSessionId(); + if (revokedId && current && tail(revokedId) === tail(current)) { + signOut(); + reporter.status( + "Your session was revoked — please sign in again.", + "info", + ); + } + } + + function sandboxPasskeyAttestation(): PasskeyAttestation { + return { + challenge: sandboxMagicFor("passkey-create-challenge") ?? "", + credentialId: sandboxMagicFor("passkey-create-cred-id-raw") ?? "", + clientDataJson: sandboxMagicFor("passkey-create-client-data-json") ?? "", + attestationObject: + sandboxMagicFor("passkey-create-attestation-object") ?? "", + }; + } + + async function addPasskey() { + await run( + "add-passkey", + async () => { + const nickname = "Security key (YubiKey)"; + const attestation = + mode === "production" + ? await registerRealPasskey(reporter, nickname) + : sandboxPasskeyAttestation(); + const { requestId, payloadToSign } = await addPasskeyIssue( + reporter, + platformAuth!, + accountId, + nickname, + attestation, + ); + if (!requestId) + throw new Error("Add security key: no requestId in the 202."); + return addPasskeyRetry( + reporter, + platformAuth!, + accountId, + nickname, + attestation, + requestId, + payloadToSign, + ); + }, + "Security key added.", + ); + } + + async function addOauth() { + await run( + "add-oauth", + async () => { + const oidc = sandboxMagicFor("oauth-add-oidc") ?? ""; + const { requestId } = await addOauthIssue( + reporter, + platformAuth!, + accountId, + oidc, + ); + if (!requestId) throw new Error("Add OAuth: no requestId in the 202."); + return addOauthRetry( + reporter, + platformAuth!, + accountId, + oidc, + requestId, + ); + }, + "OAuth credential added.", + ); + } + + // Export runs its own handler (not the generic `run`) so it can capture the + // recovered mnemonic and reveal it in the card. + async function exportAndReveal() { + if (!platformAuth) return; + setBusy("export"); + setError(null); + setNote(null); + setMnemonic(null); + setMnemonicRevealed(false); + setCopied(false); + try { + const { mnemonic: recovered } = await exportWallet( + reporter, + platformAuth, + accountId, + ); + setMnemonic(recovered); + setNote("Wallet exported — your recovery phrase is shown below."); + reporter.status("Wallet exported.", "success"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + reporter.status("Action failed.", "error"); + } finally { + setBusy(null); + } + } + + async function copyMnemonic() { + if (!mnemonic) return; + try { + await navigator.clipboard.writeText(mnemonic); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError("Couldn't copy to clipboard."); + } + } + + if (!accountId) { + return ( + + + + + + ); + } + + return ( + + {error && ( + setError(null)} + /> + )} + {note && ( + setNote(null)} + /> + )} + + {/* Credentials */} + + + + + Sign-in credentials + + The security keys and identities that can unlock this wallet. + + + + + + + + + + {credentials.length === 0 ? ( + No credentials. + ) : ( + + {credentials.map((c) => ( + + + {c.type ?? "CREDENTIAL"} + + {c.nickname || c.type || "Credential"} + {c.id} + + + + + ))} + + )} + + + + {/* Sessions */} + + + + Active sessions + + Revoke a session to sign it out everywhere. + + + + + {sessions.length === 0 ? ( + No active sessions. + ) : ( + + {sessions.map((s) => ( + + + + {s.status ?? "SESSION"} + + + {s.id} + {s.expiresAt && ( + Expires {formatDate(s.expiresAt)} + )} + + + + + ))} + + )} + + + + {/* Export */} + + + + Export wallet + + Export the wallet's private key material (HPKE-sealed to the + client). Self-custody escape hatch — handle with care. + + + + {mnemonic && ( + + + + + {mnemonicRevealed ? mnemonic : "•".repeat(48)} + + + + + + + + )} + + + + + + ); +} + +function formatDate(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(); +} + +const Stack = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; + +const HeaderLayout = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-md, 16px); + width: 100%; + flex-wrap: wrap; +`; + +const Actions = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); +`; + +const List = styled.div` + display: flex; + flex-direction: column; +`; + +const Row = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md, 16px); + padding: var(--spacing-sm, 12px) var(--spacing-md, 16px); + border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + + &:first-of-type { + border-top: none; + } +`; + +const RowLeft = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + min-width: 0; +`; + +const RowText = styled.div` + display: flex; + flex-direction: column; + min-width: 0; +`; + +const RowName = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); +`; + +const RowId = styled.span` + font-size: var(--font-size-xs, 11px); + color: var(--text-tertiary, #8a8a8a); + font-variant-numeric: tabular-nums; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RowSub = styled.span` + font-size: var(--font-size-xs, 11px); + color: var(--text-tertiary, #8a8a8a); +`; + +const EmptyRow = styled.div` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); + padding: var(--spacing-md, 16px); +`; + +const MnemonicBox = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md, 16px); + margin-top: var(--spacing-sm, 12px); + padding: var(--spacing-md, 16px); + border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + border-radius: var(--corner-radius-md, 8px); + background: var(--surface-secondary, #f0f0ee); +`; + +const MnemonicText = styled.code` + font-size: var(--font-size-sm, 13px); + line-height: 1.5; + word-break: break-word; + flex: 1; + min-width: 0; + color: var(--text-primary, #1a1a1a); + + &[data-hidden="true"] { + color: var(--text-tertiary, #8a8a8a); + letter-spacing: 2px; + } +`; + +const MnemonicActions = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + flex-shrink: 0; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/Transactions.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/Transactions.tsx new file mode 100644 index 000000000..8082bad8e --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/Transactions.tsx @@ -0,0 +1,364 @@ +import styled from "@emotion/styled"; +import { Badge, Button, Card } from "@lightsparkdev/origin"; +import { useCallback, useEffect, useState } from "react"; + +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import { RawExpander } from "../../components/RawExpander"; +import { + listTransactions, + type Transaction, + type TransactionTypeFilter, +} from "../../flows/transactions"; +import { formatMoney } from "../../lib/format-money"; +import { useAppState } from "../../state/store"; + +const PAGE_LIMIT = 20; + +const FILTERS: { value: TransactionTypeFilter; label: string }[] = [ + { value: "ALL", label: "All" }, + { value: "INCOMING", label: "Incoming" }, + { value: "OUTGOING", label: "Outgoing" }, +]; + +/** + * Transactions tab — the customer's real, server-persisted movement of funds + * (onramps / offramps / payments) from `GET /transactions`, scoped to the + * active customer and newest first. Distinct from the Activity tab, which shows + * this session's client-action log. Supports an All / Incoming / Outgoing + * filter and cursor-based "Load more" paging. + */ +export function Transactions() { + const { activeCustomer, platformAuth, reporter } = useAppState(); + const customerId = activeCustomer?.id ?? ""; + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [filter, setFilter] = useState("ALL"); + + // Memoized so the mount/refetch effect deps stay stable and we don't refetch + // on every render. `cursor: null` resets to the first page; a cursor appends. + const load = useCallback( + async (cursor: string | null) => { + if (!platformAuth || !customerId) return; + const append = cursor !== null; + if (append) setLoadingMore(true); + else setLoading(true); + try { + const page = await listTransactions(reporter, platformAuth, { + customerId, + limit: PAGE_LIMIT, + cursor, + type: filter, + }); + setItems((prev) => (append ? [...prev, ...page.data] : page.data)); + setHasMore(page.hasMore); + setNextCursor(page.nextCursor); + } catch (err) { + setError( + err instanceof Error ? err.message : "Couldn't load transactions.", + ); + } finally { + if (append) setLoadingMore(false); + else setLoading(false); + } + }, + [platformAuth, customerId, filter, reporter], + ); + + // On mount and whenever the customer or filter changes, reset and fetch fresh. + useEffect(() => { + setItems([]); + setHasMore(false); + setNextCursor(null); + setError(null); + void load(null); + }, [load]); + + if (!customerId || !platformAuth) { + return ( + + + + Not connected + + Connect the platform and act as a customer to see their + transactions. + + + + + ); + } + + return ( + + {error && ( + setError(null)} + /> + )} + + + + + Transactions + + Funds moving in and out of this wallet, newest first. + + + + + + {FILTERS.map(({ value, label }) => ( + + ))} + + + {loading ? ( + Loading transactions… + ) : items.length === 0 ? ( + + No transactions yet + + Funding, payments, and transfers for this customer appear here. + + + ) : ( + + {items.map((tx, i) => ( + + ))} + + )} + + {hasMore && !loading && ( + + + + )} + + + + ); +} + +/** One transaction row: direction + amount, counterparty, status, date, raw. */ +function TransactionRow({ tx }: { tx: Transaction }) { + const incoming = tx.type === "INCOMING"; + const money = incoming ? tx.receivedAmount : tx.sentAmount; + const amount = money ?? tx.amount; + const status = statusBadge(tx.status); + + return ( + + + + + {incoming ? "Received" : "Sent"} + + + {counterparty(tx)} + + + + + {formatSignedAmount(amount, incoming)} + + {status.label} + {formatDate(tx.createdAt)} + + + + + ); +} + +/** Map a transaction status to a label + Badge variant. */ +function statusBadge(status: string | undefined): { + variant: "green" | "yellow" | "red" | "gray"; + label: string; +} { + const s = (status ?? "").toUpperCase(); + const label = status || "Unknown"; + if (s === "COMPLETED" || s === "SETTLED" || s === "SUCCEEDED") + return { variant: "green", label }; + if (s === "PENDING" || s === "PROCESSING" || s === "CREATED") + return { variant: "yellow", label }; + if (s === "FAILED" || s === "REJECTED" || s === "CANCELLED") + return { variant: "red", label }; + return { variant: "gray", label }; +} + +/** + * Extract a readable counterparty identifier. Incoming reads `source`, outgoing + * reads `destination`; both are a OneOf keyed by `sourceType`/`destinationType`. + * Prefer a UMA address, then an account id; fall back to the OneOf's type tag. + */ +function counterparty(tx: Transaction): string { + const incoming = tx.type === "INCOMING"; + const party = incoming ? tx.source : tx.destination; + if (!party || typeof party !== "object") return "—"; + const p = party as Record; + if (typeof p.umaAddress === "string" && p.umaAddress) return p.umaAddress; + if (typeof p.accountId === "string" && p.accountId) return p.accountId; + const tag = incoming ? p.sourceType : p.destinationType; + return typeof tag === "string" && tag ? tag : "—"; +} + +/** Signed major-unit amount, e.g. `+ 12.50 USD` / `− 3.000000 USDB`. */ +function formatSignedAmount( + amount: { amount?: number; currency?: unknown } | undefined, + incoming: boolean, +): string { + if (!amount || typeof amount.amount !== "number") return "—"; + const sign = incoming ? "+" : "−"; + return `${sign} ${formatMoney(amount.amount, amount.currency)}`; +} + +/** Locale date + time; guards an invalid/missing timestamp. */ +function formatDate(value: string | undefined): string { + if (!value) return "—"; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +const Stack = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); +`; + +const Filters = styled.div` + display: flex; + gap: var(--spacing-2xs, 6px); + padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 12px); +`; + +const List = styled.div` + display: flex; + flex-direction: column; +`; + +const Row = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-2xs, 6px); + padding: var(--spacing-sm, 12px) var(--spacing-md, 16px); + border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + + &:first-of-type { + border-top: none; + } +`; + +const RowMain = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md, 16px); +`; + +const RowLeft = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + min-width: 0; +`; + +const RowRight = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + flex: none; +`; + +const Counterparty = styled.span` + font-size: var(--font-size-sm, 13px); + color: var(--text-secondary, #555); + font-variant-numeric: tabular-nums; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Amount = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); + font-variant-numeric: tabular-nums; + white-space: nowrap; + + &[data-direction="in"] { + color: var(--text-green, var(--text-primary, #1a1a1a)); + } +`; + +const When = styled.span` + font-size: var(--font-size-xs, 11px); + color: var(--text-tertiary, #8a8a8a); + font-variant-numeric: tabular-nums; + white-space: nowrap; +`; + +const LoadMore = styled.div` + display: flex; + justify-content: center; + padding: var(--spacing-md, 16px); +`; + +const Notice = styled.div` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); + padding: var(--spacing-md, 16px); +`; + +const Empty = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-2xs, 6px); + text-align: center; + padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px); +`; + +const EmptyTitle = styled.div` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #1a1a1a); +`; + +const EmptyBody = styled.div` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); + max-width: 320px; + line-height: 1.45; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/customer/WalletHome.tsx b/apps/examples/grid-global-accounts-example-app/src/views/customer/WalletHome.tsx new file mode 100644 index 000000000..e3277db34 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/customer/WalletHome.tsx @@ -0,0 +1,316 @@ +import styled from "@emotion/styled"; +import { Badge, Button, Card, Tabs } from "@lightsparkdev/origin"; +import { useCallback, useEffect, useState } from "react"; + +import { RawExpander } from "../../components/RawExpander"; +import { fetchBalance } from "../../flows/customer"; +import { currencyCode, formatMoney } from "../../lib/format-money"; +import { useAppState } from "../../state/store"; +import { Activity } from "./Activity"; +import { Fund } from "./Fund"; +import { Pay } from "./Pay"; +import { Settings } from "./Settings"; +import { Transactions } from "./Transactions"; + +type Section = + | "wallet" + | "fund" + | "pay" + | "activity" + | "transactions" + | "settings"; + +const SECTIONS: { value: Section; label: string }[] = [ + { value: "wallet", label: "Wallet" }, + { value: "fund", label: "Fund" }, + { value: "pay", label: "Pay" }, + { value: "activity", label: "Activity" }, + { value: "transactions", label: "Transactions" }, + { value: "settings", label: "Settings" }, +]; + +export interface AccountBalance { + id: unknown; + /** Currency metadata `{ code, name, symbol, decimals }` from `balance.currency`. */ + currency: unknown; + /** Amount in minor units (per `currency.decimals`). */ + balance: number; +} + +/** + * Wallet home (logged-in state). The consumer surface for the active customer: + * a balance hero + account list, with tab navigation into Fund / Pay / Activity + * / Settings. Balance comes from `fetchBalance` (real GET) scoped to the active + * customer; the money + settings flows act on the customer's `accountId` and + * the live session established at login. + */ +export function WalletHome() { + const { activeCustomer, platformAuth, reporter, signOut } = useAppState(); + const [section, setSection] = useState
("wallet"); + const [accounts, setAccounts] = useState([]); + const [rawBalance, setRawBalance] = useState(null); + const [loading, setLoading] = useState(false); + + const customerId = activeCustomer?.id ?? ""; + + const refresh = useCallback(async () => { + if (!platformAuth || !customerId) return; + setLoading(true); + try { + const { rows, raw } = await fetchBalance( + reporter, + platformAuth, + customerId, + ); + setAccounts(rows); + setRawBalance(raw); + } catch (err) { + reporter.status( + err instanceof Error ? err.message : "Couldn't load balance.", + "error", + ); + } finally { + setLoading(false); + } + }, [platformAuth, customerId, reporter]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const primary = accounts[0]; + + return ( + + + + +
+ Total balance + + {primary ? formatMoney(primary.balance, primary.currency) : "—"} + +
+ +
+ + + {accounts.length === 0 ? ( + + {loading + ? "Loading accounts…" + : "No accounts found for this customer."} + + ) : ( + accounts.map((a, i) => ( + + + + {currencyCode(a.currency) || "—"} + + {String(a.id)} + + + {formatMoney(a.balance, a.currency)} + + + )) + )} + + + +
+ + + + + + + + +
+ + + + {section === "wallet" && } + {section === "fund" && ( + void refresh()} /> + )} + {section === "pay" && ( + void refresh()} /> + )} + {section === "activity" && } + {section === "transactions" && } + {section === "settings" && } +
+ ); +} + +/** The "Wallet" tab body: a short orientation card + recent activity preview. */ +function WalletOverview() { + return ( + + + + Recent activity + + Funding, payments, and session events from this session. + + + + + + + + ); +} + +const Stack = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; + +const HeroCard = styled(Card.Root)` + background: linear-gradient( + 160deg, + var(--surface-primary, #fff) 0%, + color-mix( + in srgb, + var(--brand-blue, #2563eb) 5%, + var(--surface-primary, #fff) + ) + 100% + ); +`; + +const HeroTop = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-md, 16px); + margin-bottom: var(--spacing-lg, 24px); +`; + +const HeroLabel = styled.div` + font-size: var(--font-size-xs, 12px); + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-tertiary, #8a8a8a); + margin-bottom: var(--spacing-2xs, 6px); +`; + +const HeroAmount = styled.div` + font-size: 38px; + font-weight: var(--font-weight-semibold, 600); + letter-spacing: -0.5px; + color: var(--text-primary, #1a1a1a); + font-variant-numeric: tabular-nums; + line-height: 1.1; +`; + +const Accounts = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-2xs, 6px); +`; + +const EmptyAccounts = styled.div` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); + padding: var(--spacing-sm, 12px) 0; +`; + +const AccountRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md, 16px); + padding: var(--spacing-sm, 12px) 0; + border-top: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + + &:first-of-type { + border-top: none; + } +`; + +const AccountLeft = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + min-width: 0; +`; + +const CurrencyBadge = styled(Badge)` + font-variant-numeric: tabular-nums; +`; + +const AccountId = styled.span` + font-size: var(--font-size-xs, 11px); + color: var(--text-tertiary, #8a8a8a); + font-variant-numeric: tabular-nums; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const AccountBalanceText = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); + font-variant-numeric: tabular-nums; +`; + +const FooterRow = styled.div` + display: flex; + align-items: center; + gap: var(--spacing-sm, 12px); + width: 100%; +`; + +const Spacer = styled.div` + flex: 1; +`; + +const Nav = styled.div` + display: flex; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/Config.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/Config.tsx new file mode 100644 index 000000000..f1f3b46d2 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/Config.tsx @@ -0,0 +1,484 @@ +import styled from "@emotion/styled"; +import { + Alert, + Badge, + Button, + Card, + Field, + Input, + Select, + Tabs, +} from "@lightsparkdev/origin"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { type ApiAuth, resolveMode } from "../../api-client"; +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import { RawExpander } from "../../components/RawExpander"; +import type { Mode } from "../../config"; +import { + listPlatformFundingAccounts, + type PlatformFundingAccount, +} from "../../flows/customer"; +import { formatMoney } from "../../lib/format-money"; +import { useAppState } from "../../state/store"; + +/** Funding-account picker state: loading the list, resolved, or fetch failed. */ +type FundingState = + | { kind: "loading" } + | { kind: "ready"; accounts: PlatformFundingAccount[] } + | { kind: "error"; message: string }; + +/** + * Platform config / auth panel — the entry point for the whole Platform view. + * + * Captures the platform API credentials (`clientId` / `clientSecret`) and the + * target `mode`, then stores them in app state as `platformAuth`. Until that's + * set, nothing else on the platform side can run (the decoupled flows all take + * an `ApiAuth` argument). Shows live connection status: disconnected when no + * auth is held, connected (with a masked summary) once it is. + */ +export function Config() { + const { + platformAuth, + setPlatformAuth, + platformFundingAccountId, + setPlatformFundingAccountId, + reporter, + } = useAppState(); + const connected = platformAuth !== null; + + // Local draft so the operator can edit credentials without clobbering the + // live connection mid-keystroke; committed to the store on "Connect". + const [clientId, setClientId] = useState(platformAuth?.clientId ?? ""); + const [clientSecret, setClientSecret] = useState( + platformAuth?.clientSecret ?? "", + ); + const [mode, setMode] = useState(platformAuth?.mode ?? "sandbox"); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(null); + + // The funding account is chosen — not pasted — from the platform's own funding + // pool, listed via `GET /platform/internal-accounts` once connected. + const [funding, setFunding] = useState({ kind: "loading" }); + // Guards against a stale fetch (after disconnect/reconnect) clobbering a newer one. + const fundingSeq = useRef(0); + + const loadFundingAccounts = useCallback(async () => { + if (!platformAuth) return; + const seq = ++fundingSeq.current; + setFunding({ kind: "loading" }); + try { + const { accounts } = await listPlatformFundingAccounts( + reporter, + platformAuth, + ); + if (seq !== fundingSeq.current) return; // superseded + setFunding({ kind: "ready", accounts }); + } catch (err) { + if (seq !== fundingSeq.current) return; + setFunding({ + kind: "error", + message: err instanceof Error ? err.message : "Couldn't load accounts.", + }); + } + }, [platformAuth, reporter]); + + // Fetch the funding pool on connect; clear it when disconnected. + useEffect(() => { + if (connected) { + void loadFundingAccounts(); + } else { + fundingSeq.current++; + setFunding({ kind: "loading" }); + } + }, [connected, loadFundingAccounts]); + + function connect() { + const id = clientId.trim(); + const secret = clientSecret.trim(); + if (!id || !secret) { + setError("Both a client ID and a client secret are required to connect."); + return; + } + setError(null); + const auth: ApiAuth = { clientId: id, clientSecret: secret, mode }; + setPlatformAuth(auth); + setEditing(false); + } + + function disconnect() { + setPlatformAuth(null); + setPlatformFundingAccountId(""); + setClientSecret(""); + setEditing(false); + setError(null); + } + + return ( + + + + + + Platform + + + + {connected ? "Connected" : "Not connected"} + + + Platform configuration + + Connect with your Grid platform API credentials. These authenticate + every platform-side request — creating customers, reading config — + and never leave the browser. + + + + + + {connected && !editing ? ( + + + + Client ID + {maskMiddle(platformAuth.clientId)} + + + Client secret + •••••••••••• + + + Mode + + + {platformAuth.mode} + + + + + + void loadFundingAccounts()} + /> + + + + ) : ( +
{ + e.preventDefault(); + connect(); + }} + > + {error && ( + setError(null)} + /> + )} + + + Client ID + setClientId(e.target.value)} + /> + + + + Client secret + setClientSecret(e.target.value)} + /> + + Stored only in this tab's memory for the session. + + + + + Mode + setMode(resolveMode(value))} + > + + Sandbox + Production + + + + Sandbox accepts magic values; production requires real + ceremonies. + + + + + You'll pick the platform funding account from your funded accounts + once connected. + + + )} +
+ + + {connected && !editing ? ( + + + + + ) : ( + + + {connected && ( + + )} + + )} + +
+ ); +} + +/** + * Funding-account picker shown once connected. Lets the operator choose the + * platform's funding source from its own funded accounts (the funding pool) + * instead of pasting an LSID. Renders the load/empty/error states and, on + * selection, sets `platformFundingAccountId` from the chosen account. + */ +function FundingPicker({ + state, + selectedId, + onSelect, + onRetry, +}: { + state: FundingState; + selectedId: string; + onSelect: (id: string) => void; + onRetry: () => void; +}) { + if (state.kind === "loading") { + return ( + + Funding account + Loading your funded accounts… + + ); + } + + if (state.kind === "error") { + return ( + + Funding account + + + + ); + } + + if (state.accounts.length === 0) { + return ( + + Funding account + + + ); + } + + // Value the Select is bound to: the selected LSID, or null when none is set. + const value = selectedId || null; + + return ( + + Funding account + onSelect(typeof next === "string" ? next : "")} + > + + + {(selected) => + fundingOptionLabel( + state.accounts.find((a) => a.id === selected), + selected, + ) + } + + + + + + + + {state.accounts.map((account) => ( + + + + {fundingOptionLabel(account)} + + + ))} + + + + + + + Used as the source when funding a customer from the platform. Leave + unset if you only act as customers. + + + ); +} + +/** + * Label an account option as ` · `, e.g. + * `Internal…a1b2 · 1,000.00 USD`, so it's recognizable without the operator + * reading a raw LSID. Falls back to the raw value when the account is unknown. + */ +function fundingOptionLabel( + account: PlatformFundingAccount | undefined, + fallback?: string | null, +): string { + if (!account) return fallback ?? ""; + return `${maskMiddle(account.id)} · ${formatMoney( + account.amount, + account.currency, + )}`; +} + +/** `grid_pl…a1b2` — keep the prefix + tail, hide the middle. */ +function maskMiddle(value: string): string { + const v = value.trim(); + if (v.length <= 10) return v; + return `${v.slice(0, 6)}…${v.slice(-4)}`; +} + +const EyebrowRow = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); + margin-bottom: var(--spacing-2xs, 6px); +`; + +const StatusBadge = styled(Badge)` + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs, 6px); +`; + +const StatusDot = styled.span` + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-tertiary, #8a8a8a); + + &[data-connected="true"] { + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 22%, transparent); + } +`; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; + +const Summary = styled.div` + border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + border-radius: var(--corner-radius-lg, 12px); + background: var(--surface-base, #f5f5f7); + padding: var(--spacing-md, 16px); +`; + +const SummaryGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--spacing-md, 16px); + margin-bottom: var(--spacing-md, 16px); +`; + +const PickerSection = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 12px); + align-items: flex-start; +`; + +const PickerNote = styled.span` + font-size: var(--font-size-sm, 13px); + color: var(--text-secondary, #5a5a5a); +`; + +const SummaryItem = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-2xs, 6px); + min-width: 0; +`; + +const SummaryLabel = styled.span` + font-size: var(--font-size-xs, 12px); + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary, #8a8a8a); +`; + +const SummaryValue = styled.span` + font-size: var(--font-size-sm, 13px); + color: var(--text-primary, #1a1a1a); + font-variant-numeric: tabular-nums; + word-break: break-all; +`; + +const FooterRow = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/CreateCustomer.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/CreateCustomer.tsx new file mode 100644 index 000000000..187a41e40 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/CreateCustomer.tsx @@ -0,0 +1,175 @@ +import styled from "@emotion/styled"; +import { Button, Dialog, Field, Input } from "@lightsparkdev/origin"; +import { useState } from "react"; + +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import { createCustomer } from "../../flows/customer"; +import { useAppState, type ActiveCustomer } from "../../state/store"; + +/** + * Create-customer action: a Button that opens an Origin Dialog with the + * customer form. On submit it calls the decoupled `createCustomer` operation + * (`flows/customer.ts`) with the held `reporter` + `platformAuth`, then adds + * the result to the session-local customers list and selects it as active. + * + * Disabled until the platform is connected — `createCustomer` needs an + * `ApiAuth`, which only exists once the Config panel has stored `platformAuth`. + * + * On success it optimistically prepends the new customer (`addCustomer`) so it + * shows immediately, then fires `onCreated` so the parent table re-runs its + * single grouped `GET /customers/internal-accounts` fetch (the authoritative + * row + balance). + */ +export function CreateCustomer({ onCreated }: { onCreated?: () => void }) { + const { platformAuth, reporter, addCustomer, setActiveCustomer } = + useAppState(); + const [open, setOpen] = useState(false); + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [platformCustomerId, setPlatformCustomerId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const connected = platformAuth !== null; + + function reset() { + setFullName(""); + setEmail(""); + setPlatformCustomerId(""); + setError(null); + setSubmitting(false); + } + + async function submit() { + if (!platformAuth) return; + setSubmitting(true); + setError(null); + try { + const result = await createCustomer(reporter, platformAuth, { + fullName, + email, + platformCustomerId, + }); + const customer: ActiveCustomer = { + id: result.customerId, + name: fullName.trim() || "Test User", + email: email.trim(), + accountId: result.accountId, + status: "Active", + walletState: result.accountId ? "Provisioned" : "Pending", + }; + addCustomer(customer); + setActiveCustomer(customer); + reporter.status(`Customer ${customer.name} created.`, "success"); + onCreated?.(); + setOpen(false); + reset(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Create customer failed.", "error"); + } finally { + setSubmitting(false); + } + } + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + } + > + Create customer + + + + + + + Create customer + + Provisions a business customer and a USDB internal account on the + connected platform. + + + +
{ + e.preventDefault(); + void submit(); + }} + > + {error && ( + setError(null)} + /> + )} + + + Legal name + setFullName(e.target.value)} + /> + + Defaults to “Test User” if left blank. + + + + + Email + setEmail(e.target.value)} + /> + + + + Platform customer ID + setPlatformCustomerId(e.target.value)} + /> + + Your own reference for this customer. + + + +
+ + }> + Cancel + + + +
+
+
+ ); +} + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/CustomersTable.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/CustomersTable.tsx new file mode 100644 index 000000000..2970f1519 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/CustomersTable.tsx @@ -0,0 +1,320 @@ +import styled from "@emotion/styled"; +import { Button, Card, Table } from "@lightsparkdev/origin"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + groupCustomerWallets, + listAllInternalAccounts, + type CustomerWallet, +} from "../../flows/customer"; +import { formatMoney } from "../../lib/format-money"; +import { useAppState, type ActiveCustomer } from "../../state/store"; +import { CreateCustomer } from "./CreateCustomer"; +import { FundCustomer } from "./FundCustomer"; + +/** + * Customers table — one row per customer, derived from a SINGLE + * `GET /customers/internal-accounts` sweep (no `customerId` filter), grouped by + * owning customer. Each row shows the customer's shortened id (full on hover) + * and its spendable-wallet balance, both straight from that one fetch — no + * per-customer follow-up calls. Each row's wallet `accountId` is carried inline, + * so "Act as" scopes into the Customer view (`setActiveCustomer` + + * `setPersona("customer")`) without an extra request. + */ +export function CustomersTable() { + const { + platformAuth, + reporter, + setCustomers, + setActiveCustomer, + setPersona, + } = useAppState(); + const connected = platformAuth !== null; + + const [loading, setLoading] = useState(false); + const [truncated, setTruncated] = useState(false); + const [wallets, setWallets] = useState([]); + // Guards against a stale fetch (e.g. after disconnect/reconnect) clobbering a + // newer one's results. + const fetchSeq = useRef(0); + + const refresh = useCallback(async () => { + if (!platformAuth) return; + const seq = ++fetchSeq.current; + setLoading(true); + try { + const { accounts, truncated } = await listAllInternalAccounts( + reporter, + platformAuth, + ); + if (seq !== fetchSeq.current) return; // superseded + const grouped = groupCustomerWallets(accounts); + setWallets(grouped); + setTruncated(truncated); + // Mirror the customer ids into the store so other views (ContextChip, + // CustomerView) and the de-dupe logic stay consistent. + setCustomers( + grouped.map((w) => ({ + id: w.customerId, + name: shortenId(w.customerId), + accountId: w.accountId, + status: "Active", + })), + ); + } catch (err) { + if (seq !== fetchSeq.current) return; + reporter.status( + err instanceof Error ? err.message : "Couldn't load customers.", + "error", + ); + } finally { + if (seq === fetchSeq.current) setLoading(false); + } + }, [platformAuth, reporter, setCustomers]); + + // Fetch on connect (and clear when disconnected). + useEffect(() => { + if (connected) { + void refresh(); + } else { + fetchSeq.current++; + setWallets([]); + setTruncated(false); + setCustomers([]); + } + // setCustomers is stable per the store contract; refresh changes with auth. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connected, refresh]); + + function actAs(wallet: CustomerWallet) { + // The grouped fetch already carried the wallet account id, so we scope the + // Customer view directly — no per-customer fetch. + const customer: ActiveCustomer = { + id: wallet.customerId, + name: shortenId(wallet.customerId) || "Customer", + accountId: wallet.accountId, + status: "Active", + }; + setActiveCustomer(customer); + setPersona("customer"); + } + + const subtitle = buildSubtitle({ + connected, + loading, + shown: wallets.length, + truncated, + }); + + return ( + + + + + Customers + {subtitle} + + void refresh()} /> + + + + + {wallets.length === 0 ? ( + + {connected ? ( + <> + + {loading ? "Loading customers…" : "No customers yet"} + + + {loading + ? "Fetching this platform's customer wallets from the Grid API." + : "Create your first customer to provision a wallet and act as them."} + + + ) : ( + <> + Connect to get started + + Add your platform credentials above to list your customers. + + + )} + + ) : ( + + + + Customer + + Balance + + + Action + + + + + {wallets.map((wallet) => ( + + + + {initials(wallet.customerId)} + + {shortenId(wallet.customerId)} + + + + + + + {formatMoney(wallet.amount, wallet.currency)} + + + + + + + void refresh()} + /> + + + + + + ))} + + + )} + + + ); +} + +/** Subtitle: connect prompt, loading, or a "showing N" summary. */ +function buildSubtitle(args: { + connected: boolean; + loading: boolean; + shown: number; + truncated: boolean; +}): string { + const { connected, loading, shown, truncated } = args; + if (!connected) return "Connect your platform credentials to list customers."; + if (shown === 0) + return loading ? "Loading customers…" : "Customers you create appear here."; + if (truncated) return `Showing ${shown} customers (more available).`; + return `${shown} customer${shown === 1 ? "" : "s"}.`; +} + +/** Shorten an LSID for display: keep the prefix and the last few id chars. */ +function shortenId(id: string): string { + if (!id) return ""; + const [prefix, rest] = id.includes(":") ? id.split(/:(.*)/s) : ["", id]; + const tail = rest.length > 8 ? `…${rest.slice(-6)}` : rest; + return prefix ? `${prefix}:${tail}` : tail; +} + +/** First letters of the last id segment, uppercased. */ +function initials(id: string): string { + const rest = id.includes(":") ? id.slice(id.indexOf(":") + 1) : id; + const trimmed = rest.replace(/[^a-zA-Z0-9]/g, ""); + if (!trimmed) return "?"; + return trimmed.slice(0, 2).toUpperCase(); +} + +const HeaderLayout = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-md, 16px); + width: 100%; +`; + +const Empty = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-2xs, 6px); + text-align: center; + padding: var(--spacing-2xl, 40px) var(--spacing-lg, 24px); +`; + +const EmptyTitle = styled.div` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-semibold, 600); + color: var(--text-primary, #1a1a1a); +`; + +const EmptyBody = styled.div` + font-size: var(--font-size-sm, 13px); + color: var(--text-tertiary, #8a8a8a); + max-width: 320px; + line-height: 1.45; +`; + +const NameCell = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); + min-width: 0; +`; + +const Avatar = styled.span` + flex: none; + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: var(--font-weight-semibold, 600); + letter-spacing: 0.3px; + /* Pair with --surface-inverse: both flip by mode (dark surface + light text + * in light mode, light surface + dark text in dark mode). --text-on-primary + * was undefined and fell back to #fff, going white-on-near-white in dark. */ + color: var(--text-inverse, #f8f8f7); + background: var(--surface-inverse, #1a1a1a); +`; + +const CustomerId = styled.span` + font-size: var(--font-size-sm, 13px); + color: var(--text-primary, #1a1a1a); + font-variant-numeric: tabular-nums; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const BalanceText = styled.span` + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary, #1a1a1a); + font-variant-numeric: tabular-nums; +`; + +const RightAlign = styled.div` + display: flex; + justify-content: flex-end; +`; + +const ActionGroup = styled.div` + display: inline-flex; + align-items: center; + gap: var(--spacing-sm, 12px); +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/FundCustomer.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/FundCustomer.tsx new file mode 100644 index 000000000..bcadc6833 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/FundCustomer.tsx @@ -0,0 +1,323 @@ +import styled from "@emotion/styled"; +import { + Alert, + Button, + Dialog, + Field, + Input, + Progress, +} from "@lightsparkdev/origin"; +import { useState } from "react"; + +import { DismissibleAlert } from "../../components/DismissibleAlert"; +import { fundCustomerFromPlatform, type FundStage } from "../../flows/money"; +import { currencyCode } from "../../lib/format-money"; +import { useAppState, type ActiveCustomer } from "../../state/store"; + +/** + * Staged progress for the fund flow. The backend only signals PROCESSING → + * COMPLETE, so the earlier steps are approximated: each stage maps to a label + * and a determinate percentage, advancing the bar as `onStage` fires. + */ +const STAGE_META: Record = { + quoting: { label: "Creating quote…", value: 25 }, + executing: { label: "Executing…", value: 55 }, + processing: { label: "Processing…", value: 80 }, + completed: { label: "Complete", value: 100 }, + failed: { label: "Failed", value: 100 }, +}; + +/** + * Per-customer Fund action: a compact Button that opens an Origin Dialog with an + * amount input, then funds the customer from the platform's configured funding + * account via the proven quote → execute → poll flow (`fundCustomerFromPlatform` + * in `flows/money.ts`, mirroring + * `sparkcore/.../test_token_fund_in_live.py::_gen_create_and_execute_quote`). + * + * The amount is collected in major units and converted to minor units using the + * destination account's `currency.decimals` (the same block the balance cell + * renders). On a terminal status the parent refreshes this customer's balance. + * + * Disabled (with an explanation) when no platform funding account is configured + * or the customer has no provisioned internal account. + */ +export function FundCustomer({ + customer, + destinationAccountId, + currency, + onFunded, +}: { + customer: ActiveCustomer; + /** The customer's internal account LSID, from its balance fetch. */ + destinationAccountId: string | null; + /** The destination account's currency block (for decimals + code). */ + currency: unknown; + /** Called after a terminal transaction so the table can refresh the balance. */ + onFunded: () => void; +}) { + const { platformAuth, platformFundingAccountId, reporter } = useAppState(); + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [stage, setStage] = useState(null); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const connected = platformAuth !== null; + const hasFundingAccount = platformFundingAccountId.trim().length > 0; + const hasDestination = Boolean(destinationAccountId); + const code = currencyCode(currency); + const decimals = currencyDecimals(currency); + + // Why the action can't run, surfaced both as a disabled-state tooltip and an + // in-dialog notice. + const blockedReason = !connected + ? "Connect the platform first." + : !hasFundingAccount + ? "Set a platform funding account in the config panel above to fund customers." + : !hasDestination + ? "This customer has no provisioned internal account yet." + : null; + + function reset() { + setAmount(""); + setError(null); + setResult(null); + setSubmitting(false); + setStage(null); + } + + async function submit() { + if (!platformAuth || !destinationAccountId) return; + setSubmitting(true); + setError(null); + setResult(null); + setStage(null); + try { + const major = parseFloat(amount || "0"); + if (!Number.isFinite(major) || major <= 0) + throw new Error("Enter an amount to fund."); + const amountMinor = Math.round(major * 10 ** decimals); + if (amountMinor <= 0) throw new Error("Enter an amount to fund."); + + reporter.status(`Funding ${customer.name || customer.id}…`, "info"); + const out = await fundCustomerFromPlatform( + reporter, + platformAuth, + { + fundingAccountId: platformFundingAccountId, + destinationAccountId, + amountMinor, + }, + { onStage: setStage }, + ); + + if (out.status === "COMPLETED") { + setResult(`Funded — transaction ${out.transactionId} COMPLETED.`); + reporter.status("Funding complete.", "success"); + onFunded(); + } else if (out.status === "FAILED") { + setError(`Transaction ${out.transactionId} FAILED.`); + reporter.status("Funding failed.", "error"); + } else { + // Non-terminal: the poll timed out. Surface the last-seen status; the + // balance may still settle, so refresh anyway. + setResult( + `Submitted — transaction ${out.transactionId} is ${ + out.status || "pending" + }.`, + ); + reporter.status("Funding submitted (still settling).", "info"); + onFunded(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + reporter.status("Funding failed.", "error"); + } finally { + setSubmitting(false); + } + } + + const formId = `fund-customer-form-${customer.id}`; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + } + > + Fund + + + + + + + Fund {customer.name || "customer"} + + Send value from the platform funding account to this customer. + Creates a quote, executes it with the platform's API token (no + wallet signature), then polls the transaction to completion. + + + +
{ + e.preventDefault(); + void submit(); + }} + > + {blockedReason && ( + + )} + {error && ( + setError(null)} + /> + )} + {result && ( + setResult(null)} + /> + )} + + {(submitting || stage) && stage && ( + + + {STAGE_META[stage].label} + + + + + + + )} + + + + From + + {platformFundingAccountId || "—"} + + + + To + + {destinationAccountId ?? "—"} + + + + + + Amount{code ? ` (${code})` : ""} + setAmount(e.target.value)} + disabled={Boolean(blockedReason)} + /> + + Converted to minor units using the account's currency decimals + ({decimals}). + + + +
+ + }> + Close + + + +
+
+
+ ); +} + +/** Minor-unit decimals from a Currency block; default 2 (mirrors format-money). */ +function currencyDecimals(currency: unknown): number { + if (currency && typeof currency === "object") { + const c = currency as Record; + if (typeof c.decimals === "number" && c.decimals >= 0) return c.decimals; + } + return 2; +} + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; + +const ProgressWrap = styled.div` + display: flex; + flex-direction: column; +`; + +const Detail = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 12px); + border: var(--stroke-xs, 1px) solid var(--border-primary, #e6e6e9); + border-radius: var(--corner-radius-lg, 12px); + background: var(--surface-base, #f5f5f7); + padding: var(--spacing-md, 16px); +`; + +const DetailRow = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--spacing-md, 16px); +`; + +const DetailLabel = styled.span` + font-size: var(--font-size-xs, 12px); + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary, #8a8a8a); +`; + +const Mono = styled.span` + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-xs, 12px); + color: var(--text-secondary, #555); + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/views/platform/PlatformView.tsx b/apps/examples/grid-global-accounts-example-app/src/views/platform/PlatformView.tsx new file mode 100644 index 000000000..6ddfc5e8c --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/views/platform/PlatformView.tsx @@ -0,0 +1,29 @@ +import styled from "@emotion/styled"; + +import { StatusBanner } from "../../components/StatusBanner"; +import { Config } from "./Config"; +import { CustomersTable } from "./CustomersTable"; + +/** + * Platform view — the admin-dashboard side of a Grid integration. + * + * Composes the config / auth panel (the entry point) over the customers table + * (create + "act as"). A transient status line surfaces the latest reporter + * message so platform operations give feedback without opening the debug + * drawer. Until the platform is connected, only the config panel is actionable. + */ +export function PlatformView() { + return ( + + + + + + ); +} + +const Stack = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-lg, 24px); +`; diff --git a/apps/examples/grid-global-accounts-example-app/src/webauthn.ts b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts new file mode 100644 index 000000000..4ee7df415 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/webauthn.ts @@ -0,0 +1,215 @@ +// WebAuthn ceremony helpers (real passkeys). +// +// The sandbox flows accept magic placeholder strings, but a real Turnkey +// sub-org needs a genuine WebAuthn credential. These helpers drive the +// browser's authenticator and base64url-encode the results into the same +// fields the sandbox flow uses, so Create / Add / Verify work unchanged +// against production Turnkey. +// +// AUTHENTICATOR: we target a ROAMING security key (e.g. a YubiKey), not the +// platform authenticator (Touch ID / Windows Hello). Registration asks the +// browser for a cross-platform authenticator; authentication targets the +// registered credential id(s) over USB / NFC. This keeps the wallet's signing +// key on a removable hardware key rather than the laptop's secure enclave. +// +// NOTE: WebAuthn binds a credential to an RP ID that must be a suffix of the +// page origin — on localhost that means rpId="localhost". The Turnkey sub-org +// must have been created with the SAME RP ID or verification will fail. + +// COSE algorithm identifiers we accept for the credential public key. ES256 +// (-7) is universally supported by security keys; RS256 (-257) is a fallback. +const PUB_KEY_CRED_PARAMS: PublicKeyCredentialParameters[] = [ + { type: "public-key", alg: -7 }, // ES256 + { type: "public-key", alg: -257 }, // RS256 +]; + +// Transports a roaming security key (YubiKey) is reached over. Listed in the +// assertion's allowCredentials so the browser steers the user to the security +// key rather than offering a platform passkey. +export const SECURITY_KEY_TRANSPORTS: AuthenticatorTransport[] = ["usb", "nfc"]; + +export function bytesToB64Url(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function b64UrlToBytes(value: string): Uint8Array { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const bin = atob(padded); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +// Resolve the WebAuthn RP ID: a caller-supplied value (the passkey-rp-id form +// field, in the UI), falling back to the page hostname. Must be a suffix of the +// page origin and match the RP ID the Turnkey sub-org was created with. +export function passkeyRpId(rpId?: string): string { + return rpId?.trim() || location.hostname; +} + +export interface RealAttestation { + challenge: string; + credentialId: string; + clientDataJson: string; + attestationObject: string; +} + +/** + * Build the `PublicKeyCredentialCreationOptions` for a registration ceremony + * that targets a ROAMING SECURITY KEY (YubiKey): + * - `authenticatorAttachment: "cross-platform"` makes the browser prompt for + * a removable security key (USB / NFC), NOT the platform authenticator. + * - `residentKey: "discouraged"` + `requireResidentKey: false`: the key need + * not store a discoverable credential — we always have its id to put in + * `allowCredentials`, which lets cheaper non-resident keys work and avoids + * burning the key's limited resident-credential slots. + * - `userVerification: "preferred"`: use the PIN/biometric if the key has one, + * but don't hard-require it (a basic touch-only key still works). + * - `pubKeyCredParams` includes ES256 (-7), which every security key supports. + * + * Pure (no DOM / crypto side effects) so it can be unit-tested; the caller + * supplies the random challenge + user id. + */ +export function buildCreationOptions( + nickname: string, + rpId: string | undefined, + challenge: BufferSource, + userId: BufferSource, +): PublicKeyCredentialCreationOptions { + return { + rp: { id: passkeyRpId(rpId), name: "Grid Example App" }, + user: { + id: userId, + name: nickname || "grid-example-user", + displayName: nickname || "Grid Example User", + }, + challenge, + pubKeyCredParams: PUB_KEY_CRED_PARAMS, + authenticatorSelection: { + authenticatorAttachment: "cross-platform", + residentKey: "discouraged", + requireResidentKey: false, + userVerification: "preferred", + }, + attestation: "none", + timeout: 60000, + }; +} + +// Real registration ceremony — produces the attestation that Create/Add send. +// `attestation.credentialId` is the RAW WebAuthn credential id (base64url): the +// caller should persist it so a later assertion can target this security key +// via allowCredentials. (The Grid credential id returned by POST +// /auth/credentials is a DIFFERENT, server-side id — not usable here.) +export async function createRealPasskey( + nickname: string, + rpId?: string, +): Promise { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const userId = crypto.getRandomValues(new Uint8Array(16)); + const credential = (await navigator.credentials.create({ + publicKey: buildCreationOptions(nickname, rpId, challenge, userId), + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Passkey creation returned no credential"); + const response = credential.response as AuthenticatorAttestationResponse; + return { + challenge: bytesToB64Url(challenge), + credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), + clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), + attestationObject: bytesToB64Url( + new Uint8Array(response.attestationObject), + ), + }; +} + +export interface RealAssertion { + credentialId: string; + authenticatorData: string; + clientDataJson: string; + signature: string; +} + +/** + * Build `allowCredentials` from the registered passkeys' RAW WebAuthn credential + * ids (base64url). Each entry carries `transports: ["usb","nfc"]` so the browser + * targets the roaming security key. Blank / duplicate ids are dropped. + * + * When NO ids are known (e.g. a credential registered before we started storing + * raw ids), this returns `[]` — an empty allowCredentials lets a discoverable + * (resident) credential on the security key be presented, which is the best we + * can do without the id. Pure + DOM-free for unit testing. + */ +export function buildAllowCredentials( + credentialIds: string[], +): PublicKeyCredentialDescriptor[] { + const seen = new Set(); + const out: PublicKeyCredentialDescriptor[] = []; + for (const id of credentialIds) { + const trimmed = id?.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push({ + type: "public-key", + id: b64UrlToBytes(trimmed) as BufferSource, + transports: SECURITY_KEY_TRANSPORTS, + }); + } + return out; +} + +/** + * Build the `PublicKeyCredentialRequestOptions` for an assertion that targets a + * roaming security key. Pure (no DOM); the caller encodes the challenge bytes. + */ +export function buildAssertionOptions( + challenge: BufferSource, + credentialIds: string[], + rpId?: string, +): PublicKeyCredentialRequestOptions { + return { + rpId: passkeyRpId(rpId), + challenge, + allowCredentials: buildAllowCredentials(credentialIds), + userVerification: "preferred", + timeout: 60000, + }; +} + +// Real assertion ceremony — signs the issued session challenge with the security +// key. `credentialIds` are the RAW WebAuthn credential ids (base64url) of the +// registered passkey(s); pass every one a wallet has so the key can match any. +export async function signWithPasskey( + challengeValue: string, + credentialIds: string | string[], + rpId?: string, +): Promise { + if (!challengeValue) { + throw new Error( + "No challenge — issue a session challenge (step above) first.", + ); + } + // Turnkey's WebAuthn challenge is the UTF-8 bytes of the sha256-hex challenge + // string returned by /challenge — NOT base64url-decoded. + const challenge = new TextEncoder().encode(challengeValue); + const ids = Array.isArray(credentialIds) + ? credentialIds + : credentialIds + ? [credentialIds] + : []; + const credential = (await navigator.credentials.get({ + publicKey: buildAssertionOptions(challenge, ids, rpId), + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Passkey assertion returned no credential"); + const response = credential.response as AuthenticatorAssertionResponse; + return { + credentialId: bytesToB64Url(new Uint8Array(credential.rawId)), + authenticatorData: bytesToB64Url( + new Uint8Array(response.authenticatorData), + ), + clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)), + signature: bytesToB64Url(new Uint8Array(response.signature)), + }; +} diff --git a/apps/examples/grid-global-accounts-example-app/tsconfig.json b/apps/examples/grid-global-accounts-example-app/tsconfig.json new file mode 100644 index 000000000..e392ebf28 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/apps/examples/grid-global-accounts-example-app/vite.config.ts b/apps/examples/grid-global-accounts-example-app/vite.config.ts new file mode 100644 index 000000000..e8dda07de --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/vite.config.ts @@ -0,0 +1,25 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import settings from "../settings.json"; + +// Grid API base for the dev proxy (strips the `/api` prefix and rewrites the +// path to the versioned API channel). Defaults to production; override locally +// for a dev backend via the GRID_URL env var, e.g. +// GRID_URL=https://api.dev.dev.sparkinfra.net yarn dev +// Credentials are entered manually in the UI — never embedded here. +const PROD_GRID_URL = process.env.GRID_URL ?? "https://api.lightspark.com"; + +export default defineConfig({ + plugins: [react()], + server: { + port: settings.gridGlobalAccountsExampleApp.port, + proxy: { + "/api": { + target: PROD_GRID_URL, + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/api/, "/grid/2025-10-13"), + }, + }, + }, +}); diff --git a/apps/examples/grid-kyc-demo/README.md b/apps/examples/grid-kyc-demo/README.md new file mode 100644 index 000000000..890c3c763 --- /dev/null +++ b/apps/examples/grid-kyc-demo/README.md @@ -0,0 +1,47 @@ +# grid-kyc-demo + +Internal demo tool for exercising the Grid hosted KYC/KYB link API end-to-end. +Single-page Vite + React app, no backend. Credentials are entered at the top +and live only in this tab's `sessionStorage`. + +## What it does + +- **Create a customer** via `POST /customers` (INDIVIDUAL or BUSINESS). +- **Generate a hosted KYC link** via `POST /customers/{id}/kyc-link` and open it + in a new tab. +- **Poll customer status** via `GET /customers/{id}` so you can watch + `kycStatus` / `kybStatus` flip after the hosted flow completes. + +Every request and response is appended to a rolling log at the bottom of the +page so you can see exactly what's going over the wire. + +## Run it locally + +```bash +cd js/apps/examples/grid-kyc-demo +yarn dev +``` + +Opens on . + +The Vite dev server proxies API calls to one of three environments — pick from +the **Environment** dropdown in the UI: + +| Env | Target | +| ----- | --------------------------------------------------------- | +| prod | `https://api.lightspark.com/grid/2025-10-13` | +| dev | `https://api.dev.dev.sparkinfra.net/grid/rc` | +| local | `http://localhost:5000/grid/rc` (sparkcore on port 5000) | + +Credentials are stored under `grid-kyc-demo:creds:` so prod and dev keys +don't get mixed up. Switching env swaps the visible credential pair. + +## Tips + +- The platform you're calling against needs `customer_kyc_mode = GRID_SWITCH_OWNED` + on at least one of its currencies, otherwise grid auto-approves new customers + on creation and the link flow has nothing to do. +- For INDIVIDUAL customers on the LSP grid switch, the + `LSP_INDIVIDUAL_KYC_ENABLED` gatekeeper also has to be on for the platform. +- The redirect URI must be `https://` — Sumsub rejects `http://` and localhost. + Leave the field blank to use Sumsub's default post-flow page. diff --git a/apps/examples/grid-kyc-demo/index.html b/apps/examples/grid-kyc-demo/index.html new file mode 100644 index 000000000..074b28d71 --- /dev/null +++ b/apps/examples/grid-kyc-demo/index.html @@ -0,0 +1,30 @@ + + + + + + Grid KYC/KYB Demo + + + + +
+ + + diff --git a/apps/examples/grid-kyc-demo/package.json b/apps/examples/grid-kyc-demo/package.json new file mode 100644 index 000000000..fcd9fb8f3 --- /dev/null +++ b/apps/examples/grid-kyc-demo/package.json @@ -0,0 +1,25 @@ +{ + "name": "@lightsparkdev/grid-kyc-demo", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "start": "vite", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@lightsparkdev/origin": "*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^5.6.2", + "vite": "^8.0.14" + } +} diff --git a/apps/examples/grid-kyc-demo/public/fonts b/apps/examples/grid-kyc-demo/public/fonts new file mode 120000 index 000000000..7bf131b0d --- /dev/null +++ b/apps/examples/grid-kyc-demo/public/fonts @@ -0,0 +1 @@ +../../../../packages/origin/public/fonts \ No newline at end of file diff --git a/apps/examples/grid-kyc-demo/src/App.tsx b/apps/examples/grid-kyc-demo/src/App.tsx new file mode 100644 index 000000000..0fcc0880f --- /dev/null +++ b/apps/examples/grid-kyc-demo/src/App.tsx @@ -0,0 +1,1370 @@ +import styled from "@emotion/styled"; +import { + Alert, + Badge, + Button, + Card, + Field, + Input, + Select, + Textarea, +} from "@lightsparkdev/origin"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + callGrid, + ENV_LABELS, + nowTs, + randomSuffix, + type CustomerCreateResponse, + type GridCredentials, + type GridEnv, + type KycLinkResponse, + type LogEntry, +} from "./api"; + +type CustomerType = "INDIVIDUAL" | "BUSINESS"; +type FlowMode = "HOSTED" | "SDK"; +type Status = { kind: "ok" | "err"; message: string } | null; + +const ENV_STORAGE_KEY = "grid-kyc-demo:env"; +const CREDS_STORAGE_KEY_PREFIX = "grid-kyc-demo:creds:"; + +const ENTITY_TYPES = [ + "SOLE_PROPRIETORSHIP", + "PARTNERSHIP", + "LLC", + "CORPORATION", + "S_CORPORATION", + "NON_PROFIT", + "OTHER", +] as const; + +const BUSINESS_TYPES = [ + "AGRICULTURE_FORESTRY_FISHING_AND_HUNTING", + "MINING_QUARRYING_AND_OIL_AND_GAS_EXTRACTION", + "UTILITIES", + "CONSTRUCTION", + "MANUFACTURING", + "WHOLESALE_TRADE", + "RETAIL_TRADE", + "TRANSPORTATION_AND_WAREHOUSING", + "INFORMATION", + "FINANCE_AND_INSURANCE", + "REAL_ESTATE_AND_RENTAL_AND_LEASING", + "PROFESSIONAL_SCIENTIFIC_AND_TECHNICAL_SERVICES", + "MANAGEMENT_OF_COMPANIES_AND_ENTERPRISES", + "ADMINISTRATIVE_AND_SUPPORT_AND_WASTE_MANAGEMENT_AND_REMEDIATION_SERVICES", + "EDUCATIONAL_SERVICES", + "HEALTH_CARE_AND_SOCIAL_ASSISTANCE", + "ARTS_ENTERTAINMENT_AND_RECREATION", + "ACCOMMODATION_AND_FOOD_SERVICES", + "OTHER_SERVICES", + "PUBLIC_ADMINISTRATION", +] as const; + +const PURPOSE_OF_ACCOUNT = [ + "CONTRACTOR_PAYOUTS", + "CREATOR_PAYOUTS", + "EMPLOYEE_PAYOUTS", + "MARKETPLACE_SELLER_PAYOUTS", + "SUPPLIER_PAYMENTS", + "CROSS_BORDER_B2B", + "AR_AUTOMATION", + "AP_AUTOMATION", + "EMBEDDED_PAYMENTS", + "PLATFORM_FEE_COLLECTION", + "P2P_TRANSFERS", + "CHARITABLE_DONATIONS", + "OTHER", +] as const; + +const TX_COUNT = [ + "COUNT_UNDER_10", + "COUNT_10_TO_100", + "COUNT_100_TO_500", + "COUNT_500_TO_1000", + "COUNT_OVER_1000", +] as const; + +const TX_VOLUME = [ + "VOLUME_UNDER_10K", + "VOLUME_10K_TO_100K", + "VOLUME_100K_TO_1M", + "VOLUME_1M_TO_10M", + "VOLUME_OVER_10M", +] as const; + +interface IndividualForm { + platformCustomerId: string; + region: string; + fullName: string; + birthDate: string; + nationality: string; + email: string; + currencies: string; +} + +interface BusinessForm { + platformCustomerId: string; + region: string; + currencies: string; + legalName: string; + doingBusinessAs: string; + country: string; + registrationNumber: string; + incorporatedOn: string; + entityType: string; + taxId: string; + countriesOfOperation: string; + businessType: string; + purposeOfAccount: string; + sourceOfFunds: string; + txCount: string; + txVolume: string; + recipientJurisdictions: string; + addrLine1: string; + addrLine2: string; + addrCity: string; + addrState: string; + addrPostal: string; + addrCountry: string; +} + +function defaultIndividual(): IndividualForm { + return { + platformCustomerId: `ind-${randomSuffix()}`, + region: "US", + fullName: "Jane Smith", + birthDate: "1990-01-15", + nationality: "US", + email: "", + currencies: "USD,USDC", + }; +} + +function defaultBusiness(): BusinessForm { + return { + platformCustomerId: `biz-${randomSuffix()}`, + region: "US", + currencies: "USD,USDC", + legalName: "Acme Corporation", + doingBusinessAs: "Acme", + country: "US", + registrationNumber: "5523041", + incorporatedOn: "2018-03-14", + entityType: "LLC", + taxId: "47-1234567", + countriesOfOperation: "US", + businessType: "INFORMATION", + purposeOfAccount: "CONTRACTOR_PAYOUTS", + sourceOfFunds: "Funds derived from customer payments for software services", + txCount: "COUNT_100_TO_500", + txVolume: "VOLUME_100K_TO_1M", + recipientJurisdictions: "US,MX", + addrLine1: "123 Market Street", + addrLine2: "Suite 400", + addrCity: "San Francisco", + addrState: "CA", + addrPostal: "94105", + addrCountry: "US", + }; +} + +function splitCsv(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function buildIndividualPayload(form: IndividualForm): Record { + const currencies = splitCsv(form.currencies); + const payload: Record = { + customerType: "INDIVIDUAL", + platformCustomerId: form.platformCustomerId.trim(), + region: form.region.trim(), + fullName: form.fullName.trim(), + birthDate: form.birthDate, + nationality: form.nationality.trim(), + }; + if (currencies.length) payload.currencies = currencies; + if (form.email.trim()) payload.email = form.email.trim(); + return payload; +} + +function buildBusinessPayload(form: BusinessForm): Record { + const currencies = splitCsv(form.currencies); + const businessInfo: Record = { + legalName: form.legalName.trim(), + country: form.country.trim(), + registrationNumber: form.registrationNumber.trim(), + incorporatedOn: form.incorporatedOn, + entityType: form.entityType, + taxId: form.taxId.trim(), + countriesOfOperation: splitCsv(form.countriesOfOperation), + businessType: form.businessType, + purposeOfAccount: form.purposeOfAccount, + sourceOfFunds: form.sourceOfFunds.trim(), + expectedMonthlyTransactionCount: form.txCount, + expectedMonthlyTransactionVolume: form.txVolume, + expectedRecipientJurisdictions: splitCsv(form.recipientJurisdictions), + }; + if (form.doingBusinessAs.trim()) + businessInfo.doingBusinessAs = form.doingBusinessAs.trim(); + + const address: Record = { + line1: form.addrLine1.trim(), + city: form.addrCity.trim(), + state: form.addrState.trim(), + postalCode: form.addrPostal.trim(), + country: form.addrCountry.trim(), + }; + if (form.addrLine2.trim()) address.line2 = form.addrLine2.trim(); + + const payload: Record = { + customerType: "BUSINESS", + platformCustomerId: form.platformCustomerId.trim(), + region: form.region.trim(), + businessInfo, + address, + }; + if (currencies.length) payload.currencies = currencies; + return payload; +} + +export function App() { + const [env, setEnv] = useState(envInitial); + const [creds, setCreds] = useState(() => + loadCreds(envInitial()), + ); + const [customerType, setCustomerType] = useState("INDIVIDUAL"); + const [individual, setIndividual] = useState( + defaultIndividual, + ); + const [business, setBusiness] = useState(defaultBusiness); + const [customerId, setCustomerId] = useState(""); + const [redirectUri, setRedirectUri] = useState(""); + const [flowMode, setFlowMode] = useState("HOSTED"); + const [kycLink, setKycLink] = useState(null); + // SdkLauncher owns its own launched-or-not state so it resets cleanly on + // remount (Hosted ⇄ SDK mode cycles, regenerating the link). Lifting it + // here would leave a stale `true` value that hides the Launch button and + // shows an empty iframe container. + + const [pingStatus, setPingStatus] = useState(null); + const [createStatus, setCreateStatus] = useState(null); + const [linkStatus, setLinkStatus] = useState(null); + const [fetchStatus, setFetchStatus] = useState(null); + + const [log, setLog] = useState([]); + const logIdRef = useRef(0); + + // Persist env across reloads; swap creds when env changes. + useEffect(() => { + sessionStorage.setItem(ENV_STORAGE_KEY, env); + setCreds(loadCreds(env)); + }, [env]); + + // Persist creds synchronously when the user edits them. We can't run this + // through a `[creds, env]` effect: that fires once with (oldCreds, newEnv) + // mid-transition during an env switch, briefly writing the previous + // env's credentials into the new env's storage slot before the next + // render corrects it. Driving the write from the input handlers and + // `onClearCreds` keeps persistence in lockstep with the action that + // caused it, and the env-swap effect above owns its own loadCreds + // round-trip. + const persistCreds = useCallback( + (next: GridCredentials) => { + const id = next.id.trim(); + const secret = next.secret.trim(); + const key = CREDS_STORAGE_KEY_PREFIX + env; + if (!id && !secret) sessionStorage.removeItem(key); + else sessionStorage.setItem(key, JSON.stringify({ id, secret })); + }, + [env], + ); + + const appendLog = useCallback((entry: Omit) => { + const id = ++logIdRef.current; + setLog((prev) => [{ id, ts: nowTs(), ...entry }, ...prev].slice(0, 100)); + }, []); + + const runCall = useCallback( + async ( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise => { + try { + const result = await callGrid({ env, creds, method, path, body }); + appendLog({ + env, + method, + path, + requestBody: body, + status: result.status, + responseBody: result.data, + }); + return result.data; + } catch (err) { + const e = err as Error & { status?: number; body?: unknown }; + appendLog({ + env, + method, + path, + requestBody: body, + status: e.status, + responseBody: e.body, + error: e.message, + }); + throw err; + } + }, + [env, creds, appendLog], + ); + + const onPing = useCallback(async () => { + try { + const data = await runCall<{ data?: unknown[] }>( + "GET", + "/customers?limit=1", + ); + const count = Array.isArray(data?.data) ? data.data.length : 0; + setPingStatus({ kind: "ok", message: `OK — listed ${count} customer(s).` }); + } catch (err) { + setPingStatus({ kind: "err", message: (err as Error).message }); + } + }, [runCall]); + + const onClearCreds = useCallback(() => { + const empty = { id: "", secret: "" }; + setCreds(empty); + persistCreds(empty); + }, [persistCreds]); + + const onCreateCustomer = useCallback(async () => { + try { + const payload = + customerType === "INDIVIDUAL" + ? buildIndividualPayload(individual) + : buildBusinessPayload(business); + const data = await runCall( + "POST", + "/customers", + payload, + ); + if (data) { + setCustomerId(data.id); + setCreateStatus({ + kind: "ok", + message: `Created ${data.customerType} customer ${data.id}`, + }); + } + } catch (err) { + setCreateStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerType, individual, business, runCall]); + + const onGenerateLink = useCallback(async () => { + setKycLink(null); + try { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + // The hosted-link redirect URI is irrelevant for the embedded-SDK + // flow (the SDK runs inline; the page never navigates away), so + // omit it when the user is in SDK mode. + const body = + flowMode === "HOSTED" && redirectUri.trim() + ? { redirectUri: redirectUri.trim() } + : undefined; + const data = await runCall( + "POST", + `/customers/${encodeURIComponent(id)}/kyc-link`, + body, + ); + if (data) { + if (flowMode === "SDK" && !data.token) { + throw new Error( + "Provider returned no SDK access token. " + + "Switch to Hosted mode or use a provider that supports embedded SDK.", + ); + } + setKycLink(data); + setLinkStatus({ + kind: "ok", + message: `Link generated — expires ${data.expiresAt}`, + }); + } + } catch (err) { + setLinkStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerId, flowMode, redirectUri, runCall]); + + // The Sumsub SDK calls our token-refresh callback when the current + // access token expires. We re-call the same endpoint to mint a fresh + // one for the same customer. + const refreshSdkToken = useCallback(async (): Promise => { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + const data = await runCall( + "POST", + `/customers/${encodeURIComponent(id)}/kyc-link`, + ); + if (!data?.token) throw new Error("Provider returned no SDK token on refresh"); + return data.token; + }, [customerId, runCall]); + + const onFetchCustomer = useCallback(async () => { + try { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + const data = await runCall( + "GET", + `/customers/${encodeURIComponent(id)}`, + ); + if (data) { + const status = data.kycStatus ?? data.kybStatus ?? "(unknown)"; + setFetchStatus({ + kind: "ok", + message: `${data.customerType} status: ${status}`, + }); + } + } catch (err) { + setFetchStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerId, runCall]); + + const customerTypeOptions = useMemo( + () => [ + { value: "INDIVIDUAL", label: "INDIVIDUAL — KYC hosted link" }, + { value: "BUSINESS", label: "BUSINESS — KYB hosted link" }, + ], + [], + ); + + return ( + + + + Grid KYC/KYB Demo + + Internal demo tool for exercising the Grid hosted KYC/KYB link + API. Everything runs client-side — credentials live in this + browser tab only. Requests are proxied through Vite to the + selected environment. + + + + + + + Environment & credentials + + Credentials are stored per environment in sessionStorage so + prod and dev keys don't get mixed up. + + + + + + + Environment + setEnv(v as GridEnv)} + items={[ + { value: "prod", label: ENV_LABELS.prod }, + { value: "dev", label: ENV_LABELS.dev }, + { value: "local", label: ENV_LABELS.local }, + ]} + /> + + + + API Client ID + { + const next = { ...creds, id: e.target.value }; + setCreds(next); + persistCreds(next); + }} + autoComplete="off" + /> + + + API Client Secret + { + const next = { ...creds, secret: e.target.value }; + setCreds(next); + persistCreds(next); + }} + autoComplete="off" + /> + + + + + + + {pingStatus && ( + + )} + + + + + + + + Customer + + The customer type determines the create payload and whether the + link is KYC (individual) or KYB (business). Either way the link + is generated by POST /customers/<id>/kyc-link. + + + + + + + Customer type + setCustomerType(v as CustomerType)} + items={customerTypeOptions} + /> + + + {customerType === "INDIVIDUAL" ? ( + + ) : ( + + )} + + + + + + + + Run the flow + + + + + + {createStatus && ( + + )} + + + + + Customer ID + setCustomerId(e.target.value)} + placeholder="auto-filled from Create Customer" + /> + + + Verification mode + setFlowMode(v as FlowMode)} + items={[ + { value: "HOSTED", label: "Hosted link (open in new tab)" }, + { value: "SDK", label: "Embedded SDK (Sumsub WebSDK inline)" }, + ]} + /> + + Both modes call the same /kyc-link endpoint — + hosted mode uses the returned kycUrl, embedded + mode uses the returned provider token via + Sumsub's WebSDK script. + + + {flowMode === "HOSTED" && ( + + Redirect URI (optional) + setRedirectUri(e.target.value)} + placeholder="https://app.example.com/onboarding/done" + /> + + Where Sumsub sends the customer after the hosted flow. Must + be https://; Sumsub rejects http://{" "} + and localhost URLs. Leave blank to use Sumsub's default + post-flow page. + + + )} + + {linkStatus && ( + + )} + {kycLink && flowMode === "HOSTED" && ( + + )} + {kycLink && flowMode === "SDK" && ( + + )} + + + + + {fetchStatus && ( + + )} + + + + + + + + Response log + + Most recent first. Cleared on reload. + + + + + {log.length === 0 ? ( + No requests yet. + ) : ( + + {log.map((entry) => ( + + ))} + + )} + + + + + ); +} + +function IndividualFields({ + form, + onChange, +}: { + form: IndividualForm; + onChange: (next: IndividualForm) => void; +}) { + const set = ( + key: K, + value: IndividualForm[K], + ) => onChange({ ...form, [key]: value }); + return ( + <> + + + Platform customer ID + set("platformCustomerId", e.target.value)} + /> + + + Region (ISO 3166-1) + set("region", e.target.value)} + /> + + + + + Full name + set("fullName", e.target.value)} + /> + + + Birth date + set("birthDate", e.target.value)} + /> + + + + + Nationality (ISO 3166-1) + set("nationality", e.target.value)} + /> + + + Email (optional) + set("email", e.target.value)} + /> + + + + Currencies (comma-separated, optional) + set("currencies", e.target.value)} + /> + + + ); +} + +function BusinessFields({ + form, + onChange, +}: { + form: BusinessForm; + onChange: (next: BusinessForm) => void; +}) { + const set = (key: K, value: BusinessForm[K]) => + onChange({ ...form, [key]: value }); + return ( + <> + + + Platform customer ID + set("platformCustomerId", e.target.value)} + /> + + + Region (ISO 3166-1) + set("region", e.target.value)} + /> + + + + Currencies (comma-separated, optional) + set("currencies", e.target.value)} + /> + + + Business info + + + Legal name + set("legalName", e.target.value)} + /> + + + Doing business as (optional) + set("doingBusinessAs", e.target.value)} + /> + + + + + Country of incorporation + set("country", e.target.value)} + /> + + + Registration number + set("registrationNumber", e.target.value)} + /> + + + + + Incorporated on + set("incorporatedOn", e.target.value)} + /> + + + Entity type + set("entityType", v)} + items={ENTITY_TYPES.map((v) => ({ value: v, label: v }))} + /> + + + + + Tax ID + set("taxId", e.target.value)} + /> + + + Countries of operation + set("countriesOfOperation", e.target.value)} + /> + + + + + Business type + set("businessType", v)} + items={BUSINESS_TYPES.map((v) => ({ value: v, label: v }))} + /> + + + Purpose of account + set("purposeOfAccount", v)} + items={PURPOSE_OF_ACCOUNT.map((v) => ({ value: v, label: v }))} + /> + + + + Source of funds + set("sourceOfFunds", e.target.value)} + /> + + + + Expected monthly tx count + set("txCount", v)} + items={TX_COUNT.map((v) => ({ value: v, label: v }))} + /> + + + Expected monthly tx volume + set("txVolume", v)} + items={TX_VOLUME.map((v) => ({ value: v, label: v }))} + /> + + + + Recipient jurisdictions + set("recipientJurisdictions", e.target.value)} + /> + + + Business address + + + Line 1 + set("addrLine1", e.target.value)} + /> + + + Line 2 (optional) + set("addrLine2", e.target.value)} + /> + + + + + City + set("addrCity", e.target.value)} + /> + + + State + set("addrState", e.target.value)} + /> + + + + + Postal code + set("addrPostal", e.target.value)} + /> + + + Country (ISO 3166-1) + set("addrCountry", e.target.value)} + /> + + + + ); +} + +function SelectControl({ + value, + onValueChange, + items, +}: { + value: string; + onValueChange: (next: string) => void; + items: { value: string; label: string }[]; +}) { + return ( + { + if (next != null) onValueChange(next); + }} + > + + + {(v: string) => items.find((i) => i.value === v)?.label ?? v} + + + + + + + + {items.map((item) => ( + + + {item.label} + + ))} + + + + + + ); +} + +function KycLinkResult({ result }: { result: KycLinkResponse }) { + const [copied, setCopied] = useState(false); + return ( + + + {result.provider} + expires {result.expiresAt} + + {result.kycUrl} + + + + + {result.token && ( + + Provider token (consumed by embedded SDK mode):{" "} + {result.token.slice(0, 32)}… + + )} + + ); +} + +function SdkLauncher({ + token, + provider, + onTokenRefresh, +}: { + token: string; + provider: string; + onTokenRefresh: () => Promise; +}) { + const containerRef = useRef(null); + const [sdkError, setSdkError] = useState(null); + // Owned here (not lifted) so the launched state resets every time the + // launcher remounts — toggling Hosted ⇄ SDK mode or regenerating the + // link always lands back at the Launch button, never a stale empty + // iframe container. + const [launched, setLaunched] = useState(false); + + // Tear down the embedded SDK when the launcher unmounts (switching back + // to Hosted mode, regenerating the link, etc.). Sumsub's WebSDK builds + // its iframe + event listeners inside the container we hand it; without + // an explicit cleanup those would leak. The SDK doesn't expose a public + // destroy API, so we clear the container's children — which removes the + // iframe (and the listeners it owns) deterministically. + useEffect(() => { + const container = containerRef.current; + return () => { + if (container) container.replaceChildren(); + }; + }, []); + + const launch = useCallback(() => { + const sdk = snsWebSdk; + if (sdk === undefined) { + setSdkError( + "Sumsub WebSDK script didn't load — check the `; + }); + + html = ensureBaseHref(html, fileName); + + await Promise.all( + extractedScripts.map(({ fileName: scriptFileName, content }) => + fs.writeFile(path.join(staticDir, scriptFileName), content, "utf8"), + ), + ); + await fs.writeFile(htmlPath, html, "utf8"); +} + +function trimScriptContent(content) { + return `${content.replace(/^\n/, "").replace(/\n\s*$/, "")}\n`; +} + +function ensureBaseHref(html, fileName) { + if (/]*>/i.test(html)) { + // Storybook's iframe output currently includes target="_parent"; preserve + // existing non-href base attributes while adding the deployment href. + return html.replace(/]*>/i, withBaseHref); + } + + const baseTag = ``; + const htmlWithBase = html.replace( + /(]*>\s*)/i, + `$1\n ${baseTag}\n`, + ); + if (htmlWithBase === html) { + throw new Error(`Unable to insert base tag in ${fileName}`); + } + + return htmlWithBase; +} + +function withBaseHref(baseTag) { + const attributes = baseTag + .replace(/\s+href=(["']).*?\1/i, "") + .replace(/\s*\/?>$/i, "") + .replace(/^`; +} + +await main(); diff --git a/packages/origin/src/components/Analytics/AnalyticsContext.tsx b/packages/origin/src/components/Analytics/AnalyticsContext.tsx index 88ff7f776..febc87725 100644 --- a/packages/origin/src/components/Analytics/AnalyticsContext.tsx +++ b/packages/origin/src/components/Analytics/AnalyticsContext.tsx @@ -12,7 +12,8 @@ export type InteractionType = | "select" | "expand" | "collapse" - | "sort"; + | "sort" + | "intersect"; export interface InteractionInfo { name: string; diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.module.scss b/packages/origin/src/components/Autocomplete/Autocomplete.module.scss index 76e6d42d8..22f7c669c 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.module.scss +++ b/packages/origin/src/components/Autocomplete/Autocomplete.module.scss @@ -49,7 +49,6 @@ .popup { box-sizing: border-box; width: var(--anchor-width); - max-height: min(23rem, var(--available-height)); max-width: var(--available-width); overflow: hidden; background: var(--surface-primary); @@ -95,6 +94,7 @@ display: flex; gap: var(--spacing-2xs); align-items: center; + flex-shrink: 0; height: 36px; padding: var(--spacing-xs); @include smooth-corners(var(--corner-radius-xs)); diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx b/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx index fa5042b9c..fe548435f 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/packages/origin/src/components/Autocomplete/Autocomplete.stories.tsx @@ -21,6 +21,11 @@ const fruits: Fruit[] = [ { value: "honeydew", label: "Honeydew" }, ]; +const longFruits: Fruit[] = Array.from({ length: 40 }, (_, index) => ({ + value: `fruit-${index + 1}`, + label: `Fruit ${index + 1}`, +})); + const meta: Meta = { title: "Components/Autocomplete", component: Autocomplete.Root, @@ -62,6 +67,33 @@ export const Basic: Story = { ), }; +export const LongList: Story = { + render: () => ( +
+ item.label} + > + + + + + No results found. + + {(item: Fruit) => ( + + {item.label} + + )} + + + + + +
+ ), +}; + export const WithLeadingIcons: Story = { render: () => (
diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx b/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx index bf93332e3..144425217 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx +++ b/packages/origin/src/components/Autocomplete/Autocomplete.test-stories.tsx @@ -19,6 +19,11 @@ const fruits: Fruit[] = [ { value: "elderberry", label: "Elderberry" }, ]; +const longFruits: Fruit[] = Array.from({ length: 40 }, (_, index) => ({ + value: `fruit-${index + 1}`, + label: `Fruit ${index + 1}`, +})); + const groupedItems = [ { label: "Fruits", @@ -61,6 +66,34 @@ export function BasicAutocomplete() { ); } +/** + * Autocomplete with enough items to require list scrolling. + */ +export function LongListAutocomplete() { + return ( + item.label} + > + + + + + No results found. + + {(item: Fruit) => ( + + {item.label} + + )} + + + + + + ); +} + /** * Autocomplete with leading icons */ diff --git a/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx b/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx index d5446208f..d4053943e 100644 --- a/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx +++ b/packages/origin/src/components/Autocomplete/Autocomplete.test.tsx @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/experimental-ct-react"; import { BasicAutocomplete, + LongListAutocomplete, WithLeadingIcon, WithDisabledItems, DisabledAutocomplete, @@ -32,6 +33,94 @@ test.describe("Autocomplete", () => { await expect(page.getByRole("listbox")).toBeVisible(); }); + test("keeps long-list scrolling on the list", async ({ mount, page }) => { + const component = await mount(); + const input = component.getByPlaceholder("Search fruits..."); + + await input.focus(); + await input.press("ArrowDown"); + + const popup = page.getByTestId("autocomplete-long-list-popup"); + const listbox = page.getByTestId("autocomplete-long-list"); + await expect(listbox).toBeVisible(); + + const state = await listbox.evaluate((list) => { + const popup = document.querySelector( + '[data-testid="autocomplete-long-list-popup"]', + ); + const firstItem = list.querySelector('[role="option"]'); + + if (!(popup instanceof HTMLElement)) { + throw new Error("Autocomplete long-list popup is missing"); + } + + if (!(firstItem instanceof HTMLElement)) { + throw new Error("Autocomplete list is missing option rows"); + } + + const listStyles = window.getComputedStyle(list); + const popupStyles = window.getComputedStyle(popup); + const itemStyles = window.getComputedStyle(firstItem); + popup.scrollTop = popup.scrollHeight; + list.scrollTop = list.scrollHeight; + + return { + itemFlexShrink: itemStyles.flexShrink, + itemHeight: itemStyles.height, + itemRenderedHeight: firstItem.getBoundingClientRect().height, + popupMaxHeight: popupStyles.maxHeight, + popupOverflowY: popupStyles.overflowY, + popupHasScrollableOverflow: popup.scrollHeight > popup.clientHeight, + popupCanScroll: popup.scrollTop > 0, + listMaxHeight: listStyles.maxHeight, + listOverflowY: listStyles.overflowY, + listOverscrollBehaviorY: listStyles.overscrollBehaviorY, + listScrollPaddingBlockEnd: listStyles.scrollPaddingBlockEnd, + listScrollPaddingBlockStart: listStyles.scrollPaddingBlockStart, + listHasScrollableOverflow: list.scrollHeight > list.clientHeight, + listCanScroll: list.scrollTop > 0, + }; + }); + + expect(state.itemFlexShrink).toBe("0"); + expect(state.itemHeight).toBe("36px"); + expect(state.itemRenderedHeight).toBeGreaterThanOrEqual(34); + expect(state.popupMaxHeight).toBe("none"); + expect(state.popupOverflowY).toBe("hidden"); + expect(state.popupHasScrollableOverflow).toBe(false); + expect(state.popupCanScroll).toBe(false); + expect(state.listMaxHeight).not.toBe("none"); + expect(state.listOverflowY).toBe("auto"); + expect(state.listOverscrollBehaviorY).toBe("contain"); + expect( + Number.parseFloat(state.listScrollPaddingBlockStart), + ).toBeGreaterThan(0); + expect( + Number.parseFloat(state.listScrollPaddingBlockEnd), + ).toBeGreaterThan(0); + expect(state.listHasScrollableOverflow).toBe(true); + expect(state.listCanScroll).toBe(true); + + await expect(popup).toBeVisible(); + await expect( + page.getByRole("option", { name: "Fruit 40" }), + ).toBeVisible(); + }); + + test("filters long-list object items by label", async ({ mount, page }) => { + const component = await mount(); + const input = component.getByPlaceholder("Search fruits..."); + + await input.fill("40"); + + await expect( + page.getByRole("option", { name: "Fruit 40" }), + ).toBeVisible(); + await expect( + page.getByRole("option", { name: "Fruit 1" }), + ).not.toBeVisible(); + }); + test("filters items as user types", async ({ mount, page }) => { const component = await mount(); const input = component.getByPlaceholder("Search fruits..."); diff --git a/packages/origin/src/components/Button/Button.module.scss b/packages/origin/src/components/Button/Button.module.scss index 83016c6ea..621af9916 100644 --- a/packages/origin/src/components/Button/Button.module.scss +++ b/packages/origin/src/components/Button/Button.module.scss @@ -31,6 +31,10 @@ } } +.fullWidth { + width: 100%; +} + .dense { --button-icon-size: 12px; diff --git a/packages/origin/src/components/Button/Button.stories.tsx b/packages/origin/src/components/Button/Button.stories.tsx index 88eb01af4..826768124 100644 --- a/packages/origin/src/components/Button/Button.stories.tsx +++ b/packages/origin/src/components/Button/Button.stories.tsx @@ -54,6 +54,7 @@ const meta: Meta = { }, loading: { control: "boolean" }, disabled: { control: "boolean" }, + fullWidth: { control: "boolean" }, children: { control: "text" }, }, }; @@ -67,6 +68,7 @@ export const Default: Story = { size: "default", loading: false, disabled: false, + fullWidth: false, children: "Button", }, }; diff --git a/packages/origin/src/components/Button/Button.test-stories.tsx b/packages/origin/src/components/Button/Button.test-stories.tsx index 0e0b1cff7..574223a3f 100644 --- a/packages/origin/src/components/Button/Button.test-stories.tsx +++ b/packages/origin/src/components/Button/Button.test-stories.tsx @@ -1,4 +1,4 @@ -import { Button } from "./Button"; +import { Button, ButtonLink } from "./Button"; const ChevronLeft = () => ( @@ -55,6 +55,101 @@ export function LinkButton() { return ; } +export function AnchorButtonLink() { + return ( + + Read docs + + ); +} + +export function DisabledAnchorButtonLink() { + return ( + + Disabled docs + + ); +} + +export function RenderedButtonLink() { + return ( + } variant="secondary"> + Settings + + ); +} + +export function RenderedButtonLinkWithMergedProps() { + return ( + { + event.preventDefault(); + document.body.dataset.buttonLinkClick = "true"; + }} + render={ + { + document.body.dataset.renderClick = "true"; + }} + /> + } + variant="outline" + > + Merged props + + ); +} + +export function RenderedButtonLinkWithMergedRefs() { + return ( + { + if (node) { + document.body.dataset.buttonLinkRef = node.getAttribute("href") ?? ""; + } + }} + render={ + { + if (node) { + document.body.dataset.renderRef = node.getAttribute("href") ?? ""; + } + }} + /> + } + variant="outline" + > + Merged refs + + ); +} + +export function DisabledRenderedButtonLink() { + return ( + { + document.body.dataset.disabledButtonLinkClick = "true"; + }} + render={ + { + document.body.dataset.disabledRenderClick = "true"; + }} + /> + } + > + Disabled link + + ); +} + export function DisabledLinkButton() { return (
+ +
+ ); +} + export function DisabledSecondaryButton() { return ( + + + + {legalName || "no legal name"} + + {entityType ?? "none"} + + {registrationCountry ?? "none"} + + + {countrySearch || "empty"} + + + {countryOpen ? "open" : "closed"} + + + {comboboxRoles.join(",") || "none"} + + + {checkboxRoles.join(",") || "none"} + +
+ ); +} + +export function CompositeFormErrorsBoundary() { + return ( +
+ + Country + + + + {(value: string | null) => getLabel(countryOptions, value)} + + + + + + + + {countryOptions.map((option) => ( + + + {option.label} + + ))} + + + + + + Select a country + + + + Business type + + items={businessTypeOptions} + itemToStringValue={(option) => option.label} + > + + + + + + + + + + No business types found + + {(option: ProductOption) => ( + + + {option.label} + + )} + + + + + + Select a business type + +
+ ); +} + +export function FieldRootRenderFormBoundary() { + return ( +
+ + } + > + Registered business name + + Enter a registered business name + +
+ ); +} diff --git a/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx b/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx new file mode 100644 index 000000000..e549543a8 --- /dev/null +++ b/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx @@ -0,0 +1,168 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + CompositeFormErrorsBoundary, + FieldRootRenderFormBoundary, + KybOriginFormCompositionBoundary, +} from "./FormCompositionBoundary.test-stories"; + +test.describe("Origin form composition boundaries", () => { + test("connects Form errors, Field names, external invalid state, controlled Input, and invalid focus", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByRole("button", { name: "Review" }).click(); + await expect(page.getByText("Enter a legal business name")).toBeVisible(); + await expect( + page.getByPlaceholder("Enter legal business name"), + ).toBeFocused(); + + const legalName = page.getByPlaceholder("Enter legal business name"); + await legalName.fill("Acme Treasury LLC"); + await expect(page.getByTestId("legal-name-value")).toHaveText( + "Acme Treasury LLC", + ); + + await page.getByRole("button", { name: "Review" }).click(); + await expect(page.getByText("Select a registration country")).toBeVisible(); + await expect(page.getByText("Enter a business purpose")).toBeVisible(); + await expect(page.getByPlaceholder("Search countries")).toBeFocused(); + await expect(page.getByPlaceholder("Search countries")).toHaveAttribute( + "data-invalid", + "", + ); + + const purpose = page.getByPlaceholder("Describe business purpose"); + await purpose.fill("Treasury operations"); + await expect(page.getByText("Enter a business purpose")).not.toBeVisible(); + }); + + test("maps product-style Select options to a controlled string value", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByTestId("entity-type-trigger").click(); + await page + .getByRole("option", { name: "Limited liability company" }) + .click(); + + await expect(page.getByTestId("entity-type-value")).toHaveText("llc"); + await expect(page.getByTestId("entity-type-trigger")).toContainText( + "Limited liability company", + ); + }); + + test("maps searchable Combobox objects to product string state with controlled input, popup, and portal state", async ({ + mount, + page, + }) => { + await mount(); + + const countryInput = page.getByPlaceholder("Search countries"); + await countryInput.click(); + await expect(page.getByTestId("country-open-state")).toHaveText("open"); + await expect( + page.getByTestId("country-portal").getByRole("listbox"), + ).toBeVisible(); + + await countryInput.fill("Can"); + await expect(page.getByTestId("country-search-value")).toHaveText("Can"); + + await page.getByRole("option", { name: "Canada" }).click(); + + await expect(page.getByTestId("country-value")).toHaveText("CA"); + await expect(countryInput).toHaveValue("Canada"); + await expect(page.getByTestId("country-open-state")).toHaveText("closed"); + }); + + test("supports Combobox multi-select chips with accessible chip removal", async ({ + mount, + page, + }) => { + await mount(); + + const rolesInput = page.getByPlaceholder("Add owner roles"); + await rolesInput.click(); + await page.getByRole("option", { name: "Control person" }).click(); + await page.getByRole("option", { name: "Signer" }).click(); + + await expect(page.getByTestId("combobox-roles-value")).toHaveText( + "control-person,signer", + ); + await expect( + page.getByRole("toolbar").getByText("Control person"), + ).toBeVisible(); + await expect(page.getByRole("toolbar").getByText("Signer")).toBeVisible(); + + await page.getByRole("button", { name: "Remove Signer" }).click(); + + await expect(page.getByTestId("combobox-roles-value")).toHaveText( + "control-person", + ); + }); + + test("supports Checkbox.Group owner-role-style controlled multi selection", async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByTestId("checkbox-roles-value")).toHaveText( + "control-person", + ); + + await page.getByTestId("checkbox-role-signer").click(); + + await expect(page.getByTestId("checkbox-roles-value")).toHaveText( + "control-person,signer", + ); + }); + + test("supports Field.Root render with merged classes and Form invalid state", async ({ + mount, + page, + }) => { + await mount(); + + const root = page.getByTestId("form-rendered-field-root"); + await expect(root).toBeVisible(); + await expect(root).toHaveJSProperty("tagName", "SECTION"); + await expect(root).toHaveAttribute("data-custom-root", ""); + await expect(root).toHaveAttribute("data-invalid", ""); + await expect(root).toHaveCSS("display", "flex"); + await expect(root).toHaveCSS("flex-direction", "column"); + await expect(root).toHaveClass(/consumer-form-field-root/); + await expect(root).toHaveClass(/rendered-form-field-root/); + await expect( + page.getByPlaceholder("Enter registered business name"), + ).toHaveAttribute("data-invalid", ""); + await expect( + page.getByText("Enter a registered business name"), + ).toBeVisible(); + }); + + test("propagates Form errors to composite Select and Combobox fields without explicit invalid props", async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText("Select a country")).toBeVisible(); + await expect(page.getByText("Select a business type")).toBeVisible(); + + await expect(page.getByTestId("country-trigger")).toHaveAttribute( + "data-invalid", + "", + ); + await expect(page.getByTestId("business-type-wrapper")).toHaveAttribute( + "data-invalid", + "", + ); + await expect( + page.getByPlaceholder("Search business types"), + ).toHaveAttribute("data-invalid", ""); + }); +}); diff --git a/packages/origin/src/components/LoadMore/LoadMore.module.scss b/packages/origin/src/components/LoadMore/LoadMore.module.scss new file mode 100644 index 000000000..7c2bd36af --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.module.scss @@ -0,0 +1,12 @@ +@use "../../tokens/mixins" as *; + +.sentinel { + display: block; + width: 100%; + height: 1px; + pointer-events: none; +} + +.status { + @include visually-hidden; +} diff --git a/packages/origin/src/components/LoadMore/LoadMore.stories.tsx b/packages/origin/src/components/LoadMore/LoadMore.stories.tsx new file mode 100644 index 000000000..000e7ff1a --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.stories.tsx @@ -0,0 +1,250 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import * as React from "react"; +import { LoadMore } from "./LoadMore"; +import { useLoadMore } from "./useLoadMore"; +import { Button } from "../Button"; + +interface Item { + id: string; + label: string; +} + +function generatePage(offset: number, size: number): Item[] { + return Array.from({ length: size }, (_, i) => ({ + id: `${offset + i}`, + label: `Item ${offset + i + 1}`, + })); +} + +function ItemList({ items }: { items: Item[] }) { + return ( +
    + {items.map((item) => ( +
  • + {item.label} +
  • + ))} +
+ ); +} + +const meta: Meta = { + title: "Components/LoadMore", + component: LoadMore.Root, + parameters: { layout: "centered" }, +}; + +export default meta; +type Story = StoryObj; + +export const ManualTrigger: Story = { + render: () => { + const [items, setItems] = React.useState(() => generatePage(0, 10)); + const [loading, setLoading] = React.useState(false); + const [hasMore, setHasMore] = React.useState(true); + + const onLoadMore = () => { + setLoading(true); + setTimeout(() => { + setItems((prev) => { + const next = [...prev, ...generatePage(prev.length, 10)]; + if (next.length >= 30) setHasMore(false); + return next; + }); + setLoading(false); + }, 600); + }; + + return ( +
+ + + + +
+ ); + }, +}; + +export const AutoSentinel: Story = { + render: () => { + const { items, hasMore, loading, loadingMore, loadMore } = + useLoadMore({ + fetchPage: async (cursor) => { + const offset = cursor ? Number(cursor) : 0; + await new Promise((r) => setTimeout(r, 500)); + const data = generatePage(offset, 10); + const next = offset + 10; + return { + data, + nextCursor: next < 50 ? String(next) : undefined, + hasMore: next < 50, + }; + }, + }); + + return ( +
+ + + + + {({ loading, hasMore }) => + loading + ? "Loading more results" + : !hasMore + ? "End of results" + : "" + } + + +
+ ); + }, +}; + +export const SentinelWithFallbackTrigger: Story = { + render: () => { + const { items, hasMore, loading, loadingMore, loadMore } = + useLoadMore({ + fetchPage: async (cursor) => { + const offset = cursor ? Number(cursor) : 0; + await new Promise((r) => setTimeout(r, 400)); + const data = generatePage(offset, 5); + const next = offset + 5; + return { + data, + nextCursor: next < 25 ? String(next) : undefined, + hasMore: next < 25, + }; + }, + }); + + return ( +
+ + + +
+ +
+ + {({ loading, hasMore }) => + loading + ? "Loading more results" + : !hasMore + ? "End of results" + : "" + } + +
+
+ ); + }, +}; + +export const EndOfResults: Story = { + render: () => ( +
+ + undefined} + > + + + {({ hasMore }) => (!hasMore ? "End of results" : "")} + + +
+ ), +}; + +export const LoadingState: Story = { + render: () => ( +
+ + undefined}> + + +
+ ), +}; + +export const CustomTriggerRender: Story = { + render: () => ( + undefined}> + Show more} /> + + ), +}; + +export const WithFilterReset: Story = { + render: () => { + const [filter, setFilter] = React.useState("all"); + const { items, hasMore, loading, loadingMore, loadMore } = + useLoadMore({ + fetchPage: async (cursor) => { + const offset = cursor ? Number(cursor) : 0; + await new Promise((r) => setTimeout(r, 300)); + const data = generatePage(offset, 5).map((item) => ({ + ...item, + label: `${filter}: ${item.label}`, + })); + const next = offset + 5; + return { + data, + nextCursor: next < 20 ? String(next) : undefined, + hasMore: next < 20, + }; + }, + resetOn: [filter], + }); + + return ( +
+
+ {["all", "starred", "archived"].map((value) => ( + + ))} +
+ + + + +
+ ); + }, +}; diff --git a/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx b/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx new file mode 100644 index 000000000..b37e21ab9 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx @@ -0,0 +1,233 @@ +"use client"; + +import * as React from "react"; +import { LoadMore } from "./LoadMore"; +import { Button } from "../Button"; +import { useLoadMore } from "./useLoadMore"; +import { AnalyticsProvider } from "../Analytics"; +import type { AnalyticsHandler, InteractionInfo } from "../Analytics"; + +interface CounterRefs { + loadCount: number; +} + +function ManualHarness({ + hasMore = true, + loading = false, +}: { + hasMore?: boolean; + loading?: boolean; +}) { + const [count, setCount] = React.useState(0); + return ( + setCount((c) => c + 1)} + > + +

Loads: {count}

+
+ ); +} + +export function TriggerEnabled() { + return ; +} + +export function TriggerNoMore() { + return ; +} + +export function TriggerLoading() { + return ; +} + +export function TriggerCustomRender() { + const [count, setCount] = React.useState(0); + return ( + setCount((c) => c + 1)} + > + Show more} /> +

Loads: {count}

+
+ ); +} + +function SentinelHarness({ + initialHasMore = true, + hold = false, +}: { + initialHasMore?: boolean; + hold?: boolean; +}) { + const [count, setCount] = React.useState(0); + const [hasMore, setHasMore] = React.useState(initialHasMore); + const [loading, setLoading] = React.useState(false); + + const onLoadMore = React.useCallback(() => { + setCount((c) => c + 1); + if (hold) { + setLoading(true); + return; + } + setLoading(true); + setTimeout(() => { + setLoading(false); + setHasMore(false); + }, 50); + }, [hold]); + + return ( +
+
+ + +

Loads: {count}

+
+
+ ); +} + +export function SentinelTriggersOnScroll() { + return ; +} + +export function SentinelDoesNotRefireWhileLoading() { + return ; +} + +export function SentinelDisabled() { + return ( + undefined}> + + + ); +} + +export function StatusAnnouncements({ + hasMore = true, + loading = false, +}: { + hasMore?: boolean; + loading?: boolean; +}) { + return ( + undefined} + > + + {({ loading, hasMore }) => + loading + ? "Loading more results" + : !hasMore + ? "End of results" + : "More available" + } + + + ); +} + +export function StatusLoading() { + return ; +} + +export function StatusEnd() { + return ; +} + +export function ContextOutsideRoot() { + return ( + + + + ); +} + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return
{this.state.error.message}
; + } + return this.props.children; + } +} + +export function AnalyticsTrigger() { + const [events, setEvents] = React.useState([]); + const handler = React.useMemo( + () => ({ + onInteraction: (info) => setEvents((prev) => [...prev, info]), + }), + [], + ); + + return ( + + undefined} + analyticsName="results" + > + + +
{JSON.stringify(events)}
+
+ ); +} + +export function HookIntegration() { + const fetchPage = React.useCallback(async (cursor: string | undefined) => { + const offset = cursor ? Number(cursor) : 0; + const data = Array.from({ length: 5 }, (_, i) => ({ + id: `${offset + i}`, + })); + const next = offset + 5; + return { + data, + nextCursor: next < 15 ? String(next) : undefined, + hasMore: next < 15, + }; + }, []); + + const { items, hasMore, loading, loadingMore, loadMore } = useLoadMore<{ + id: string; + }>({ + fetchPage, + }); + + return ( +
+
    + {items.map((item) => ( +
  • {item.id}
  • + ))} +
+ + + +
+ ); +} diff --git a/packages/origin/src/components/LoadMore/LoadMore.test.tsx b/packages/origin/src/components/LoadMore/LoadMore.test.tsx new file mode 100644 index 000000000..3cb9e9dee --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.test.tsx @@ -0,0 +1,167 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + TriggerEnabled, + TriggerNoMore, + TriggerLoading, + TriggerCustomRender, + SentinelTriggersOnScroll, + SentinelDoesNotRefireWhileLoading, + SentinelDisabled, + StatusAnnouncements, + StatusLoading, + StatusEnd, + ContextOutsideRoot, + AnalyticsTrigger, + HookIntegration, +} from "./LoadMore.test-stories"; + +test.describe("LoadMore.Trigger", () => { + test("calls onLoadMore on click and increments the counter", async ({ + mount, + page, + }) => { + await mount(); + const trigger = page.getByRole("button", { name: /load more/i }); + await expect(trigger).toBeEnabled(); + await expect(trigger).toHaveAttribute("data-has-more", "true"); + await trigger.click(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("is disabled when hasMore is false", async ({ mount, page }) => { + await mount(); + const trigger = page.getByRole("button", { name: /load more/i }); + await expect(trigger).toBeDisabled(); + await expect(trigger).toHaveAttribute("data-disabled", "true"); + await expect(trigger).not.toHaveAttribute("data-has-more", "true"); + }); + + test("is disabled and aria-busy while loading", async ({ mount, page }) => { + await mount(); + const trigger = page.getByRole("button"); + await expect(trigger).toBeDisabled(); + await expect(trigger).toHaveAttribute("aria-busy", "true"); + await expect(trigger).toHaveAttribute("data-loading", "true"); + }); + + test("render prop swaps the underlying element and still tracks clicks", async ({ + mount, + page, + }) => { + await mount(); + const trigger = page.getByRole("button", { name: /show more/i }); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); +}); + +test.describe("LoadMore.Sentinel", () => { + test("calls onLoadMore when scrolled into view", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 0"); + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }), + ); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + // Stays at 1 — hasMore is now false after the timeout completes. + await page.waitForTimeout(150); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("does not refire while loading is held true", async ({ + mount, + page, + }) => { + await mount(); + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }), + ); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + await page.waitForTimeout(200); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("renders no DOM when disabled", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("sentinel")).toHaveCount(0); + }); +}); + +test.describe("LoadMore.Status", () => { + test("renders 'More available' by default with aria-live polite", async ({ + mount, + page, + }) => { + await mount(); + const status = page.getByTestId("status"); + await expect(status).toHaveAttribute("aria-live", "polite"); + await expect(status).toHaveAttribute("aria-atomic", "true"); + await expect(status).toHaveText("More available"); + }); + + test("announces loading text", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("status")).toHaveText("Loading more results"); + await expect(page.getByTestId("status")).toHaveAttribute( + "data-loading", + "true", + ); + }); + + test("announces end-of-results text", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("status")).toHaveText("End of results"); + await expect(page.getByTestId("status")).toHaveAttribute( + "data-end", + "true", + ); + }); +}); + +test.describe("Context safety", () => { + test("Trigger throws when used outside Root", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("error")).toHaveText( + /must be placed within /, + ); + }); +}); + +test.describe("Analytics", () => { + test("emits LoadMore.click with part metadata when analyticsName is set", async ({ + mount, + page, + }) => { + await mount(); + await page.getByRole("button", { name: /load more/i }).click(); + const log = await page.getByTestId("analytics-log").textContent(); + expect(log).toBeTruthy(); + const events = JSON.parse(log ?? "[]"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + name: "results", + component: "LoadMore", + interaction: "click", + metadata: { part: "trigger" }, + }); + }); +}); + +test.describe("Hook integration", () => { + test("paginates via useLoadMore until hasMore is false", async ({ + mount, + page, + }) => { + await mount(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(5); + + const trigger = page.getByRole("button", { name: /load more/i }); + await trigger.click(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(10); + + await trigger.click(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(15); + await expect(trigger).toBeDisabled(); + }); +}); diff --git a/packages/origin/src/components/LoadMore/LoadMore.tsx b/packages/origin/src/components/LoadMore/LoadMore.tsx new file mode 100644 index 000000000..2afabc3b3 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.tsx @@ -0,0 +1,432 @@ +"use client"; + +import * as React from "react"; +import { Button, type ButtonProps } from "../Button"; +import { useTrackedCallback } from "../Analytics/useTrackedCallback"; +import { useRender, mergeProps } from "../../lib/base-ui-utils"; +import styles from "./LoadMore.module.scss"; + +export interface LoadMoreContextValue { + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; + analyticsName: string | undefined; +} + +const LoadMoreContext = React.createContext(null); + +/** Access the surrounding `LoadMore.Root` state. Throws if used outside one. */ +export function useLoadMoreContext(): LoadMoreContextValue { + const context = React.useContext(LoadMoreContext); + if (context === null) { + throw new Error("LoadMore parts must be placed within ."); + } + return context; +} + +export interface LoadMoreRootProps { + /** Whether more items are available. */ + hasMore: boolean; + /** + * Whether a load is currently in flight. Trigger and Sentinel use this to + * disable themselves and prevent re-firing. + */ + loading: boolean; + /** Called when the user (or sentinel intersection) requests another page. */ + onLoadMore: () => void; + /** + * Optional analytics identifier. Trigger emits `${name}.click` (interaction + * `click`) and Sentinel emits `${name}.intersect` (interaction `intersect`) + * with metadata `{ part: "trigger" | "sentinel" }`. + */ + analyticsName?: string; + children?: React.ReactNode; +} + +/** Headless context provider — renders only its children. */ +export function LoadMoreRoot(props: LoadMoreRootProps) { + const { hasMore, loading, onLoadMore, analyticsName, children } = props; + + const value = React.useMemo( + () => ({ hasMore, loading, onLoadMore, analyticsName }), + [hasMore, loading, onLoadMore, analyticsName], + ); + + return ( + + {children} + + ); +} + +type TriggerRenderState = { + hasMore: boolean; + loading: boolean; + disabled: boolean; +}; + +type TriggerRenderProp = useRender.RenderProp; + +export interface LoadMoreTriggerProps + extends Omit { + /** + * Override the auto-derived disabled state (`!hasMore || loading`). Pass + * `false` to force-enable; pass `true` to force-disable. + */ + disabled?: boolean; + /** + * Replace the default `Button` element. Receives the merged click/disabled + * props the trigger would otherwise pass to `Button`. + */ + render?: TriggerRenderProp; + /** Visible label. Defaults to `"Load more"`. */ + children?: React.ReactNode; +} + +interface RenderTriggerProps { + render: TriggerRenderProp; + state: TriggerRenderState; + forwardedProps: Record; + onClick: (event: React.MouseEvent) => void; + isDisabled: boolean; + loading: boolean; + forwardedRef: React.ForwardedRef; +} + +function RenderTrigger({ + render, + state, + forwardedProps, + onClick, + isDisabled, + loading, + forwardedRef, +}: RenderTriggerProps) { + const internalProps = { + onClick, + disabled: isDisabled, + "aria-busy": loading || undefined, + "data-loading": loading || undefined, + "data-has-more": state.hasMore || undefined, + "data-disabled": isDisabled || undefined, + } as React.ComponentPropsWithRef<"button">; + return useRender({ + render, + ref: forwardedRef as React.Ref, + state, + props: mergeProps<"button">( + internalProps, + forwardedProps as React.ComponentPropsWithRef<"button">, + ), + }); +} + +/** Manual button trigger. Defaults to Origin's `Button`. */ +export const LoadMoreTrigger = React.forwardRef< + HTMLButtonElement, + LoadMoreTriggerProps +>(function LoadMoreTrigger(props, forwardedRef) { + const { disabled, render, children = "Load more", ...rest } = props; + const { hasMore, loading, onLoadMore, analyticsName } = useLoadMoreContext(); + const isDisabled = disabled ?? (!hasMore || loading); + + const handleClick = useTrackedCallback( + analyticsName, + "LoadMore", + "click", + () => { + if (isDisabled) return; + onLoadMore(); + }, + () => ({ part: "trigger" }), + ); + + if (render) { + return ( + } + onClick={handleClick} + isDisabled={isDisabled} + loading={loading} + forwardedRef={forwardedRef} + /> + ); + } + + return ( + + ); +}); + +export interface LoadMoreSentinelProps + extends React.HTMLAttributes { + /** + * IntersectionObserver root. Defaults to the viewport. Pass a scroll + * container to scope observations to a scrolling region. + */ + root?: Element | Document | null; + /** Defaults to `"0px 0px 200px 0px"` — preload 200px before reaching the sentinel. */ + rootMargin?: string; + /** Defaults to `0`. */ + threshold?: number | number[]; + /** + * Disable the observer entirely. When `true` no DOM is rendered, so callers + * can fall back to a manual `Trigger`. + */ + disabled?: boolean; + /** Override the rendered element. */ + render?: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; +} + +/** Invisible viewport-intersection trigger for infinite scroll. */ +export const LoadMoreSentinel = React.forwardRef< + HTMLDivElement, + LoadMoreSentinelProps +>(function LoadMoreSentinel(props, forwardedRef) { + const { + root = null, + rootMargin = "0px 0px 200px 0px", + threshold = 0, + disabled, + render, + className, + ...rest + } = props; + const { hasMore, loading, onLoadMore, analyticsName } = useLoadMoreContext(); + + const onLoadMoreRef = React.useRef(onLoadMore); + onLoadMoreRef.current = onLoadMore; + const loadingRef = React.useRef(loading); + loadingRef.current = loading; + const hasMoreRef = React.useRef(hasMore); + hasMoreRef.current = hasMore; + + const trackedIntersect = useTrackedCallback( + analyticsName, + "LoadMore", + "intersect", + () => onLoadMoreRef.current(), + () => ({ part: "sentinel" }), + ); + + const isMountedRef = React.useRef(false); + + const localRef = React.useRef(null); + const setRef = React.useCallback( + (node: HTMLDivElement | null) => { + localRef.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + React.useEffect(() => { + if (disabled) return; + const node = localRef.current; + if (!node || typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + if (loadingRef.current) continue; + if (!hasMoreRef.current) continue; + trackedIntersect(); + break; + } + }, + { root: root ?? null, rootMargin, threshold }, + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [disabled, root, rootMargin, threshold, trackedIntersect]); + + // After loading flips false, re-evaluate intersection in case the new page + // didn't grow tall enough to scroll the sentinel out of view. Skipped on + // initial mount so we don't double-fire alongside the IntersectionObserver + // setup effect when the sentinel is already in view. + React.useEffect(() => { + if (!isMountedRef.current) { + isMountedRef.current = true; + return; + } + if (loading || !hasMore || disabled) return; + const node = localRef.current; + if (!node || typeof window === "undefined") return; + const rect = node.getBoundingClientRect(); + const inView = rect.top < window.innerHeight && rect.bottom > 0; + if (inView) trackedIntersect(); + }, [loading, hasMore, disabled, trackedIntersect]); + + if (disabled) return null; + + const baseProps = { + "aria-hidden": true as const, + role: "presentation" as const, + "data-loading": loading || undefined, + "data-active": (hasMore && !loading) || undefined, + className: [styles.sentinel, className].filter(Boolean).join(" "), + }; + + if (render) { + return ( + } + setRef={setRef} + /> + ); + } + + return
; +}); + +interface RenderSentinelProps { + render: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; + state: { hasMore: boolean; loading: boolean }; + baseProps: Record; + forwardedProps: Record; + setRef: React.RefCallback; +} + +function RenderSentinel({ + render, + state, + baseProps, + forwardedProps, + setRef, +}: RenderSentinelProps) { + return useRender({ + render, + ref: setRef, + state, + props: mergeProps<"div">( + baseProps as React.ComponentPropsWithRef<"div">, + forwardedProps as React.ComponentPropsWithRef<"div">, + ), + }); +} + +type StatusRenderState = { loading: boolean; hasMore: boolean }; + +export interface LoadMoreStatusProps + extends Omit, "children"> { + /** + * Either static content, or a render function that receives the current + * load state and returns the announcement text. + */ + children?: React.ReactNode | ((state: StatusRenderState) => React.ReactNode); + /** Defaults to `"polite"`. */ + "aria-live"?: "polite" | "assertive" | "off"; + render?: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; +} + +/** SR-only `aria-live` announcement slot. */ +export const LoadMoreStatus = React.forwardRef< + HTMLDivElement, + LoadMoreStatusProps +>(function LoadMoreStatus(props, forwardedRef) { + const { + children, + "aria-live": ariaLive = "polite", + render, + className, + ...rest + } = props; + const { hasMore, loading } = useLoadMoreContext(); + + const content = + typeof children === "function" + ? (children as (state: StatusRenderState) => React.ReactNode)({ + loading, + hasMore, + }) + : children; + + const baseProps = { + "aria-live": ariaLive, + "aria-atomic": true as const, + "data-loading": loading || undefined, + "data-end": !hasMore || undefined, + className: [styles.status, className].filter(Boolean).join(" "), + }; + + if (render) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +}); + +interface RenderStatusProps { + render: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; + state: { hasMore: boolean; loading: boolean }; + baseProps: Record; + forwardedProps: Record; + forwardedRef: React.ForwardedRef; +} + +function RenderStatus({ + render, + state, + baseProps, + forwardedProps, + forwardedRef, +}: RenderStatusProps) { + return useRender({ + render, + ref: forwardedRef as React.Ref, + state, + props: mergeProps<"div">( + baseProps as React.ComponentPropsWithRef<"div">, + forwardedProps as React.ComponentPropsWithRef<"div">, + ), + }); +} + +if (process.env.NODE_ENV !== "production") { + LoadMoreTrigger.displayName = "LoadMoreTrigger"; + LoadMoreSentinel.displayName = "LoadMoreSentinel"; + LoadMoreStatus.displayName = "LoadMoreStatus"; +} + +export const LoadMore = { + Root: LoadMoreRoot, + Trigger: LoadMoreTrigger, + Sentinel: LoadMoreSentinel, + Status: LoadMoreStatus, +}; + +export default LoadMore; diff --git a/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx b/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx new file mode 100644 index 000000000..6f84630c5 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx @@ -0,0 +1,81 @@ +/** + * LoadMore Unit Tests (Vitest + @testing-library/react) + * + * For real browser testing (IntersectionObserver, scroll, accessibility), + * see LoadMore.test.tsx (Playwright CT). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "@testing-library/react"; +import * as React from "react"; +import { LoadMore } from "./LoadMore"; + +type ObserverCallback = ( + entries: IntersectionObserverEntry[], + observer: IntersectionObserver, +) => void; + +interface MockObserver { + observe: ReturnType; + unobserve: ReturnType; + disconnect: ReturnType; + takeRecords: ReturnType; + callback: ObserverCallback; +} + +const observers: MockObserver[] = []; + +beforeEach(() => { + observers.length = 0; + class MockIntersectionObserver implements MockObserver { + callback: ObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + + constructor(callback: ObserverCallback) { + this.callback = callback; + observers.push(this); + } + } + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function fireIntersect(observer: MockObserver) { + const target = observer.observe.mock.calls[0]?.[0] as Element; + observer.callback( + [ + { + isIntersecting: true, + target, + intersectionRatio: 1, + boundingClientRect: target.getBoundingClientRect(), + intersectionRect: target.getBoundingClientRect(), + rootBounds: null, + time: 0, + } as IntersectionObserverEntry, + ], + observer as unknown as IntersectionObserver, + ); +} + +describe("LoadMore.Sentinel initial mount", () => { + it("fires onLoadMore exactly once when the sentinel mounts already in view", () => { + const onLoadMore = vi.fn(); + render( + + + , + ); + + expect(observers).toHaveLength(1); + fireIntersect(observers[0]); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/origin/src/components/LoadMore/index.ts b/packages/origin/src/components/LoadMore/index.ts new file mode 100644 index 000000000..26baa0074 --- /dev/null +++ b/packages/origin/src/components/LoadMore/index.ts @@ -0,0 +1,14 @@ +export { LoadMore, useLoadMoreContext } from "./LoadMore"; +export type { + LoadMoreRootProps, + LoadMoreTriggerProps, + LoadMoreSentinelProps, + LoadMoreStatusProps, + LoadMoreContextValue, +} from "./LoadMore"; +export { useLoadMore } from "./useLoadMore"; +export type { + UseLoadMoreOptions, + UseLoadMoreResult, + UseLoadMoreFetchResult, +} from "./useLoadMore"; diff --git a/packages/origin/src/components/LoadMore/useLoadMore.ts b/packages/origin/src/components/LoadMore/useLoadMore.ts new file mode 100644 index 000000000..a325d2a93 --- /dev/null +++ b/packages/origin/src/components/LoadMore/useLoadMore.ts @@ -0,0 +1,150 @@ +"use client"; + +import * as React from "react"; + +export interface UseLoadMoreFetchResult { + data: T[]; + /** Cursor for the next page. Omit when there is no next page. */ + nextCursor?: TCursor; + /** Whether `loadMore` should be enabled after this page. */ + hasMore: boolean; +} + +export interface UseLoadMoreOptions { + /** + * Fetches a page. Receives the cursor from the previous page, or `undefined` + * for the initial fetch (and after `refetch`/`resetOn` change). Reject the + * promise to surface an error in `result.error`. + */ + fetchPage: ( + cursor: TCursor | undefined, + ) => Promise>; + /** + * When any value changes (by `JSON.stringify` value), pagination resets and + * an initial fetch is kicked off. Values must be JSON-serializable; for + * object dependencies, pass a stable id. Defaults to `[]` (fetch once). + */ + resetOn?: React.DependencyList; + /** Skip the initial fetch when `false`. Defaults to `true`. */ + enabled?: boolean; + /** Starting cursor for the first page. */ + initialCursor?: TCursor; +} + +export interface UseLoadMoreResult { + items: T[]; + /** True only during the initial fetch (and after refetch/reset). */ + loading: boolean; + /** True only during subsequent (`loadMore`) fetches. */ + loadingMore: boolean; + hasMore: boolean; + error: Error | undefined; + nextCursor: TCursor | undefined; + /** No-op when `!hasMore`, `loading`, or `loadingMore`. */ + loadMore: () => void; + /** Resets accumulated items and re-fetches the first page. */ + refetch: () => void; +} + +/** + * Transport-agnostic infinite-scroll pagination state. Pair with + * `LoadMore.Sentinel` / `LoadMore.Trigger` to drive a forward-only paginated + * list. Stale responses are dropped via an internal request id so concurrent + * `refetch`/`resetOn` changes never clobber newer state. + */ +export function useLoadMore( + options: UseLoadMoreOptions, +): UseLoadMoreResult { + const { fetchPage, resetOn, enabled = true, initialCursor } = options; + + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(enabled); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(undefined); + const [nextCursor, setNextCursor] = React.useState( + initialCursor, + ); + const [hasMore, setHasMore] = React.useState(true); + + const fetchPageRef = React.useRef(fetchPage); + fetchPageRef.current = fetchPage; + + const requestIdRef = React.useRef(0); + + const runFetch = React.useCallback( + async (cursor: TCursor | undefined, isInitial: boolean) => { + const reqId = ++requestIdRef.current; + if (isInitial) { + setLoading(true); + } else { + setLoadingMore(true); + } + setError(undefined); + try { + const result = await fetchPageRef.current(cursor); + if (reqId !== requestIdRef.current) return; + setItems((prev) => + isInitial ? result.data : [...prev, ...result.data], + ); + setHasMore(result.hasMore); + setNextCursor(result.nextCursor); + } catch (e) { + if (reqId !== requestIdRef.current) return; + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + if (reqId === requestIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [], + ); + + const refetch = React.useCallback(() => { + setItems([]); + setNextCursor(initialCursor); + setHasMore(true); + void runFetch(initialCursor, true); + }, [runFetch, initialCursor]); + + // JSON.stringify gives us value-equality semantics for the dep array, + // matching the pattern in useGridApiPaginatedQuery. + const resetKey = React.useMemo( + () => JSON.stringify(resetOn ?? []), + [resetOn], + ); + + React.useEffect(() => { + if (!enabled) { + requestIdRef.current++; + setLoading(false); + setLoadingMore(false); + return; + } + setItems([]); + setNextCursor(initialCursor); + setHasMore(true); + void runFetch(initialCursor, true); + // initialCursor intentionally excluded: it's used only as a starting value + // and changing it shouldn't on its own re-fetch (callers can pass it in + // resetOn if they want that behavior). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, resetKey, runFetch]); + + const loadMore = React.useCallback(() => { + if (!hasMore || loading || loadingMore) return; + void runFetch(nextCursor, false); + }, [hasMore, loading, loadingMore, nextCursor, runFetch]); + + return { + items, + loading, + loadingMore, + hasMore, + error, + nextCursor, + loadMore, + refetch, + }; +} diff --git a/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts b/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts new file mode 100644 index 000000000..224d6f98d --- /dev/null +++ b/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useLoadMore, type UseLoadMoreFetchResult } from "./useLoadMore"; + +type Item = { id: string }; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("useLoadMore", () => { + it("fetches the first page on mount and exposes its items", async () => { + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => ({ + data: [{ id: cursor ?? "a" }], + nextCursor: "b", + hasMore: true, + }), + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + expect(result.current.loading).toBe(true); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(fetchPage).toHaveBeenLastCalledWith(undefined); + expect(result.current.items).toEqual([{ id: "a" }]); + expect(result.current.hasMore).toBe(true); + expect(result.current.nextCursor).toBe("b"); + expect(result.current.error).toBeUndefined(); + }); + + it("does not fetch when enabled is false; toggling true triggers a fetch", async () => { + const fetchPage = vi.fn( + async (): Promise> => ({ + data: [{ id: "x" }], + hasMore: false, + }), + ); + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useLoadMore({ fetchPage, enabled }), + { initialProps: { enabled: false } }, + ); + + expect(result.current.loading).toBe(false); + expect(fetchPage).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(result.current.items).toEqual([{ id: "x" }]); + }); + + it("accumulates items across loadMore calls and forwards the cursor", async () => { + const pages: Record> = { + first: { data: [{ id: "1" }], nextCursor: "p2", hasMore: true }, + p2: { data: [{ id: "2" }], nextCursor: "p3", hasMore: true }, + p3: { data: [{ id: "3" }], hasMore: false }, + }; + const fetchPage = vi.fn(async (cursor: string | undefined) => { + return pages[cursor ?? "first"]; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }]); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }, { id: "2" }]); + expect(fetchPage).toHaveBeenLastCalledWith("p2"); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([ + { id: "1" }, + { id: "2" }, + { id: "3" }, + ]); + expect(result.current.hasMore).toBe(false); + }); + + it("treats loadMore as a no-op when hasMore is false", async () => { + const fetchPage = vi.fn( + async (): Promise> => ({ + data: [{ id: "only" }], + hasMore: false, + }), + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.loadMore(); + }); + + expect(fetchPage).toHaveBeenCalledTimes(1); + }); + + it("treats a second loadMore as a no-op while one is in flight", async () => { + const initial = deferred>(); + const next = deferred>(); + let call = 0; + const fetchPage = vi.fn(async () => { + call += 1; + return call === 1 ? initial.promise : next.promise; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + initial.resolve({ data: [{ id: "1" }], nextCursor: "n", hasMore: true }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.loadMore(); + }); + expect(result.current.loadingMore).toBe(true); + + act(() => { + result.current.loadMore(); + }); + expect(fetchPage).toHaveBeenCalledTimes(2); + + await act(async () => { + next.resolve({ data: [{ id: "2" }], hasMore: false }); + await next.promise; + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }, { id: "2" }]); + }); + + it("drops stale responses when refetch races a slow first page", async () => { + const slow = deferred>(); + const fresh = deferred>(); + let call = 0; + const fetchPage = vi.fn(async () => { + call += 1; + return call === 1 ? slow.promise : fresh.promise; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + act(() => { + result.current.refetch(); + }); + + await act(async () => { + fresh.resolve({ data: [{ id: "fresh" }], hasMore: false }); + await fresh.promise; + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "fresh" }]); + + await act(async () => { + slow.resolve({ data: [{ id: "stale" }], hasMore: true }); + await slow.promise; + }); + + expect(result.current.items).toEqual([{ id: "fresh" }]); + expect(result.current.hasMore).toBe(false); + }); + + it("resets accumulated state when resetOn changes", async () => { + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => ({ + data: [{ id: cursor ?? "first" }], + hasMore: false, + }), + ); + + const { result, rerender } = renderHook( + ({ filter }: { filter: string }) => + useLoadMore({ fetchPage, resetOn: [filter] }), + { initialProps: { filter: "a" } }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "first" }]); + expect(fetchPage).toHaveBeenCalledTimes(1); + + rerender({ filter: "b" }); + + await waitFor(() => expect(fetchPage).toHaveBeenCalledTimes(2)); + expect(fetchPage).toHaveBeenLastCalledWith(undefined); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "first" }]); + }); + + it("refetch clears items and re-fetches the first page", async () => { + let call = 0; + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => { + call += 1; + if (cursor === undefined) { + return { data: [{ id: `init-${call}` }], hasMore: false }; + } + return { data: [], hasMore: false }; + }, + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "init-1" }]); + + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "init-2" }]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + it("surfaces fetch errors and preserves prior items", async () => { + let call = 0; + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => { + call += 1; + if (call === 1) { + return { data: [{ id: "1" }], nextCursor: "n", hasMore: true }; + } + throw new Error("boom"); + }, + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }]); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("boom"); + expect(result.current.items).toEqual([{ id: "1" }]); + }); + + it("clears the error on the next fetch", async () => { + let call = 0; + const fetchPage = vi.fn(async (): Promise> => { + call += 1; + if (call === 1) throw new Error("first"); + return { data: [{ id: "after-retry" }], hasMore: false }; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error?.message).toBe("first"); + + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeUndefined(); + expect(result.current.items).toEqual([{ id: "after-retry" }]); + }); +}); diff --git a/packages/origin/src/components/Pager/Pager.module.scss b/packages/origin/src/components/Pager/Pager.module.scss new file mode 100644 index 000000000..e30f24c28 --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.module.scss @@ -0,0 +1,76 @@ +@use "../../tokens/text-styles" as *; +@use "../../tokens/mixins" as *; + +.root { + display: flex; + align-items: center; + gap: var(--spacing-lg); +} + +.status { + @include body-sm; + color: var(--text-primary); + white-space: nowrap; +} + +.navigation { + display: flex; + @include smooth-corners(var(--corner-radius-sm)); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: var(--stroke-xs) solid var(--border-primary); + background-color: var(--surface-primary); + color: var(--icon-primary); + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: + background-color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease; + + &:first-child { + border-radius: var(--corner-radius-sm) 0 0 var(--corner-radius-sm); + border-right: none; + } + + &:last-child { + border-radius: 0 var(--corner-radius-sm) var(--corner-radius-sm) 0; + } + + @media (hover: hover) { + &:hover:not(:disabled) { + background-color: var(--surface-hover); + } + } + + &:active:not(:disabled) { + background-color: var(--surface-secondary); + } + + &:focus-visible { + outline: none; + border-color: var(--border-secondary); + box-shadow: 0 0 0 3px var(--state-focus); + position: relative; + z-index: 1; + } + + &:disabled { + opacity: 0.5; + box-shadow: none; + cursor: not-allowed; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} diff --git a/packages/origin/src/components/Pager/Pager.stories.tsx b/packages/origin/src/components/Pager/Pager.stories.tsx new file mode 100644 index 000000000..2b4f387f4 --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import * as React from "react"; +import { Pager } from "./Pager"; +import { Pagination } from "../Pagination"; + +interface Cursor { + prev: string | null; + next: string | null; +} + +const PAGES: Cursor[] = [ + { prev: null, next: "p2" }, + { prev: "p1", next: "p3" }, + { prev: "p2", next: "p4" }, + { prev: "p3", next: null }, +]; + +function PagerDemo({ + hasPrevious: hasPreviousProp, + hasNext: hasNextProp, + withStatus = true, +}: { + hasPrevious?: boolean; + hasNext?: boolean; + withStatus?: boolean; +}) { + const [index, setIndex] = React.useState(1); + const cursor = PAGES[index]; + const hasPrevious = hasPreviousProp ?? cursor.prev !== null; + const hasNext = hasNextProp ?? cursor.next !== null; + + return ( + setIndex((i) => Math.max(0, i - 1))} + onNext={() => setIndex((i) => Math.min(PAGES.length - 1, i + 1))} + > + {withStatus ? ( + + Page {index + 1} of {PAGES.length} + + ) : null} + + + + + + ); +} + +const meta: Meta = { + title: "Components/Pager", + component: Pager.Root, + parameters: { + layout: "centered", + }, + argTypes: { + hasPrevious: { control: { type: "boolean" } }, + hasNext: { control: { type: "boolean" } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; + +export const NoPreviousCursor: Story = { + render: () => , +}; + +export const NoNextCursor: Story = { + render: () => , +}; + +export const BothEdges: Story = { + render: () => ( + + No results + + + + + + ), +}; + +export const WithoutStatus: Story = { + render: () => , +}; + +export const WithRenderPropAsLink: Story = { + render: () => ( + + + } /> + } /> + + + ), +}; + +export const SideBySideWithPagination: Story = { + render: () => ( +
+ + Showing 25 results + + + + + + + + + + + + +
+ ), +}; diff --git a/packages/origin/src/components/Pager/Pager.test-stories.tsx b/packages/origin/src/components/Pager/Pager.test-stories.tsx new file mode 100644 index 000000000..63c931c6e --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.test-stories.tsx @@ -0,0 +1,182 @@ +"use client"; + +import * as React from "react"; +import { Pager } from "./Pager"; +import { Pagination } from "../Pagination"; + +interface Cursor { + prev: string | null; + next: string | null; +} + +const PAGES: Cursor[] = [ + { prev: null, next: "p2" }, + { prev: "p1", next: "p3" }, + { prev: "p2", next: null }, +]; + +export function BasicPager() { + return ( + + Showing 25 results + + + + + + ); +} + +export function NoPreviousCursor() { + return ( + + Showing 25 results + + + + + + ); +} + +export function NoNextCursor() { + return ( + + Showing 25 results + + + + + + ); +} + +export function BothEdges() { + return ( + + No results + + + + + + ); +} + +export function ControlledPager() { + const [index, setIndex] = React.useState(1); + const cursor = PAGES[index]; + + return ( +
+ setIndex((i) => Math.max(0, i - 1))} + onNext={() => setIndex((i) => Math.min(PAGES.length - 1, i + 1))} + > + Page {index + 1} + + + + + +

+ prev:{cursor.prev ?? "null"} next:{cursor.next ?? "null"} +

+
+ ); +} + +export function PagerWithLinkRender() { + return ( + + + } /> + } /> + + + ); +} + +export function PagerWithCustomLabel() { + return ( + + + Older + Newer + + + ); +} + +export function PagerWithPreventDefault() { + const [count, setCount] = React.useState(0); + + return ( +
+ setCount((c) => c + 1)} + onNext={() => setCount((c) => c + 1)} + > + + { + event.preventDefault(); + }} + /> + + + +

count:{count}

+
+ ); +} + +export function ExplicitDisabledOverride() { + return ( + + + + + + + ); +} + +export function PagerSideBySidePagination() { + return ( +
+ + + + + + + + + + + + +
+ ); +} + +export function PagerWithoutHandlers() { + return ( + + + + + + + ); +} diff --git a/packages/origin/src/components/Pager/Pager.test.tsx b/packages/origin/src/components/Pager/Pager.test.tsx new file mode 100644 index 000000000..d9728db6d --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.test.tsx @@ -0,0 +1,282 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + BasicPager, + NoPreviousCursor, + NoNextCursor, + BothEdges, + ControlledPager, + PagerWithLinkRender, + PagerWithCustomLabel, + PagerWithPreventDefault, + ExplicitDisabledOverride, + PagerSideBySidePagination, +} from "./Pager.test-stories"; + +test.describe("Pager", () => { + test.describe("Structure", () => { + test("renders nav with Navigation, Previous, and Next", async ({ + mount, + page, + }) => { + await mount(); + + const nav = page.getByRole("navigation", { name: "Pager" }); + await expect(nav).toBeVisible(); + + const group = page.getByRole("group", { name: "Page navigation" }); + await expect(group).toBeVisible(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toBeVisible(); + }); + + test("renders Status with polite live region", async ({ mount, page }) => { + await mount(); + + const status = page.getByRole("status"); + await expect(status).toHaveText("Showing 25 results"); + await expect(status).toHaveAttribute("aria-live", "polite"); + await expect(status).toHaveAttribute("aria-atomic", "true"); + }); + }); + + test.describe("Disabled state derivation", () => { + test("Previous disabled when hasPrevious is false", async ({ + mount, + page, + }) => { + await mount(); + + const prev = page.getByRole("button", { name: "Previous page" }); + await expect(prev).toBeDisabled(); + await expect(prev).toHaveAttribute("data-disabled", ""); + }); + + test("Next disabled when hasNext is false", async ({ mount, page }) => { + await mount(); + + const next = page.getByRole("button", { name: "Next page" }); + await expect(next).toBeDisabled(); + await expect(next).toHaveAttribute("data-disabled", ""); + }); + + test("both disabled at empty edges", async ({ mount, page }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toBeDisabled(); + }); + + test("both enabled when both cursors present", async ({ mount, page }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toBeEnabled(); + }); + + test("explicit disabled prop wins over derived state", async ({ + mount, + page, + }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeEnabled(); + }); + }); + + test.describe("Interaction", () => { + test("clicking Previous fires onPrevious", async ({ mount, page }) => { + await mount(); + + await expect(page.getByTestId("cursor")).toHaveText("prev:p1 next:p3"); + + await page.getByRole("button", { name: "Previous page" }).click(); + await expect(page.getByTestId("cursor")).toHaveText("prev:null next:p2"); + }); + + test("clicking Next fires onNext", async ({ mount, page }) => { + await mount(); + + await page.getByRole("button", { name: "Next page" }).click(); + await expect(page.getByTestId("cursor")).toHaveText("prev:p2 next:null"); + }); + + test("preventDefault on consumer onClick suppresses context handler", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByRole("button", { name: "Previous page" }).click(); + await expect(page.getByTestId("count")).toHaveText("count:0"); + + await page.getByRole("button", { name: "Next page" }).click(); + await expect(page.getByTestId("count")).toHaveText("count:1"); + }); + + test("Enter and Space activate the buttons", async ({ mount, page }) => { + await mount(); + + const next = page.getByRole("button", { name: "Next page" }); + await next.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("cursor")).toHaveText("prev:p2 next:null"); + + const prev = page.getByRole("button", { name: "Previous page" }); + await prev.focus(); + await page.keyboard.press("Space"); + await expect(page.getByTestId("cursor")).toHaveText("prev:p1 next:p3"); + }); + }); + + test.describe("Render prop", () => { + test("swaps Previous and Next to anchor elements", async ({ + mount, + page, + }) => { + await mount(); + + const prev = page.getByRole("link", { name: "Previous page" }); + await expect(prev).toBeVisible(); + await expect(prev).toHaveAttribute("href", "?before=p1"); + await expect(prev).toHaveAttribute("data-direction", "previous"); + + const next = page.getByRole("link", { name: "Next page" }); + await expect(next).toBeVisible(); + await expect(next).toHaveAttribute("href", "?after=p2"); + await expect(next).toHaveAttribute("data-direction", "next"); + }); + + test("supports custom aria-label and children", async ({ mount, page }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Older results" }), + ).toHaveText("Older"); + await expect( + page.getByRole("button", { name: "Newer results" }), + ).toHaveText("Newer"); + }); + }); + + test.describe("Data attributes", () => { + test("Root carries data-no-previous and data-no-next on edges", async ({ + mount, + page, + }) => { + await mount(); + + const root = page.getByRole("navigation", { name: "Pager" }); + await expect(root).toHaveAttribute("data-no-previous", ""); + await expect(root).toHaveAttribute("data-no-next", ""); + }); + + test("Root omits data-no-* when both cursors present", async ({ + mount, + page, + }) => { + await mount(); + + const root = page.getByRole("navigation", { name: "Pager" }); + await expect(root).not.toHaveAttribute("data-no-previous", ""); + await expect(root).not.toHaveAttribute("data-no-next", ""); + }); + + test("Previous and Next always carry data-direction", async ({ + mount, + page, + }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toHaveAttribute("data-direction", "previous"); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toHaveAttribute("data-direction", "next"); + }); + + test("data-disabled only when disabled", async ({ mount, page }) => { + await mount(); + + const prev = page.getByRole("button", { name: "Previous page" }); + await expect(prev).not.toHaveAttribute("data-disabled", ""); + }); + }); + + test.describe("Visual parity with Pagination", () => { + test("buttons have identical size and border radius", async ({ + mount, + page, + }) => { + await mount(); + + const pagerButtons = page.getByTestId("pager").getByRole("button"); + const paginationButtons = page + .getByTestId("pagination") + .getByRole("button"); + + const pagerPrev = pagerButtons.first(); + const pagerNext = pagerButtons.last(); + const paginationPrev = paginationButtons.first(); + const paginationNext = paginationButtons.last(); + + const pagerPrevBox = await pagerPrev.boundingBox(); + const pagerNextBox = await pagerNext.boundingBox(); + expect(pagerPrevBox?.width).toBe(24); + expect(pagerPrevBox?.height).toBe(24); + expect(pagerNextBox?.width).toBe(24); + expect(pagerNextBox?.height).toBe(24); + + const pagerPrevRadius = await pagerPrev.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + const pagerNextRadius = await pagerNext.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + expect(pagerPrevRadius).toBe("6px 0px 0px 6px"); + expect(pagerNextRadius).toBe("0px 6px 6px 0px"); + + const paginationPrevRadius = await paginationPrev.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + const paginationNextRadius = await paginationNext.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + expect(pagerPrevRadius).toBe(paginationPrevRadius); + expect(pagerNextRadius).toBe(paginationNextRadius); + + const pagerStyle = await pagerPrev.evaluate((el) => { + const cs = getComputedStyle(el); + return { + backgroundColor: cs.backgroundColor, + borderColor: cs.borderTopColor, + boxShadow: cs.boxShadow, + }; + }); + const paginationStyle = await paginationPrev.evaluate((el) => { + const cs = getComputedStyle(el); + return { + backgroundColor: cs.backgroundColor, + borderColor: cs.borderTopColor, + boxShadow: cs.boxShadow, + }; + }); + expect(pagerStyle).toEqual(paginationStyle); + }); + }); +}); diff --git a/packages/origin/src/components/Pager/Pager.tsx b/packages/origin/src/components/Pager/Pager.tsx new file mode 100644 index 000000000..640ffb2df --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.tsx @@ -0,0 +1,327 @@ +"use client"; + +import * as React from "react"; +import clsx from "clsx"; +import { CentralIcon } from "../Icon"; +import { useTrackedCallback } from "../Analytics/useTrackedCallback"; +import { + useRender, + type StateAttributesMapping, +} from "../../lib/base-ui-utils"; +import styles from "./Pager.module.scss"; + +export interface PagerContextValue { + hasPrevious: boolean; + hasNext: boolean; + onPrevious?: (() => void) | undefined; + onNext?: (() => void) | undefined; +} + +export const PagerContext = React.createContext( + undefined, +); + +/** Reads the current `Pager` context. Throws when used outside `Pager.Root`. */ +export function usePagerContext(): PagerContextValue { + const context = React.useContext(PagerContext); + if (context === undefined) { + throw new Error("Pager parts must be placed within ."); + } + return context; +} + +export type PagerRootState = { + hasPrevious: boolean; + hasNext: boolean; +}; + +export type PagerNavigationState = { + hasPrevious: boolean; + hasNext: boolean; +}; + +export type PagerButtonState = { + disabled: boolean; + direction: "previous" | "next"; +}; + +export interface PagerRootProps + extends Omit, "onChange"> { + /** Whether a previous page exists. Drives `Pager.Previous` disabled state. */ + hasPrevious: boolean; + /** Whether a next page exists. Drives `Pager.Next` disabled state. */ + hasNext: boolean; + /** Fires when `Pager.Previous` is activated. */ + onPrevious?: () => void; + /** Fires when `Pager.Next` is activated. */ + onNext?: () => void; + /** Optional analytics name. Clicks emit `Pager.click` with `{ direction }` metadata. */ + analyticsName?: string; + /** Override the rendered element. Defaults to `