Skip to content

Commit

Permalink
feat: simple sign in flow
Browse files Browse the repository at this point in the history
  • Loading branch information
BrickheadJohnny committed Nov 19, 2024
1 parent 02174ea commit 89e7ce1
Show file tree
Hide file tree
Showing 16 changed files with 346 additions and 24 deletions.
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"foxact": "^0.2.41",
"jotai": "^2.10.2",
"next": "15.0.3",
"next-themes": "^0.4.3",
"react": "19.0.0-rc-66855b96-20241106",
Expand Down
66 changes: 66 additions & 0 deletions src/app/actions/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use server";

import { GUILD_AUTH_COOKIE_NAME } from "app/config/constants";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";

const authSchema = z.object({
message: z.string(),
token: z.string(),
userId: z.string().uuid(),
});

export const signIn = async ({
message,
signature,
}: {
message: string;
signature: string;
}) => {
const cookieStore = await cookies();

const requestInit = {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
signature,
}),
} satisfies RequestInit;

const signInRes = await fetch(
`${process.env.NEXT_PUBLIC_API}/auth/siwe/login`,
requestInit,
);

if (signInRes.status === 401) {
const registerRes = await fetch(
`${process.env.NEXT_PUBLIC_API}/auth/siwe/register`,
requestInit,
);
const json = await registerRes.json();

const registerData = authSchema.parse(json);
cookieStore.set(GUILD_AUTH_COOKIE_NAME, registerData.token);

return registerData;
}

const json = await signInRes.json();

const signInData = authSchema.parse(json);

cookieStore.set(GUILD_AUTH_COOKIE_NAME, signInData.token);

return signInData;
};

export const signOut = async (redirectTo?: string) => {
const cookieStore = await cookies();
cookieStore.delete(GUILD_AUTH_COOKIE_NAME);
redirect(redirectTo ?? "/explorer");
};
17 changes: 17 additions & 0 deletions src/app/components/AuthBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cookies } from "next/headers";
import { GUILD_AUTH_COOKIE_NAME } from "../config/constants";

export const AuthBoundary = async ({
fallback,
children,
}: Readonly<{
fallback: React.ReactNode;
children: React.ReactNode;
}>) => {
const cookieStore = await cookies();
const authCookie = cookieStore.get(GUILD_AUTH_COOKIE_NAME);

if (!authCookie) return <>{fallback}</>;

return <>{children}</>;
};
15 changes: 15 additions & 0 deletions src/app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AuthBoundary } from "./AuthBoundary";
import { SignInButton } from "./SignInButton";
import { SignOutButton } from "./SignOutButton";
import { Card } from "./ui/Card";

export const Header = () => (
<header className="flex h-14 items-center justify-between gap-4 p-2">
{/* TODO: NavMenu component */}
<Card className="h-10 w-24 rounded-xl" />

<AuthBoundary fallback={<SignInButton />}>
<SignOutButton />
</AuthBoundary>
</header>
);
23 changes: 23 additions & 0 deletions src/app/components/SignInButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { SignIn } from "@phosphor-icons/react/dist/ssr";
import { signInDialogOpenAtom } from "app/config/atoms";
import { useSetAtom } from "jotai";
import { Button } from "./ui/Button";
import { Card } from "./ui/Card";

export const SignInButton = () => {
const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom);

return (
<Card className="rounded-xl">
<Button
className="h-10"
leftIcon={<SignIn weight="bold" />}
onClick={() => setSignInDialogOpen(true)}
>
Sign in
</Button>
</Card>
);
};
130 changes: 130 additions & 0 deletions src/app/components/SignInDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"use client";

