From 5fbd8cfb79c8dcf7768241fca5bc608586286421 Mon Sep 17 00:00:00 2001 From: snooopdog Date: Thu, 24 Sep 2026 18:45:35 +0100 Subject: [PATCH] feat(invoice): add AccessCodeGate for private invoices with client-side SHA-256 verification --- src/__tests__/AccessCodeGate.test.tsx | 145 ++++++++++++++ src/app/invoice/[id]/page.tsx | 20 +- src/components/invoice/AccessCodeGate.tsx | 219 ++++++++++++++++++++++ src/lib/accessCode.ts | 34 ++++ 4 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/AccessCodeGate.test.tsx create mode 100644 src/components/invoice/AccessCodeGate.tsx create mode 100644 src/lib/accessCode.ts diff --git a/src/__tests__/AccessCodeGate.test.tsx b/src/__tests__/AccessCodeGate.test.tsx new file mode 100644 index 0000000..6dd3ac9 --- /dev/null +++ b/src/__tests__/AccessCodeGate.test.tsx @@ -0,0 +1,145 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AccessCodeGate, { hashAccessCode, verifyAccessCode } from "@/components/invoice/AccessCodeGate"; + +describe("AccessCodeGate & Hash Verification", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + describe("hashAccessCode & verifyAccessCode", () => { + it("hashes an access code to a 64-character SHA-256 hex string", async () => { + const hash1 = await hashAccessCode("my-secret-code"); + const hash2 = await hashAccessCode("my-secret-code"); + + expect(hash1).toHaveLength(64); + expect(hash1).toBe(hash2); + }); + + it("verifies matching access code against stored hash", async () => { + const code = "Stellar2026!"; + const hash = await hashAccessCode(code); + + const isValid = await verifyAccessCode(code, hash); + expect(isValid).toBe(true); + }); + + it("rejects non-matching access code", async () => { + const code = "Stellar2026!"; + const hash = await hashAccessCode(code); + + const isValid = await verifyAccessCode("wrong-code", hash); + expect(isValid).toBe(false); + }); + }); + + describe("AccessCodeGate component", () => { + it("renders children directly if invoice is not private", () => { + render( + +
Secret Invoice Details
+
+ ); + + expect(screen.getByTestId("invoice-content")).toBeInTheDocument(); + expect(screen.queryByLabelText(/access code/i)).not.toBeInTheDocument(); + }); + + it("renders access code entry screen if invoice is private and locked", () => { + render( + +
Secret Invoice Details
+
+ ); + + expect(screen.queryByTestId("invoice-content")).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: /private invoice access code entry/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/access code/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /unlock invoice/i })).toBeInTheDocument(); + }); + + it("toggles password visibility with show/hide toggle", () => { + render( + +
Secret Invoice Details
+
+ ); + + const input = screen.getByLabelText(/access code/i); + expect(input).toHaveAttribute("type", "password"); + + const toggleButton = screen.getByRole("button", { name: /show access code/i }); + fireEvent.click(toggleButton); + + expect(input).toHaveAttribute("type", "text"); + expect(screen.getByRole("button", { name: /hide access code/i })).toBeInTheDocument(); + }); + + it("displays error message on incorrect access code", async () => { + const user = userEvent.setup(); + const expectedHash = await hashAccessCode("correct-code"); + + render( + +
Secret Invoice Details
+
+ ); + + const input = screen.getByLabelText(/access code/i); + await user.type(input, "wrong-code"); + + const unlockButton = screen.getByRole("button", { name: /unlock invoice/i }); + fireEvent.click(unlockButton); + + await waitFor(() => { + expect(screen.getByText("Incorrect access code")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("invoice-content")).not.toBeInTheDocument(); + }); + + it("unlocks and persists to sessionStorage on correct code", async () => { + const user = userEvent.setup(); + const onUnlock = vi.fn(); + const expectedHash = await hashAccessCode("correct-code"); + + render( + +
Secret Invoice Details
+
+ ); + + const input = screen.getByLabelText(/access code/i); + await user.type(input, "correct-code"); + + const unlockButton = screen.getByRole("button", { name: /unlock invoice/i }); + fireEvent.click(unlockButton); + + await waitFor(() => { + expect(screen.getByTestId("invoice-content")).toBeInTheDocument(); + }); + + expect(onUnlock).toHaveBeenCalledTimes(1); + expect(sessionStorage.getItem("invoice_unlocked_123")).toBe("true"); + }); + + it("restores unlocked state from sessionStorage on mount", () => { + sessionStorage.setItem("invoice_unlocked_123", "true"); + + render( + +
Secret Invoice Details
+
+ ); + + expect(screen.getByTestId("invoice-content")).toBeInTheDocument(); + expect(screen.queryByLabelText(/access code/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 501ed5d..afd0b83 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -26,6 +26,7 @@ import CopyLinkButton from "@/components/CopyLinkButton"; import CopyButton from "@/components/CopyButton"; import TxConfirmModal from "@/components/TxConfirmModal"; import CancelModal from "@/components/CancelModal"; +import AccessCodeGate from "@/components/invoice/AccessCodeGate"; import DuplicateModal from "@/components/DuplicateModal"; import TransferOwnershipModal from "@/components/TransferOwnershipModal"; import ShareModal from "@/components/ShareModal"; @@ -499,8 +500,24 @@ export default function InvoiceDetailPage({ params }: Props) { const stellarDestination = process.env.NEXT_PUBLIC_CONTRACT_ID ?? invoice.token; + const isPrivate = + (invoice as any)?.visibility === "Private" || + (invoice as any)?.isPrivate === true || + (loadedSplitMeta as any)?.visibility === "Private" || + Boolean((invoice as any)?.accessCodeHash || (invoice as any)?.access_code_hash); + + const accessCodeHash = + (invoice as any)?.accessCodeHash || + (invoice as any)?.access_code_hash || + (loadedSplitMeta as any)?.accessCodeHash; + return ( -
+ +
{/* Reconnecting indicator */} @@ -1153,5 +1170,6 @@ export default function InvoiceDetailPage({ params }: Props) { onClose={() => setShowShareQRModal(false)} />
+
); } diff --git a/src/components/invoice/AccessCodeGate.tsx b/src/components/invoice/AccessCodeGate.tsx new file mode 100644 index 0000000..50a9bfb --- /dev/null +++ b/src/components/invoice/AccessCodeGate.tsx @@ -0,0 +1,219 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import Link from "next/link"; +import { verifyAccessCode, hashAccessCode } from "@/lib/accessCode"; + +export { verifyAccessCode, hashAccessCode }; + +export interface AccessCodeGateProps { + invoiceId: string; + expectedHash?: string; + isPrivate?: boolean; + onUnlock?: () => void; + children: React.ReactNode; +} + +export default function AccessCodeGate({ + invoiceId, + expectedHash, + isPrivate = false, + onUnlock, + children, +}: AccessCodeGateProps) { + const [isUnlocked, setIsUnlocked] = useState(false); + const [accessCode, setAccessCode] = useState(""); + const [showCode, setShowCode] = useState(false); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [isShaking, setIsShaking] = useState(false); + const inputRef = useRef(null); + + const storageKey = `invoice_unlocked_${invoiceId}`; + + // Check sessionStorage on mount + useEffect(() => { + if (typeof window !== "undefined") { + const stored = sessionStorage.getItem(storageKey); + if (stored === "true") { + setIsUnlocked(true); + } + } + }, [storageKey]); + + // Focus input when private and locked + useEffect(() => { + if (isPrivate && !isUnlocked) { + inputRef.current?.focus(); + } + }, [isPrivate, isUnlocked]); + + // If not private or already unlocked, render children directly + if (!isPrivate || isUnlocked) { + return <>{children}; + } + + const handleUnlock = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); + const trimmed = accessCode.trim(); + if (!trimmed) { + setError("Please enter the access code"); + triggerShake(); + return; + } + + setIsVerifying(true); + setError(null); + + try { + // If expectedHash is specified, verify against it; otherwise check if non-empty + const isValid = expectedHash ? await verifyAccessCode(trimmed, expectedHash) : true; + + if (isValid) { + if (typeof window !== "undefined") { + sessionStorage.setItem(storageKey, "true"); + } + setIsUnlocked(true); + if (onUnlock) onUnlock(); + } else { + setError("Incorrect access code"); + triggerShake(); + } + } catch (err) { + setError("Failed to verify access code"); + triggerShake(); + } finally { + setIsVerifying(false); + } + }; + + const triggerShake = () => { + setIsShaking(true); + setTimeout(() => setIsShaking(false), 500); + inputRef.current?.focus(); + }; + + return ( +
+ + +
+ {/* Lock Icon */} +
+ +
+ + + Private Invoice #{invoiceId} + + +

Access Code Required

+

+ This invoice is restricted. Please enter the access code to unlock invoice details and submit payments. +

+ +
+
+ +
+ { + setAccessCode(e.target.value); + if (error) setError(null); + }} + placeholder="Enter access code" + aria-label="Access code" + aria-required="true" + aria-invalid={Boolean(error)} + aria-describedby={error ? "access-code-error" : undefined} + disabled={isVerifying} + className={`w-full bg-gray-800/90 border rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 pr-12 focus:outline-none focus:ring-2 transition-colors ${ + error + ? "border-red-500 focus:ring-red-500/50" + : "border-gray-700 focus:ring-indigo-500/50 focus:border-indigo-500" + }`} + /> + +
+ + {error && ( + + )} +
+ + +
+ +
+ + ← Return to Dashboard + +
+
+
+ ); +} diff --git a/src/lib/accessCode.ts b/src/lib/accessCode.ts new file mode 100644 index 0000000..0666c4d --- /dev/null +++ b/src/lib/accessCode.ts @@ -0,0 +1,34 @@ +/** + * Client-side access code hashing and verification utilities. + */ + +/** + * Hashes an access code using SHA-256. + */ +export async function hashAccessCode(code: string): Promise { + const trimmed = code.trim(); + if (typeof crypto !== "undefined" && crypto.subtle) { + const encoder = new TextEncoder(); + const data = encoder.encode(trimmed); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + // Fallback for Node.js / test environments + try { + const nodeCrypto = await import("crypto"); + return nodeCrypto.createHash("sha256").update(trimmed).digest("hex"); + } catch { + throw new Error("Crypto API unavailable"); + } +} + +/** + * Verifies an access code against a stored SHA-256 hash. + */ +export async function verifyAccessCode(code: string, storedHash?: string): Promise { + if (!code || !storedHash) return false; + const computed = await hashAccessCode(code); + return computed.toLowerCase() === storedHash.trim().toLowerCase(); +}