From e7c876e30d49564b938b72fa315a3bb38f737987 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Fri, 6 Dec 2024 11:46:53 +0100 Subject: [PATCH 01/22] feat(RewardCard): change layout with container queries --- package.json | 1 + src/components/rewards/RewardCard.tsx | 56 +++++++++++++++------------ tailwind.config.ts | 5 ++- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index e55151a33d..e5fd88ca5e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@t3-oss/env-nextjs": "^0.11.1", + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.15", "@tanstack/react-query": "^5.60.2", "@tanstack/react-query-devtools": "^5.61.0", diff --git a/src/components/rewards/RewardCard.tsx b/src/components/rewards/RewardCard.tsx index 402ba807ac..3015724513 100644 --- a/src/components/rewards/RewardCard.tsx +++ b/src/components/rewards/RewardCard.tsx @@ -16,33 +16,35 @@ export const RewardCard = ({ description?: string; className?: string; }>) => ( - -
-
- {!image ? ( - - ) : typeof image === "string" ? ( - Reward icon - ) : ( - image - )} -
+ +
+
+
+ {!image ? ( + + ) : typeof image === "string" ? ( + Reward icon + ) : ( + image + )} +
- - {title} - + + {title} + - {description && ( - {description} - )} + {description && ( + + {description} + + )} +
+ {children}
- {children}
); @@ -50,5 +52,9 @@ export const RewardCardButton = ({ className, ...props }: Omit) => ( - -
+

+ {role.description} +

+ + Loading rewards...

}> + +
+ - {/* TODO group rules by access groups */} -
- {role.accessGroups[0].rules?.map((rule) => ( - - ))} -
+
+
+ + REQUIREMENTS + +
- - ); -}; -const Reward = ({ reward }: { reward: Schemas["Reward"] }) => { - return ( -
-
{reward.name}
-
{reward.description}
-
-        {JSON.stringify(reward.permissions, null, 2)}
-      
+ {/* TODO group rules by access groups */} +
+ {role.accessGroups[0].rules?.map((rule) => ( + + ))} +
+ +); + +const RoleRewards = ({ roleRewards }: { roleRewards: Role["rewards"] }) => { + const { guildUrlName } = useParams<{ guildUrlName: string }>(); + const { data: guild } = useSuspenseQuery( + guildOptions({ guildIdLike: guildUrlName }), ); + const { data: rewards } = useSuspenseQuery({ + queryKey: ["reward", "search", guild.id], + queryFn: () => + fetchGuildApiData<{ items: GuildReward[] }>( + `reward/search?customQuery=@guildId:{${guild.id}}`, + ).then((data) => data.items), // TODO: we shouldn't do this, we should just get back an array on this endpoint in my opinion + }); + + return roleRewards?.length > 0 && rewards?.length > 0 ? ( +
+ {roleRewards.map((roleReward) => { + // TODO: How to find guildRewards? + const guildReward = rewards.find((gr) => gr.id === roleReward.rewardId); + if (!guildReward) return null; + + const hasRewardCard = ( + rewardType: GuildRewardType, + ): rewardType is keyof typeof rewardCards => rewardType in rewardCards; + + const RewardCard = hasRewardCard(guildReward.type) + ? rewardCards[guildReward.type] + : null; + + if (!RewardCard) return null; + + return ( + + ); + })} +
+ ) : null; }; export default GuildPage; diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx new file mode 100644 index 0000000000..9d1d9bed48 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useInfiniteQuery } from "@tanstack/react-query"; +import { leaderboardOptions } from "../options"; +import { LeaderboardUserCard } from "./LeaderboardUserCard"; + +export const Leaderboard = ({ rewardId }: { rewardId: string }) => { + const { data: rawData } = useInfiniteQuery(leaderboardOptions({ rewardId })); + + const data = rawData?.pages[0]; + + // TODO: use useSuspenseQuery & render proper skeleton loaders + if (!data) return <>Loading...; + + return ( +
+ {/*
+

Point name - your position

+ +
*/} + +
+

Point name leaderboard

+ {data.leaderboard.map((user, index) => ( + + ))} +
+
+ ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/LeaderboardUserCard.tsx b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/LeaderboardUserCard.tsx new file mode 100644 index 0000000000..953ad88bfb --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/LeaderboardUserCard.tsx @@ -0,0 +1,26 @@ +import { Card } from "@/components/ui/Card"; +import type { Leaderboard } from "@/lib/schemas/leaderboard"; +import { User } from "@phosphor-icons/react/dist/ssr"; + +export const LeaderboardUserCard = ({ + user, +}: { user: Leaderboard["user"] }) => ( + +
+ {`#${user.rank}`} +
+ +
+
+
+ +
+ {user.userId} +
+ + {`${user.amount} `} + point name + +
+
+); diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/options.ts b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/options.ts new file mode 100644 index 0000000000..bca99dc5d8 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/options.ts @@ -0,0 +1,16 @@ +import { fetchLeaderboard } from "@/app/(dashboard)/explorer/fetchers"; +import { infiniteQueryOptions } from "@tanstack/react-query"; + +export const leaderboardOptions = ({ rewardId }: { rewardId: string }) => { + return infiniteQueryOptions({ + queryKey: ["leaderboard", rewardId], + queryFn: ({ pageParam }) => + fetchLeaderboard({ rewardId, offset: pageParam }), + initialPageParam: 1, + enabled: rewardId !== undefined, + getNextPageParam: (lastPage) => + lastPage.total / lastPage.limit <= lastPage.offset + ? undefined + : lastPage.offset + 1, + }); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/page.tsx b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/page.tsx new file mode 100644 index 0000000000..7687165ef0 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/page.tsx @@ -0,0 +1,22 @@ +import { getQueryClient } from "@/lib/getQueryClient"; +import type { DynamicRoute } from "@/lib/types"; +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import { Leaderboard } from "./components/Leaderboard"; +import { leaderboardOptions } from "./options"; + +const LeaderboardPage = async ({ + params, +}: DynamicRoute<{ guildUrlName: string; rewardId: string }>) => { + const { rewardId } = await params; + + const queryClient = getQueryClient(); + await queryClient.prefetchInfiniteQuery(leaderboardOptions({ rewardId })); + + return ( + + + + ); +}; + +export default LeaderboardPage; diff --git a/src/app/(dashboard)/explorer/fetchers.ts b/src/app/(dashboard)/explorer/fetchers.ts index f9e275d2f1..e498d043f6 100644 --- a/src/app/(dashboard)/explorer/fetchers.ts +++ b/src/app/(dashboard)/explorer/fetchers.ts @@ -1,4 +1,5 @@ import { fetchGuildApiData } from "@/lib/fetchGuildApi"; +import type { Leaderboard } from "@/lib/schemas/leaderboard"; import { tryGetParsedToken } from "@/lib/token"; import type { PaginatedResponse } from "@/lib/types"; import type { Schemas } from "@guildxyz/types"; @@ -19,3 +20,13 @@ export const fetchGuildSearch = async ({ `guild/search?page=${pageParam}&pageSize=${PAGE_SIZE}&search=${search}`, ); }; + +export const fetchLeaderboard = async ({ + rewardId, + offset = 0, +}: { rewardId: string; offset?: number }) => { + console.log("fetching leaderboard", `reward/${rewardId}/leaderboard`); + return fetchGuildApiData< + Leaderboard & { total: number; offset: number; limit: number } + >(`reward/${rewardId}/leaderboard`); // TODO: use the offset param +}; diff --git a/src/components/rewards/GuildPermissionRewardCard.tsx b/src/components/rewards/GuildPermissionRewardCard.tsx index 1de5a50323..025edc4fac 100644 --- a/src/components/rewards/GuildPermissionRewardCard.tsx +++ b/src/components/rewards/GuildPermissionRewardCard.tsx @@ -7,7 +7,8 @@ export const GuildPermissionRewardCard: FunctionComponent = ({ reward, }) => ( } /> diff --git a/src/components/rewards/PointsRewardCard.tsx b/src/components/rewards/PointsRewardCard.tsx index ee7f93add4..6ebc0c3174 100644 --- a/src/components/rewards/PointsRewardCard.tsx +++ b/src/components/rewards/PointsRewardCard.tsx @@ -1,5 +1,7 @@ +import type { GuildReward } from "@/lib/schemas/guildReward"; import { PointsRoleRewardDataSchema } from "@/lib/schemas/roleReward"; -import { Star } from "@phosphor-icons/react/dist/ssr"; +import { ArrowRight, Star } from "@phosphor-icons/react/dist/ssr"; +import { usePathname, useRouter } from "next/navigation"; import type { FunctionComponent } from "react"; import { RewardCard, RewardCardButton } from "./RewardCard"; import type { RewardCardProps } from "./types"; @@ -8,19 +10,29 @@ export const PointsRewardCard: FunctionComponent = ({ reward, }) => { const { + id, data: { name }, - } = reward.guildReward; + } = reward.guildReward as Extract; // Should we use Zod here? const roleRewardData = PointsRoleRewardDataSchema.parse( reward.roleReward.data, ); + // We should think about this... I'm pretty sure we shouldn't use pathname here + const pathname = usePathname(); + const router = useRouter(); + return ( } > - Claim points + } + onClick={() => router.push(`${pathname}/leaderboard/${id}`)} + > + View leaderboard + ); }; diff --git a/src/components/rewards/rewardCards.ts b/src/components/rewards/rewardCards.ts index 20a1c30aa6..2ffd1dbbab 100644 --- a/src/components/rewards/rewardCards.ts +++ b/src/components/rewards/rewardCards.ts @@ -4,4 +4,4 @@ import { PointsRewardCard } from "./PointsRewardCard"; export const rewardCards = { GUILD: GuildPermissionRewardCard, POINTS: PointsRewardCard, -} as const; +} as const; // TODO: add "satisfies..." diff --git a/src/lib/schemas/guildReward.ts b/src/lib/schemas/guildReward.ts index a9a7d1d5b5..9847c7299c 100644 --- a/src/lib/schemas/guildReward.ts +++ b/src/lib/schemas/guildReward.ts @@ -47,7 +47,7 @@ const TelegramRewardSchema = z.object({ const PointsRewardSchema = z.object({ type: z.literal(GuildRewardTypeSchema.enum.POINTS), data: NameAndImageSchema.extend({ - pointsId: z.string().uuid(), + pointId: z.string().uuid(), }), }); diff --git a/src/lib/schemas/leaderboard.ts b/src/lib/schemas/leaderboard.ts new file mode 100644 index 0000000000..822bc523e7 --- /dev/null +++ b/src/lib/schemas/leaderboard.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +const LeaderboardUserSchema = z.object({ + userId: z.string().uuid(), + amount: z.number(), + rank: z.number(), +}); + +const LeaderboardSchema = z.object({ + leaderboard: z.array(LeaderboardUserSchema.omit({ rank: true })), + user: LeaderboardUserSchema, +}); + +export type Leaderboard = z.infer; From 1476c4221efb44b0ff6d44c63b4e00db817030e7 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Fri, 6 Dec 2024 19:28:55 +0100 Subject: [PATCH 05/22] fix: remove points name for now --- .../leaderboard/[rewardId]/components/Leaderboard.tsx | 5 +++-- .../[rewardId]/components/LeaderboardUserCard.tsx | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx index 9d1d9bed48..baee2aca62 100644 --- a/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx +++ b/src/app/(dashboard)/[guildUrlName]/leaderboard/[rewardId]/components/Leaderboard.tsx @@ -15,12 +15,13 @@ export const Leaderboard = ({ rewardId }: { rewardId: string }) => { return (
{/*
-

Point name - your position

+

Your position

*/}
-

Point name leaderboard

+ {/* TODO: display points' name */} +

Leaderboard

{data.leaderboard.map((user, index) => ( {`${user.amount} `} - point name + {/* TODO: display point' name */} + {/* point name */}
From 227c405b5d59cc7126e0b522d0be4c53e2e168e5 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Tue, 10 Dec 2024 11:16:13 +0100 Subject: [PATCH 06/22] feat: separate join and leave guild components --- .../[guildUrlName]/[pageUrlName]/page.tsx | 3 +- .../components/ActionButton.tsx | 21 +++++ .../[guildUrlName]/components/GuildTabs.tsx | 4 +- .../[guildUrlName]/components/JoinGuild.tsx | 93 +++++++++++++++++++ .../[guildUrlName]/components/LeaveGuild.tsx | 56 +++++++++++ .../[guildUrlName]/hooks/useGuildUrlName.ts | 7 ++ src/app/(dashboard)/[guildUrlName]/layout.tsx | 4 +- 7 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx create mode 100644 src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx create mode 100644 src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx create mode 100644 src/app/(dashboard)/[guildUrlName]/hooks/useGuildUrlName.ts diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx index d37f283bb8..cf019f6632 100644 --- a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -13,6 +13,7 @@ import { ImageSquare, Lock } from "@phosphor-icons/react/dist/ssr"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { Suspense } from "react"; +import { useGuildUrlName } from "../hooks/useGuildUrlName"; const GuildPage = () => { const { pageUrlName, guildUrlName } = useParams<{ @@ -91,7 +92,7 @@ const RoleCard = ({ role }: { role: Role }) => ( ); const RoleRewards = ({ roleRewards }: { roleRewards: Role["rewards"] }) => { - const { guildUrlName } = useParams<{ guildUrlName: string }>(); + const guildUrlName = useGuildUrlName(); const { data: guild } = useSuspenseQuery( guildOptions({ guildIdLike: guildUrlName }), ); diff --git a/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx new file mode 100644 index 0000000000..acd32c2ae2 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx @@ -0,0 +1,21 @@ +import { guildOptions, userOptions } from "@/lib/options"; +import { useQuery } from "@tanstack/react-query"; +import { useGuildUrlName } from "../hooks/useGuildUrlName"; +import { JoinGuild } from "./JoinGuild"; +import { LeaveGuild } from "./LeaveGuild"; + +export const ActionButton = () => { + const guildUrlName = useGuildUrlName(); + const user = useQuery(userOptions()); + const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); + + if (!guild.data) { + throw new Error("Failed to fetch guild"); + } + + const isJoined = !!user.data?.guilds?.some( + ({ guildId }) => guildId === guild.data.id, + ); + + return isJoined ? : ; +}; diff --git a/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx index f6c0cae90a..5b20b6ceb7 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx @@ -6,11 +6,11 @@ import { Skeleton } from "@/components/ui/Skeleton"; import { cn } from "@/lib/cssUtils"; import { guildOptions, pageBatchOptions } from "@/lib/options"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; +import { useGuildUrlName } from "../hooks/useGuildUrlName"; import { PageNavLink } from "./RoleGroupNavLink"; export const GuildTabs = () => { - const { guildUrlName } = useParams<{ guildUrlName: string }>(); + const guildUrlName = useGuildUrlName(); const { data: guild } = useSuspenseQuery( guildOptions({ guildIdLike: guildUrlName }), ); diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx new file mode 100644 index 0000000000..5bc90341c1 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Button } from "@/components/ui/Button"; +import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; +import { env } from "@/lib/env"; +import { getCookieClientSide } from "@/lib/getCookieClientSide"; +import { guildOptions, userOptions } from "@/lib/options"; +import type { Schemas } from "@guildxyz/types"; +import { CheckCircle } from "@phosphor-icons/react/dist/ssr"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { EventSourcePlus } from "event-source-plus"; +import { useParams } from "next/navigation"; +import { toast } from "sonner"; + +export const JoinGuild = () => { + const { guildUrlName } = useParams<{ guildUrlName: string }>(); + const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); + const queryClient = useQueryClient(); + + if (!guild.data) { + throw new Error("Failed to fetch guild"); + } + + const { mutate, isPending } = useMutation({ + mutationFn: async () => { + //TODO: Handle error here, throw error in funciton if needed + const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME)!; + const url = new URL( + `api/guild/${guild.data.id}/join`, + env.NEXT_PUBLIC_API, + ); + const eventSource = new EventSourcePlus(url.toString(), { + retryStrategy: "on-error", + method: "post", + maxRetryCount: 0, + headers: { + "x-auth-token": token, + "content-type": "application/json", + }, + }); + + const { resolve, reject, promise } = + Promise.withResolvers(); + eventSource.listen({ + onMessage: (sseMessage) => { + try { + const { status, message, data } = JSON.parse( + sseMessage.data, + // biome-ignore lint/suspicious/noExplicitAny: TODO: fill missing types + ) as any; + if (status === "Completed") { + if (data === undefined) { + throw new Error( + "Server responded with success, but returned no user", + ); + } + resolve(data); + } else if (status === "error") { + reject(); + } + + toast(status, { + description: message, + icon: + status === "Completed" ? ( + + ) : undefined, + }); + } catch (e) { + console.warn("JSON parsing failed on join event stream", e); + } + }, + }); + + return promise; + }, + onSuccess: async (user) => { + queryClient.setQueryData(userOptions().queryKey, user); + }, + }); + + return ( + + ); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx new file mode 100644 index 0000000000..c230f60415 --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Button } from "@/components/ui/Button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/Tooltip"; +import { fetchGuildLeave } from "@/lib/fetchers"; +import { guildOptions, userOptions } from "@/lib/options"; +import { SignOut } from "@phosphor-icons/react/dist/ssr"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useGuildUrlName } from "../hooks/useGuildUrlName"; + +export const LeaveGuild = () => { + const guildUrlName = useGuildUrlName(); + const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); + const queryClient = useQueryClient(); + + if (!guild.data) { + throw new Error("Failed to fetch guild"); + } + + const { mutate, isPending } = useMutation({ + mutationFn: () => fetchGuildLeave({ guildId: guild.data.id }), + onSuccess: async () => { + const prev = queryClient.getQueryData(userOptions().queryKey); + if (prev) { + queryClient.setQueryData(userOptions().queryKey, { + ...prev, + guilds: prev?.guilds?.filter( + ({ guildId }) => guildId !== guild.data.id, + ), + }); + } + }, + }); + + return ( + + + {/* TODO: IconButton component */} +
{/* TODO: JoinButton should open a modal where the user can sign in and also connect the required platforms. So we won't need an AuthBoundary here. */} - +

From 4c7f21c8b1f4554a313aad470872c58a9279f8bb Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Tue, 10 Dec 2024 11:24:05 +0100 Subject: [PATCH 07/22] feat: useGuild hook --- .../[guildUrlName]/[pageUrlName]/page.tsx | 9 +++------ .../[guildUrlName]/components/ActionButton.tsx | 14 ++++++++++---- .../[guildUrlName]/components/GuildTabs.tsx | 7 +++---- .../[guildUrlName]/components/LeaveGuild.tsx | 13 ++++--------- .../(dashboard)/[guildUrlName]/hooks/useGuild.ts | 13 +++++++++++++ src/app/(dashboard)/[guildUrlName]/layout.tsx | 6 ++++-- 6 files changed, 37 insertions(+), 25 deletions(-) create mode 100644 src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts diff --git a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx index cf019f6632..cd35addcb5 100644 --- a/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx +++ b/src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx @@ -6,14 +6,14 @@ import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { Skeleton } from "@/components/ui/Skeleton"; import { fetchGuildApiData } from "@/lib/fetchGuildApi"; -import { guildOptions, roleBatchOptions } from "@/lib/options"; +import { roleBatchOptions } from "@/lib/options"; import type { GuildReward, GuildRewardType } from "@/lib/schemas/guildReward"; import type { Role } from "@/lib/schemas/role"; import { ImageSquare, Lock } from "@phosphor-icons/react/dist/ssr"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { Suspense } from "react"; -import { useGuildUrlName } from "../hooks/useGuildUrlName"; +import { useGuild } from "../hooks/useGuild"; const GuildPage = () => { const { pageUrlName, guildUrlName } = useParams<{ @@ -92,10 +92,7 @@ const RoleCard = ({ role }: { role: Role }) => ( ); const RoleRewards = ({ roleRewards }: { roleRewards: Role["rewards"] }) => { - const guildUrlName = useGuildUrlName(); - const { data: guild } = useSuspenseQuery( - guildOptions({ guildIdLike: guildUrlName }), - ); + const { data: guild } = useGuild(); const { data: rewards } = useSuspenseQuery({ queryKey: ["reward", "search", guild.id], queryFn: () => diff --git a/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx index acd32c2ae2..a859d42222 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx @@ -1,13 +1,13 @@ -import { guildOptions, userOptions } from "@/lib/options"; +import { Button } from "@/components/ui/Button"; +import { userOptions } from "@/lib/options"; import { useQuery } from "@tanstack/react-query"; -import { useGuildUrlName } from "../hooks/useGuildUrlName"; +import { useGuild } from "../hooks/useGuild"; import { JoinGuild } from "./JoinGuild"; import { LeaveGuild } from "./LeaveGuild"; export const ActionButton = () => { - const guildUrlName = useGuildUrlName(); const user = useQuery(userOptions()); - const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); + const guild = useGuild(); if (!guild.data) { throw new Error("Failed to fetch guild"); @@ -19,3 +19,9 @@ export const ActionButton = () => { return isJoined ? : ; }; + +export const ActionButtonSkeleton = () => ( + +); diff --git a/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx index 5b20b6ceb7..bf2cb70921 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/GuildTabs.tsx @@ -4,16 +4,15 @@ import { Card } from "@/components/ui/Card"; import { ScrollArea, ScrollBar } from "@/components/ui/ScrollArea"; import { Skeleton } from "@/components/ui/Skeleton"; import { cn } from "@/lib/cssUtils"; -import { guildOptions, pageBatchOptions } from "@/lib/options"; +import { pageBatchOptions } from "@/lib/options"; import { useSuspenseQuery } from "@tanstack/react-query"; +import { useGuild } from "../hooks/useGuild"; import { useGuildUrlName } from "../hooks/useGuildUrlName"; import { PageNavLink } from "./RoleGroupNavLink"; export const GuildTabs = () => { const guildUrlName = useGuildUrlName(); - const { data: guild } = useSuspenseQuery( - guildOptions({ guildIdLike: guildUrlName }), - ); + const { data: guild } = useGuild(); const { data: pages } = useSuspenseQuery( pageBatchOptions({ guildIdLike: guildUrlName }), ); diff --git a/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx index c230f60415..8e1b98018c 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/LeaveGuild.tsx @@ -7,20 +7,15 @@ import { TooltipTrigger, } from "@/components/ui/Tooltip"; import { fetchGuildLeave } from "@/lib/fetchers"; -import { guildOptions, userOptions } from "@/lib/options"; +import { userOptions } from "@/lib/options"; import { SignOut } from "@phosphor-icons/react/dist/ssr"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useGuildUrlName } from "../hooks/useGuildUrlName"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useGuild } from "../hooks/useGuild"; export const LeaveGuild = () => { - const guildUrlName = useGuildUrlName(); - const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); + const guild = useGuild(); const queryClient = useQueryClient(); - if (!guild.data) { - throw new Error("Failed to fetch guild"); - } - const { mutate, isPending } = useMutation({ mutationFn: () => fetchGuildLeave({ guildId: guild.data.id }), onSuccess: async () => { diff --git a/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts b/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts new file mode 100644 index 0000000000..1acf47abef --- /dev/null +++ b/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts @@ -0,0 +1,13 @@ +import { guildOptions } from "@/lib/options"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export const useGuild = (urlName?: string) => { + const { guildUrlName: urlNameFromHook } = useParams<{ + guildUrlName: string; + }>(); + + const guildIdLike = urlName ?? urlNameFromHook; + + return useSuspenseQuery(guildOptions({ guildIdLike })); +}; diff --git a/src/app/(dashboard)/[guildUrlName]/layout.tsx b/src/app/(dashboard)/[guildUrlName]/layout.tsx index ffb5793e4d..6c19e28fa1 100644 --- a/src/app/(dashboard)/[guildUrlName]/layout.tsx +++ b/src/app/(dashboard)/[guildUrlName]/layout.tsx @@ -10,7 +10,7 @@ import { import type { DynamicRoute } from "@/lib/types"; import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; import { type PropsWithChildren, Suspense } from "react"; -import { ActionButton } from "./components/ActionButton"; +import { ActionButton, ActionButtonSkeleton } from "./components/ActionButton"; import { GuildTabs, GuildTabsSkeleton } from "./components/GuildTabs"; const GuildLayout = async ({ @@ -75,7 +75,9 @@ const GuildLayout = async ({ {/* TODO: JoinButton should open a modal where the user can sign in and also connect the required platforms. So we won't need an AuthBoundary here. */} - + }> + +

From 81d3c38c86c1daedf8c4dbfd680445c73fc450da Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Tue, 10 Dec 2024 11:25:02 +0100 Subject: [PATCH 08/22] fix: add missing use client directives --- src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx | 2 ++ src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx index a859d42222..306f14c6ba 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/ActionButton.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Button } from "@/components/ui/Button"; import { userOptions } from "@/lib/options"; import { useQuery } from "@tanstack/react-query"; diff --git a/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts b/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts index 1acf47abef..8e0f0892f5 100644 --- a/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts +++ b/src/app/(dashboard)/[guildUrlName]/hooks/useGuild.ts @@ -1,3 +1,5 @@ +"use client"; + import { guildOptions } from "@/lib/options"; import { useSuspenseQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; From da7220f192bec9079f85580aec1bbe4c1faeae65 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny <92519134+BrickheadJohnny@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:50:55 +0100 Subject: [PATCH 09/22] feat: auth rework (#1581) * feat: use `https` in development * feat: use httpOnly cookie for authenticated requests --- .gitignore | 2 + package.json | 3 +- src/actions/auth.ts | 52 ------------------- .../[guildUrlName]/components/JoinButton.tsx | 5 -- .../[guildUrlName]/components/JoinGuild.tsx | 5 -- .../components/CreateGuildButton.tsx | 6 --- src/app/(dashboard)/explorer/fetchers.ts | 7 +-- src/app/(dashboard)/explorer/layout.tsx | 4 +- src/app/layout.tsx | 3 +- src/components/AuthBoundary.tsx | 18 ++++--- src/components/PrefetchUserBoundary.tsx | 15 ++++++ src/components/SignInDialog.tsx | 24 +++++++-- src/components/SignOutButton.tsx | 27 ++++++++-- src/lib/fetchGuildApi.ts | 24 ++++----- src/lib/fetchers.ts | 7 +-- src/lib/getCookieClientSide.ts | 21 -------- src/lib/token.ts | 27 ---------- 17 files changed, 91 insertions(+), 159 deletions(-) delete mode 100644 src/actions/auth.ts create mode 100644 src/components/PrefetchUserBoundary.tsx delete mode 100644 src/lib/getCookieClientSide.ts delete mode 100644 src/lib/token.ts diff --git a/.gitignore b/.gitignore index 1fffb8e724..d1a75623fe 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ bun.lockb /playwright/.cache/ /playwright/.auth/ /playwright/results + +certificates \ No newline at end of file diff --git a/package.json b/package.json index 624f2ce3a3..883536d381 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "prepare": "husky", - "dev": "next dev --turbo", + "dev": "next dev --turbo --experimental-https", "build": "next build", "start": "next start", "type-check": "tsc --pretty --noEmit --incremental false", @@ -41,7 +41,6 @@ "event-source-plus": "^0.1.8", "foxact": "^0.2.43", "jotai": "^2.10.3", - "jwt-decode": "^4.0.0", "mini-svg-data-uri": "^1.4.4", "next": "15.0.3", "next-themes": "^0.4.4", diff --git a/src/actions/auth.ts b/src/actions/auth.ts deleted file mode 100644 index 8ef8f78b97..0000000000 --- a/src/actions/auth.ts +++ /dev/null @@ -1,52 +0,0 @@ -"use server"; - -import { associatedGuildsOption } from "@/app/(dashboard)/explorer/options"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; -import { fetchGuildApi } from "@/lib/fetchGuildApi"; -import { getQueryClient } from "@/lib/getQueryClient"; -import { userOptions } from "@/lib/options"; -import { authSchema, tokenSchema } from "@/lib/schemas/user"; -import { jwtDecode } from "jwt-decode"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; - -export const signIn = async ({ - message, - signature, -}: { - message: string; - signature: string; -}) => { - const cookieStore = await cookies(); - - const requestInit = { - method: "POST", - body: JSON.stringify({ - message, - signature, - }), - } satisfies RequestInit; - - const signInRes = await fetchGuildApi("auth/siwe/login", requestInit); - let json = signInRes.data; - if (signInRes.response.status === 401) { - const registerRes = await fetchGuildApi("auth/siwe/register", requestInit); - json = registerRes.data; - } - const authData = authSchema.parse(json); - const { exp } = tokenSchema.parse(jwtDecode(authData.token)); - - cookieStore.set(GUILD_AUTH_COOKIE_NAME, authData.token, { - expires: new Date(exp * 1000), - }); - return authData; -}; - -export const signOut = async (redirectTo?: string) => { - const cookieStore = await cookies(); - cookieStore.delete(GUILD_AUTH_COOKIE_NAME); - const queryClient = getQueryClient(); - queryClient.removeQueries(associatedGuildsOption()); - queryClient.removeQueries(userOptions()); - redirect(redirectTo ?? "/explorer"); -}; diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx index 2468544274..0b0554aef2 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx @@ -6,10 +6,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/Tooltip"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; import { env } from "@/lib/env"; import { fetchGuildLeave } from "@/lib/fetchers"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; import { guildOptions, userOptions } from "@/lib/options"; import type { Schemas } from "@guildxyz/types"; import { CheckCircle, SignOut } from "@phosphor-icons/react/dist/ssr"; @@ -34,8 +32,6 @@ export const JoinButton = () => { const joinMutation = useMutation({ mutationFn: async () => { - //TODO: Handle error here, throw error in funciton if needed - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME)!; const url = new URL( `api/guild/${guild.data.id}/join`, env.NEXT_PUBLIC_API, @@ -45,7 +41,6 @@ export const JoinButton = () => { method: "post", maxRetryCount: 0, headers: { - "x-auth-token": token, "content-type": "application/json", }, }); diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx index 5bc90341c1..b4737cf9f6 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx @@ -1,9 +1,7 @@ "use client"; import { Button } from "@/components/ui/Button"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; import { env } from "@/lib/env"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; import { guildOptions, userOptions } from "@/lib/options"; import type { Schemas } from "@guildxyz/types"; import { CheckCircle } from "@phosphor-icons/react/dist/ssr"; @@ -23,8 +21,6 @@ export const JoinGuild = () => { const { mutate, isPending } = useMutation({ mutationFn: async () => { - //TODO: Handle error here, throw error in funciton if needed - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME)!; const url = new URL( `api/guild/${guild.data.id}/join`, env.NEXT_PUBLIC_API, @@ -34,7 +30,6 @@ export const JoinGuild = () => { method: "post", maxRetryCount: 0, headers: { - "x-auth-token": token, "content-type": "application/json", }, }); diff --git a/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx b/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx index 441b0c21c9..9c6d757eb3 100644 --- a/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx +++ b/src/app/(dashboard)/create-guild/components/CreateGuildButton.tsx @@ -2,9 +2,7 @@ import { useConfetti } from "@/components/ConfettiProvider"; import { Button } from "@/components/ui/Button"; -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; import { fetchGuildApiData } from "@/lib/fetchGuildApi"; -import { getCookieClientSide } from "@/lib/getCookieClientSide"; import type { CreateGuildForm, Guild } from "@/lib/schemas/guild"; import { CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr"; import { useMutation } from "@tanstack/react-query"; @@ -21,10 +19,6 @@ const CreateGuildButton = () => { const { mutate: onSubmit, isPending } = useMutation({ mutationFn: async (data: CreateGuildForm) => { - const token = getCookieClientSide(GUILD_AUTH_COOKIE_NAME); - - if (!token) throw new Error("Unauthorized"); // TODO: custom errors? - const guild = { ...data, contact: undefined, diff --git a/src/app/(dashboard)/explorer/fetchers.ts b/src/app/(dashboard)/explorer/fetchers.ts index e498d043f6..335db2db96 100644 --- a/src/app/(dashboard)/explorer/fetchers.ts +++ b/src/app/(dashboard)/explorer/fetchers.ts @@ -1,14 +1,15 @@ import { fetchGuildApiData } from "@/lib/fetchGuildApi"; +import { userOptions } from "@/lib/options"; import type { Leaderboard } from "@/lib/schemas/leaderboard"; -import { tryGetParsedToken } from "@/lib/token"; import type { PaginatedResponse } from "@/lib/types"; import type { Schemas } from "@guildxyz/types"; +import { useQuery } from "@tanstack/react-query"; import { PAGE_SIZE } from "./constants"; export const fetchAssociatedGuilds = async () => { - const { userId } = await tryGetParsedToken(); + const { data: user } = useQuery(userOptions()); return fetchGuildApiData>( - `guild/search?page=1&pageSize=${Number.MAX_SAFE_INTEGER}&sortBy=name&reverse=false&customQuery=@owner:{${userId}}`, + `guild/search?page=1&pageSize=${Number.MAX_SAFE_INTEGER}&sortBy=name&reverse=false&customQuery=@owner:{${user?.id}}`, ); }; diff --git a/src/app/(dashboard)/explorer/layout.tsx b/src/app/(dashboard)/explorer/layout.tsx index 2ba50a5015..70965bcbe9 100644 --- a/src/app/(dashboard)/explorer/layout.tsx +++ b/src/app/(dashboard)/explorer/layout.tsx @@ -1,12 +1,12 @@ import { getQueryClient } from "@/lib/getQueryClient"; import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; import type { PropsWithChildren } from "react"; -import { guildSearchOptions } from "./options"; +import { associatedGuildsOption, guildSearchOptions } from "./options"; const ExplorerLayout = async ({ children }: PropsWithChildren) => { const queryClient = getQueryClient(); await queryClient.prefetchInfiniteQuery(guildSearchOptions({})); - // await queryClient.prefetchQuery(associatedGuildsOption()); + await queryClient.prefetchQuery(associatedGuildsOption()); return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 94c5da2364..1ac31cc799 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "@/styles/globals.css"; +import { PrefetchUserBoundary } from "@/components/PrefetchUserBoundary"; import { PreloadResources } from "@/components/PreloadResources"; import { Providers } from "@/components/Providers"; import { SignInDialog } from "@/components/SignInDialog"; @@ -27,7 +28,7 @@ const RootLayout = ({ - {children} + {children} diff --git a/src/components/AuthBoundary.tsx b/src/components/AuthBoundary.tsx index d90ec5af4e..a9103dfca1 100644 --- a/src/components/AuthBoundary.tsx +++ b/src/components/AuthBoundary.tsx @@ -1,16 +1,18 @@ -import { tryGetToken } from "@/lib/token"; +"use client"; -export const AuthBoundary = async ({ +import { userOptions } from "@/lib/options"; +import { useQuery } from "@tanstack/react-query"; + +export const AuthBoundary = ({ fallback, children, }: Readonly<{ fallback: React.ReactNode; children: React.ReactNode; }>) => { - try { - await tryGetToken(); - return <>{children}; - } catch { - return <>{fallback}; - } + const { data: user } = useQuery(userOptions()); + + if (user?.id) return children; + + return fallback; }; diff --git a/src/components/PrefetchUserBoundary.tsx b/src/components/PrefetchUserBoundary.tsx new file mode 100644 index 0000000000..2a360fe7aa --- /dev/null +++ b/src/components/PrefetchUserBoundary.tsx @@ -0,0 +1,15 @@ +import { getQueryClient } from "@/lib/getQueryClient"; +import { userOptions } from "@/lib/options"; +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; + +export const PrefetchUserBoundary = async ({ children }: PropsWithChildren) => { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(userOptions()); + + return ( + + {children} + + ); +}; diff --git a/src/components/SignInDialog.tsx b/src/components/SignInDialog.tsx index 740745b7ad..e921c983bc 100644 --- a/src/components/SignInDialog.tsx +++ b/src/components/SignInDialog.tsx @@ -1,8 +1,8 @@ "use client"; -import { signIn } from "@/actions/auth"; import { signInDialogOpenAtom } from "@/config/atoms"; import { fetchGuildApi } from "@/lib/fetchGuildApi"; +import { authSchema } from "@/lib/schemas/user"; import { SignIn, User, Wallet, XCircle } from "@phosphor-icons/react/dist/ssr"; import { DialogDescription } from "@radix-ui/react-dialog"; import { useMutation } from "@tanstack/react-query"; @@ -21,7 +21,6 @@ import { ResponsiveDialogHeader, ResponsiveDialogTitle, } from "./ui/ResponsiveDialog"; - const CUSTOM_CONNECTOR_ICONS = { "com.brave.wallet": "/walletLogos/brave.svg", walletConnect: "/walletLogos/walletconnect.svg", @@ -155,7 +154,26 @@ const SignInWithEthereum = () => { const signature = await signMessageAsync({ message }); - return signIn({ message, signature }); + const requestInit = { + method: "POST", + body: JSON.stringify({ + message, + signature, + }), + } satisfies RequestInit; + + const signInRes = await fetchGuildApi("auth/siwe/login", requestInit); + let json = signInRes.data; + if (signInRes.response.status === 401) { + const registerRes = await fetchGuildApi( + "auth/siwe/register", + requestInit, + ); + json = registerRes.data; + } + const authData = authSchema.parse(json); + + return authData; }, onSuccess: () => setSignInDialogOpen(false), onError: (error) => { diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx index 38540a6e67..7d7c77a84c 100644 --- a/src/components/SignOutButton.tsx +++ b/src/components/SignOutButton.tsx @@ -1,17 +1,36 @@ "use client"; -import { signOut } from "@/actions/auth"; +import { associatedGuildsOption } from "@/app/(dashboard)/explorer/options"; +import { fetchGuildApi } from "@/lib/fetchGuildApi"; +import { userOptions } from "@/lib/options"; import { SignOut } from "@phosphor-icons/react/dist/ssr"; -import { usePathname } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "./ui/Button"; export const SignOutButton = () => { - const pathname = usePathname(); + const queryClient = useQueryClient(); + + const { mutate: signOut, isPending } = useMutation({ + mutationFn: () => + fetchGuildApi("auth/logout", { + method: "POST", + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [userOptions().queryKey], + }); + queryClient.invalidateQueries({ + queryKey: associatedGuildsOption().queryKey, + }); + }, + }); + return ( diff --git a/src/lib/fetchGuildApi.ts b/src/lib/fetchGuildApi.ts index 1449d0a650..d1e0d7cfad 100644 --- a/src/lib/fetchGuildApi.ts +++ b/src/lib/fetchGuildApi.ts @@ -1,5 +1,4 @@ -import { signOut } from "@/actions/auth"; -import { tryGetToken } from "@/lib/token"; +import { isServer } from "@tanstack/react-query"; import { env } from "./env"; import type { ErrorLike } from "./types"; @@ -71,30 +70,27 @@ export const fetchGuildApi = async ( } const url = new URL(`api/${pathname}`, env.NEXT_PUBLIC_API); - let token: string | undefined; - try { - token = await tryGetToken(); - } catch (_) {} - const headers = new Headers(requestInit?.headers); - if (token) { - headers.set("X-Auth-Token", token); - } + if (requestInit?.body instanceof FormData) { headers.set("Content-Type", "multipart/form-data"); } else if (requestInit?.body) { headers.set("Content-Type", "application/json"); } + // Next.js won't include cookies automatically, so we include them manually on the server + if (isServer) { + const { cookies } = await import("next/headers"); + const cookiesHeader = (await cookies()).toString(); + headers.set("cookie", cookiesHeader); + } + const response = await fetch(url, { ...requestInit, headers, + credentials: "include", }); - if (response.status === 401) { - signOut(); - } - const contentType = response.headers.get("content-type"); if (!contentType?.includes("application/json")) { throw new Error("Guild API failed to respond with json"); diff --git a/src/lib/fetchers.ts b/src/lib/fetchers.ts index 5cd1925426..0471d87c30 100644 --- a/src/lib/fetchers.ts +++ b/src/lib/fetchers.ts @@ -1,6 +1,5 @@ import { fetchGuildApiData } from "@/lib/fetchGuildApi"; import { resolveIdLikeRequest } from "@/lib/resolveIdLikeRequest"; -import { tryGetParsedToken } from "@/lib/token"; import type { Entity, EntitySchema, @@ -26,11 +25,7 @@ export const fetchEntity = async ({ }; export const fetchUser = async () => { - const { userId } = await tryGetParsedToken(); - return fetchEntity({ - entity: "user", - idLike: userId, - }); + return fetchGuildApiData("auth/me"); }; export const fetchGuildLeave = async ({ guildId }: { guildId: string }) => { diff --git a/src/lib/getCookieClientSide.ts b/src/lib/getCookieClientSide.ts deleted file mode 100644 index 40ca44e1eb..0000000000 --- a/src/lib/getCookieClientSide.ts +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; - -export const getCookieClientSide = (name: string) => { - const decodedCookie = decodeURIComponent(document.cookie); - const cookiesArray = decodedCookie.split(";"); - - for (let i = 0; i < cookiesArray.length; i++) { - const [cookieName, cookieValue] = cookiesArray[i] - .split("=") - .map((v) => v.trim()); - if (cookieName === name) return cookieValue; - } - - return undefined; -}; - -export const getTokenClientSide = () => { - return getCookieClientSide(GUILD_AUTH_COOKIE_NAME); -}; diff --git a/src/lib/token.ts b/src/lib/token.ts deleted file mode 100644 index a9bc0ff87d..0000000000 --- a/src/lib/token.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { GUILD_AUTH_COOKIE_NAME } from "@/config/constants"; -import { getTokenClientSide } from "@/lib/getCookieClientSide"; -import { tokenSchema } from "@/lib/schemas/user"; -import { isServer } from "@tanstack/react-query"; -import { jwtDecode } from "jwt-decode"; - -export const tryGetToken = async () => { - let token: string | undefined; - if (isServer) { - const { cookies } = await import("next/headers"); - token = (await cookies()).get(GUILD_AUTH_COOKIE_NAME)?.value; - } else { - token = getTokenClientSide(); - } - - if (!token) { - throw new Error( - "Failed to retrieve JWT token on auth request initialization.", - ); - } - return token; -}; - -export const tryGetParsedToken = async () => { - const token = await tryGetToken(); - return tokenSchema.parse(jwtDecode(token)); -}; From 4ed0ffc8032fd3998c67cec26056290c878c86bc Mon Sep 17 00:00:00 2001 From: BrickheadJohnny <92519134+BrickheadJohnny@users.noreply.github.com> Date: Tue, 10 Dec 2024 18:34:38 +0100 Subject: [PATCH 10/22] feat: third party auth (#1582) * feat: 3rd party auth flow * feat(ConnectResultToast): add toast icons * chore: update readme * cleanup: remove unnecessary components --- README.md | 7 ++-- src/app/layout.tsx | 13 +++++--- src/components/ConnectResultToast.tsx | 47 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 src/components/ConnectResultToast.tsx diff --git a/README.md b/README.md index 1997db2a30..da0c30ee85 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,11 @@ Open source interface for Guild.xyz -- a tool for platformless membership manage ### Running the interface locally 1. `bun i` -2. `bun run dev` -3. If you don't have the secret environment variables, copy the `.env.example` as `.env.local`. +2. Append `127.0.0.1 local.openguild.xyz` to `/etc/hosts` +3. If you don't have the secret environment variables, copy the `.env.example` as `.env.local` +4. Run `bun dev`, create certificate if prompted +5. Open `https://local.openguild.xyz:3000` and dismiss the unsecure site warning -Open [http://localhost:3000](http://localhost:3000) in your browser to see the result. ### Getting secret environment variables (for core team members): diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1ac31cc799..9853c91e72 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "@/styles/globals.css"; +import { ConnectResultToast } from "@/components/ConnectResultToast"; import { PrefetchUserBoundary } from "@/components/PrefetchUserBoundary"; import { PreloadResources } from "@/components/PreloadResources"; import { Providers } from "@/components/Providers"; @@ -7,15 +8,16 @@ import { SignInDialog } from "@/components/SignInDialog"; import { Toaster } from "@/components/ui/Toaster"; import { dystopian } from "@/lib/fonts"; import { cn } from "lib/cssUtils"; +import { Suspense } from "react"; export const metadata: Metadata = { title: "Guildhall", applicationName: "Guildhall", description: "Automated membership management for the platforms your community already uses.", - // icons: { - // icon: "/guild-icon.png", - // }, + icons: { + icon: "/guild-icon.png", + }, }; const RootLayout = ({ @@ -30,8 +32,11 @@ const RootLayout = ({ {children} - + + + + diff --git a/src/components/ConnectResultToast.tsx b/src/components/ConnectResultToast.tsx new file mode 100644 index 0000000000..6d0059612a --- /dev/null +++ b/src/components/ConnectResultToast.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect } from "react"; +import { toast } from "sonner"; + +const SUCCESS_PARAM = "connectSuccess"; +const ERROR_MSG_PARAM = "connectErrorMessage"; + +export const ConnectResultToast = () => { + const { push } = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // TODO: types + const connectSuccessPlatform = searchParams.get(SUCCESS_PARAM); + const connectErrorMessage = searchParams.get(ERROR_MSG_PARAM); + + const removeSearchParam = useCallback( + (param: string) => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.delete(param); + push(`${pathname}?${newSearchParams.toString()}`); + }, + [searchParams, pathname, push], + ); + + useEffect(() => { + if (!connectSuccessPlatform) return; + toast(`Successfully connected ${connectSuccessPlatform}!`, { + icon: , + }); + removeSearchParam(SUCCESS_PARAM); + }, [connectSuccessPlatform, removeSearchParam]); + + useEffect(() => { + if (!connectErrorMessage) return; + toast("Error", { + description: connectErrorMessage, + icon: , + }); + removeSearchParam(ERROR_MSG_PARAM); + }, [connectErrorMessage, removeSearchParam]); + + return null; +}; From e01371fa980367ad15c35a5881989b9762eb5ba5 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Wed, 11 Dec 2024 13:21:00 +0100 Subject: [PATCH 11/22] fix: invalidate queries when signing in/out --- src/components/SignInDialog.tsx | 12 ++++++++++-- src/components/SignOutButton.tsx | 6 ++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/SignInDialog.tsx b/src/components/SignInDialog.tsx index e921c983bc..f1ccf1c155 100644 --- a/src/components/SignInDialog.tsx +++ b/src/components/SignInDialog.tsx @@ -2,10 +2,11 @@ import { signInDialogOpenAtom } from "@/config/atoms"; import { fetchGuildApi } from "@/lib/fetchGuildApi"; +import { userOptions } from "@/lib/options"; import { authSchema } from "@/lib/schemas/user"; import { SignIn, User, Wallet, XCircle } from "@phosphor-icons/react/dist/ssr"; import { DialogDescription } from "@radix-ui/react-dialog"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAtom, useSetAtom } from "jotai"; import { shortenHex } from "lib/shortenHex"; import { toast } from "sonner"; @@ -132,6 +133,8 @@ const SignInWithEthereum = () => { const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom); + const queryClient = useQueryClient(); + const { mutate: signInWithEthereum, isPending } = useMutation({ mutationKey: ["SIWE"], mutationFn: async () => { @@ -175,7 +178,12 @@ const SignInWithEthereum = () => { return authData; }, - onSuccess: () => setSignInDialogOpen(false), + onSuccess: () => { + setSignInDialogOpen(false); + queryClient.invalidateQueries({ + queryKey: userOptions().queryKey, + }); + }, onError: (error) => { toast("Sign in error", { description: error.message, diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx index 7d7c77a84c..33b94a0778 100644 --- a/src/components/SignOutButton.tsx +++ b/src/components/SignOutButton.tsx @@ -16,10 +16,8 @@ export const SignOutButton = () => { method: "POST", }), onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [userOptions().queryKey], - }); - queryClient.invalidateQueries({ + queryClient.resetQueries({ queryKey: userOptions().queryKey }); + queryClient.resetQueries({ queryKey: associatedGuildsOption().queryKey, }); }, From bbe0179044b98d13f01b354b1460ba5071ace591 Mon Sep 17 00:00:00 2001 From: BrickheadJohnny Date: Wed, 11 Dec 2024 13:21:25 +0100 Subject: [PATCH 12/22] feat: join modal --- .../[guildUrlName]/components/JoinButton.tsx | 139 ---------------- .../[guildUrlName]/components/JoinGuild.tsx | 152 +++++++++++++++++- src/app/(dashboard)/[guildUrlName]/layout.tsx | 11 +- src/components/SignInButton.tsx | 8 +- src/config/constants.ts | 20 ++- src/lib/options.ts | 1 + src/lib/schemas/user.ts | 4 + 7 files changed, 182 insertions(+), 153 deletions(-) delete mode 100644 src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx deleted file mode 100644 index 0b0554aef2..0000000000 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinButton.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/Button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/Tooltip"; -import { env } from "@/lib/env"; -import { fetchGuildLeave } from "@/lib/fetchers"; -import { guildOptions, userOptions } from "@/lib/options"; -import type { Schemas } from "@guildxyz/types"; -import { CheckCircle, SignOut } from "@phosphor-icons/react/dist/ssr"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { EventSourcePlus } from "event-source-plus"; -import { useParams } from "next/navigation"; -import { toast } from "sonner"; - -export const JoinButton = () => { - const { guildUrlName } = useParams<{ guildUrlName: string }>(); - const user = useQuery(userOptions()); - const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); - const queryClient = useQueryClient(); - - if (!guild.data) { - throw new Error("Failed to fetch guild"); - } - - const isJoined = !!user.data?.guilds?.some( - ({ guildId }) => guildId === guild.data.id, - ); - - const joinMutation = useMutation({ - mutationFn: async () => { - const url = new URL( - `api/guild/${guild.data.id}/join`, - env.NEXT_PUBLIC_API, - ); - const eventSource = new EventSourcePlus(url.toString(), { - retryStrategy: "on-error", - method: "post", - maxRetryCount: 0, - headers: { - "content-type": "application/json", - }, - }); - - const { resolve, reject, promise } = - Promise.withResolvers(); - eventSource.listen({ - onMessage: (sseMessage) => { - try { - const { status, message, data } = JSON.parse( - sseMessage.data, - // biome-ignore lint/suspicious/noExplicitAny: TODO: fill missing types - ) as any; - if (status === "Completed") { - if (data === undefined) { - throw new Error( - "Server responded with success, but returned no user", - ); - } - resolve(data); - } else if (status === "error") { - reject(); - } - - toast(status, { - description: message, - icon: - status === "Completed" ? ( - - ) : undefined, - }); - } catch (e) { - console.warn("JSON parsing failed on join event stream", e); - } - }, - }); - - return promise; - }, - onSuccess: async (user) => { - await queryClient.cancelQueries(userOptions()); - queryClient.setQueryData(userOptions().queryKey, user); - }, - onSettled: () => { - queryClient.invalidateQueries(userOptions()); - }, - }); - - const leaveMutation = useMutation({ - mutationFn: () => fetchGuildLeave({ guildId: guild.data.id }), - onSuccess: async () => { - await queryClient.cancelQueries(userOptions()); - const prev = queryClient.getQueryData(userOptions().queryKey); - if (prev) { - queryClient.setQueryData(userOptions().queryKey, { - ...prev, - guilds: prev?.guilds?.filter( - ({ guildId }) => guildId !== guild.data.id, - ), - }); - } - }, - }); - - return isJoined ? ( - - - {/* TODO: IconButton component */} - - ); -}; diff --git a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx index b4737cf9f6..9719f5f3ad 100644 --- a/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx +++ b/src/app/(dashboard)/[guildUrlName]/components/JoinGuild.tsx @@ -1,18 +1,150 @@ "use client"; +import { SignInButton } from "@/components/SignInButton"; import { Button } from "@/components/ui/Button"; +import { + ResponsiveDialog, + ResponsiveDialogBody, + ResponsiveDialogContent, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/ResponsiveDialog"; +import { IDENTITY_STYLES } from "@/config/constants"; +import { cn } from "@/lib/cssUtils"; import { env } from "@/lib/env"; import { guildOptions, userOptions } from "@/lib/options"; +import type { IdentityType } from "@/lib/schemas/user"; import type { Schemas } from "@guildxyz/types"; -import { CheckCircle } from "@phosphor-icons/react/dist/ssr"; +import { Check, CheckCircle, XCircle } from "@phosphor-icons/react/dist/ssr"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { EventSourcePlus } from "event-source-plus"; -import { useParams } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useEffect, useState } from "react"; import { toast } from "sonner"; +const JOIN_MODAL_SEARCH_PARAM = "join"; + export const JoinGuild = () => { + const searchParams = useSearchParams(); + const shouldOpen = searchParams.has(JOIN_MODAL_SEARCH_PARAM); + + const [open, onOpenChange] = useState(false); + + useEffect(() => { + if (!shouldOpen) return; + onOpenChange(true); + }, [shouldOpen]); + + const { data: user } = useQuery(userOptions()); + + return ( + + + + + + + Join guild + + + + } + /> + {/* TODO: add `requiredIdentities` prop to the Guild entity & list only the necessary identity connect buttons here */} + + + + + + + + + ); +}; + +const JoinStep = ({ + complete, + label, + button, +}: { complete: boolean; label: string; button: ReactNode }) => ( +

+
+ {complete && } +
+
+ {label} +
{button}
+
+
+); + +const getReturnToURLWithSearchParams = () => { + if (typeof window === "undefined") return ""; + + const url = new URL(window.location.href); + const searchParams = new URLSearchParams(url.searchParams); + + if (searchParams.get(JOIN_MODAL_SEARCH_PARAM)) { + return url.toString(); + } + + searchParams.set(JOIN_MODAL_SEARCH_PARAM, "true"); + + return `${window.location.href.split("?")[0]}?${searchParams.toString()}`; +}; + +const ConnectIdentityJoinStep = ({ identity }: { identity: IdentityType }) => { + const router = useRouter(); + const { data: user } = useQuery(userOptions()); + + const connected = !!user?.identities?.find((i) => i.platform === identity); + + const Icon = IDENTITY_STYLES[identity].icon; + + return ( + + router.push( + `${env.NEXT_PUBLIC_API}/connect/${identity}?returnTo=${getReturnToURLWithSearchParams()}`, + ) + } + leftIcon={ + connected ? : + } + disabled={!user || connected} // TODO: once we allow users to log in with 3rd party accounts, we can remove the "!user" part of this + className={IDENTITY_STYLES[identity].buttonColorsClassName} + > + Connect + + } + /> + ); +}; + +const JoinGuildButton = () => { const { guildUrlName } = useParams<{ guildUrlName: string }>(); const guild = useQuery(guildOptions({ guildIdLike: guildUrlName })); + + const { data: user } = useQuery(userOptions()); + const queryClient = useQueryClient(); if (!guild.data) { @@ -32,6 +164,7 @@ export const JoinGuild = () => { headers: { "content-type": "application/json", }, + credentials: "include", }); const { resolve, reject, promise } = @@ -65,6 +198,9 @@ export const JoinGuild = () => { console.warn("JSON parsing failed on join event stream", e); } }, + onResponseError: (ctx) => { + return reject(ctx.error); + }, }); return promise; @@ -72,17 +208,25 @@ export const JoinGuild = () => { onSuccess: async (user) => { queryClient.setQueryData(userOptions().queryKey, user); }, + onError: (error: Error) => { + toast("Join error", { + description: error.message, + icon: , + }); + }, }); return ( ); }; diff --git a/src/app/(dashboard)/[guildUrlName]/layout.tsx b/src/app/(dashboard)/[guildUrlName]/layout.tsx index 6c19e28fa1..8cac86dc61 100644 --- a/src/app/(dashboard)/[guildUrlName]/layout.tsx +++ b/src/app/(dashboard)/[guildUrlName]/layout.tsx @@ -1,4 +1,3 @@ -import { AuthBoundary } from "@/components/AuthBoundary"; import { GuildImage } from "@/components/GuildImage"; import { getQueryClient } from "@/lib/getQueryClient"; import { @@ -73,12 +72,10 @@ const GuildLayout = async ({ {guild.data.name} - {/* TODO: JoinButton should open a modal where the user can sign in and also connect the required platforms. So we won't need an AuthBoundary here. */} - - }> - - - + + }> + +

{guild.data.description} diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx index 9715059b4b..e4d82f5dc2 100644 --- a/src/components/SignInButton.tsx +++ b/src/components/SignInButton.tsx @@ -1,18 +1,22 @@ "use client"; import { signInDialogOpenAtom } from "@/config/atoms"; -import { SignIn } from "@phosphor-icons/react/dist/ssr"; +import { userOptions } from "@/lib/options"; +import { Check, SignIn } from "@phosphor-icons/react/dist/ssr"; +import { useQuery } from "@tanstack/react-query"; import { useSetAtom } from "jotai"; import type { ComponentProps } from "react"; import { Button } from "./ui/Button"; export const SignInButton = (props: ComponentProps) => { const setSignInDialogOpen = useSetAtom(signInDialogOpenAtom); + const { data: user } = useQuery(userOptions()); return (