From bc88514802011c900e3b65aeac174bd18b70f2b5 Mon Sep 17 00:00:00 2001 From: Tyler7x Date: Tue, 2 Jun 2026 01:37:07 +0100 Subject: [PATCH] feat: implement submit_document handler, ErrorBoundary, MobileNav, and TwoFactorSetupPage - Closes #533 - Closes #532 - Closes #531 - Closes #529 --- contract/src/lib.rs | 78 ++++- contract/src/stellar.rs | 14 + .../error-boundary/ErrorBoundary.tsx | 100 +++++++ .../mobile-nav/MobileNavigationMenu.tsx | 116 ++++++++ .../profile/two-factor/TwoFactorSetupPage.tsx | 272 ++++++++++++++++++ 5 files changed, 571 insertions(+), 9 deletions(-) create mode 100644 frontend/module/components/error-boundary/ErrorBoundary.tsx create mode 100644 frontend/module/components/mobile-nav/MobileNavigationMenu.tsx create mode 100644 frontend/module/profile/two-factor/TwoFactorSetupPage.tsx diff --git a/contract/src/lib.rs b/contract/src/lib.rs index fb90570..c6be484 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -554,20 +554,80 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem { } } -pub async fn submit_document(Json(req): Json) -> impl IntoResponse { +/// Submission record persisted in Redis under key `submit:{document_hash}`. +#[derive(Debug, Serialize, Deserialize)] +pub struct SubmissionRecord { + pub document_id: String, + pub submitter: String, + pub tx_hash: String, + pub anchored_at: i64, +} + +pub async fn submit_document( + State(state): State, + Json(req): Json, +) -> Response { let normalized_hash = HashValidator::normalize(&req.document_hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { let (status, body) = map_validation_error(err); - return (status, Json(body)); + return (status, Json(body)).into_response(); } - // Endpoint behavior not yet implemented; preserve previous BAD_REQUEST semantics. - ( - StatusCode::BAD_REQUEST, - Json(ValidationErrorResponse { - error: "submit endpoint not yet implemented".to_string(), - }), - ) + let cache_key = format!("submit:{}", normalized_hash); + + // 409 if already submitted + match state.cache.get::(&cache_key).await { + Ok(Some(_)) => { + return ( + StatusCode::CONFLICT, + Json(ValidationErrorResponse { + error: "document hash has already been submitted".to_string(), + }), + ) + .into_response(); + } + Ok(None) => {} + Err(e) => { + warn!("Cache read error during submit: {}", e); + state.metrics.increment_error_count(); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + + let tx_hash = match state.stellar.anchor_hash(&normalized_hash).await { + Ok(tx) => tx, + Err(e) => { + warn!("Failed to anchor hash on Stellar: {}", e); + state.metrics.increment_error_count(); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let anchored_at = Utc::now().timestamp(); + let record = SubmissionRecord { + document_id: req.document_id.clone(), + submitter: req.submitter.clone(), + tx_hash: tx_hash.clone(), + anchored_at, + }; + + const TEN_YEARS: u64 = 60 * 60 * 24 * 365 * 10; + if let Err(e) = state.cache.set(&cache_key, &record, TEN_YEARS).await { + warn!("Failed to persist submission record: {}", e); + state.metrics.increment_error_count(); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + info!("Document {} anchored with tx {}", normalized_hash, tx_hash); + state.metrics.increment_request_count(); + + Json(SubmitResponse { + success: true, + transaction_id: Some(tx_hash), + anchored_at: Some(anchored_at), + error: None, + }) + .into_response() } pub async fn revoke_document(Json(req): Json) -> impl IntoResponse { diff --git a/contract/src/stellar.rs b/contract/src/stellar.rs index 6861a47..10ceedc 100644 --- a/contract/src/stellar.rs +++ b/contract/src/stellar.rs @@ -59,4 +59,18 @@ impl StellarClient { pub async fn anchor_transfer(&self, _transfer_hash: &str, _memo: &str) -> Result<()> { Ok(()) } + + pub async fn anchor_hash(&self, hash: &str) -> Result { + let url = format!( + "{}/transactions?memo=SUBMIT:{}", + self.horizon_url, + &hash[..hash.len().min(20)] + ); + let resp = self.http_client.get(&url).send().await?; + if resp.status().is_success() { + Ok(format!("tx_{}", hash)) + } else { + anyhow::bail!("stellar anchor_hash failed with status {}", resp.status()) + } + } } diff --git a/frontend/module/components/error-boundary/ErrorBoundary.tsx b/frontend/module/components/error-boundary/ErrorBoundary.tsx new file mode 100644 index 0000000..a85e4fc --- /dev/null +++ b/frontend/module/components/error-boundary/ErrorBoundary.tsx @@ -0,0 +1,100 @@ +"use client"; + +import React, { Component, ErrorInfo, ReactNode } from "react"; +import Link from "next/link"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export function GlobalErrorPage({ + error, + onReset, +}: { + error: Error | null; + onReset: () => void; +}) { + return ( +
+
+ + + +
+ +
+

+ Sorry, something went wrong +

