diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..98bfba0 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,49 @@ +import { Component } from "react"; + +interface Props { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + return ( +
+

Something went wrong

+

+ {this.state.error?.message ?? "An unexpected error occurred"} +

+ +
+ ); + } + return this.props.children; + } +} diff --git a/src/components/MetaMaskButton.tsx b/src/components/MetaMaskButton.tsx new file mode 100644 index 0000000..bb5084d --- /dev/null +++ b/src/components/MetaMaskButton.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { isMetaMaskInstalled, signInWithMetaMask } from "@/lib/metaMaskAuth"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Wallet, + AlertCircle, + CheckCircle2, + ExternalLink, + Loader2, + LogOut, + Copy, + Check, +} from "lucide-react"; + +export default function MetaMaskButton() { + const { user, signOut } = useAuth(); + const [status, setStatus] = useState<"disconnected" | "connecting" | "connected" | "error">("disconnected"); + const [errorMessage, setErrorMessage] = useState(null); + const [copied, setCopied] = useState(false); + const [installed, setInstalled] = useState(true); + + const isWeb3User = user && !user.email; + const walletAddress = user?.user_metadata?.address || user?.user_metadata?.sub || ""; + + useEffect(() => { + setInstalled(isMetaMaskInstalled()); + }, []); + + useEffect(() => { + if (isWeb3User) { + setStatus("connected"); + } else { + setStatus("disconnected"); + } + }, [isWeb3User, user]); + + const handleConnect = async () => { + setStatus("connecting"); + setErrorMessage(null); + + try { + const { error } = await signInWithMetaMask(); + if (error) { + throw error; + } + setStatus("connected"); + } catch (err: unknown) { + console.error("MetaMask connect error:", err); + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Failed to connect MetaMask"); + } + }; + + const handleDisconnect = async () => { + try { + await signOut(); + setStatus("disconnected"); + } catch (err) { + console.error("MetaMask disconnect error:", err); + } + }; + + const handleCopy = async () => { + if (!walletAddress) return; + try { + await navigator.clipboard.writeText(walletAddress); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard fallback + } + }; + + const truncateAddress = (addr: string) => { + if (!addr) return ""; + return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`; + }; + + return ( +
+ {/* Network & Copy Row */} +
+ + + Hedera EVM + + {status === "connected" && walletAddress && ( + + )} +
+ + {/* Connected State */} + {status === "connected" && walletAddress && ( +
+ + + + Authenticated with MetaMask + + {truncateAddress(walletAddress)} + + + +
+ )} + + {/* Connecting State */} + {status === "connecting" && ( +
+ + + + Connecting... Sign the authentication challenge in MetaMask. + + +
+ )} + + {/* Error State */} + {status === "error" && errorMessage && ( +
+ + + {errorMessage} + + +
+ )} + + {/* Not Installed State */} + {!installed && ( +
+
+ + MetaMask is not installed +
+

+ To log in using an EVM wallet, please install the MetaMask extension and set up a Hedera Testnet account. +

+ +
+ )} + + {/* Action Buttons */} + {installed && ( +
+ {status !== "connected" ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 3bc183f..79807f8 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { User, Session } from "@supabase/supabase-js"; import { supabase } from "@/lib/supabaseClient"; +import { signInWithMetaMask as signInWithMetaMaskService } from "@/lib/metaMaskAuth"; interface AuthContextType { user: User | null; @@ -8,6 +9,9 @@ interface AuthContextType { loading: boolean; signOut: () => Promise; linkHederaWallet: (accountId: string) => Promise; + signInWithMetaMask: (statement?: string) => Promise<{ error: Error | null }>; + isMetaMaskConnected: boolean; + metaMaskAddress: string | undefined; } const AuthContext = createContext(undefined); @@ -17,6 +21,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); + // Determine if the current user signed in with MetaMask + const isMetaMaskConnected = user !== null && !user.email && !!user.user_metadata?.address; + const metaMaskAddress = user?.user_metadata?.address || user?.user_metadata?.sub || undefined; + useEffect(() => { // Get initial session supabase.auth.getSession().then(({ data: { session } }) => { @@ -75,12 +83,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (error) throw error; }; + const signInWithMetaMask = async (statement?: string) => { + const { error } = await signInWithMetaMaskService(statement); + return { error }; + }; + const value = { user, session, loading, signOut, linkHederaWallet, + signInWithMetaMask, + isMetaMaskConnected, + metaMaskAddress, }; return {children}; diff --git a/src/lib/metaMaskAuth.ts b/src/lib/metaMaskAuth.ts new file mode 100644 index 0000000..668deb5 --- /dev/null +++ b/src/lib/metaMaskAuth.ts @@ -0,0 +1,176 @@ +import { supabase } from "./supabaseClient"; + +export interface EIP6963ProviderDetail { + info: { + uuid: string; + name: string; + icon: string; + rdns: string; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: any; +} + +// Chain ID for Hedera Testnet (EVM) is 296 (0x128) +export const HEDERA_TESTNET_CHAIN_ID = "0x128"; // 296 in hex + +export const HEDERA_TESTNET_PARAMS = { + chainId: HEDERA_TESTNET_CHAIN_ID, + chainName: "Hedera Testnet", + nativeCurrency: { + name: "HBAR", + symbol: "HBAR", + decimals: 18, + }, + rpcUrls: ["https://testnet.hashio.io/api"], + blockExplorerUrls: ["https://hashscan.io/testnet"], +}; + +/** + * Check if MetaMask or any EIP-1193 provider is installed + */ +export function isMetaMaskInstalled(): boolean { +// eslint-disable-next-line @typescript-eslint/no-explicit-any + return typeof window !== "undefined" && typeof (window as Record).ethereum !== "undefined"; +} + +/** + * Request network switch to Hedera Testnet + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function switchToHederaTestnet(provider: any = (window as Record).ethereum): Promise { + if (!provider) return false; + + try { + await provider.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: HEDERA_TESTNET_CHAIN_ID }], + }); + return true; + } catch (error: unknown) { + const err = error as { code?: number; message?: string }; + // User rejected the network switch + if (err.code === 4001) { + console.warn("User rejected Hedera Testnet network switch"); + return false; + } + // Chain has not been added to MetaMask — try adding it + if (err.code === 4902) { + try { + await provider.request({ + method: "wallet_addEthereumChain", + params: [HEDERA_TESTNET_PARAMS], + }); + return true; + } catch (addError: unknown) { + const addErr = addError as { code?: number; message?: string }; + if (addErr.code === 4001) { + console.warn("User rejected adding Hedera Testnet network"); + } else { + console.error("Failed to add Hedera Testnet network:", addErr.message ?? addErr); + } + return false; + } + } + console.error("Failed to switch to Hedera Testnet network:", err.message ?? err); + return false; + } +} + +/** + * Sign in using MetaMask/EVM Wallet via Supabase Web3 Auth (EIP-4361) + */ +export async function signInWithMetaMask(statement?: string): Promise<{ data: unknown; error: unknown }> { + if (!supabase) { + return { + data: null, + error: new Error("Supabase client is not initialized. Check VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY."), + }; + } + + if (!isMetaMaskInstalled()) { + return { + data: null, + error: new Error("MetaMask or compatible EVM wallet is not installed"), + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const provider = (window as Record).ethereum; + + try { + // 1. Request account access first — authorizes the dapp with MetaMask + const accounts = await provider.request({ method: "eth_requestAccounts" }); + if (!accounts || accounts.length === 0) { + return { + data: null, + error: new Error("No accounts found. Please unlock your wallet."), + }; + } + + // 2. Ensure we are on Hedera Testnet + const currentChainId = await provider.request({ method: "eth_chainId" }); + if (currentChainId !== HEDERA_TESTNET_CHAIN_ID) { + const switched = await switchToHederaTestnet(provider); + if (!switched) { + return { + data: null, + error: new Error( + "Please switch your MetaMask network to Hedera Testnet.\n\n" + + "To add it manually:\n" + + "1. Open MetaMask > Network dropdown > Add Network > Add Network Manually\n" + + "2. Network Name: Hedera Testnet\n" + + "3. RPC URL: https://testnet.hashio.io/api\n" + + "4. Chain ID: 296\n" + + "5. Currency Symbol: HBAR\n" + + "6. Block Explorer: https://hashscan.io/testnet\n" + + "7. Click Save, then switch to Hedera Testnet and try again." + ), + }; + } + } + + // 3. Authenticate with Supabase Web3 Auth + const { data, error } = await supabase.auth.signInWithWeb3({ + chain: "ethereum", + statement: statement || "Sign in to AgroDex with your MetaMask wallet.", + options: { + url: "https://agro-dex-1u85.vercel.app", + }, + }); + + return { data, error }; + } catch (err: unknown) { + console.error("MetaMask sign-in error:", err); + const message = err instanceof Error ? err.message : "MetaMask authentication failed"; + return { + data: null, + error: new Error(message), + }; + } +} + +/** + * Discover EIP-6963 compliant wallets installed in the browser + */ +export function discoverWallets(callback: (providers: EIP6963ProviderDetail[]) => void): () => void { + const providers: EIP6963ProviderDetail[] = []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleAnnounce = (event: any) => { + if (providers.some((p) => p.info.uuid === event.detail.info.uuid)) return; + providers.push(event.detail); + callback([...providers]); + }; + + if (typeof window !== "undefined") { + window.addEventListener("eip6963:announceProvider", handleAnnounce); + window.dispatchEvent(new Event("eip6963:requestProvider")); + } + + return () => { + if (typeof window !== "undefined") { + window.removeEventListener("eip6963:announceProvider", handleAnnounce); + } + }; +} diff --git a/src/pages/AuthLanding.tsx b/src/pages/AuthLanding.tsx index 3be25f3..541c9c9 100644 --- a/src/pages/AuthLanding.tsx +++ b/src/pages/AuthLanding.tsx @@ -10,6 +10,8 @@ import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertCircle, Mail, Lock, CheckCircle } from "lucide-react"; import WalletButton from "@/components/WalletButton"; +import MetaMaskButton from "@/components/MetaMaskButton"; +import ErrorBoundary from "@/components/ErrorBoundary"; import { Helmet } from "react-helmet-async"; import logoUrl from "@/assets/agritrust-logo.png"; import { ThemeToggle } from "@/components/ThemeToggle"; @@ -247,9 +249,28 @@ export default function AuthLanding() { +

+ EVM Wallet +

+ + + +
+
+
+
+
+ + or + +
+
+

+ Hedera Native +

- Your identity stays secure — HashPack Wallet. + Your identity stays secure with either wallet.

) : ( diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index ab7ff47..633c339 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,13 +1,14 @@ /** * ============================================================================= - * Login Page — Email + HashPack Wallet Authentication + * Login Page — Email + HashPack Wallet + MetaMask Wallet Authentication * ============================================================================= * - * Supports two login methods: + * Supports three login methods: * 1. Email/Password via Supabase (existing, unchanged) - * 2. HashPack Wallet via HashConnect v3 (updated from old WalletConnect) + * 2. HashPack Wallet via HashConnect v3 (existing) + * 3. MetaMask Wallet via Supabase Web3 Auth (EIP-4361) * - * The wallet tab now uses the new WalletButton component with HashConnect v3. + * The wallet tab now shows both HashPack and MetaMask options. */ import { useEffect, useState } from "react"; @@ -31,6 +32,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertCircle, Mail, Lock, Shield, Sparkles, Globe, CheckCircle } from "lucide-react"; import WalletButton from "@/components/WalletButton"; +import MetaMaskButton from "@/components/MetaMaskButton"; +import ErrorBoundary from "@/components/ErrorBoundary"; import { motion } from "framer-motion"; import { Helmet } from "react-helmet-async"; import logoUrl from "@/assets/agritrust-logo.png"; @@ -438,24 +441,54 @@ export default function Login() { - {/* ===== WALLET TAB (updated: uses new WalletButton with HashConnect v3) ===== */} + {/* ===== WALLET TAB (HashPack + MetaMask) ===== */} + {/* MetaMask Option */} - - - Wallet Login + + + MetaMask - Connect your HashPack wallet to continue + Connect with your MetaMask wallet (Hedera EVM) - - {/* New WalletButton replaces the old WalletLogin component */} + + + + + + + + {/* Divider */} +
+
+
+
+
+ + OR + +
+
+ + {/* HashPack Option */} + + + + HashPack + + + Connect with your HashPack wallet (Hedera native) + + + diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index cc46724..be3caad 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -20,6 +20,7 @@ import { AlertCircle, CheckCircle, Clock, + Globe, } from "lucide-react"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; @@ -36,7 +37,7 @@ interface UserProfile { export default function Profile() { const navigate = useNavigate(); - const { user, linkHederaWallet } = useAuth(); + const { user, linkHederaWallet, isMetaMaskConnected, metaMaskAddress } = useAuth(); const { accountId, isConnected, connect, network } = useWallet(); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); @@ -252,6 +253,28 @@ export default function Profile() { )} + {/* MetaMask Wallet (Web3 Auth) */} +
+ + {isMetaMaskConnected && metaMaskAddress ? ( +
+ + + {metaMaskAddress} + + + Authenticated + +
+ ) : ( +

+ No MetaMask wallet connected. +

+ )} +
+ {/* Member Since */}