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
78 changes: 69 additions & 9 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,20 +554,80 @@ async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem {
}
}

pub async fn submit_document(Json(req): Json<SubmitRequest>) -> 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<AppState>,
Json(req): Json<SubmitRequest>,
) -> 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::<SubmissionRecord>(&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<RevokeRequest>) -> impl IntoResponse {
Expand Down
14 changes: 14 additions & 0 deletions contract/src/stellar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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())
}
}
}
100 changes: 100 additions & 0 deletions frontend/module/components/error-boundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 p-8 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<svg
className="h-8 w-8 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
</div>

<div className="space-y-2">
<h1 className="text-2xl font-bold text-gray-900">
Sorry, something went wrong
</h1>
{process.env.NODE_ENV === "development" && error && (
<p className="max-w-md text-sm text-red-600">{error.message}</p>
)}
</div>

<div className="flex gap-4">
<button
onClick={onReset}
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700"
>
Try Again
</button>
<Link
href="/dashboard"
className="rounded-lg border border-gray-300 px-5 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50"
>
Go to Dashboard
</Link>
</div>
</div>
);
}

export class ErrorBoundary extends Component<Props, State> {
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 (
<GlobalErrorPage error={this.state.error} onReset={this.handleReset} />
);
}
return this.props.children;
}
}

export default ErrorBoundary;
116 changes: 116 additions & 0 deletions frontend/module/components/mobile-nav/MobileNavigationMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="md:hidden">
<button
onClick={() => setOpen(true)}
aria-label="Open navigation menu"
className="rounded-md p-2 text-gray-700 hover:bg-gray-100"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>

{open && (
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
)}

<div
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
style={{
transform: open ? "translateX(0)" : "translateX(-100%)",
transition: "transform 0.25s ease-in-out",
}}
className="fixed inset-y-0 left-0 z-50 w-72 bg-white shadow-xl"
>
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4">
<span className="text-lg font-bold text-gray-900">SMALDA</span>
<button
onClick={() => setOpen(false)}
aria-label="Close navigation menu"
className="rounded-md p-1 text-gray-500 hover:bg-gray-100"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

<nav className="flex flex-col gap-1 p-4">
{links.map((link) => {
const isActive = pathname === link.href || pathname.startsWith(link.href + "/");
return (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className={`rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
isActive
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{link.label}
</Link>
);
})}
</nav>
</div>
</div>
);
}
Loading