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
49 changes: 49 additions & 0 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 (
<div className="p-4 rounded-xl border border-red-200 bg-red-50 text-red-700">
<p className="font-semibold">Something went wrong</p>
<p className="text-sm mt-1">
{this.state.error?.message ?? "An unexpected error occurred"}
</p>
<button
onClick={this.handleRetry}
className="mt-3 text-sm font-medium text-red-600 hover:text-red-800 underline"
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
203 changes: 203 additions & 0 deletions src/components/MetaMaskButton.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="space-y-4">
{/* Network & Copy Row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700 border border-emerald-200">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
Hedera EVM
</span>
{status === "connected" && walletAddress && (
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
title="Copy address"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied!" : "Copy Address"}
</button>
)}
</div>

{/* Connected State */}
{status === "connected" && walletAddress && (
<div className="transition-all duration-200">
<Alert variant="default" className="bg-emerald-50 border-emerald-200 border-2">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<AlertDescription className="text-emerald-800 font-medium">
<span className="block text-sm">Authenticated with MetaMask</span>
<span className="block font-mono text-base mt-0.5">
{truncateAddress(walletAddress)}
</span>
</AlertDescription>
</Alert>
</div>
)}

{/* Connecting State */}
{status === "connecting" && (
<div className="transition-all duration-200">
<Alert variant="default" className="bg-blue-50 border-blue-200 border-2">
<Loader2 className="h-4 w-4 text-blue-600 animate-spin" />
<AlertDescription className="text-blue-800">
Connecting... Sign the authentication challenge in MetaMask.
</AlertDescription>
</Alert>
</div>
)}

{/* Error State */}
{status === "error" && errorMessage && (
<div className="transition-all duration-200 space-y-3">
<Alert variant="destructive" className="border-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
<Button onClick={handleConnect} variant="outline" size="sm" className="w-full">
Retry Connection
</Button>
</div>
)}

{/* Not Installed State */}
{!installed && (
<div className="transition-all duration-200 space-y-3 p-4 rounded-xl border bg-gradient-to-br from-orange-50 to-amber-50 border-orange-200">
<div className="font-semibold text-gray-900 flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600" />
MetaMask is not installed
</div>
<p className="text-sm text-gray-700">
To log in using an EVM wallet, please install the MetaMask extension and set up a Hedera Testnet account.
</p>
<Button asChild variant="default" size="sm" className="bg-orange-600 hover:bg-orange-700 text-white w-full">
<a href="https://metamask.io/download/" target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
Install MetaMask
</a>
</Button>
</div>
)}

{/* Action Buttons */}
{installed && (
<div className="flex gap-2">
{status !== "connected" ? (
<div className="flex-1">
<Button
onClick={handleConnect}
className="w-full h-12 bg-gradient-to-r from-orange-500 to-amber-600 hover:from-orange-600 hover:to-amber-700 text-white font-bold text-base rounded-xl shadow-lg hover:shadow-xl transition-all active:scale-[0.98]"
disabled={status === "connecting"}
>
{status === "connecting" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Wallet className="mr-2 h-4 w-4" />
Connect MetaMask
</>
)}
</Button>
</div>
) : (
<div className="flex-1">
<Button
onClick={handleDisconnect}
variant="outline"
className="w-full h-12 border-2 border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300 font-bold rounded-xl transition-all"
>
<LogOut className="mr-2 h-4 w-4" />
Disconnect MetaMask
</Button>
</div>
)}
</div>
)}
</div>
);
}
16 changes: 16 additions & 0 deletions src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
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;
session: Session | null;
loading: boolean;
signOut: () => Promise<void>;
linkHederaWallet: (accountId: string) => Promise<void>;
signInWithMetaMask: (statement?: string) => Promise<{ error: Error | null }>;
isMetaMaskConnected: boolean;
metaMaskAddress: string | undefined;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);
Expand All @@ -17,6 +21,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(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 } }) => {
Expand Down Expand Up @@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
Expand Down
Loading
Loading