From 89e7ce1c039f38f1b25a5e9a0e0b2f46e6d2a45d Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Tue, 19 Nov 2024 18:01:16 +0100 Subject: [PATCH] feat: simple sign in flow --- package-lock.json | 22 ++++ package.json | 1 + src/app/actions/auth.ts | 66 +++++++++++ src/app/components/AuthBoundary.tsx | 17 +++ src/app/components/Header.tsx | 15 +++ src/app/components/SignInButton.tsx | 23 ++++ src/app/components/SignInDialog.tsx | 130 +++++++++++++++++++++ src/app/components/SignOutButton.tsx | 19 +++ src/app/components/ui/ResponsiveDialog.tsx | 19 +-- src/app/config/atoms.ts | 3 + src/app/config/constants.ts | 1 + src/app/config/wagmi.ts | 17 +++ src/app/globals.css | 8 +- src/app/layout.tsx | 12 +- src/components/Providers.tsx | 13 ++- src/lib/shortenHex.ts | 4 + 16 files changed, 346 insertions(+), 24 deletions(-) create mode 100644 src/app/actions/auth.ts create mode 100644 src/app/components/AuthBoundary.tsx create mode 100644 src/app/components/Header.tsx create mode 100644 src/app/components/SignInButton.tsx create mode 100644 src/app/components/SignInDialog.tsx create mode 100644 src/app/components/SignOutButton.tsx create mode 100644 src/app/config/atoms.ts create mode 100644 src/app/config/constants.ts create mode 100644 src/app/config/wagmi.ts create mode 100644 src/lib/shortenHex.ts diff --git a/package-lock.json b/package-lock.json index 277081ad55..d833218e1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,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", @@ -12612,6 +12613,27 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jotai": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.10.2.tgz", + "integrity": "sha512-DqsBTlRglIBviuJLfK6JxZzpd6vKfbuJ4IqRCz70RFEDeZf46Fcteb/FXxNr1UnoxR5oUy3oq7IE8BrEq0G5DQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 60308783ef..c2c58b7681 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts new file mode 100644 index 0000000000..fdd580ded3 --- /dev/null +++ b/src/app/actions/auth.ts @@ -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"); +}; diff --git a/src/app/components/AuthBoundary.tsx b/src/app/components/AuthBoundary.tsx new file mode 100644 index 0000000000..f5a1a08fbd --- /dev/null +++ b/src/app/components/AuthBoundary.tsx @@ -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}; +}; diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx new file mode 100644 index 0000000000..ff0be76648 --- /dev/null +++ b/src/app/components/Header.tsx @@ -0,0 +1,15 @@ +import { AuthBoundary } from "./AuthBoundary"; +import { SignInButton } from "./SignInButton"; +import { SignOutButton } from "./SignOutButton"; +import { Card } from "./ui/Card"; + +export const Header = () => ( +
+ {/* TODO: NavMenu component */} + + + }> + + +
+); diff --git a/src/app/components/SignInButton.tsx b/src/app/components/SignInButton.tsx new file mode 100644 index 0000000000..2bb021d294 --- /dev/null +++ b/src/app/components/SignInButton.tsx @@ -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 ( + + + + ); +}; diff --git a/src/app/components/SignInDialog.tsx b/src/app/components/SignInDialog.tsx new file mode 100644 index 0000000000..f147c72b4d --- /dev/null +++ b/src/app/components/SignInDialog.tsx @@ -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 ( + + + + Sign in + + + + {isConnected ? : } + + + + ); +}; + +const WalletList = () => { + const { connectors, connect, isPending, variables } = useConnect(); + + return ( +
+ {connectors.map((connector) => ( + + ))} +
+ ); +}; + +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!, + chainId: 1, + domain: "localhost:3000", + nonce, + uri: "localhost:3000", + version: "1", + }); + + const signature = await signMessageAsync({ message }); + + return signIn({ message, signature }); + }, + onSuccess: () => setSignInDialogOpen(false), + }); + + return ( +
+ + +
+ ); +}; diff --git a/src/app/components/SignOutButton.tsx b/src/app/components/SignOutButton.tsx new file mode 100644 index 0000000000..3a8addaf51 --- /dev/null +++ b/src/app/components/SignOutButton.tsx @@ -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 ( + + ); +}; diff --git a/src/app/components/ui/ResponsiveDialog.tsx b/src/app/components/ui/ResponsiveDialog.tsx index ab41a71868..2f0b81e4bd 100644 --- a/src/app/components/ui/ResponsiveDialog.tsx +++ b/src/app/components/ui/ResponsiveDialog.tsx @@ -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, @@ -27,26 +27,13 @@ import { const useIsMobile = () => useMediaQuery("(max-width: 640px)", false); export const ResponsiveDialog = ({ - open: openProp, - onOpenChange: onOpenChangeProp, ...props }: ComponentProps & ComponentProps) => { - 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 ; + if (isMobile) return ; - return ; + return ; }; ResponsiveDialog.displayName = "ResponsiveDialog"; diff --git a/src/app/config/atoms.ts b/src/app/config/atoms.ts new file mode 100644 index 0000000000..2be70c4792 --- /dev/null +++ b/src/app/config/atoms.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const signInDialogOpenAtom = atom(false); diff --git a/src/app/config/constants.ts b/src/app/config/constants.ts new file mode 100644 index 0000000000..698f9f9e61 --- /dev/null +++ b/src/app/config/constants.ts @@ -0,0 +1 @@ +export const GUILD_AUTH_COOKIE_NAME = "guild-auth"; diff --git a/src/app/config/wagmi.ts b/src/app/config/wagmi.ts new file mode 100644 index 0000000000..5d4bb14c22 --- /dev/null +++ b/src/app/config/wagmi.ts @@ -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; + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 9f7d481955..37a468764e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -130,9 +130,9 @@ html { --button-primary-subtle: var(--indigo-300); --button-primary-subtle-foreground: var(--indigo-500); - --button-secondary: var(--blackAlpha); - --button-secondary-hover: var(--blackAlpha-medium); - --button-secondary-active: var(--blackAlpha-hard); + --button-secondary: var(--whiteAlpha); + --button-secondary-hover: var(--whiteAlpha-medium); + --button-secondary-active: var(--whiteAlpha-hard); --button-secondary-foreground: var(--foreground); --button-secondary-subtle: var(--gray-400); --button-secondary-subtle-foreground: var(--foreground); @@ -151,7 +151,7 @@ html { --button-success-subtle: var(--green-300); --button-success-subtle-foreground: var(--green-500); - --badge-background: var(--blackAlpha); + --badge-background: var(--whiteAlpha); --badge-foreground: var(--foreground); --drawer-handle: var(--whiteAlpha); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7d4bb1e8f7..85ba30e47e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from "next"; import "./globals.css"; -import { CsrProviders } from "components/Providers"; +import { Providers } from "components/Providers"; import { dystopian, inter } from "fonts"; import { cn } from "lib/cssUtils"; +import { Header } from "./components/Header"; +import { SignInDialog } from "./components/SignInDialog"; export const metadata: Metadata = { title: "Guildhall", @@ -22,7 +24,13 @@ const RootLayout = ({ return ( - {children} + +
+ {children} + + {/* TODO: maybe load this dynamically? */} + + ); diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 50115c2fe8..bb04febc46 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,9 +1,14 @@ "use client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { wagmiConfig } from "app/config/wagmi"; import { ThemeProvider } from "next-themes"; import type { FunctionComponent, PropsWithChildren } from "react"; +import { WagmiProvider } from "wagmi"; -export const CsrProviders: FunctionComponent = ({ +const queryClient = new QueryClient(); + +export const Providers: FunctionComponent = ({ children, }) => { return ( @@ -13,7 +18,11 @@ export const CsrProviders: FunctionComponent = ({ enableSystem disableTransitionOnChange > - {children} + + + {children} + + ); }; diff --git a/src/lib/shortenHex.ts b/src/lib/shortenHex.ts new file mode 100644 index 0000000000..bd924c62c6 --- /dev/null +++ b/src/lib/shortenHex.ts @@ -0,0 +1,4 @@ +export const shortenHex = (hex: string, length = 4): string => + `${hex.substring(0, length + (hex.startsWith("0x") ? 2 : 0))}…${hex.substring( + hex.length - length, + )}`;