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.
-
-
- )}
@@ -71,14 +47,9 @@ export function DeleteProfileDialog({ children }: DeleteProfileDialogProps) {
- {isLoadingNonce
- ? "Loading..."
- : deleteProfile.isPending
- ? "Deleting..."
- : "Delete"
- }
+ {deleteProfile.isPending ? "Deleting..." : "Delete"}
{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.
-
-
- )}
@@ -182,15 +153,9 @@ export function EditProfileDialog({
- {isLoadingNonce
- ? "Loading..."
- : updateProfile.isPending
- ? "Updating..."
- : "Update"}
+ {updateProfile.isPending ? "Updating..." : "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