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
2 changes: 2 additions & 0 deletions frontend/src/components/AppWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -39,6 +40,7 @@ export function AppWrapper({ children }: AppWrapperProps) {
</div>
<div className="flex items-center space-x-4">
<ActivityTokenBalance />
<LoginButton />
<ConnectButton />
</div>
</div>
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/components/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant="outline"
size="sm"
onClick={handleLogout}
className="flex items-center gap-2"
>
<LogOut className="h-4 w-4" />
<span>Logout</span>
</Button>
);
}

return (
<Button
size="sm"
onClick={handleLogin}
disabled={isLoading}
className="flex items-center gap-2"
>
<LogIn className="h-4 w-4" />
<span>{isLoading ? "Signing..." : "Sign In"}</span>
</Button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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." }),
Expand All @@ -41,32 +38,19 @@ 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<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { name: "", description: "" },
});

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);
Expand Down Expand Up @@ -136,35 +120,14 @@ export function CreateProfileButton() {
</FormItem>
)}
/>
{siweMessage && (
<div className="space-y-2">
<FormLabel>Message to Sign</FormLabel>
<div className="p-3 bg-gray-50 rounded-md text-sm font-mono break-all">
{siweMessage}
</div>
<p className="text-xs text-gray-600">
This message will be signed with your wallet to authenticate
your profile creation.
</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={
createProfile.isPending || isLoadingNonce || !siweMessage
}
>
{isLoadingNonce
? "Loading..."
: createProfile.isPending
? "Creating..."
: "Create"}
<Button type="submit" disabled={createProfile.isPending}>
{createProfile.isPending ? "Creating..." : "Create"}
</Button>
</div>
{createProfile.isError ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
};
Expand All @@ -51,17 +38,6 @@ export function DeleteProfileDialog({ children }: DeleteProfileDialogProps) {
profile.
</DialogDescription>
</DialogHeader>
{siweMessage && (
<div className="space-y-2">
<label className="text-sm font-medium">Message to Sign</label>
<div className="p-3 bg-gray-50 rounded-md text-sm font-mono break-all">
{siweMessage}
</div>
<p className="text-xs text-gray-600">
This message will be signed with your wallet to authenticate profile deletion.
</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<DialogClose asChild>
<Button type="button" variant="secondary">
Expand All @@ -71,14 +47,9 @@ export function DeleteProfileDialog({ children }: DeleteProfileDialogProps) {
<Button
variant="destructive"
onClick={onConfirm}
disabled={deleteProfile.isPending || isLoadingNonce || !siweMessage}
disabled={deleteProfile.isPending}
>
{isLoadingNonce
? "Loading..."
: deleteProfile.isPending
? "Deleting..."
: "Delete"
}
{deleteProfile.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
{deleteProfile.isError ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FormValues>({
resolver: zodResolver(formSchema),
Expand All @@ -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);
Expand Down Expand Up @@ -162,18 +145,6 @@ export function EditProfileDialog({
</FormItem>
)}
/>
{siweMessage && (
<div className="space-y-2">
<FormLabel>Message to Sign</FormLabel>
<div className="p-3 bg-gray-50 rounded-md text-sm font-mono break-all">
{siweMessage}
</div>
<p className="text-xs text-gray-600">
This message will be signed with your wallet to authenticate
your profile update.
</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<DialogClose asChild>
<Button type="button" variant="secondary">
Expand All @@ -182,15 +153,9 @@ export function EditProfileDialog({
</DialogClose>
<Button
type="submit"
disabled={
updateProfile.isPending || isLoadingNonce || !siweMessage
}
disabled={updateProfile.isPending}
>
{isLoadingNonce
? "Loading..."
: updateProfile.isPending
? "Updating..."
: "Update"}
{updateProfile.isPending ? "Updating..." : "Update"}
</Button>
</div>
{updateProfile.isError ? (
Expand Down
34 changes: 21 additions & 13 deletions frontend/src/hooks/profiles/use-create-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,36 @@ 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<CreateProfileResponse> {
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),
});

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 {
Expand All @@ -44,7 +42,6 @@ async function postCreateProfile(

type MutationVariables = {
input: CreateProfileInput;
signature: string;
};

export function useCreateProfile(): UseMutationResult<
Expand All @@ -54,18 +51,29 @@ export function useCreateProfile(): UseMutationResult<
> {
const { address } = useAccount();
const queryClient = useQueryClient();
const { login } = useLogin();

return useMutation<CreateProfileResponse, Error, MutationVariables>({
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"] });
},
});
}
Loading
Loading