diff --git a/frontend/src/components/AppWrapper.tsx b/frontend/src/components/AppWrapper.tsx index 9aa3b3a..ad2a6f4 100644 --- a/frontend/src/components/AppWrapper.tsx +++ b/frontend/src/components/AppWrapper.tsx @@ -11,6 +11,7 @@ import { import { AppSidebar } from "@/components/AppSidebar"; import { ActivityTokenBalance } from "@/components/ActivityTokenBalance"; import { Background } from "@/components/Background"; +import { LoginButton } from "@/components/LoginButton"; const queryClient = new QueryClient(); @@ -39,6 +40,7 @@ export function AppWrapper({ children }: AppWrapperProps) {
+
diff --git a/frontend/src/components/LoginButton.tsx b/frontend/src/components/LoginButton.tsx new file mode 100644 index 0000000..7fe06b7 --- /dev/null +++ b/frontend/src/components/LoginButton.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useAccount } from "wagmi"; +import { useLogin } from "@/hooks/use-login"; +import { isTokenValid, clearToken } from "@/lib/utils/jwt"; +import { Button } from "@/components/ui/button"; +import { LogIn, LogOut } from "lucide-react"; + +export function LoginButton() { + const { address, isConnected } = useAccount(); + const { login, isLoading, error } = useLogin(); + const [isLoggedIn, setIsLoggedIn] = useState(isTokenValid()); + + const handleLogin = async () => { + try { + await login(); + setIsLoggedIn(true); + } catch (err) { + console.error("Login failed:", err); + } + }; + + const handleLogout = () => { + clearToken(); + setIsLoggedIn(false); + }; + + // Show nothing if wallet not connected + if (!isConnected || !address) { + return null; + } + + if (isLoggedIn) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/profiles/action-buttons/CreateProfileDialog.tsx b/frontend/src/components/profiles/action-buttons/CreateProfileDialog.tsx index 1a9d448..d69d4f1 100644 --- a/frontend/src/components/profiles/action-buttons/CreateProfileDialog.tsx +++ b/frontend/src/components/profiles/action-buttons/CreateProfileDialog.tsx @@ -25,9 +25,6 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useGetNonce } from "@/hooks/profiles/use-get-nonce"; -import { generateSiweMessage } from "@/lib/utils/siwe"; -import { useAccount, useSignMessage } from "wagmi"; const formSchema = z.object({ name: z.string().min(2, { message: "Name must be at least 2 characters." }), @@ -41,11 +38,6 @@ export function CreateProfileButton() { const [open, setOpen] = useState(false); const createProfile = useCreateProfile(); const queryClient = useQueryClient(); - const { address } = useAccount(); - const { signMessageAsync } = useSignMessage(); - const { data: nonceData, isLoading: isLoadingNonce } = useGetNonce(address); - - const siweMessage = nonceData ? generateSiweMessage(nonceData) : ""; const form = useForm({ resolver: zodResolver(formSchema), @@ -53,20 +45,12 @@ export function CreateProfileButton() { }); const onSubmit = async (values: FormValues) => { - if (!siweMessage) { - throw new Error("SIWE message not available"); - } - - // Sign the SIWE message - const signature = await signMessageAsync({ message: siweMessage }); - await createProfile.mutateAsync({ input: { name: values.name, description: values.description || "", github_login: values.githubLogin || "", }, - signature, }); await queryClient.invalidateQueries({ queryKey: ["profiles"] }); setOpen(false); @@ -136,35 +120,14 @@ export function CreateProfileButton() { )} /> - {siweMessage && ( -
- Message to Sign -
- {siweMessage} -
-

- This message will be signed with your wallet to authenticate - your profile creation. -

-
- )}
-
{createProfile.isError ? ( diff --git a/frontend/src/components/profiles/action-buttons/DeleteProfileDialog.tsx b/frontend/src/components/profiles/action-buttons/DeleteProfileDialog.tsx index 10854c0..9c8d632 100644 --- a/frontend/src/components/profiles/action-buttons/DeleteProfileDialog.tsx +++ b/frontend/src/components/profiles/action-buttons/DeleteProfileDialog.tsx @@ -11,9 +11,6 @@ import { Button } from "@/components/ui/button"; import { useDeleteProfile } from "@/hooks/profiles/use-delete-profile"; import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import { useGetNonce } from "@/hooks/profiles/use-get-nonce"; -import { generateSiweMessage } from "@/lib/utils/siwe"; -import { useAccount, useSignMessage } from "wagmi"; interface DeleteProfileDialogProps { children: React.ReactNode; @@ -23,19 +20,9 @@ export function DeleteProfileDialog({ children }: DeleteProfileDialogProps) { const [open, setOpen] = useState(false); const deleteProfile = useDeleteProfile(); const queryClient = useQueryClient(); - const { address } = useAccount(); - const { signMessageAsync } = useSignMessage(); - const { data: nonceData, isLoading: isLoadingNonce } = useGetNonce(address); - - const siweMessage = nonceData ? generateSiweMessage(nonceData) : ""; const onConfirm = async () => { - if (!siweMessage) { - throw new Error("SIWE message not available"); - } - - const signature = await signMessageAsync({ message: siweMessage }); - await deleteProfile.mutateAsync({ signature }); + await deleteProfile.mutateAsync(); await queryClient.invalidateQueries({ queryKey: ["profiles"] }); setOpen(false); }; @@ -51,17 +38,6 @@ export function DeleteProfileDialog({ children }: DeleteProfileDialogProps) { profile. - {siweMessage && ( -
- -
- {siweMessage} -
-

- This message will be signed with your wallet to authenticate profile deletion. -

-
- )}
{deleteProfile.isError ? ( diff --git a/frontend/src/components/profiles/action-buttons/EditProfileDialog.tsx b/frontend/src/components/profiles/action-buttons/EditProfileDialog.tsx index 70e3093..2666969 100644 --- a/frontend/src/components/profiles/action-buttons/EditProfileDialog.tsx +++ b/frontend/src/components/profiles/action-buttons/EditProfileDialog.tsx @@ -24,9 +24,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useUpdateProfile } from "@/hooks/profiles/use-update-profile"; import { useQueryClient } from "@tanstack/react-query"; import { useState, useEffect } from "react"; -import { useGetNonce } from "@/hooks/profiles/use-get-nonce"; -import { generateSiweMessage } from "@/lib/utils/siwe"; -import { useAccount, useSignMessage } from "wagmi"; interface EditProfileDialogProps { address: string; @@ -54,12 +51,6 @@ export function EditProfileDialog({ const [open, setOpen] = useState(false); const updateProfile = useUpdateProfile(); const queryClient = useQueryClient(); - const { address: signerAddress } = useAccount(); - const { signMessageAsync } = useSignMessage(); - const { data: nonceData, isLoading: isLoadingNonce } = - useGetNonce(signerAddress); - - const siweMessage = nonceData ? generateSiweMessage(nonceData) : ""; const form = useForm({ resolver: zodResolver(formSchema), @@ -81,21 +72,13 @@ export function EditProfileDialog({ }, [open, name, description, githubLogin, form]); const onSubmit = async (values: FormValues) => { - if (!siweMessage) { - throw new Error("SIWE message not available"); - } - try { - // Sign the SIWE message - const signature = await signMessageAsync({ message: siweMessage }); - await updateProfile.mutateAsync({ input: { name: values.name, description: values.description || "", github_login: values.githubLogin || "", }, - signature, }); await queryClient.invalidateQueries({ queryKey: ["profiles"] }); setOpen(false); @@ -162,18 +145,6 @@ export function EditProfileDialog({ )} /> - {siweMessage && ( -
- Message to Sign -
- {siweMessage} -
-

- This message will be signed with your wallet to authenticate - your profile update. -

-
- )}
{updateProfile.isError ? ( diff --git a/frontend/src/hooks/profiles/use-create-profile.ts b/frontend/src/hooks/profiles/use-create-profile.ts index f3bd1ae..d4c5672 100644 --- a/frontend/src/hooks/profiles/use-create-profile.ts +++ b/frontend/src/hooks/profiles/use-create-profile.ts @@ -3,24 +3,24 @@ import { type UseMutationResult, useQueryClient, } from "@tanstack/react-query"; -import { useAccount, useSignMessage } from "wagmi"; +import { useAccount } from "wagmi"; import type { CreateProfileInput, CreateProfileResponse, } from "@/lib/types/api"; import { API_BASE_URL } from "@/lib/constants/apiConstants"; +import { isTokenValid, getToken } from "@/lib/utils/jwt"; +import { useLogin } from "@/hooks/use-login"; async function postCreateProfile( input: CreateProfileInput, - address: string, - signature: string + token: string ): Promise { const response = await fetch(`${API_BASE_URL}/profiles/`, { method: "POST", headers: { "Content-Type": "application/json", - "x-eth-address": address, - "x-eth-signature": signature, + Authorization: `Bearer ${token}`, }, body: JSON.stringify(input), }); @@ -28,13 +28,11 @@ async function postCreateProfile( if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error( - `Failed to create profile: ${response.status} ${response.statusText}${ - text ? ` - ${text}` : "" + `Failed to create profile: ${response.status} ${response.statusText}${text ? ` - ${text}` : "" }` ); } - // Attempt to parse JSON, but allow empty body try { return (await response.json()) as CreateProfileResponse; } catch { @@ -44,7 +42,6 @@ async function postCreateProfile( type MutationVariables = { input: CreateProfileInput; - signature: string; }; export function useCreateProfile(): UseMutationResult< @@ -54,18 +51,29 @@ export function useCreateProfile(): UseMutationResult< > { const { address } = useAccount(); const queryClient = useQueryClient(); + const { login } = useLogin(); return useMutation({ mutationKey: ["create-profile"], - mutationFn: async ({ input, signature }) => { + mutationFn: async ({ input }) => { if (!address) { throw new Error("Wallet not connected"); } - return postCreateProfile(input, address, signature); + + // Check if token is valid, if not trigger login + if (!isTokenValid()) { + await login(); + } + + const token = getToken(); + if (!token) { + throw new Error("Authentication required. Please sign in."); + } + + return postCreateProfile(input, token); }, onSuccess: () => { - // Invalidate nonce query since it was incremented - queryClient.invalidateQueries({ queryKey: ["nonce", address] }); + queryClient.invalidateQueries({ queryKey: ["profiles"] }); }, }); } diff --git a/frontend/src/hooks/profiles/use-delete-profile.ts b/frontend/src/hooks/profiles/use-delete-profile.ts index 6e5c961..867948c 100644 --- a/frontend/src/hooks/profiles/use-delete-profile.ts +++ b/frontend/src/hooks/profiles/use-delete-profile.ts @@ -1,30 +1,33 @@ -import { useMutation, type UseMutationResult, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from "@tanstack/react-query"; import { useAccount } from "wagmi"; import type { DeleteProfileInput, DeleteProfileResponse, } from "@/lib/types/api"; import { API_BASE_URL } from "@/lib/constants/apiConstants"; +import { isTokenValid, getToken } from "@/lib/utils/jwt"; +import { useLogin } from "@/hooks/use-login"; async function deleteProfile( address: string, - signerAddress: string, - signature: string + token: string ): Promise { const response = await fetch(`${API_BASE_URL}/profiles/${address}`, { method: "DELETE", headers: { "Content-Type": "application/json", - "x-eth-address": signerAddress, - "x-eth-signature": signature, + Authorization: `Bearer ${token}`, }, }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error( - `Failed to delete profile: ${response.status} ${response.statusText}${ - text ? ` - ${text}` : "" + `Failed to delete profile: ${response.status} ${response.statusText}${text ? ` - ${text}` : "" }` ); } @@ -36,27 +39,34 @@ async function deleteProfile( } } -type MutationVariables = { - signature: string; -}; - export function useDeleteProfile(): UseMutationResult< DeleteProfileResponse, Error, - MutationVariables + void > { const { address } = useAccount(); const queryClient = useQueryClient(); + const { login } = useLogin(); - return useMutation({ + return useMutation({ mutationKey: ["delete-profile"], - mutationFn: async ({ signature }) => { + mutationFn: async () => { if (!address) throw new Error("Wallet not connected"); - return deleteProfile(address, address, signature); + + // Check if token is valid, if not trigger login + if (!isTokenValid()) { + await login(); + } + + const token = getToken(); + if (!token) { + throw new Error("Authentication required. Please sign in."); + } + + return deleteProfile(address, token); }, onSuccess: () => { - // Invalidate nonce query since it was incremented - queryClient.invalidateQueries({ queryKey: ["nonce", address] }); + queryClient.invalidateQueries({ queryKey: ["profiles"] }); }, }); } diff --git a/frontend/src/hooks/profiles/use-update-profile.ts b/frontend/src/hooks/profiles/use-update-profile.ts index 7be91b1..d619618 100644 --- a/frontend/src/hooks/profiles/use-update-profile.ts +++ b/frontend/src/hooks/profiles/use-update-profile.ts @@ -1,23 +1,27 @@ -import { useMutation, type UseMutationResult, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from "@tanstack/react-query"; import { useAccount } from "wagmi"; import type { UpdateProfileInput, UpdateProfileResponse, } from "@/lib/types/api"; import { API_BASE_URL } from "@/lib/constants/apiConstants"; +import { isTokenValid, getToken } from "@/lib/utils/jwt"; +import { useLogin } from "@/hooks/use-login"; async function putUpdateProfile( address: string, body: UpdateProfileInput, - signerAddress: string, - signature: string + token: string ): Promise { const response = await fetch(`${API_BASE_URL}/profiles/${address}`, { method: "PUT", headers: { "Content-Type": "application/json", - "x-eth-address": signerAddress, - "x-eth-signature": signature, + Authorization: `Bearer ${token}`, }, body: JSON.stringify(body), }); @@ -25,8 +29,7 @@ async function putUpdateProfile( if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error( - `Failed to update profile: ${response.status} ${response.statusText}${ - text ? ` - ${text}` : "" + `Failed to update profile: ${response.status} ${response.statusText}${text ? ` - ${text}` : "" }` ); } @@ -40,7 +43,6 @@ async function putUpdateProfile( type MutationVariables = { input: UpdateProfileInput; - signature: string; }; export function useUpdateProfile(): UseMutationResult< @@ -50,16 +52,27 @@ export function useUpdateProfile(): UseMutationResult< > { const { address } = useAccount(); const queryClient = useQueryClient(); + const { login } = useLogin(); return useMutation({ mutationKey: ["update-profile"], - mutationFn: async ({ input, signature }) => { + mutationFn: async ({ input }) => { if (!address) throw new Error("Wallet not connected"); - return putUpdateProfile(address, input, address, signature); + + // Check if token is valid, if not trigger login + if (!isTokenValid()) { + await login(); + } + + const token = getToken(); + if (!token) { + throw new Error("Authentication required. Please sign in."); + } + + return putUpdateProfile(address, input, token); }, onSuccess: () => { - // Invalidate nonce query since it was incremented - queryClient.invalidateQueries({ queryKey: ["nonce", address] }); + queryClient.invalidateQueries({ queryKey: ["profiles"] }); }, }); } diff --git a/frontend/src/hooks/use-auth-header.ts b/frontend/src/hooks/use-auth-header.ts new file mode 100644 index 0000000..8a2694e --- /dev/null +++ b/frontend/src/hooks/use-auth-header.ts @@ -0,0 +1,80 @@ +import { useCallback, useState } from "react"; +import { useAccount, useSignMessage } from "wagmi"; +import { getToken, saveToken, clearToken, isTokenValid } from "@/lib/utils/jwt"; +import { useGetNonce } from "./profiles/use-get-nonce"; +import { generateSiweMessage } from "@/lib/utils/siwe"; +import { API_BASE_URL } from "@/lib/constants/apiConstants"; + +interface AuthHeaders { + [key: string]: string; +} + +export function useAuthHeader() { + const { address } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const { data: nonceData } = useGetNonce(address); + const [isRefreshing, setIsRefreshing] = useState(false); + + const getAuthHeaders = useCallback((): AuthHeaders => { + // Check if token is valid and not expired + if (isTokenValid()) { + const token = getToken(); + if (token) { + return { + Authorization: `Bearer ${token}`, + }; + } + } + + // Fall back to empty headers (caller will add SIWE headers) + return {}; + }, []); + + const refreshToken = useCallback(async (): Promise => { + try { + setIsRefreshing(true); + + if (!address || !nonceData) { + return false; + } + + // Sign a new message to get a new token + const siweMessage = generateSiweMessage(nonceData); + const signature = await signMessageAsync({ message: siweMessage }); + + // Get new JWT token + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-eth-address": address, + "x-eth-signature": signature, + }, + }); + + if (!response.ok) { + clearToken(); + return false; + } + + const data = (await response.json()) as { + token: string; + address: string; + }; + saveToken(data); + return true; + } catch { + clearToken(); + return false; + } finally { + setIsRefreshing(false); + } + }, [address, nonceData, signMessageAsync]); + + return { + getAuthHeaders, + refreshToken, + isRefreshing, + hasToken: isTokenValid, + }; +} diff --git a/frontend/src/hooks/use-login.ts b/frontend/src/hooks/use-login.ts new file mode 100644 index 0000000..020fd2a --- /dev/null +++ b/frontend/src/hooks/use-login.ts @@ -0,0 +1,69 @@ +import { useAccount, useSignMessage } from "wagmi"; +import { useGetNonce } from "./profiles/use-get-nonce"; +import { generateSiweMessage } from "@/lib/utils/siwe"; +import { saveToken } from "@/lib/utils/jwt"; +import { API_BASE_URL } from "@/lib/constants/apiConstants"; +import { useCallback, useState } from "react"; + +interface LoginResponse { + token: string; + address: string; +} + +export function useLogin() { + const { address } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const { data: nonceData } = useGetNonce(address); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const login = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + if (!address) { + throw new Error("Wallet not connected"); + } + + if (!nonceData) { + throw new Error("Nonce not available"); + } + + // Generate SIWE message and sign it + const siweMessage = generateSiweMessage(nonceData); + const signature = await signMessageAsync({ message: siweMessage }); + + // Send signature to backend to get JWT token + const response = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-eth-address": address, + "x-eth-signature": signature, + }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Login failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : "" + }` + ); + } + + const data = (await response.json()) as LoginResponse; + saveToken(data); + + return data; + } catch (err) { + const error = err instanceof Error ? err : new Error("Unknown error"); + setError(error); + throw error; + } finally { + setIsLoading(false); + } + }, [address, nonceData, signMessageAsync]); + + return { login, isLoading, error }; +} diff --git a/frontend/src/lib/utils/jwt.ts b/frontend/src/lib/utils/jwt.ts new file mode 100644 index 0000000..76f66fd --- /dev/null +++ b/frontend/src/lib/utils/jwt.ts @@ -0,0 +1,52 @@ +const JWT_TOKEN_KEY = "jwt_token"; +const JWT_ADDRESS_KEY = "jwt_address"; +const JWT_EXPIRY_KEY = "jwt_expiry"; + +export interface JwtToken { + token: string; + address: string; +} + +export function saveToken(token: JwtToken, expiresInSeconds = 86400): void { + const expiryTime = Date.now() + expiresInSeconds * 1000; + localStorage.setItem(JWT_TOKEN_KEY, token.token); + localStorage.setItem(JWT_ADDRESS_KEY, token.address); + localStorage.setItem(JWT_EXPIRY_KEY, expiryTime.toString()); +} + +export function getToken(): string | null { + if (!isTokenValid()) { + clearToken(); + return null; + } + return localStorage.getItem(JWT_TOKEN_KEY); +} + +export function getAddress(): string | null { + return localStorage.getItem(JWT_ADDRESS_KEY); +} + +export function clearToken(): void { + localStorage.removeItem(JWT_TOKEN_KEY); + localStorage.removeItem(JWT_ADDRESS_KEY); + localStorage.removeItem(JWT_EXPIRY_KEY); +} + +export function isTokenValid(): boolean { + const token = localStorage.getItem(JWT_TOKEN_KEY); + const expiryStr = localStorage.getItem(JWT_EXPIRY_KEY); + + if (!token || !expiryStr) { + return false; + } + + const expiryTime = parseInt(expiryStr, 10); + const isExpired = Date.now() >= expiryTime; + + if (isExpired) { + clearToken(); + return false; + } + + return true; +} \ No newline at end of file