Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auth signed writes #18

Merged
merged 6 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
102 changes: 102 additions & 0 deletions packages/nextjs/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";

import { useEffect, useState } from "react";
import { useAccount, useSignTypedData } from "wagmi";
import { GrantData } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { notification } from "~~/utils/scaffold-eth";

// ToDo. "Protect" with address header or PROTECT with signing the read.
// ToDo. Submitted grants
// ToDo. Loading states (initial, actions, etc)
// ToDo. Refresh list after action
Comment on lines +47 to +48
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use something like swr ? or even tanstack react-query (it might be a bit over powered) because they come in with this built in and also some extra goodies like caching etc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, happy to try swr!

This app won't be super intensive on requests, just a couple of forms + /admin... but we can explore if it's worth it.

Maybe in another PR?

const AdminPage = () => {
const { address } = useAccount();
const [grants, setGrants] = useState<GrantData[]>([]);
const { signTypedDataAsync } = useSignTypedData();

useEffect(() => {
const getGrants = async () => {
try {
const response = await fetch("/api/grants/review");
const grants: GrantData[] = (await response.json()).data;
setGrants(grants);
} catch (error) {
notification.error("Error getting grants for review");
}
};

getGrants();
}, []);

const reviewGrant = async (grant: GrantData, action: ProposalStatusType) => {
let signature;
try {
signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
},
});
} catch (e) {
console.error("Error signing message", e);
notification.error("Error signing message");
return;
}

try {
await fetch(`/api/grants/${grant.id}/review`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ signature, signer: address, action }),
});
notification.success(`Grant reviewed: ${action}`);
} catch (error) {
notification.error("Error reviewing grant");
}
};

return (
<div className="container mx-auto max-w-screen-md mt-12">
<h1 className="text-4xl font-bold">Admin page</h1>
{grants && (
<>
<h2 className="font-bold mt-8">All grants that need review:</h2>
{grants.map(grant => (
<div key={grant.id} className="border p-4 my-4">
<h3 className="font-bold">
{grant.title}
<span className="text-sm text-gray-500 ml-2">({grant.id})</span>
</h3>
<p>{grant.description}</p>
{grant.status === PROPOSAL_STATUS.PROPOSED && (
<div className="mt-4">
<button
className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
onClick={() => reviewGrant(grant, PROPOSAL_STATUS.APPROVED)}
>
Approve
</button>
<button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded ml-4"
onClick={() => reviewGrant(grant, PROPOSAL_STATUS.REJECTED)}
>
Reject
</button>
</div>
)}
</div>
))}
</>
)}
</div>
);
};

export default AdminPage;
35 changes: 35 additions & 0 deletions packages/nextjs/app/api/grants/[grantId]/review/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { recoverTypedDataAddress } from "viem";
import { reviewGrant } from "~~/services/database/grants";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";

export async function POST(req: NextRequest, { params }: { params: { grantId: string } }) {
const { grantId } = params;
const { signature, signer, action } = await req.json();

// Validate Signature
const recoveredAddress = await recoverTypedDataAddress({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grantId,
action: action,
},
signature,
});

if (recoveredAddress !== signer) {
console.error("Signature error", recoveredAddress, signer);
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
await reviewGrant(grantId, action);
} catch (error) {
console.error("Error approving grant", error);
return NextResponse.json({ error: "Error approving grant" }, { status: 500 });
}

return NextResponse.json({ success: true });
}
8 changes: 8 additions & 0 deletions packages/nextjs/app/api/grants/review/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { getAllGrantsForReview } from "~~/services/database/grants";

export async function GET() {
const grants = await getAllGrantsForReview();

return NextResponse.json({ data: grants });
}
53 changes: 52 additions & 1 deletion packages/nextjs/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,72 @@
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: <LockClosedIcon className="h-4 w-4" />,
},
];

export const HeaderMenuLinks = () => {
const pathname = usePathname();

return (
<>
{menuLinks.map(({ label, href, icon }) => {
const isActive = pathname === href;
return (
<li key={href}>
<Link
href={href}
passHref
className={`${
isActive ? "underline" : ""
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
>
{icon}
<span>{label}</span>
</Link>
</li>
);
})}
</>
);
};

/**
* Site header
*/
export const Header = () => {
return (
<div className="navbar items-start bg-base-200 px-5 py-4">
<div className="navbar-start">
<div className="navbar-start gap-10">
<Link href="/" passHref className="flex items-center">
<div className="flex relative w-[130px] md:w-[150px] h-[36px]">
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
</div>
</Link>
<ul className="hidden lg:flex lg:flex-nowrap menu menu-horizontal px-1 gap-2">
<HeaderMenuLinks />
</ul>
</div>

<div className="navbar-end flex-grow z-10">
<RainbowKitCustomConnectButton />
</div>
Expand Down
34 changes: 26 additions & 8 deletions packages/nextjs/services/database/grants.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { getFirestoreConnector } from "./firestoreDB";
import { GrantData } from "./schema";

export const PROPOSAL_STATUS = {
PROPOSED: "proposed",
APPROVED: "approved",
SUBMITTED: "submitted",
COMPLETED: "completed",
REJECTED: "rejected",
} as const;
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";

const firestoreDB = getFirestoreConnector();
const grantsCollection = firestoreDB.collection("grants");
Expand Down Expand Up @@ -43,6 +36,22 @@ export const getAllGrants = async () => {
}
};

export const getAllGrantsForReview = async () => {
try {
const grantsSnapshot = await grantsCollection
.where("status", "in", [PROPOSAL_STATUS.PROPOSED, PROPOSAL_STATUS.SUBMITTED])
.get();
const grants: GrantData[] = [];
grantsSnapshot.forEach(doc => {
grants.push({ id: doc.id, ...doc.data() } as GrantData);
});
return grants;
} catch (error) {
console.error("Error getting all completed grants:", error);
throw error;
}
};

export const getAllCompletedGrants = async () => {
try {
const grantsSnapshot = await grantsCollection.where("status", "==", PROPOSAL_STATUS.COMPLETED).get();
Expand All @@ -57,6 +66,15 @@ export const getAllCompletedGrants = async () => {
}
};

export const reviewGrant = async (grantId: string, action: ProposalStatusType) => {
try {
await grantsCollection.doc(grantId).update({ status: action });
} catch (error) {
console.error("Error approving the grant:", error);
throw error;
}
};

export const getGrantsStats = async () => {
// Summation of askAmount for completed grants: total_eth_granted
// Total number of completed grants : total_completed_grants
Expand Down
13 changes: 13 additions & 0 deletions packages/nextjs/utils/eip712.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const EIP_712_DOMAIN = {
name: "BuidlGuidl Grants",
version: "1",
chainId: 10,
} as const;

// ToDo. We could add more fields (grant title, builder, etc)
export const EIP_712_TYPES__REVIEW_GRANT = {
Message: [
{ name: "grantId", type: "string" },
{ name: "action", type: "string" },
],
} as const;
9 changes: 9 additions & 0 deletions packages/nextjs/utils/grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const PROPOSAL_STATUS = {
PROPOSED: "proposed",
APPROVED: "approved",
SUBMITTED: "submitted",
COMPLETED: "completed",
REJECTED: "rejected",
} as const;

export type ProposalStatusType = (typeof PROPOSAL_STATUS)[keyof typeof PROPOSAL_STATUS];
Loading