diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 1c965a578..a81c20975 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -53,14 +53,12 @@ export const envValidationSchema = Joi.object({ BACKUP_S3_REGION: Joi.string().optional(), BACKUP_AWS_ACCESS_KEY_ID: Joi.string().optional(), BACKUP_AWS_SECRET_ACCESS_KEY: Joi.string().optional(), - BACKUP_ENCRYPTION_KEY: Joi.string() - .length(64) - .hex() - .optional(), + BACKUP_ENCRYPTION_KEY: Joi.string().length(64).hex().optional(), BACKUP_RETENTION_DAYS: Joi.number().integer().min(1).default(30).optional(), BACKUP_TMP_DIR: Joi.string().optional(), BACKUP_TEST_DB_HOST: Joi.string().hostname().optional(), BACKUP_TEST_DB_PORT: Joi.number().port().default(5432).optional(), BACKUP_TEST_DB_USER: Joi.string().optional(), BACKUP_TEST_DB_PASSWORD: Joi.string().optional(), - BACKUP_TEST_DB_NAME: Joi.string().default('nestera_restore_test').optional(),}).or('DATABASE_URL', 'DB_HOST'); // enforce at least one DB connection strategy + BACKUP_TEST_DB_NAME: Joi.string().default('nestera_restore_test').optional(), +}).or('DATABASE_URL', 'DB_HOST'); // enforce at least one DB connection strategy diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 514ddb8ac..2a69c88e6 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -40,4 +40,5 @@ } } ] -} \ No newline at end of file +} + diff --git a/frontend/app/components/ErrorBoundary.tsx b/frontend/app/components/ErrorBoundary.tsx new file mode 100644 index 000000000..6dfe180a3 --- /dev/null +++ b/frontend/app/components/ErrorBoundary.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React, { Component, ErrorInfo, ReactNode } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; +} + +/** + * React Error Boundary — catches render-time errors in the component tree. + * Note: does NOT catch errors in event handlers (use try/catch there). + */ +class ErrorBoundary extends Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack); + } + + handleReset = () => this.setState({ hasError: false }); + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + return ( +
+

Something went wrong

+

+ An unexpected error occurred. You can try again or refresh the page. +

+ +
+ ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/app/components/Navbar.tsx b/frontend/app/components/Navbar.tsx index 29739bc09..0e8cc3f2d 100644 --- a/frontend/app/components/Navbar.tsx +++ b/frontend/app/components/Navbar.tsx @@ -1,12 +1,13 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Loader2, Wallet } from "lucide-react"; import ThemeToggle from "./ThemeToggle"; import { useWallet } from "../context/WalletContext"; import { useToast } from "../context/ToastContext"; +import { useFocusTrap } from "../hooks/useFocusTrap"; interface NavLink { label: string; @@ -36,6 +37,10 @@ const Navbar: React.FC = () => { const { address, network, isConnected, isLoading, error, connect } = useWallet(); const toast = useToast(); const previousConnectedRef = useRef(isConnected); + const menuRef = useRef(null); + const menuButtonRef = useRef(null); + + const closeMenu = useCallback(() => setIsMobileMenuOpen(false), []); const isActiveLink = (href: string): boolean => { return pathname === href || pathname?.startsWith(href + "/") || false; @@ -45,6 +50,45 @@ const Navbar: React.FC = () => { ? `${address.slice(0, 4)}...${address.slice(-4)}` : null; + // Focus trap inside mobile menu + useFocusTrap({ + isOpen: isMobileMenuOpen, + containerRef: menuRef, + onEscape: closeMenu, + }); + + // Body scroll lock when menu is open + useEffect(() => { + if (isMobileMenuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isMobileMenuOpen]); + + // Click-outside to close + useEffect(() => { + if (!isMobileMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if ( + menuRef.current && + !menuRef.current.contains(target) && + menuButtonRef.current && + !menuButtonRef.current.contains(target) + ) { + closeMenu(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMobileMenuOpen, closeMenu]); + useEffect(() => { if (error) { toast.error("Wallet connection failed", error); @@ -90,7 +134,10 @@ const Navbar: React.FC = () => { }; return ( -