diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index e41580b..33d1cfb 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -15,3 +15,6 @@ FIRESTORE_EMULATOR_HOST=localhost:8080 # Setup you project id for firebase FIREBASE_PROJECT_ID="buidlguidl-v3" + +# JWT secret for signing and verifying tokens +JWT_SECRET="your_secret_here" diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx new file mode 100644 index 0000000..f353de1 --- /dev/null +++ b/packages/nextjs/app/admin/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useReadLocalStorage } from "usehooks-ts"; +import { useAccount } from "wagmi"; +import SIWE from "~~/components/SIWE"; +import { GrantData } from "~~/services/database/schema"; +import { notification } from "~~/utils/scaffold-eth"; + +const AdminPage = () => { + const [grants, setGrants] = useState([]); + const { isConnected } = useAccount(); + const jwt = useReadLocalStorage("jwt"); + + // In use effect, make get request (with JWT) to /api/grants/all + useEffect(() => { + const getGrants = async () => { + try { + const response = await fetch("/api/grants/all", { + headers: { + Authorization: `Bearer ${jwt}`, + }, + }); + const grants: GrantData[] = (await response.json()).data; + setGrants(grants); + } catch (error) { + notification.error("Error getting grants"); + } + }; + + if (jwt) getGrants(); + }, [jwt]); + + return ( +
+

Admin page

+ +

Admin data:

