diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3861c1a6..8680932e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -38,7 +38,6 @@ export default function AdminPanel() { const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(""); const [roleFilter, setRoleFilter] = useState("All"); - const [isAdmin, setIsAdmin] = useState(false); const [editingProfile, setEditingProfile] = useState<{ [key: string]: { assigned_agent_address: string; @@ -49,32 +48,9 @@ export default function AdminPanel() { const [sortOrder, setSortOrder] = useState(null); useEffect(() => { - checkAdminStatus(); fetchProfiles(); }, []); - const checkAdminStatus = async () => { - try { - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) throw new Error("Not authenticated"); - - const { data, error } = await supabase - .from("profiles") - .select("role") - .eq("id", user.id) - .single(); - - if (error) throw error; - setIsAdmin(data.role === "Admin"); - } catch (error) { - console.error("Failed to verify admin status:", error); - setError("Failed to verify admin status"); - setIsAdmin(false); - } - }; - const formatEmail = (email: string): string => { return email.split("@")[0].toUpperCase(); }; @@ -123,11 +99,6 @@ export default function AdminPanel() { }; const updateProfile = async (userId: string): Promise => { - if (!isAdmin) { - setError("Only admins can update profiles"); - return; - } - try { setError(null); const updates: Partial = { @@ -221,20 +192,6 @@ export default function AdminPanel() { return ; } - if (!isAdmin) { - return ( - - - - - Access denied. Only administrators can manage profiles. - - - - - ); - } - return ( diff --git a/src/app/application-layout.tsx b/src/app/application-layout.tsx index 3f73ed09..b5e28c99 100644 --- a/src/app/application-layout.tsx +++ b/src/app/application-layout.tsx @@ -42,6 +42,7 @@ import { useUserData } from "@/hooks/useUserData"; import { Wallet } from "lucide-react"; import SignOut from "@/components/auth/SignOut"; import Image from "next/image"; +import { supabase } from "@/utils/supabase/client"; function AccountDropdownMenu({ anchor, @@ -73,7 +74,24 @@ function AccountDropdownMenu({ export function ApplicationLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); - const { data: userData, isLoading } = useUserData(); + const { data: userData, isLoading, refetch } = useUserData(); + + // Add a listener for auth state changes + React.useEffect(() => { + const { data: authListener } = supabase.auth.onAuthStateChange( + async (event) => { + if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") { + // Refetch user data when signed in or token is refreshed + await refetch(); + } + } + ); + + // Cleanup subscription + return () => { + authListener.subscription.unsubscribe(); + }; + }, [refetch]); const displayAddress = React.useMemo(() => { if (!userData?.stxAddress) return ""; @@ -136,10 +154,6 @@ export function ApplicationLayout({ children }: { children: React.ReactNode }) { - {/* - - Dashboard - */} Chat @@ -181,41 +195,45 @@ export function ApplicationLayout({ children }: { children: React.ReactNode }) { Terms of Service - - - - {isLoading ? "Loading..." : displayAgentAddress} - {userData?.agentAddress && ( - - {userData.agentBalance !== null - ? `${userData.agentBalance.toFixed(5)} STX` - : "Loading balance..."} - - )} - - + {userData && !isLoading && ( + + + + {isLoading ? "Loading..." : displayAgentAddress} + {userData?.agentAddress && ( + + {userData.agentBalance !== null + ? `${userData.agentBalance.toFixed(5)} STX` + : "Loading balance..."} + + )} + + + )} - - - - - - - - {isLoading ? "Loading..." : displayAddress} - - - {isLoading ? "Loading..." : displayRole} + {userData && !isLoading && ( + + + + + + + + {displayAddress} + + + {displayRole} + - - - - - - + + + + + + )} } > diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 7fcf2eb4..c3a77cff 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,10 +1,27 @@ import React from "react"; +import { headers } from "next/headers"; import Chat from "@/components/chat/Chat"; +import Link from "next/link"; + +export const runtime = "edge"; const page = () => { - return ( - - ); + const headersList = headers(); + const authStatus = headersList.get("x-auth-status"); + + if (authStatus === "unauthorized") { + return ( +
+
+ {/* THIS IS SHOWN WHEN THE USER IS NOT AUTHENTICATED INSTEAD OF FULL REDIRECT TO CONNECT*/} +

Limited Access

+ connect to access the chat +
+
+ ); + } + // Render chat component if authenticated + return ; }; export default page; diff --git a/src/app/connect/page.tsx b/src/app/connect/page.tsx new file mode 100644 index 00000000..e38f134c --- /dev/null +++ b/src/app/connect/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useSearchParams } from "next/navigation"; +import { useAuth } from "@/hooks/useAuth"; + +export default function ConnectPage() { + const { connectWallet } = useAuth(); + const searchParams = useSearchParams(); + const redirectPath = searchParams.get("redirect") || "/chat"; + const hasInitiatedConnection = useRef(false); + + useEffect(() => { + const initiateConnection = async () => { + if (hasInitiatedConnection.current) return; + hasInitiatedConnection.current = true; + + try { + console.log("Starting connection process"); + console.log("Redirect path:", redirectPath); + + const result = await connectWallet(); + + console.log("Connection result:", result); + + if (result.success) { + console.log("Attempting to redirect to:", redirectPath); + window.location.href = redirectPath; + } else { + console.log("Connection failed, redirecting to home"); + window.location.href = "/"; + } + } catch (error) { + console.error("Error in connection process:", error); + window.location.href = "/"; + } + }; + + initiateConnection(); + }, [connectWallet, redirectPath]); + + return ( +
+
+

Redirecting to {redirectPath}...

+
+
+ ); +} diff --git a/src/app/crews/page.tsx b/src/app/crews/page.tsx index 5c648d63..38a25b72 100644 --- a/src/app/crews/page.tsx +++ b/src/app/crews/page.tsx @@ -1,13 +1,30 @@ - import React from "react"; import Crews from "@/components/crews/Crews"; import { Metadata } from "next"; +import { headers } from "next/headers"; +import Link from "next/link"; export const metadata: Metadata = { title: "Crews", }; +export const runtime = "edge"; + const page = () => { + const headersList = headers(); + const authStatus = headersList.get("x-auth-status"); + + if (authStatus === "unauthorized") { + return ( +
+
+ {/* THIS IS SHOWN WHEN THE USER IS NOT AUTHENTICATED INSTEAD OF FULL REDIRECT TO CONNECT*/} +

Limited Access

+ connect to access the crews +
+
+ ); + } return ; }; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index f7f3fcd8..1579d82f 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,19 +5,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@/components/ui/theme-provider"; import { Toaster } from "@/components/ui/toaster"; import { ApplicationLayout } from "./application-layout"; -import { usePathname } from "next/navigation"; const queryClient = new QueryClient(); export function Providers({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); + // const pathname = usePathname(); - const content = - pathname === "/" ? ( - children - ) : ( - {children} - ); + // const content = + // pathname === "/" ? ( + // children + // ) : ( + // {children} + // ); return ( - {content} + {children} diff --git a/src/components/Home/Home.tsx b/src/components/Home/Home.tsx index b3d78619..463491eb 100644 --- a/src/components/Home/Home.tsx +++ b/src/components/Home/Home.tsx @@ -3,7 +3,7 @@ import React from "react"; import Image from "next/image"; // import Authentication from "../auth/Authentication"; #FOR GITHUB AUTH -import SignIn from "../auth/StacksAuth"; +// import SignIn from "../auth/StacksAuth"; export default function Home() { return ( @@ -17,11 +17,12 @@ export default function Home() { height={400} /> + {/* WE CAN REMOVE THIS NOW */}
{/* Authentication component */} -
+ {/*
-
+
*/}
diff --git a/src/components/auth/StacksAuth.tsx b/src/components/auth/StacksAuth.tsx index 538c88d5..b0c22435 100644 --- a/src/components/auth/StacksAuth.tsx +++ b/src/components/auth/StacksAuth.tsx @@ -1,121 +1,25 @@ "use client"; import React, { useState, useEffect } from "react"; -import { AppConfig, showConnect, UserSession } from "@stacks/connect"; -import { supabase } from "@/utils/supabase/client"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; - -const appConfig = new AppConfig(["store_write", "publish_data"]); -const userSession = new UserSession({ appConfig }); +import { useAuth } from "@/hooks/useAuth"; export default function StacksAuth() { const [mounted, setMounted] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const { connectWallet, isLoading } = useAuth(); const router = useRouter(); - const { toast } = useToast(); useEffect(() => { setMounted(true); }, []); - const handleAuthentication = async (stxAddress: string) => { - try { - // Try to sign in first - const { error: signInError } = await supabase.auth.signInWithPassword({ - email: `${stxAddress}@stacks.id`, - password: stxAddress, - }); - - if (signInError && signInError.status === 400) { - // User doesn't exist, proceed with sign up - toast({ - description: "Creating your account...", - }); - - const { error: signUpError } = await supabase.auth.signUp({ - email: `${stxAddress}@stacks.id`, - password: stxAddress, - }); - - if (signUpError) throw signUpError; - - toast({ - description: "Successfully signed up...", - variant: "default", - }); - - return true; - } else if (signInError) { - throw signInError; - } - - toast({ - description: "Redirecting to dashboard...", - variant: "default", - }); - - return true; - } catch (error) { - console.error("Authentication error:", error); - toast({ - description: "Authentication failed. Please try again.", - variant: "destructive", - }); - return false; - } - }; - const handleAuth = async () => { - setIsLoading(true); - try { - toast({ - description: "Connecting wallet...", - }); - - // Connect wallet - await new Promise((resolve) => { - showConnect({ - appDetails: { - name: "AIBTC Champions Sprint", - icon: window.location.origin + "/logos/aibtcdev-avatar-1000px.png", - }, - onCancel: () => { - toast({ - description: "Wallet connection cancelled.", - }); - setIsLoading(false); - }, - onFinish: () => resolve(), - userSession, - }); - }); - - const userData = userSession.loadUserData(); - const stxAddress = userData.profile.stxAddress.mainnet; - - toast({ - description: "Wallet connected. Authenticating...", - }); - - const success = await handleAuthentication(stxAddress); + const { success } = await connectWallet(); - if (success) { - // Delay redirect to show success message - setTimeout(() => { - router.push("/chat"); - }, 2000); - } - } catch (error) { - console.error("Wallet connection error:", error); - toast({ - description: "Failed to connect wallet. Please try again.", - variant: "destructive", - }); - } finally { - setIsLoading(false); + if (success) { + router.push("/chat"); } }; diff --git a/src/helpers/authHelpers.ts b/src/helpers/authHelpers.ts new file mode 100644 index 00000000..5d8da699 --- /dev/null +++ b/src/helpers/authHelpers.ts @@ -0,0 +1,162 @@ +import { AppConfig, UserSession } from "@stacks/auth"; +import { StacksMainnet } from "@stacks/network"; +import { showConnect, openSignatureRequestPopup } from "@stacks/connect"; +import { verifyMessageSignatureRsv } from "@stacks/encryption"; + + +export const appConfig = new AppConfig(["store_write", "publish_data"]); +export const userSession = new UserSession({ appConfig }); + +export interface AuthResult { + stxAddress: string; + sessionToken: string; +} + +export const checkSessionToken = async (): Promise => { + // Retrieve stored session token and STX address from local storage + const storedSessionToken = localStorage.getItem("sessionToken"); + const storedStxAddress = localStorage.getItem("stxAddress"); + console.log(process.env.NEXT_PUBLIC_AIBTC_SECRET_KEY!) + + // If both token and address exist, attempt to verify + if (storedSessionToken && storedStxAddress) { + try { + // Send a request to backend to verify the session token + const response = await fetch(`${process.env.NEXT_PUBLIC_AIBTC_SERVICE_URL}/auth/verify-session-token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: process.env.NEXT_PUBLIC_AIBTC_SECRET_KEY!, + }, + body: JSON.stringify({ data: storedSessionToken }), + }); + + // If verification is successful, return the authentication result + if (response.ok) { + return { + stxAddress: storedStxAddress, + sessionToken: storedSessionToken + }; + } + } catch (error) { + console.error("Error verifying session token:", error); + } + } + return null; +}; + + +export const initiateAuthentication = (onSuccess: (address: string) => void) => { + showConnect({ + + appDetails: { + name: "sprint.aibtc.dev", + icon: window.location.origin + "/app-icon.png", + }, + redirectTo: "/", + onFinish: () => { + const userData = userSession.loadUserData(); + onSuccess(userData.profile.stxAddress.mainnet); + }, + userSession: userSession, + }); +}; + +export const promptSignMessage = ( + stxAddress: string, + onSuccess: (result: AuthResult) => void, + onError: (error: string) => void +) => { + // Predefined message to be signed + const message = "Welcome to aibtcdev!"; + + // Open signature request popup using Stacks Connect + openSignatureRequestPopup({ + message, + network: new StacksMainnet(), + appDetails: { + name: "sprint.aibtc.dev", + icon: window.location.origin + "/app-icon.png", + }, + stxAddress, + // Callback when signature is completed + onFinish: (data) => verifyAndSendSignedMessage( + message, + data.signature, + data.publicKey, + stxAddress, + onSuccess, + onError + ), + // Callback if user cancels signing + onCancel: () => { + onError("Message signing was cancelled."); + }, + }); +}; + + +// Verifies the signed message and requests an authentication token from the backend +export const verifyAndSendSignedMessage = async ( + message: string, + signature: string, + publicKey: string, + stxAddress: string, + onSuccess: (result: AuthResult) => void, + onError: (error: string) => void +) => { + try { + // Locally verify the signature to ensure its validity + const isSignatureValid = verifyMessageSignatureRsv({ + message, + signature, + publicKey, + }); + + // If signature is invalid, trigger error callback + if (!isSignatureValid) { + onError("Signature verification failed"); + return; + } + + // Send signature to backend to request authentication token + const response = await fetch(`${process.env.NEXT_PUBLIC_AIBTC_SERVICE_URL}/auth/request-auth-token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: process.env.NEXT_PUBLIC_AIBTC_SECRET_KEY!, + }, + body: JSON.stringify({ + data: signature, + }), + }); + + const data = await response.json(); + // Handle successful authentication + if (response.ok) { + // Store authentication details in local storage + localStorage.setItem("sessionToken", data.sessionToken); + localStorage.setItem("stxAddress", stxAddress); + + // Prepare authentication result + const authResult = { + stxAddress, + sessionToken: data.sessionToken + }; + onSuccess(authResult); + } else { + console.error("Auth Error:", data); + onError(data.error || "Authentication failed. Please try again."); + } + } catch (error) { + console.error("Error getting auth token:", error); + onError("Authentication failed. Please try again."); + } +}; + + +// Logs out the user by removing authentication tokens from local storage +export const logout = () => { + localStorage.removeItem("sessionToken"); + localStorage.removeItem("stxAddress"); +}; \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 00000000..ebe983e1 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { AppConfig, showConnect, UserSession } from "@stacks/connect"; +import { supabase } from "@/utils/supabase/client"; +import { useToast } from "@/hooks/use-toast"; +import { useRouter } from 'next/navigation'; + +const appConfig = new AppConfig(["store_write", "publish_data"]); +const userSession = new UserSession({ appConfig }); + +export const useAuth = () => { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleAuthentication = async (stxAddress: string) => { + try { + // Try to sign in first + const { error: signInError } = await supabase.auth.signInWithPassword({ + email: `${stxAddress}@stacks.id`, + password: stxAddress, + }); + + if (signInError && signInError.status === 400) { + // User doesn't exist, proceed with sign up + toast({ + description: "Creating your account...", + }); + + const { error: signUpError } = await supabase.auth.signUp({ + email: `${stxAddress}@stacks.id`, + password: stxAddress, + }); + + if (signUpError) throw signUpError; + + toast({ + description: "Successfully signed up...", + variant: "default", + }); + + return true; + } else if (signInError) { + throw signInError; + } + + toast({ + description: "Redirecting...", + variant: "default", + }); + + return true; + } catch (error) { + console.error("Authentication error:", error); + toast({ + description: "Authentication failed. Please try again.", + variant: "destructive", + }); + return false; + } + }; + + const connectWallet = async () => { + setIsLoading(true); + try { + toast({ + description: "Connecting wallet...", + }); + + // Connect wallet + await new Promise((resolve) => { + showConnect({ + appDetails: { + name: "AIBTC Champions Sprint", + icon: window.location.origin + "/logos/aibtcdev-avatar-1000px.png", + }, + onCancel: () => { + toast({ + description: "Wallet connection cancelled.", + }); + router.push("/") + setIsLoading(false); + }, + onFinish: () => resolve(), + userSession, + }); + }); + + const userData = userSession.loadUserData(); + const stxAddress = userData.profile.stxAddress.mainnet; + + toast({ + description: "Wallet connected. Authenticating...", + }); + + return { stxAddress, success: await handleAuthentication(stxAddress) }; + } catch (error) { + console.error("Wallet connection error:", error); + toast({ + description: "Failed to connect wallet. Please try again.", + variant: "destructive", + }); + return { stxAddress: null, success: false }; + } finally { + setIsLoading(false); + } + }; + + return { + connectWallet, + isLoading, + }; +}; \ No newline at end of file diff --git a/src/utils/supabase/middleware.ts b/src/utils/supabase/middleware.ts index 4ebca4aa..8be6221d 100644 --- a/src/utils/supabase/middleware.ts +++ b/src/utils/supabase/middleware.ts @@ -1,6 +1,16 @@ import { createServerClient } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; +// Define routes with different authentication strategies +const protectedPaths = { + '/chat': { type: 'component' }, + '/crews': { type: 'component' }, + '/marketplace': { type: 'component' }, + '/profile': { type: 'component' }, + '/admin': { type: 'redirect' }, + '/admin/:path*': { type: 'redirect' }, +} as const; + export const updateSession = async (request: NextRequest) => { try { // Create an unmodified response @@ -12,9 +22,10 @@ export const updateSession = async (request: NextRequest) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!supabaseUrl || !supabaseAnonKey) { throw new Error( - "middleware: missing supabase url or supabase anon key in env vars" + "middleware: missing supabase URL or supabase anon key in env vars" ); } @@ -37,16 +48,31 @@ export const updateSession = async (request: NextRequest) => { }, }); + // Check if current path matches a protected route + const pathname = request.nextUrl.pathname; + const matchedPath = Object.entries(protectedPaths).find(([route]) => { + const pattern = new RegExp( + `^${route.replace(/\/:path\*/, '(/.*)?').replace(/\//g, '\\/')}$` + ); + return pattern.test(pathname); + }); + // Get the user const { data: { user }, error: userError, } = await supabase.auth.getUser(); - // If trying to access admin route - if (request.nextUrl.pathname.startsWith("/admin")) { + // Set authentication headers + response.headers.set( + 'x-authenticated', + !userError && !!user ? 'true' : 'false' + ); + + // Special handling for admin routes + if (pathname.startsWith("/admin")) { + // If no user immediately redirect to home without showing connect popup if (userError || !user) { - // If no user, redirect to login return NextResponse.redirect(new URL("/", request.url)); } @@ -57,40 +83,43 @@ export const updateSession = async (request: NextRequest) => { .eq("id", user.id) .single(); + // If not admin, redirect to chat if (profileError || !profileData || profileData.role !== "Admin") { - // If not admin, redirect to dashboard return NextResponse.redirect(new URL("/chat", request.url)); } - } - - // Regular route protection - if (request.nextUrl.pathname.startsWith("/dashboard") && userError) { - return NextResponse.redirect(new URL("/", request.url)); - } - // Add chat to protected route - if (request.nextUrl.pathname.startsWith("/chat") && (userError || !user)) { - return NextResponse.redirect(new URL("/", request.url)); + // Admin user, allow access + response.headers.set('x-auth-status', 'authorized'); + return response; } - if ( - request.nextUrl.pathname.startsWith("/public-crew") && - (userError || !user) - ) { - return NextResponse.redirect(new URL("/", request.url)); - } + // Handle other protected routes + if (matchedPath && (userError || !user)) { + const [, config] = matchedPath; - if(request.nextUrl.pathname.startsWith("/profile")&&(userError || !user)){ - return NextResponse.redirect(new URL("/", request.url)) + switch (config.type) { + case 'redirect': { + // Redirect to connect page with original destination + const connectUrl = new URL("/connect", request.url); + connectUrl.searchParams.set('redirect', pathname); + return NextResponse.redirect(connectUrl); + } + case 'component': { + // Allow rendering but mark as unauthorized + response.headers.set('x-auth-status', 'unauthorized'); + break; + } + } } - if (request.nextUrl.pathname === "/" && !userError) { - return NextResponse.redirect(new URL("/chat", request.url)); + // Authenticated routes + if (!userError && user) { + response.headers.set('x-auth-status', 'authorized'); } return response; } catch (error) { - console.error(error); + console.error("Middleware authentication error:", error); return NextResponse.next({ request: { headers: request.headers, @@ -98,3 +127,7 @@ export const updateSession = async (request: NextRequest) => { }); } }; + +export const config = { + matcher: ['/admin/:path*', '/chat', '/crews', '/marketplace', '/profile'], +}; \ No newline at end of file