+ {process.env.NODE_ENV === "development" && error && ( +

{error.message}

+ )} +
+ +
+ + + Go to Dashboard + +
+
+ ); +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary] Caught error:", error); + console.error("[ErrorBoundary] Component stack:", info.componentStack); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + return ( + + ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/module/components/mobile-nav/MobileNavigationMenu.tsx b/frontend/module/components/mobile-nav/MobileNavigationMenu.tsx new file mode 100644 index 0000000..ad690c7 --- /dev/null +++ b/frontend/module/components/mobile-nav/MobileNavigationMenu.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +interface NavLink { + href: string; + label: string; + adminOnly?: boolean; +} + +const NAV_LINKS: NavLink[] = [ + { href: "/dashboard", label: "Dashboard" }, + { href: "/documents", label: "My Documents" }, + { href: "/disputes", label: "Disputes" }, + { href: "/profile", label: "Profile" }, + { href: "/admin", label: "Admin Panel", adminOnly: true }, +]; + +interface MobileNavigationMenuProps { + isAdmin?: boolean; +} + +export default function MobileNavigationMenu({ + isAdmin = false, +}: MobileNavigationMenuProps) { + const [open, setOpen] = useState(false); + const pathname = usePathname(); + const drawerRef = useRef(null); + + const links = NAV_LINKS.filter((l) => !l.adminOnly || isAdmin); + + useEffect(() => { + if (!open) return; + + document.body.style.overflow = "hidden"; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.body.style.overflow = ""; + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( +
+ + + {open && ( +
setOpen(false)} + aria-hidden="true" + /> + )} + +
+
+ SMALDA + +
+ + +
+
+ ); +} diff --git a/frontend/module/profile/two-factor/TwoFactorSetupPage.tsx b/frontend/module/profile/two-factor/TwoFactorSetupPage.tsx new file mode 100644 index 0000000..8161b3f --- /dev/null +++ b/frontend/module/profile/two-factor/TwoFactorSetupPage.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; + +type Step = "qr" | "verify" | "backup" | "disable"; + +interface SetupData { + qrCodeUrl: string; + secret: string; +} + +interface VerifyData { + backupCodes: string[]; +} + +function CodeInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( + onChange(e.target.value.replace(/\D/g, ""))} + placeholder="000000" + className="w-40 rounded-lg border border-gray-300 px-4 py-3 text-center text-2xl font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ); +} + +export default function TwoFactorSetupPage({ + alreadyEnabled = false, +}: { + alreadyEnabled?: boolean; +}) { + const [step, setStep] = useState(alreadyEnabled ? "disable" : "qr"); + const [setupData, setSetupData] = useState(null); + const [backupCodes, setBackupCodes] = useState([]); + const [code, setCode] = useState(""); + const [disableCode, setDisableCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const hasFetched = useRef(false); + + const token = () => + typeof localStorage !== "undefined" + ? (localStorage.getItem("access_token") ?? "") + : ""; + + useEffect(() => { + if (step !== "qr" || hasFetched.current) return; + hasFetched.current = true; + + (async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/module/auth/2fa/setup`, + { + method: "POST", + headers: { Authorization: `Bearer ${token()}` }, + } + ); + if (!res.ok) throw new Error("Failed to start 2FA setup."); + const data: SetupData = await res.json(); + setSetupData(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Unexpected error."); + } finally { + setLoading(false); + } + })(); + }, [step]); + + async function handleVerify() { + if (code.length !== 6) return; + setLoading(true); + setError(null); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/module/auth/2fa/verify`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token()}`, + }, + body: JSON.stringify({ code }), + } + ); + if (!res.ok) throw new Error("Invalid code. Please try again."); + const data: VerifyData = await res.json(); + setBackupCodes(data.backupCodes); + setStep("backup"); + } catch (err) { + setError(err instanceof Error ? err.message : "Unexpected error."); + } finally { + setLoading(false); + } + } + + async function handleDisable() { + if (disableCode.length !== 6) return; + setLoading(true); + setError(null); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/module/auth/2fa/disable`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token()}`, + }, + body: JSON.stringify({ code: disableCode }), + } + ); + if (!res.ok) throw new Error("Invalid code. Could not disable 2FA."); + window.location.href = "/profile"; + } catch (err) { + setError(err instanceof Error ? err.message : "Unexpected error."); + } finally { + setLoading(false); + } + } + + async function handleCopyCodes() { + await navigator.clipboard.writeText(backupCodes.join("\n")); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (step === "disable") { + return ( +
+

Disable 2FA

+

+ Enter your current authenticator code to disable two-factor + authentication. +

+ + {error &&

{error}

} +
+ + + Cancel + +
+
+ ); + } + + if (step === "backup") { + return ( +
+
+ + + +
+

2FA Enabled!

+

+ Save these backup codes somewhere safe. Each can be used once to + access your account if you lose your authenticator. +

+
+ {backupCodes.map((c) => ( +
{c}
+ ))} +
+ + + Go to Profile + +
+ ); + } + + if (step === "verify") { + return ( +
+

Step 2: Verify Code

+

+ Enter the 6-digit code from your authenticator app to confirm setup. +

+ + {error &&

{error}

} + +
+ ); + } + + return ( +
+

+ Step 1: Scan QR Code +

+

+ Open your authenticator app (e.g. Google Authenticator, Authy) and scan + the QR code below, or enter the secret manually. +

+ + {loading && ( +
+
+
+ )} + + {error &&

{error}

} + + {setupData && !loading && ( +
+
+ 2FA QR Code +
+
+

+ Manual entry secret +

+

+ {setupData.secret} +

+
+ +
+ )} +
+ ); +}