+ {!isConnected || !jwt ? ( +

Connect & authenticate to see admin data

+ ) : ( + <> +

All grants:

+ {grants.map(grant => ( +
+

{grant.title}

+

{grant.description}

+
+ ))} + + )} +
+ ); +}; + +export default AdminPage; diff --git a/packages/nextjs/app/api/auth/siwe/route.tsx b/packages/nextjs/app/api/auth/siwe/route.tsx new file mode 100644 index 0000000..a0464e6 --- /dev/null +++ b/packages/nextjs/app/api/auth/siwe/route.tsx @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; +import { verifyMessage } from "viem"; +import { findUserByAddress } from "~~/services/database/users"; + +export async function POST(request: Request) { + const { signature, address } = await request.json(); + if (!process.env.JWT_SECRET) return new Response("Internal Server Error: JWT", { status: 500 }); + if (!signature || !address) return new Response("Bad Request", { status: 400 }); + + const user = await findUserByAddress(address); + if (!user.exists || user.data?.role !== "admin") return new Response("Unauthorized", { status: 401 }); + + const signedMessage = `I want to sign in to grants.buidlguidl.com as ${address}`; + const isMessageValid = await verifyMessage({ message: signedMessage, signature, address }); + + if (!isMessageValid) return new Response("Unauthorized", { status: 401 }); + + const token = jwt.sign({ address }, process.env.JWT_SECRET, { expiresIn: "1w" }); + + return NextResponse.json({ token }); +} diff --git a/packages/nextjs/app/api/grants/all/route.tsx b/packages/nextjs/app/api/grants/all/route.tsx new file mode 100644 index 0000000..a3df800 --- /dev/null +++ b/packages/nextjs/app/api/grants/all/route.tsx @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; +import { getAllGrants } from "~~/services/database/grants"; + +export async function GET(request: Request) { + // ToDo. We probably want to use a middleware for this. + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new Response("Unauthorized", { status: 401 }); + } + + const token = authHeader.split(" ")[1]; + try { + jwt.verify(token, process.env.JWT_SECRET || ""); + } catch (error) { + return new Response("Unauthorized", { status: 401 }); + } + + const grants = await getAllGrants(); + + return NextResponse.json({ data: grants }); +} diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index e4770b1..b927560 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -1,7 +1,54 @@ import React from "react"; import Image from "next/image"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { RainbowKitCustomConnectButton } from "./scaffold-eth"; +import { LockClosedIcon } from "@heroicons/react/24/outline"; + +type HeaderMenuLink = { + label: string; + href: string; + icon?: React.ReactNode; +}; + +export const menuLinks: HeaderMenuLink[] = [ + { + label: "Home", + href: "/", + }, + // ToDo. Show only on admins + { + label: "Admin", + href: "/admin", + icon: , + }, +]; + +export const HeaderMenuLinks = () => { + const pathname = usePathname(); + + return ( + <> + {menuLinks.map(({ label, href, icon }) => { + const isActive = pathname === href; + return ( +
  • + + {icon} + {label} + +
  • + ); + })} + + ); +}; /** * Site header @@ -9,13 +56,17 @@ import { RainbowKitCustomConnectButton } from "./scaffold-eth"; export const Header = () => { return (
    -
    +
    SE2 logo
    +
      + +
    +
    diff --git a/packages/nextjs/components/SIWE.tsx b/packages/nextjs/components/SIWE.tsx new file mode 100644 index 0000000..cae8006 --- /dev/null +++ b/packages/nextjs/components/SIWE.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as React from "react"; +import { useLocalStorage } from "usehooks-ts"; +import { useAccount, useSignMessage } from "wagmi"; +import { notification } from "~~/utils/scaffold-eth"; + +// ToDo. "Connect wallet" info tooltip if disabled +// ToDo. Nonce? +// ToDo. Check if expired? +const SIWE = () => { + const { isConnected, address } = useAccount(); + const [jwt, setJwt] = useLocalStorage("jwt", "", { + initializeWithValue: false, + }); + + const { signMessageAsync } = useSignMessage(); + + const signIn = async () => { + try { + const signature = await signMessageAsync({ message: `I want to sign in to grants.buidlguidl.com as ${address}` }); + + // Verify signature + const verifyRes = await fetch("/api/auth/siwe", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ signature, address }), + }); + if (!verifyRes.ok) throw new Error("Error verifying message"); + const { token } = await verifyRes.json(); + setJwt(token); + notification.success("Signed in successfully"); + } catch (error) { + notification.error("Error signing in"); + } + }; + + return ( +
    + {jwt ? ( +
    +

    Already signed in!!

    +
    + ) : ( + + )} +
    + ); +}; + +export default SIWE; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 77df898..0d019c0 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -22,6 +22,7 @@ "blo": "^1.0.1", "daisyui": "4.5.0", "firebase-admin": "^11.11.1", + "jsonwebtoken": "^9.0.2", "next": "^14.0.4", "next-themes": "^0.2.1", "nprogress": "^0.2.0", @@ -38,6 +39,7 @@ }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", + "@types/jsonwebtoken": "^9", "@types/node": "^17.0.35", "@types/nprogress": "^0", "@types/react": "^18.0.9", diff --git a/yarn.lock b/yarn.lock index c29da88..24c8001 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2301,6 +2301,7 @@ __metadata: "@heroicons/react": ^2.0.11 "@rainbow-me/rainbowkit": 1.3.5 "@trivago/prettier-plugin-sort-imports": ^4.1.1 + "@types/jsonwebtoken": ^9 "@types/node": ^17.0.35 "@types/nprogress": ^0 "@types/react": ^18.0.9 @@ -2317,6 +2318,7 @@ __metadata: eslint-config-prettier: ^8.5.0 eslint-plugin-prettier: ^4.2.1 firebase-admin: ^11.11.1 + jsonwebtoken: ^9.0.2 next: ^14.0.4 next-themes: ^0.2.1 nprogress: ^0.2.0 @@ -2994,7 +2996,7 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:^9.0.2": +"@types/jsonwebtoken@npm:^9, @types/jsonwebtoken@npm:^9.0.2": version: 9.0.5 resolution: "@types/jsonwebtoken@npm:9.0.5" dependencies: @@ -10315,7 +10317,7 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:^9.0.0": +"jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2": version: 9.0.2 resolution: "jsonwebtoken@npm:9.0.2" dependencies: