diff --git a/src/features/demo-admin-dashboard/DemoAdminDashboard.tsx b/src/features/demo-admin-dashboard/DemoAdminDashboard.tsx index d310f64d..69d48501 100644 --- a/src/features/demo-admin-dashboard/DemoAdminDashboard.tsx +++ b/src/features/demo-admin-dashboard/DemoAdminDashboard.tsx @@ -1,20 +1,30 @@ import { useState, type ReactNode } from "react"; -import { Activity, BarChart3, FileText, LayoutDashboard, Mail, Shield, Users } from "lucide-react"; +import { Activity, BarChart3, FileText, LayoutDashboard, Mail, Shield, Users, X, Paperclip, Calendar } from "lucide-react"; import { cn } from "@/lib/utils"; import type { DashboardNavItem, DashboardSection, DemoAdminDashboardProps, StatCard, + PresetId, + PresetAccount, + PresetMail, + PresetAuditEvent, + PresetAttachment, + PresetEvent, } from "./types"; import { TemplatePicker } from "./templates"; +import { PRESET_SCENARIOS } from "./fixtures/presets"; +import { AdminDataTable, type Column } from "./components/AdminDataTable"; -// ─── Deterministic fake data ────────────────────────────────────────────────── +// ─── Default Deterministic fake data ────────────────────────────────────────── const NAV_ITEMS: DashboardNavItem[] = [ { id: "overview", label: "Overview", description: "High-level demo system status" }, { id: "accounts", label: "Accounts", description: "Demo Stellar accounts and balances" }, { id: "mail", label: "Mail", description: "Demo mail fixtures and delivery states" }, + { id: "attachments", label: "Attachments", description: "Demo mail attachment fixtures" }, + { id: "events", label: "Events", description: "Demo calendar and protocol events" }, { id: "templates", label: "Templates", description: "Pick message templates to populate drafts" }, { id: "audit", label: "Audit", description: "Demo protocol event log" }, { id: "analytics", label: "Analytics", description: "Privacy-preserving product analytics" }, @@ -27,21 +37,69 @@ const OVERVIEW_STATS: StatCard[] = [ { label: "Total Postage (XLM)", value: "1,240.5", delta: "+45.2" }, ]; -const ACCOUNTS_FAKE: { name: string; address: string; balance: string; type: string }[] = [ +const ACCOUNTS_FAKE: PresetAccount[] = [ { name: "Alice Demo", address: "GABCD...1234", balance: "500.0 XLM", type: "User" }, { name: "Bob Demo", address: "GBCDE...2345", balance: "320.0 XLM", type: "User" }, { name: "Relay East", address: "GCDEF...3456", balance: "1,200.0 XLM", type: "Relay" }, { name: "Relay West", address: "GDEFG...4567", balance: "980.0 XLM", type: "Relay" }, ]; -const MAIL_FIXTURES: { subject: string; status: string; folder: string }[] = [ - { subject: "Welcome to Stealth", status: "delivered", folder: "inbox" }, - { subject: "Invoice #1042", status: "pending", folder: "requests" }, - { subject: "Meeting notes", status: "delivered", folder: "inbox" }, - { subject: "Newsletter #47", status: "held", folder: "spam" }, +const MAIL_FIXTURES: PresetMail[] = [ + { + subject: "Welcome to Stealth", + status: "delivered", + folder: "inbox", + from: "Stealth Team", + email: "welcome*stealth.demo", + body: "Hi there,\n\nYour Stealth mailbox is set up. You decide who can reach you: trusted contacts arrive instantly, everyone else follows the policy you choose.\n\nReply any time to start a conversation.\n\n— The Stealth demo team", + time: "9:42 AM", + unread: true, + starred: true, + labels: ["onboarding", "intro"], + avatarColor: "#5b6470", + }, + { + subject: "Invoice #1042", + status: "pending", + folder: "requests", + from: "Vendor Demo", + email: "billing*stealth.demo", + body: "Please find attached your invoice #1042.\n\nAmount: 120 XLM\nStatus: pending", + time: "9:18 AM", + unread: true, + starred: false, + labels: ["invoice"], + avatarColor: "#7a8290", + }, + { + subject: "Meeting notes", + status: "delivered", + folder: "inbox", + from: "Bob Demo", + email: "bob*stealth.demo", + body: "Here are the meeting notes from today's discussion.", + time: "8:57 AM", + unread: false, + starred: false, + labels: ["notes"], + avatarColor: "#4d5560", + }, + { + subject: "Newsletter #47", + status: "held", + folder: "spam", + from: "Newsletter System", + email: "digest*stealth.demo", + body: "Your weekly newsletter is ready to view.", + time: "Yesterday", + unread: false, + starred: false, + labels: ["digest"], + avatarColor: "#9098a4", + }, ]; -const AUDIT_EVENTS_FAKE: { action: string; actor: string; timestamp: string }[] = [ +const AUDIT_EVENTS_FAKE: PresetAuditEvent[] = [ { action: "Session started", actor: "demo-user-1", timestamp: "2026-06-16T09:00:00Z" }, { action: "Policy default changed to request", @@ -49,19 +107,61 @@ const AUDIT_EVENTS_FAKE: { action: string; actor: string; timestamp: string }[] timestamp: "2026-06-16T09:05:00Z", }, { - action: "Sender approved: alice*stealth.xyz", + action: "Sender approved: alice*stealth.demo", actor: "demo-user-1", timestamp: "2026-06-16T09:10:00Z", }, { action: "Postage refunded for msg_abc123", actor: "system", timestamp: "2026-06-16T09:12:00Z" }, ]; +const ATTACHMENTS_FAKE: PresetAttachment[] = [ + { + id: "att-inv-1042", + fileName: "invoice_1042.pdf", + fileSize: "120 KB", + fileType: "PDF Document", + messageSubject: "Invoice #1042", + sender: "Vendor Demo", + }, + { + id: "att-roundtable", + fileName: "roundtable_agenda.pdf", + fileSize: "85 KB", + fileType: "PDF Document", + messageSubject: "You're invited: Stealth demo roundtable", + sender: "events*stealth.demo", + }, +]; + +const EVENTS_FAKE: PresetEvent[] = [ + { + id: "evt-roundtable", + title: "Stealth demo roundtable", + date: "2026-07-09", + time: "3:00 PM", + location: "Demo room", + organizer: "events*stealth.demo", + status: "confirmed", + }, + { + id: "evt-sync", + title: "Weekly Sync", + date: "2026-06-18", + time: "10:00 AM", + location: "Virtual", + organizer: "bob*stealth.demo", + status: "confirmed", + }, +]; + // ─── Section icon map ───────────────────────────────────────────────────────── const SECTION_ICON: Record = { overview: LayoutDashboard, accounts: Users, mail: Mail, + attachments: Paperclip, + events: Calendar, templates: FileText, audit: Activity, analytics: PieChart, @@ -69,14 +169,22 @@ const SECTION_ICON: Record = { // ─── Content region components ──────────────────────────────────────────────── -function OverviewContent() { +function OverviewContent({ + activePresetId, + setActivePresetId, + stats, +}: { + activePresetId: PresetId; + setActivePresetId: (id: PresetId) => void; + stats: StatCard[]; +}) { return (

