Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/__tests__/AccessCodeGate.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AccessCodeGate invoiceId="123" isPrivate={false}>
<div data-testid="invoice-content">Secret Invoice Details</div>
</AccessCodeGate>
);

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(
<AccessCodeGate invoiceId="123" isPrivate={true}>
<div data-testid="invoice-content">Secret Invoice Details</div>
</AccessCodeGate>
);

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(
<AccessCodeGate invoiceId="123" isPrivate={true}>
<div data-testid="invoice-content">Secret Invoice Details</div>
</AccessCodeGate>
);

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(
<AccessCodeGate invoiceId="123" expectedHash={expectedHash} isPrivate={true}>
<div data-testid="invoice-content">Secret Invoice Details</div>
</AccessCodeGate>
);

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(
<AccessCodeGate
invoiceId="123"
expectedHash={expectedHash}
isPrivate={true}
onUnlock={onUnlock}
>
<div data-testid="invoice-content">Secret Invoice Details</div>
</AccessCodeGate>
);

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(
<AccessCodeGate invoiceId="123" isPrivate={true}>
<div data-testid="invoice-content">Secret Invoice Details</div>
</AccessCodeGate>
);

expect(screen.getByTestId("invoice-content")).toBeInTheDocument();
expect(screen.queryByLabelText(/access code/i)).not.toBeInTheDocument();
});
});
});
19 changes: 18 additions & 1 deletion src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,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 (
<main className="w-full max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 overflow-x-hidden">
<AccessCodeGate
invoiceId={id}
expectedHash={accessCodeHash}
isPrivate={isPrivate}
>
<main className="w-full max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 overflow-x-hidden">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6">
<div className="min-w-0">
{/* Reconnecting indicator */}
Expand Down Expand Up @@ -1210,5 +1226,6 @@ export default function InvoiceDetailPage({ params }: Props) {
onClose={() => setShowShareQRModal(false)}
/>
</main>
</AccessCodeGate>
);
}
219 changes: 219 additions & 0 deletions src/components/invoice/AccessCodeGate.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isShaking, setIsShaking] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div
className="min-h-[70vh] flex items-center justify-center px-4 py-12"
role="region"
aria-label="Private invoice access code entry"
>
<style>{`
@keyframes shakeKeyframe {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-8px); }
40%, 80% { transform: translateX(8px); }
}
.animate-shake-gate {
animation: shakeKeyframe 0.4s ease-in-out;
}
`}</style>

<div
className={`w-full max-w-md bg-gray-900 border border-gray-800 rounded-2xl p-8 shadow-2xl text-center transition-all ${
isShaking ? "animate-shake-gate border-red-500/80 shadow-red-950/40" : ""
}`}
>
{/* Lock Icon */}
<div className="w-16 h-16 rounded-full bg-indigo-950/60 border border-indigo-700/50 flex items-center justify-center mx-auto mb-5 text-indigo-400">
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.75}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>

<span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-gray-800 text-gray-300 mb-3 border border-gray-700">
Private Invoice #{invoiceId}
</span>

<h1 className="text-2xl font-bold text-white mb-2">Access Code Required</h1>
<p className="text-sm text-gray-400 mb-6">
This invoice is restricted. Please enter the access code to unlock invoice details and submit payments.
</p>

<form onSubmit={handleUnlock} className="space-y-4 text-left" noValidate>
<div>
<label htmlFor="access-code-input" className="block text-xs font-medium text-gray-300 mb-1.5">
Access Code
</label>
<div className="relative">
<input
ref={inputRef}
id="access-code-input"
type={showCode ? "text" : "password"}
value={accessCode}
onChange={(e) => {
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"
}`}
/>
<button
type="button"
onClick={() => setShowCode(!showCode)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200 p-1 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded"
aria-label={showCode ? "Hide access code" : "Show access code"}
>
{showCode ? "Hide" : "Show"}
</button>
</div>

{error && (
<p id="access-code-error" role="alert" className="text-xs text-red-400 mt-2 font-medium">
{error}
</p>
)}
</div>

<button
type="submit"
disabled={isVerifying}
className="w-full min-h-11 px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold text-sm transition-colors shadow-lg shadow-indigo-600/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 flex items-center justify-center gap-2"
>
{isVerifying ? (
<>
<svg className="animate-spin h-4 w-4 text-white" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Verifying...</span>
</>
) : (
"Unlock Invoice"
)}
</button>
</form>

<div className="mt-6 pt-5 border-t border-gray-800">
<Link
href="/dashboard"
className="text-xs text-gray-400 hover:text-gray-200 transition-colors inline-flex items-center gap-1"
>
← Return to Dashboard
</Link>
</div>
</div>
</div>
);
}
Loading