import { SignIn, User, Wallet } from "@phosphor-icons/react/dist/ssr";
import { useMutation } from "@tanstack/react-query";
import { signIn } from "app/actions/auth";
import { signInDialogOpenAtom } from "app/config/atoms";
import { useAtom, useSetAtom } from "jotai";
import { shortenHex } from "lib/shortenHex";
import { createSiweMessage } from "viem/siwe";
import { useAccount, useConnect, useSignMessage } from "wagmi";
import { z } from "zod";
import { Button } from "./ui/Button";
import {
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "./ui/ResponsiveDialog";

export const SignInDialog = () => {
const { isConnected } = useAccount();
const [open, setOpen] = useAtom(signInDialogOpenAtom);

return (
<ResponsiveDialog open={open} onOpenChange={setOpen}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Sign in</ResponsiveDialogTitle>
</ResponsiveDialogHeader>

<ResponsiveDialogBody>
{isConnected ? <SignInWithEthereum /> : <WalletList />}
</ResponsiveDialogBody>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
};

const WalletList = () => {
const { connectors, connect, isPending, variables } = useConnect();

return (
<div className="grid gap-2">
{connectors.map((connector) => (
<Button
key={connector.uid}
onClick={() => connect({ connector })}
leftIcon={
connector.icon ? (
<img
src={connector.icon}
alt={`${connector.name} icon`}
className="size-6"
/>
) : (
<Wallet weight="bold" className="size-6" />
)
}
size="xl"
isLoading={
!!variables?.connector &&
"id" in variables.connector &&
variables.connector.id === connector.id &&
isPending
}
loadingText="Check your wallet"
className="justify-start"
>
{connector.name}
</Button>
))}
</div>
);
};

const SignInWithEthereum = () => {
const { address } = useAccount();
const { signMessageAsync } = useSignMessage();

const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom);

const { mutate: signInWithEthereum, isPending } = useMutation({
mutationKey: ["SIWE"],
mutationFn: async () => {
const { nonce } = await fetch(
`${process.env.NEXT_PUBLIC_API}/auth/siwe/nonce`,
)
.then((res) => res.json())
.then((data) => z.object({ nonce: z.string() }).parse(data));

const message = createSiweMessage({
address: address!,

Check warning on line 93 in src/app/components/SignInDialog.tsx

View workflow job for this annotation

GitHub Actions / quality-assurance

lint/style/noNonNullAssertion

Forbidden non-null assertion.
chainId: 1,
domain: "localhost:3000",
nonce,
uri: "localhost:3000",
version: "1",
});

const signature = await signMessageAsync({ message });

return signIn({ message, signature });
},
onSuccess: () => setSignInDialogOpen(false),
});

return (
<div className="grid gap-2">
<Button
leftIcon={<User weight="bold" />}
size="xl"
className="justify-start"
disabled
>
{shortenHex(address!)}

Check warning on line 116 in src/app/components/SignInDialog.tsx

View workflow job for this annotation

GitHub Actions / quality-assurance

lint/style/noNonNullAssertion

Forbidden non-null assertion.
</Button>
<Button
leftIcon={<SignIn weight="bold" />}
colorScheme="success"
size="xl"
onClick={() => signInWithEthereum()}
isLoading={isPending}
loadingText="Check your wallet"
>
Sign in with Ethereum
</Button>
</div>
);
};
19 changes: 19 additions & 0 deletions src/app/components/SignOutButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { SignOut } from "@phosphor-icons/react/dist/ssr";
import { signOut } from "app/actions/auth";
import { usePathname } from "next/navigation";
import { Button } from "./ui/Button";

export const SignOutButton = () => {
const pathname = usePathname();
return (
<Button
className="h-10"
leftIcon={<SignOut weight="bold" />}
onClick={() => signOut(pathname)}
>
Sign out
</Button>
);
};
19 changes: 3 additions & 16 deletions src/app/components/ui/ResponsiveDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMediaQuery } from "foxact/use-media-query";
import { type ComponentProps, useCallback, useState } from "react";
import type { ComponentProps } from "react";
import {
Dialog,
DialogBody,
Expand Down Expand Up @@ -27,26 +27,13 @@ import {
const useIsMobile = () => useMediaQuery("(max-width: 640px)", false);

export const ResponsiveDialog = ({
open: openProp,
onOpenChange: onOpenChangeProp,
...props
}: ComponentProps<typeof Dialog> & ComponentProps<typeof Drawer>) => {
const [open, setOpen] = useState(openProp);

const onOpenChange = useCallback(
(newOpen: boolean) => {
setOpen(newOpen);
if (typeof onOpenChangeProp === "function") onOpenChangeProp(newOpen);
},
[onOpenChangeProp],
);

const isMobile = useIsMobile();

if (isMobile)
return <Drawer {...props} open={open} onOpenChange={onOpenChange} />;
if (isMobile) return <Drawer {...props} />;

return <Dialog {...props} open={open} onOpenChange={onOpenChange} />;
return <Dialog {...props} />;
};
ResponsiveDialog.displayName = "ResponsiveDialog";

Expand Down
3 changes: 3 additions & 0 deletions src/app/config/atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { atom } from "jotai";

export const signInDialogOpenAtom = atom(false);
1 change: 1 addition & 0 deletions src/app/config/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const GUILD_AUTH_COOKIE_NAME = "guild-auth";
17 changes: 17 additions & 0 deletions src/app/config/wagmi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { http, createConfig } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";

export const wagmiConfig = createConfig({
chains: [mainnet, sepolia],
transports: {
[mainnet.id]: http(),
[sepolia.id]: http(),
},
ssr: true,
});

declare module "wagmi" {
interface Register {
config: typeof wagmiConfig;
}
}
Loading

0 comments on commit 89e7ce1

Please sign in to comment.