Summary of the demo environment. All data is synthetic and resets on each page load.

- {OVERVIEW_STATS.map((stat) => ( + {stats.map((stat) => (
))}
+ + {/* Preset Selector */} +
+
+

+ + Protocol Scenario Presets +

+

+ Select a preset to populate the dashboard tables with simulated ledger states, relay nodes, and pending proof mail flows. +

+
+
+ {[ + { id: "none" as const, name: "Default System", desc: "Standard demo system stats and static fixtures." }, + { id: "relay-verification" as const, name: "Relay Verification", desc: "Simulates registering and verifying a new relay node." }, + { id: "proof-pending" as const, name: "Proof Pending", desc: "Simulates an on-chain cryptographic proof generation delay." }, + { id: "receipt-settlement" as const, name: "Receipt Settlement", desc: "Simulates postage fees and read receipts confirming on-chain." }, + ].map((preset) => { + const active = activePresetId === preset.id; + return ( + + ); + })} +
+
); } -function AccountsContent() { +function AccountsContent({ + accounts, + selectedAccountAddress, + setSelectedAccountAddress, +}: { + accounts: PresetAccount[]; + selectedAccountAddress: string | null; + setSelectedAccountAddress: (addr: string | null) => void; +}) { + const columns: Column[] = [ + { + key: "name", + header: "Name", + sortable: true, + render: (acct) => ( +
+ {acct.name} + {acct.relayMetadata && ( + + Inspectable + + )} +
+ ), + }, + { + key: "address", + header: "Address", + sortable: true, + render: (acct) => {acct.address}, + }, + { + key: "balance", + header: "Balance", + sortable: true, + sortValue: (acct) => parseFloat(acct.balance.replace(/[^0-9.]/g, "")), + render: (acct) => {acct.balance}, + }, + { + key: "type", + header: "Type", + sortable: true, + render: (acct) => ( + + {acct.type} + + ), + }, + { + key: "status", + header: "Status", + sortable: true, + sortValue: (acct) => acct.relayMetadata?.status ?? "none", + render: (acct) => { + const status = acct.relayMetadata?.status; + if (!status) return ; + return ( + + {status} + + ); + }, + }, + ]; + return (

- Demo Stellar accounts used for populating the inbox UI. + Demo Stellar accounts used for populating the inbox UI. Rows with metadata can be clicked to inspect details.

-
- - - - - - - - - - - {ACCOUNTS_FAKE.map((acct) => ( - - - - - - - ))} - -
NameAddressBalanceType
{acct.name} - {acct.address} - {acct.balance}{acct.type}
-
+ { + if (acct.relayMetadata) { + setSelectedAccountAddress(selectedAccountAddress === acct.address ? null : acct.address); + } + }} + selectedRowKey={(acct) => selectedAccountAddress === acct.address} + defaultSortKey="name" + />
); } -function MailContent() { +function MailContent({ + mail, + selectedMailSubject, + setSelectedMailSubject, +}: { + mail: PresetMail[]; + selectedMailSubject: string | null; + setSelectedMailSubject: (subject: string | null) => void; +}) { + const columns: Column[] = [ + { + key: "subject", + header: "Subject", + sortable: true, + render: (item) => ( +
+
+ {item.subject} + {item.proofMetadata && ( + + Has Proof + + )} +
+ + From: {item.from} ({item.email}) + +
+ ), + }, + { + key: "status", + header: "Status", + sortable: true, + render: (item) => ( + + {item.status} + + ), + }, + { + key: "folder", + header: "Folder", + sortable: true, + render: (item) => {item.folder}, + }, + { + key: "time", + header: "Time", + sortable: true, + }, + ]; + return (

- Mail fixtures available for populating the demo inbox. + Mail fixtures available for populating the demo inbox. Rows with cryptographic proofs can be clicked to inspect ledger details.

-
- - - - - - - - - - {MAIL_FIXTURES.map((mail, i) => ( - - - - - - ))} - -
SubjectStatusFolder
{mail.subject} - - {mail.status} - - {mail.folder}
-
+ { + if (item.proofMetadata) { + setSelectedMailSubject(selectedMailSubject === item.subject ? null : item.subject); + } + }} + selectedRowKey={(item) => selectedMailSubject === item.subject} + defaultSortKey="time" + defaultSortDirection="desc" + /> +
+ ); +} + +function AttachmentsContent({ + attachments, +}: { + attachments: PresetAttachment[]; +}) { + const columns: Column[] = [ + { + key: "fileName", + header: "File Name", + sortable: true, + render: (att) => ( +
+ + {att.fileName} +
+ ), + }, + { + key: "fileSize", + header: "Size", + sortable: true, + }, + { + key: "fileType", + header: "Type", + sortable: true, + render: (att) => ( + + {att.fileType} + + ), + }, + { + key: "messageSubject", + header: "Source Message", + sortable: true, + render: (att) => {att.messageSubject}, + }, + { + key: "sender", + header: "Sender", + sortable: true, + render: (att) => {att.sender}, + }, + ]; + + return ( +
+

+ Deterministic file attachments mock list, extracted from active mail fixtures. +

+ +
+ ); +} + +function EventsContent({ + events, +}: { + events: PresetEvent[]; +}) { + const columns: Column[] = [ + { + key: "title", + header: "Title", + sortable: true, + render: (evt) => ( +
+ + {evt.title} +
+ ), + }, + { + key: "date", + header: "Scheduled Time", + sortable: true, + sortValue: (evt) => new Date(`${evt.date}T${evt.time.replace(" PM", "").replace(" AM", "")}`).getTime(), + render: (evt) => {evt.date} · {evt.time}, + }, + { + key: "location", + header: "Location", + sortable: true, + render: (evt) => {evt.location}, + }, + { + key: "organizer", + header: "Organizer", + sortable: true, + render: (evt) => {evt.organizer}, + }, + { + key: "status", + header: "Status", + sortable: true, + render: (evt) => ( + + {evt.status} + + ), + }, + ]; + + return ( +
+

+ Stellar node registration and verification events. +

+
); } -function AuditContent() { +function AuditContent({ + auditEvents, +}: { + auditEvents: PresetAuditEvent[]; +}) { + const columns: Column[] = [ + { + key: "action", + header: "Action", + sortable: true, + render: (evt) => ( +
+ + {evt.action} +
+ ), + }, + { + key: "actor", + header: "Actor", + sortable: true, + render: (evt) => {evt.actor}, + }, + { + key: "timestamp", + header: "Timestamp", + sortable: true, + sortValue: (evt) => new Date(evt.timestamp).getTime(), + render: (evt) => ( + + {new Date(evt.timestamp).toLocaleString()} + + ), + }, + ]; + return (

Recent demo protocol events. No real user data or message body content is recorded.

-
- {AUDIT_EVENTS_FAKE.map((evt, i) => ( -
-
- -
-
-

{evt.action}

-

- {evt.actor} · {new Date(evt.timestamp).toLocaleTimeString()} -

-
-
- ))} -
+
); } @@ -200,26 +612,38 @@ function TemplatesContent() { return ; } -const SECTION_CONTENT: Record ReactNode> = { - overview: OverviewContent, - accounts: AccountsContent, - mail: MailContent, - templates: TemplatesContent, - audit: AuditContent, - analytics: AnalyticsContent, -}; - // ─── Dashboard Shell ────────────────────────────────────────────────────────── export function DemoAdminDashboard({ className }: DemoAdminDashboardProps) { const [activeSection, setActiveSection] = useState("overview"); + const [activePresetId, setActivePresetId] = useState("none"); + const [selectedAccountAddress, setSelectedAccountAddress] = useState(null); + const [selectedMailSubject, setSelectedMailSubject] = useState(null); + + const activePreset = PRESET_SCENARIOS.find((p) => p.id === activePresetId); + + const stats = activePreset ? activePreset.stats : OVERVIEW_STATS; + const accounts = activePreset ? activePreset.accounts : ACCOUNTS_FAKE; + const mail = activePreset ? activePreset.mail : MAIL_FIXTURES; + const attachments = activePreset ? activePreset.attachments : ATTACHMENTS_FAKE; + const events = activePreset ? activePreset.events : EVENTS_FAKE; + const auditEvents = activePreset ? activePreset.auditEvents : AUDIT_EVENTS_FAKE; + + const selectedAccount = accounts.find((a) => a.address === selectedAccountAddress); + const selectedMail = mail.find((m) => m.subject === selectedMailSubject); + + const handleSectionChange = (section: DashboardSection) => { + setActiveSection(section); + setSelectedAccountAddress(null); + setSelectedMailSubject(null); + }; + const Icon = SECTION_ICON[activeSection]; - const ContentComponent = SECTION_CONTENT[activeSection]; return (
@@ -236,9 +660,16 @@ export function DemoAdminDashboard({ className }: DemoAdminDashboardProps) {

- - Demo - +
+ {activePresetId !== "none" && ( + + Preset: {activePreset?.name} + + )} + + Demo + +
{/* ── Navigation slots ── */} @@ -256,7 +687,7 @@ export function DemoAdminDashboard({ className }: DemoAdminDashboardProps) { role="tab" aria-selected={isActive} aria-label={item.description} - onClick={() => setActiveSection(item.id)} + onClick={() => handleSectionChange(item.id)} className={cn( "flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition", isActive @@ -279,14 +710,191 @@ export function DemoAdminDashboard({ className }: DemoAdminDashboardProps) { >
{/* Section header */} -
- -

{activeSection}

+
+
+ +

{activeSection}

+
+ {activePresetId !== "none" && ( + + Simulated {activePreset?.name} flow active + + )}
- + {activeSection === "overview" && ( + { + setActivePresetId(id); + setSelectedAccountAddress(null); + setSelectedMailSubject(null); + }} + stats={stats} + /> + )} + + {activeSection === "accounts" && ( + + )} + + {activeSection === "mail" && ( + + )} + + {activeSection === "attachments" && ( + + )} + + {activeSection === "events" && ( + + )} + + {activeSection === "templates" && } + + {activeSection === "audit" && }
+ + {/* ── Slide-out Inspection Panel (Drawer for Account/Relay Metadata) ── */} + {selectedAccount && selectedAccount.relayMetadata && ( +
+
+
+
+

Relay Node Inspector

+

{selectedAccount.name}

+
+ +
+ +
+
+ Relay Registry Metadata +
+
+
+ Node Address: + {selectedAccount.relayMetadata.nodeUri} +
+
+ Stellar Account: + {selectedAccount.address} +
+
+ Routing Latency: + {selectedAccount.relayMetadata.latency} +
+
+ Signature Scheme: + {selectedAccount.relayMetadata.signatureScheme} +
+
+ Owner Account: + {selectedAccount.relayMetadata.owner} +
+
+ Verification Status: + + {selectedAccount.relayMetadata.status} + +
+
+
+
+ +
+ )} + + {/* ── Slide-out Inspection Panel (Drawer for Cryptographic Ledger Proof) ── */} + {selectedMail && selectedMail.proofMetadata && ( +
+
+
+
+

Ledger Proof Inspector

+

{selectedMail.subject}

+
+ +
+ +
+
+ Cryptographic Details +
+
+
+ Message Hash: + {selectedMail.proofMetadata.messageHash} +
+
+ Payment Preimage Hash: + {selectedMail.proofMetadata.paymentHash} +
+
+ Soroban Contract: + {selectedMail.proofMetadata.contractAddress} +
+
+ Relay Latency: + {selectedMail.proofMetadata.latency} +
+
+ Cryptographic Signature: + {selectedMail.proofMetadata.signature} +
+
+ Postage State: + + {selectedMail.proofMetadata.postageStatus} + +
+
+
+
+ +
+ )} ); } diff --git a/src/features/demo-admin-dashboard/README.md b/src/features/demo-admin-dashboard/README.md index 5239dfd1..1505a6dd 100644 --- a/src/features/demo-admin-dashboard/README.md +++ b/src/features/demo-admin-dashboard/README.md @@ -26,7 +26,7 @@ npx vitest run src/features/demo-admin-dashboard/__tests__/layout.test.ts ``` The fixture data in `fixtures/demoData.ts` is deterministic, fake, and safe for public repository review. -======= + This folder is the implementation boundary for the admin dashboard used to populate and manage demo data in the Stealth demo inbox UI. Contributors working on demo-admin issues should keep new dashboard code, local state helpers, fixtures, validators, UI components, test utilities, and documentation inside: diff --git a/src/features/demo-admin-dashboard/__tests__/AdminDataTable.test.ts b/src/features/demo-admin-dashboard/__tests__/AdminDataTable.test.ts new file mode 100644 index 00000000..33af328c --- /dev/null +++ b/src/features/demo-admin-dashboard/__tests__/AdminDataTable.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { sortData } from "../components/AdminDataTable"; + +interface TestItem { + id: number; + name: string; + count: number; + optional?: string | null; +} + +describe("AdminDataTable sortData helper", () => { + const items: TestItem[] = [ + { id: 1, name: "Charlie", count: 15, optional: "Yes" }, + { id: 2, name: "Alice", count: 42, optional: null }, + { id: 3, name: "Bob", count: 7, optional: "No" }, + ]; + + it("returns original data if sortKey is null", () => { + const result = sortData(items, null, "asc"); + expect(result).toEqual(items); + // Should be a reference match (or at least return the exact same array/elements) + expect(result).toBe(items); + }); + + it("sorts string fields in ascending order", () => { + const result = sortData(items, "name", "asc"); + expect(result.map((item) => item.name)).toEqual(["Alice", "Bob", "Charlie"]); + }); + + it("sorts string fields in descending order", () => { + const result = sortData(items, "name", "desc"); + expect(result.map((item) => item.name)).toEqual(["Charlie", "Bob", "Alice"]); + }); + + it("sorts numeric fields in ascending order", () => { + const result = sortData(items, "count", "asc"); + expect(result.map((item) => item.count)).toEqual([7, 15, 42]); + }); + + it("sorts numeric fields in descending order", () => { + const result = sortData(items, "count", "desc"); + expect(result.map((item) => item.count)).toEqual([42, 15, 7]); + }); + + it("handles null/undefined values by treating them as empty strings", () => { + const result = sortData(items, "optional", "asc"); + // null optional value (Alice) should sort first in ascending + expect(result[0].name).toBe("Alice"); + }); + + it("uses custom sortValue function if provided", () => { + const column = { + key: "name", + header: "Name", + sortValue: (row: TestItem) => row.name.length, + }; + // Charlie (7), Alice (5), Bob (3) + const result = sortData(items, "name", "asc", column); + expect(result.map((item) => item.name)).toEqual(["Bob", "Alice", "Charlie"]); + }); + + it("does not mutate the original array", () => { + const itemsCopy = [...items]; + sortData(items, "name", "asc"); + expect(items).toEqual(itemsCopy); + }); +}); diff --git a/src/features/demo-admin-dashboard/__tests__/localStorageAdapter.test.ts b/src/features/demo-admin-dashboard/__tests__/localStorageAdapter.test.ts index 498277a3..fa66e8e7 100644 --- a/src/features/demo-admin-dashboard/__tests__/localStorageAdapter.test.ts +++ b/src/features/demo-admin-dashboard/__tests__/localStorageAdapter.test.ts @@ -18,15 +18,19 @@ const mockStorage = (() => { }; })(); -// Helper to attach mock to global window +// Helper to attach mock to global window and globalThis function setWindowStorage() { // @ts-ignore global.window = { localStorage: mockStorage } as any; + // @ts-ignore + global.localStorage = mockStorage; } function clearWindowStorage() { // @ts-ignore delete (global as any).window; + // @ts-ignore + delete (global as any).localStorage; } describe('localStorageAdapter', () => { diff --git a/src/features/demo-admin-dashboard/__tests__/presets.test.ts b/src/features/demo-admin-dashboard/__tests__/presets.test.ts new file mode 100644 index 00000000..90bd0faa --- /dev/null +++ b/src/features/demo-admin-dashboard/__tests__/presets.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { PRESET_SCENARIOS } from "../fixtures/presets"; + +describe("demo admin dashboard presets", () => { + it("defines the three required scenario flows", () => { + const ids = PRESET_SCENARIOS.map((p) => p.id); + expect(ids).toContain("relay-verification"); + expect(ids).toContain("proof-pending"); + expect(ids).toContain("receipt-settlement"); + expect(PRESET_SCENARIOS.length).toBe(3); + }); + + it("contains deterministic and valid stats, accounts, mail, and audit logs for each scenario", () => { + for (const scenario of PRESET_SCENARIOS) { + expect(scenario.name.trim()).not.toBe(""); + expect(scenario.description.trim()).not.toBe(""); + expect(scenario.stats.length).toBeGreaterThan(0); + expect(scenario.accounts.length).toBeGreaterThan(0); + expect(scenario.mail.length).toBeGreaterThan(0); + expect(scenario.attachments.length).toBeGreaterThan(0); + expect(scenario.events.length).toBeGreaterThan(0); + expect(scenario.auditEvents.length).toBeGreaterThan(0); + + // Verify stats shape + for (const stat of scenario.stats) { + expect(stat.label.trim()).not.toBe(""); + expect(stat.value.trim()).not.toBe(""); + } + + // Verify accounts shape + for (const acct of scenario.accounts) { + expect(acct.name.trim()).not.toBe(""); + expect(acct.address.trim()).not.toBe(""); + expect(acct.balance.trim()).not.toBe(""); + expect(acct.type.trim()).not.toBe(""); + } + + // Verify attachments shape + for (const att of scenario.attachments) { + expect(att.id.trim()).not.toBe(""); + expect(att.fileName.trim()).not.toBe(""); + expect(att.fileSize.trim()).not.toBe(""); + expect(att.fileType.trim()).not.toBe(""); + expect(att.messageSubject.trim()).not.toBe(""); + expect(att.sender.trim()).not.toBe(""); + } + + // Verify events shape + for (const evt of scenario.events) { + expect(evt.id.trim()).not.toBe(""); + expect(evt.title.trim()).not.toBe(""); + expect(evt.date.trim()).not.toBe(""); + expect(evt.time.trim()).not.toBe(""); + expect(evt.location.trim()).not.toBe(""); + expect(evt.organizer.trim()).not.toBe(""); + expect(["confirmed", "tentative", "cancelled"]).toContain(evt.status); + } + + // Verify audit events shape + for (const event of scenario.auditEvents) { + expect(event.action.trim()).not.toBe(""); + expect(event.actor.trim()).not.toBe(""); + expect(event.timestamp.trim()).not.toBe(""); + } + } + }); + + it("uses only safe, fake demo emails", () => { + for (const scenario of PRESET_SCENARIOS) { + for (const item of scenario.mail) { + expect(item.email).toMatch(/(\*stealth\.demo|@example\.(com|org))$/); + } + for (const item of scenario.attachments) { + if (item.sender.includes("@") || item.sender.includes("*")) { + expect(item.sender).toMatch(/(\*stealth\.demo|@example\.(com|org))$/); + } + } + for (const item of scenario.events) { + if (item.organizer.includes("@") || item.organizer.includes("*")) { + expect(item.organizer).toMatch(/(\*stealth\.demo|@example\.(com|org))$/); + } + } + } + }); + + it("attaches required relay verification metadata in relay verification scenario", () => { + const relayVerification = PRESET_SCENARIOS.find((p) => p.id === "relay-verification"); + expect(relayVerification).toBeDefined(); + + const pendingRelay = relayVerification?.accounts.find((a) => a.name === "Relay Node 07"); + expect(pendingRelay).toBeDefined(); + expect(pendingRelay?.relayMetadata).toBeDefined(); + expect(pendingRelay?.relayMetadata?.status).toBe("pending"); + expect(pendingRelay?.relayMetadata?.nodeUri).toBe("relay07*stealth.demo"); + + const verificationMail = relayVerification?.mail.find((m) => m.subject === "Your relay verification code"); + expect(verificationMail).toBeDefined(); + expect(verificationMail?.status).toBe("pending"); + expect(verificationMail?.folder).toBe("pending"); + expect(verificationMail?.proofMetadata).toBeDefined(); + }); + + it("attaches required proof pending metadata in proof pending scenario", () => { + const proofPending = PRESET_SCENARIOS.find((p) => p.id === "proof-pending"); + expect(proofPending).toBeDefined(); + + const pendingMail = proofPending?.mail.find((m) => m.subject === "Soroban proof generation pending"); + expect(pendingMail).toBeDefined(); + expect(pendingMail?.status).toBe("pending"); + expect(pendingMail?.folder).toBe("pending"); + expect(pendingMail?.proofMetadata).toBeDefined(); + expect(pendingMail?.proofMetadata?.postageStatus).toBe("pending"); + }); + + it("attaches required receipt settlement metadata in receipt settlement scenario", () => { + const receiptSettlement = PRESET_SCENARIOS.find((p) => p.id === "receipt-settlement"); + expect(receiptSettlement).toBeDefined(); + + const deliveryReceiptMail = receiptSettlement?.mail.find((m) => m.subject === "Delivery receipt settled"); + expect(deliveryReceiptMail).toBeDefined(); + expect(deliveryReceiptMail?.status).toBe("delivered"); + expect(deliveryReceiptMail?.folder).toBe("receipts"); + expect(deliveryReceiptMail?.proofMetadata).toBeDefined(); + expect(deliveryReceiptMail?.proofMetadata?.postageStatus).toBe("settled"); + }); +}); diff --git a/src/features/demo-admin-dashboard/components/AdminDataTable.tsx b/src/features/demo-admin-dashboard/components/AdminDataTable.tsx new file mode 100644 index 00000000..79d1fd74 --- /dev/null +++ b/src/features/demo-admin-dashboard/components/AdminDataTable.tsx @@ -0,0 +1,154 @@ +import React, { useState, useMemo } from "react"; +import { ArrowUpDown, ChevronUp, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface Column { + key: string; + header: string; + sortable?: boolean; + sortValue?: (row: T) => string | number | boolean; + render?: (row: T) => React.ReactNode; +} + +interface AdminDataTableProps { + data: T[]; + columns: Column[]; + onRowClick?: (row: T) => void; + selectedRowKey?: (row: T) => boolean; + defaultSortKey?: string; + defaultSortDirection?: "asc" | "desc"; + emptyMessage?: string; + className?: string; +} + +/** + * Pure helper function to sort data based on a key and direction. + */ +export function sortData( + data: T[], + sortKey: string | null, + sortDirection: "asc" | "desc", + column?: Column +): T[] { + if (!sortKey) return data; + + return [...data].sort((a, b) => { + let valA: any = column?.sortValue ? column.sortValue(a) : (a as any)[sortKey]; + let valB: any = column?.sortValue ? column.sortValue(b) : (b as any)[sortKey]; + + if (valA === undefined || valA === null) valA = ""; + if (valB === undefined || valB === null) valB = ""; + + if (typeof valA === "string" && typeof valB === "string") { + return sortDirection === "asc" + ? valA.localeCompare(valB) + : valB.localeCompare(valA); + } + + if (valA < valB) return sortDirection === "asc" ? -1 : 1; + if (valA > valB) return sortDirection === "asc" ? 1 : -1; + return 0; + }); +} + +/** + * Reusable, sortable table component for the Demo Admin Dashboard. + * Confined to displaying messages, senders, attachments, events, and audit entries. + */ +export function AdminDataTable({ + data, + columns, + onRowClick, + selectedRowKey, + defaultSortKey, + defaultSortDirection = "asc", + emptyMessage = "No records found.", + className, +}: AdminDataTableProps) { + const [sortKey, setSortKey] = useState(defaultSortKey || null); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">(defaultSortDirection); + + const handleSort = (key: string) => { + if (sortKey === key) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDirection("asc"); + } + }; + + const sortedData = useMemo(() => { + const col = columns.find((c) => c.key === sortKey); + return sortData(data, sortKey, sortDirection, col); + }, [data, sortKey, sortDirection, columns]); + + return ( +
+ + + + {columns.map((col) => { + const isSorted = sortKey === col.key; + return ( + + ); + })} + + + + {sortedData.length === 0 ? ( + + + + ) : ( + sortedData.map((row, i) => { + const isSelected = selectedRowKey ? selectedRowKey(row) : false; + const isClickable = !!onRowClick; + return ( + onRowClick && onRowClick(row)} + className={cn( + "border-b border-white/[0.04] last:border-0 transition-colors", + isClickable ? "cursor-pointer hover:bg-white/[0.02]" : "", + isSelected ? "bg-white/[0.04]" : "" + )} + > + {columns.map((col) => ( + + ))} + + ); + }) + )} + +
col.sortable && handleSort(col.key)} + className={cn( + "px-4 py-3 font-medium text-muted-foreground select-none", + col.sortable ? "cursor-pointer hover:text-foreground transition-colors" : "" + )} + > +
+ {col.header} + {col.sortable && ( + + {!isSorted ? ( + + ) : sortDirection === "asc" ? ( + + ) : ( + + )} + + )} +
+
+ {emptyMessage} +
+ {col.render ? col.render(row) : String((row as any)[col.key] ?? "")} +
+
+ ); +} diff --git a/src/features/demo-admin-dashboard/fixtures/presets.ts b/src/features/demo-admin-dashboard/fixtures/presets.ts new file mode 100644 index 00000000..be9bae70 --- /dev/null +++ b/src/features/demo-admin-dashboard/fixtures/presets.ts @@ -0,0 +1,308 @@ +import type { PresetScenario } from "../types"; + +export const PRESET_SCENARIOS: PresetScenario[] = [ + { + id: "relay-verification", + name: "Relay Verification", + description: "Simulates registration, OTP generation, and identity verification for a new message routing relay.", + stats: [ + { label: "Active Accounts", value: "13", delta: "+3" }, + { label: "Messages Sent", value: "847", delta: "+12%" }, + { label: "Pending Requests", value: "4", delta: "+1" }, + { label: "Total Postage (XLM)", value: "1,240.5", delta: "+45.2" }, + ], + accounts: [ + { name: "Alice Demo", address: "GABCD...1234", balance: "500.0 XLM", type: "User" }, + { name: "Bob Demo", address: "GBCDE...2345", balance: "320.0 XLM", type: "User" }, + { name: "Relay East", address: "GCDEF...3456", balance: "1,200.0 XLM", type: "Relay" }, + { name: "Relay West", address: "GDEFG...4567", balance: "980.0 XLM", type: "Relay" }, + { + name: "Relay Node 07", + address: "GCRLY...N007", + balance: "1,500.0 XLM", + type: "Relay (Pending)", + relayMetadata: { + nodeUri: "relay07*stealth.demo", + latency: "42ms", + signatureScheme: "Ed25519", + status: "pending", + owner: "GDREL...OWNR", + }, + }, + ], + mail: [ + { + subject: "Your relay verification code", + status: "pending", + folder: "pending", + from: "Relay Node 07", + email: "relay07*stealth.demo", + body: "Hi Eve,\n\nA new relay session is requesting authorization on Node 07. Use the one-time passkey below to confirm it's you.\n\nYour OTP code: 482 015\n\nThis code expires in 10 minutes. If you didn't initiate this, ignore the message and your session will stay locked.\n\n— Relay Node 07", + time: "Just now", + unread: true, + starred: false, + labels: ["Security", "OTP"], + avatarColor: "#4d5560", + verifiedSender: false, + proofMetadata: { + messageHash: "0x0303030303030303030303030303030303030303030303030303030303d8c7e9", + paymentHash: "0x03pay03pay03pay03pay03pay03pay03pay03pay03pay03pay03payf12a3d", + diagnosticId: "d1f038c7-4b1d-44a6-8968-3e5f49230503", + contractAddress: "CB3333333333333333333333333333333333333333333333339999", + latency: "42ms", + signature: "Ed25519 [0x0303030303030303f31b]", + postageStatus: "pending", + }, + }, + { + subject: "Welcome to Stealth", + status: "delivered", + folder: "inbox", + from: "Stealth Team", + email: "welcome*stealth.demo", + body: "Hi there,\n\nYour Stealth mailbox is set up. You decide who can reach you: trusted contacts arrive instantly, everyone else follows the policy you choose.\n\nReply any time to start a conversation.\n\n— The Stealth demo team", + time: "9:42 AM", + unread: false, + starred: true, + labels: ["Onboarding"], + avatarColor: "#5b6470", + }, + ], + attachments: [ + { + id: "att-relay-spec", + fileName: "relay_specification.json", + fileSize: "4.2 KB", + fileType: "JSON", + messageSubject: "Your relay verification code", + sender: "relay07*stealth.demo", + }, + ], + events: [ + { + id: "evt-relay-register", + title: "Relay Node 07 Registration", + date: "2026-06-16", + time: "12:00 PM", + location: "Stellar Network", + organizer: "GDREL...OWNR", + status: "tentative", + }, + ], + auditEvents: [ + { action: "Relay Node 07 registration initiated", actor: "relay07*stealth.demo", timestamp: "2026-06-16T12:00:00Z" }, + { action: "Authorization OTP generated and dispatched", actor: "system", timestamp: "2026-06-16T12:01:00Z" }, + { action: "Relay verification check pending for GCRLY...N007", actor: "system", timestamp: "2026-06-16T12:02:00Z" }, + ], + }, + { + id: "proof-pending", + name: "Proof Pending", + description: "Simulates an incoming message bridge event holding for cryptographic proof generation on the Stellar ledger.", + stats: [ + { label: "Active Accounts", value: "13", delta: "+3" }, + { label: "Messages Sent", value: "847", delta: "+12%" }, + { label: "Pending Requests", value: "4", delta: "+1" }, + { label: "Total Postage (XLM)", value: "1,240.5", delta: "+45.2" }, + ], + accounts: [ + { name: "Alice Demo", address: "GABCD...1234", balance: "500.0 XLM", type: "User" }, + { name: "Bob Demo", address: "GBCDE...2345", balance: "320.0 XLM", type: "User" }, + { name: "Relay East", address: "GCDEF...3456", balance: "1,200.0 XLM", type: "Relay" }, + { name: "Relay West", address: "GDEFG...4567", balance: "980.0 XLM", type: "Relay" }, + { + name: "Stellar Bridge Relay", + address: "GCSBR...BREG", + balance: "10,000.0 XLM", + type: "Relay (Active)", + relayMetadata: { + nodeUri: "bridge*stealth.demo", + latency: "15ms", + signatureScheme: "Ed25519", + status: "verified", + owner: "GDBRG...OWNR", + }, + }, + ], + mail: [ + { + subject: "Soroban proof generation pending", + status: "pending", + folder: "pending", + from: "Legacy Bridge", + email: "bridge*stealth.demo", + body: "This message was bridged from SMTP and is currently waiting for a Soroban cryptographic validity proof to be generated and registered on-chain.\n\nTransaction: 0x5b39...aef2\nRelay: bridge*stealth.demo", + time: "5m ago", + unread: true, + starred: false, + labels: ["Bridge", "Pending"], + avatarColor: "#7a8290", + verifiedSender: false, + proofMetadata: { + messageHash: "0x0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0ed8c7e9", + paymentHash: "0x0epay0epay0epay0epay0epay0epay0epay0epay0epay0epay0epayf12a3d", + diagnosticId: "d1f038c7-4b1d-44a6-8968-3e5f4923050e", + contractAddress: "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB9999", + latency: "84ms", + signature: "Ed25519 [0x0e0e0e0e0e0e0e0e0e0e0ed8c7e9f31b]", + postageStatus: "pending", + }, + }, + { + subject: "Message request awaiting approval", + status: "pending", + folder: "requests", + from: "Anonymous Sender", + email: "gckn...n4xq@example.com", + body: "This sender paid postage but is not in your trusted contacts yet.\n\nPostage paid: 1.0 XLM\nPreimage status: pending", + time: "10m ago", + unread: true, + starred: false, + labels: ["Request", "Paid"], + avatarColor: "#3d434d", + postageAmount: "10000000", + verifiedSender: false, + proofMetadata: { + messageHash: "0x0505050505050505050505050505050505050505050505050505050505d8c7e9", + paymentHash: "0x05pay05pay05pay05pay05pay05pay05pay05pay05pay05pay05payf12a3d", + diagnosticId: "d1f038c7-4b1d-44a6-8968-3e5f49230505", + contractAddress: "CB5555555555555555555555555555555555555555555555559999", + latency: "62ms", + signature: "Ed25519 [0x0505050505050505f31b]", + postageStatus: "pending", + }, + }, + ], + attachments: [ + { + id: "att-soroban-tx", + fileName: "soroban_transaction.tx", + fileSize: "12.8 KB", + fileType: "Transaction Payload", + messageSubject: "Soroban proof generation pending", + sender: "bridge*stealth.demo", + }, + { + id: "att-invoice-1042", + fileName: "invoice_1042.pdf", + fileSize: "120 KB", + fileType: "PDF Document", + messageSubject: "Message request awaiting approval", + sender: "gckn...n4xq@example.com", + }, + ], + events: [ + { + id: "evt-proof-schedule", + title: "Soroban Proof Ledger Verification", + date: "2026-06-16", + time: "12:12 PM", + location: "Soroban VM", + organizer: "system", + status: "tentative", + }, + ], + auditEvents: [ + { action: "Incoming bridge message captured", actor: "bridge*stealth.demo", timestamp: "2026-06-16T12:10:00Z" }, + { action: "Postage preimage submitted to escrow contract", actor: "bridge*stealth.demo", timestamp: "2026-06-16T12:11:00Z" }, + { action: "On-chain Soroban ledger proof check scheduled", actor: "system", timestamp: "2026-06-16T12:12:00Z" }, + ], + }, + { + id: "receipt-settlement", + name: "Receipt Settlement", + description: "Simulates the successful settlement of postage fees and read confirmation receipts on the Soroban smart contract ledger.", + stats: [ + { label: "Active Accounts", value: "12", delta: "0" }, + { label: "Messages Sent", value: "849", delta: "+15%" }, + { label: "Pending Requests", value: "3", delta: "-2" }, + { label: "Total Postage (XLM)", value: "1,240.5001", delta: "+0.0001" }, + ], + accounts: [ + { name: "Alice Demo", address: "GABCD...1234", balance: "500.0 XLM", type: "User" }, + { name: "Bob Demo", address: "GBCDE...2345", balance: "320.0 XLM", type: "User" }, + { name: "Relay East", address: "GCDEF...3456", balance: "1,200.0001 XLM", type: "Relay" }, + { name: "Relay West", address: "GDEFG...4567", balance: "980.0 XLM", type: "Relay" }, + { + name: "Receipt Contract", + address: "CCLC7...RECP", + balance: "25,000.0 XLM", + type: "Soroban Contract", + }, + ], + mail: [ + { + subject: "Delivery receipt settled", + status: "delivered", + folder: "receipts", + from: "Receipt Contract", + email: "receipts*stealth.demo", + body: "Delivery receipt settled.\n\nMessage: 48fb...c29a\nContract: CCLC7...RECP\nEvent: read_proof\nFee: 0.00002 XLM\n\nPostage is now settled on-chain.", + time: "1h ago", + unread: false, + starred: false, + labels: ["Receipt", "Soroban"], + avatarColor: "#9098a4", + verifiedSender: true, + proofMetadata: { + messageHash: "0x0707070707070707070707070707070707070707070707070707070707d8c7e9", + paymentHash: "0x07pay07pay07pay07pay07pay07pay07pay07pay07pay07pay07payf12a3d", + diagnosticId: "d1f038c7-4b1d-44a6-8968-3e5f49230507", + contractAddress: "CB7777777777777777777777777777777777777777777777779999", + latency: "24ms", + signature: "Ed25519 [0x0707070707070707f31b]", + postageStatus: "settled", + }, + }, + { + subject: "Postage settled for your message", + status: "delivered", + folder: "receipts", + from: "Billing System", + email: "billing*stealth.demo", + body: "Your message postage has settled.\n\nAmount: 0.0001 XLM\nStatus: settled\nReference: demo-memo-0001\n\nThis is a demo receipt and carries no real value.", + time: "2h ago", + unread: false, + starred: false, + labels: ["Postage", "Billing"], + avatarColor: "#3d434d", + verifiedSender: true, + proofMetadata: { + messageHash: "0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0ad8c7e9", + paymentHash: "0x0apay0apay0apay0apay0apay0apay0apay0apay0apay0apay0apayf12a3d", + diagnosticId: "d1f038c7-4b1d-44a6-8968-3e5f4923050a", + contractAddress: "CBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9999", + latency: "35ms", + signature: "Ed25519 [0x0a0a0a0a0a0a0a0a0a0a0af31b]", + postageStatus: "settled", + }, + }, + ], + attachments: [ + { + id: "att-read-proof", + fileName: "read_receipt.proof", + fileSize: "2.1 KB", + fileType: "Cryptographic Proof", + messageSubject: "Delivery receipt settled", + sender: "receipts*stealth.demo", + }, + ], + events: [ + { + id: "evt-postage-release", + title: "Postage Release Settlement", + date: "2026-06-16", + time: "11:05 PM", + location: "Soroban Contract CCLC7...RECP", + organizer: "system", + status: "confirmed", + }, + ], + auditEvents: [ + { action: "Soroban delivery receipt contract invoked: CCLC7...RECP", actor: "system", timestamp: "2026-06-16T11:00:00Z" }, + { action: "Cryptographic read proof signature verified on-chain", actor: "system", timestamp: "2026-06-16T11:02:00Z" }, + { action: "Postage fee of 0.0001 XLM released to Relay East", actor: "system", timestamp: "2026-06-16T11:05:00Z" }, + ], + }, +]; diff --git a/src/features/demo-admin-dashboard/index.ts b/src/features/demo-admin-dashboard/index.ts index 5e1f3763..d4c59850 100644 --- a/src/features/demo-admin-dashboard/index.ts +++ b/src/features/demo-admin-dashboard/index.ts @@ -1,4 +1,5 @@ -export { DemoAdminDashboard } from "./components/DemoAdminDashboard"; +export { DemoAdminDashboard } from "./DemoAdminDashboard"; +export { DemoAdminDashboard as DemoAdminLayoutDashboard } from "./components/DemoAdminDashboard"; export { ADMIN_DASHBOARD_MIN_SUPPORTED_WIDTH, getAdminDashboardBreakpoint, @@ -19,6 +20,8 @@ export type { DashboardSection, DemoAdminDashboardProps, StatCard, + PresetAttachment, + PresetEvent, } from "./types"; export { diff --git a/src/features/demo-admin-dashboard/types.ts b/src/features/demo-admin-dashboard/types.ts index 5885c27d..173ee65c 100644 --- a/src/features/demo-admin-dashboard/types.ts +++ b/src/features/demo-admin-dashboard/types.ts @@ -43,7 +43,14 @@ export interface DashboardNavItem { } /** The available top-level sections in the admin dashboard. */ -export type DashboardSection = "overview" | "accounts" | "mail" | "templates" | "audit"; +export type DashboardSection = + | "overview" + | "accounts" + | "mail" + | "attachments" + | "events" + | "templates" + | "audit"; /** Props passed to the dashboard shell. */ export interface DemoAdminDashboardProps { @@ -59,3 +66,97 @@ export interface StatCard { delta?: string; } +export type PresetId = "none" | "relay-verification" | "proof-pending" | "receipt-settlement"; + +export interface PresetAccount { + name: string; + address: string; + balance: string; + type: string; + relayMetadata?: { + nodeUri: string; + latency: string; + signatureScheme: string; + status: "verified" | "pending" | "failed"; + owner: string; + }; +} + +export interface PresetMail { + subject: string; + status: string; + folder: string; + from: string; + email: string; + body: string; + time: string; + unread: boolean; + starred: boolean; + labels: string[]; + avatarColor: string; + postageAmount?: string; + verifiedSender?: boolean; + receiptState?: "none" | "pending" | "sent"; + proofMetadata?: { + messageHash: string; + paymentHash: string; + diagnosticId: string; + contractAddress: string; + latency: string; + signature: string; + postageStatus: "pending" | "settled" | "refunded"; + }; +} + +export interface PresetAuditEvent { + action: string; + actor: string; + timestamp: string; +} + +export interface PresetAttachment { + id: string; + fileName: string; + fileSize: string; + fileType: string; + messageSubject: string; + sender: string; +} + +export interface PresetEvent { + id: string; + title: string; + date: string; + time: string; + location: string; + organizer: string; + status: "confirmed" | "tentative" | "cancelled"; +} + +export interface PresetScenario { + id: PresetId; + name: string; + description: string; + stats: StatCard[]; + accounts: PresetAccount[]; + mail: PresetMail[]; + attachments: PresetAttachment[]; + events: PresetEvent[]; + auditEvents: PresetAuditEvent[]; +} + +export interface DemoUser { + id: string; + name: string; + email: string; + role: string; +} + +export interface DemoItem { + id: string; + title: string; + description: string; +} + + +