From 67a9ba7488e4886b609bf7f35a7d326b57356247 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Mon, 25 May 2026 11:54:33 -0400 Subject: [PATCH 1/4] add shadcn input component --- packages/ui/src/components/ui/input.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 packages/ui/src/components/ui/input.tsx diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx new file mode 100644 index 000000000..d4a9306ee --- /dev/null +++ b/packages/ui/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { cn } from "@repo/ui/lib/utils"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; From 01e561653382e72d1a2ab100dccf68b45d607694 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Mon, 25 May 2026 11:56:01 -0400 Subject: [PATCH 2/4] Group UI --- .../app/(home)/auth/group/[groupId]/page.tsx | 78 +++++++++++++ apps/website/app/(home)/auth/group/page.tsx | 30 +++-- .../app/components/auth/CreateGroup.tsx | 55 +++++++++ .../app/components/auth/GroupInvite.tsx | 53 +++++++++ .../app/components/auth/GroupMemberList.tsx | 110 ++++++++++++++++++ .../website/app/components/auth/JoinGroup.tsx | 52 +++++++++ 6 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 apps/website/app/(home)/auth/group/[groupId]/page.tsx create mode 100644 apps/website/app/components/auth/CreateGroup.tsx create mode 100644 apps/website/app/components/auth/GroupInvite.tsx create mode 100644 apps/website/app/components/auth/GroupMemberList.tsx create mode 100644 apps/website/app/components/auth/JoinGroup.tsx diff --git a/apps/website/app/(home)/auth/group/[groupId]/page.tsx b/apps/website/app/(home)/auth/group/[groupId]/page.tsx new file mode 100644 index 000000000..abbcd45e6 --- /dev/null +++ b/apps/website/app/(home)/auth/group/[groupId]/page.tsx @@ -0,0 +1,78 @@ +import { redirect, notFound } from "next/navigation"; +import Link from "next/link"; +import { createClient } from "~/utils/supabase/server"; +import { getSessionBaseUserData } from "~/utils/supabase/account"; +import { GroupMemberList } from "~/components/auth/GroupMemberList"; +import { GroupInvite } from "~/components/auth/GroupInvite"; +import internalError from "~/utils/internalErrorSsr"; + +const Page = async ({ + params, + searchParams, +}: { + params: Promise<{ groupId: string }>; + searchParams: Promise<{ + token?: string; + tokenError?: string; + removeError?: string; + }>; +}) => { + const { groupId } = await params; + const sp = await searchParams; + + const client = await createClient(); + const userData = await getSessionBaseUserData(client); + if (!userData) + redirect("/auth/error?error=" + encodeURIComponent("Not logged in")); + + const membershipReq = await client + .from("group_membership") + .select("admin") + .eq("group_id", groupId) + .eq("member_id", userData.id) + .maybeSingle(); + if (membershipReq.error) { + internalError({ error: membershipReq.error }); + redirect("/auth/error?error=" + encodeURIComponent("Could not load group")); + } + if (!membershipReq.data) notFound(); + const isAdmin = membershipReq.data.admin === true; + + const groupReq = await client + .from("my_groups") + .select("name") + .eq("id", groupId) + .maybeSingle(); + if (groupReq.error) { + internalError({ error: groupReq.error }); + redirect("/auth/error?error=" + encodeURIComponent("Could not load group")); + } + const groupName = groupReq.data?.name ?? groupId; + + return ( +
+
+ + Back to group page + +
+

{groupName}

+
+ + {isAdmin && ( + + )} +
+
+ ); +}; + +export default Page; diff --git a/apps/website/app/(home)/auth/group/page.tsx b/apps/website/app/(home)/auth/group/page.tsx index 7d31fa17b..0f5ff8c8e 100644 --- a/apps/website/app/(home)/auth/group/page.tsx +++ b/apps/website/app/(home)/auth/group/page.tsx @@ -1,11 +1,27 @@ import { ListGroups } from "~/components/auth/ListGroups"; +import { JoinGroup } from "~/components/auth/JoinGroup"; +import { CreateGroup } from "~/components/auth/CreateGroup"; -const Page = () => ( -
-
- -
-
-); +const Page = async ({ + searchParams, +}: { + searchParams: Promise<{ + error?: string; + joined?: string; + createError?: string; + created?: string; + }>; +}) => { + const params = await searchParams; + return ( +
+
+ + + +
+
+ ); +}; export default Page; diff --git a/apps/website/app/components/auth/CreateGroup.tsx b/apps/website/app/components/auth/CreateGroup.tsx new file mode 100644 index 000000000..657194be6 --- /dev/null +++ b/apps/website/app/components/auth/CreateGroup.tsx @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation"; +import { createClient } from "~/utils/supabase/server"; +import { createGroup } from "~/utils/supabase/account"; +import { Button } from "@repo/ui/components/ui/button"; +import { Input } from "@repo/ui/components/ui/input"; + +export const CreateGroup = ({ + error, + created, +}: { + error?: string; + created?: string; +}) => { + const createGroupAction = async (formData: FormData) => { + "use server"; + const name = formData.get("name"); + if (typeof name !== "string" || !name.trim()) { + redirect( + "/auth/group?createError=" + + encodeURIComponent("Please enter a group name"), + ); + } + const client = await createClient(); + const { groupId, error: err } = await createGroup(client, name.trim()); + if (err) { + redirect("/auth/group?createError=" + encodeURIComponent(err)); + } + if (!groupId) { + redirect( + "/auth/group?createError=" + + encodeURIComponent("Failed to create group"), + ); + } + redirect("/auth/group?created=" + encodeURIComponent(groupId)); + }; + + return ( +
+

Create a group

+ {created && ( +

Group created successfully.

+ )} + {error &&

{error}

} +
{ + void createGroupAction(formData); + }} + className="flex gap-2" + > + + +
+
+ ); +}; diff --git a/apps/website/app/components/auth/GroupInvite.tsx b/apps/website/app/components/auth/GroupInvite.tsx new file mode 100644 index 000000000..fb13bb6b5 --- /dev/null +++ b/apps/website/app/components/auth/GroupInvite.tsx @@ -0,0 +1,53 @@ +import { redirect } from "next/navigation"; +import { createClient } from "~/utils/supabase/server"; +import { createGroupInvitation } from "~/utils/supabase/account"; +import { Button } from "@repo/ui/components/ui/button"; + +export const GroupInvite = ({ + groupId, + token, + tokenError, +}: { + groupId: string; + token?: string; + tokenError?: string; +}) => { + const createToken = async (formData: FormData) => { + "use server"; + const admin = formData.get("admin") === "true"; + const client = await createClient(); + const t = await createGroupInvitation({ client, groupId, admin }); + if (!t) { + redirect( + `/auth/group/${groupId}?tokenError=` + + encodeURIComponent("Could not create invitation token"), + ); + } + redirect(`/auth/group/${groupId}?token=` + encodeURIComponent(t)); + }; + + return ( +
+

Invite to group

+ {tokenError &&

{tokenError}

} + {token && ( +
+

Invitation token (valid 60 days):

+ {token} +
+ )} +
+
{ + void createToken(formData); + }} + > + + +
+
+
+ ); +}; diff --git a/apps/website/app/components/auth/GroupMemberList.tsx b/apps/website/app/components/auth/GroupMemberList.tsx new file mode 100644 index 000000000..9acb055ac --- /dev/null +++ b/apps/website/app/components/auth/GroupMemberList.tsx @@ -0,0 +1,110 @@ +import { redirect } from "next/navigation"; +import { createClient } from "~/utils/supabase/server"; +import { + removeFromGroup, + getSessionBaseUserData, +} from "~/utils/supabase/account"; +import { Button } from "@repo/ui/components/ui/button"; +import internalError from "~/utils/internalErrorSsr"; + +export const GroupMemberList = async ({ + groupId, + isAdmin, + removeError, +}: { + groupId: string; + isAdmin: boolean; + removeError?: string; +}) => { + const client = await createClient(); + const clientData = await getSessionBaseUserData(client); + const myUserId = clientData?.id; + if (!myUserId) { + internalError({ error: "Not logged in" }); + redirect( + "/auth/error?error=" + + encodeURIComponent("Not logged in.\nPlease log in from application."), + ); + } + + const pseudoAccountReq = await client + .from("my_pseudo_accounts") + .select() + .eq("group_id", groupId); + + if (pseudoAccountReq.error) { + internalError({ error: pseudoAccountReq.error }); + redirect( + "/auth/error?error=" + encodeURIComponent("Could not load group members"), + ); + } + const pseudoAccountInfo = pseudoAccountReq.data ?? []; + + const removeSpace = async (formData: FormData) => { + "use server"; + const memberId = formData.get("memberId"); + if (typeof memberId !== "string") return; + const c = await createClient(); + const error = await removeFromGroup({ client: c, groupId, memberId }); + if (error) { + redirect( + `/auth/group/${groupId}?removeError=` + encodeURIComponent(error), + ); + } + redirect(`/auth/group/${groupId}`); + }; + + return ( +
+

Member spaces

+ {removeError &&

{removeError}

} + {pseudoAccountInfo.length === 0 ? ( +

No spaces yet.

+ ) : ( +
    + {pseudoAccountInfo.map((pseudoAccount) => { + const memberId = pseudoAccount.dg_account; + return ( +
  • + + {pseudoAccount.name} + + ({pseudoAccount.platform}) + + {pseudoAccount.dg_account === myUserId && ( + + me + + )} + {pseudoAccount.admin && ( + + admin + + )} + + {memberId && + // allow admins to remove others, non-admins to remove self. + // admins should not remove self (unless there's another admin? tbd) + isAdmin !== (pseudoAccount.dg_account === myUserId) && ( +
    { + void removeSpace(formData); + }} + > + + +
    + )} +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/apps/website/app/components/auth/JoinGroup.tsx b/apps/website/app/components/auth/JoinGroup.tsx new file mode 100644 index 000000000..128f37126 --- /dev/null +++ b/apps/website/app/components/auth/JoinGroup.tsx @@ -0,0 +1,52 @@ +import { redirect } from "next/navigation"; +import { createClient } from "~/utils/supabase/server"; +import { acceptGroupInvitation } from "~/utils/supabase/account"; +import { Button } from "@repo/ui/components/ui/button"; +import { Input } from "@repo/ui/components/ui/input"; + +export const JoinGroup = ({ + error, + joined, +}: { + error?: string; + joined?: boolean; +}) => { + const joinGroup = async (formData: FormData) => { + "use server"; + const token = formData.get("token"); + if (typeof token !== "string" || !token.trim()) { + redirect( + "/auth/group?error=" + encodeURIComponent("Please enter a token"), + ); + } + const client = await createClient(); + const err = await acceptGroupInvitation(client, token.trim()); + if (err) { + redirect("/auth/group?error=" + encodeURIComponent(err)); + } + redirect("/auth/group?joined=1"); + }; + + return ( +
+

Join a group

+ {joined && ( +

Successfully joined the group.

+ )} + {error &&

{error}

} +
{ + void joinGroup(formData); + }} + className="flex gap-2" + > + + +
+
+ ); +}; From eb3d9e6942a2dc451d9a26d29889535826a558bb Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Mon, 25 May 2026 20:37:49 -0400 Subject: [PATCH 3/4] The linter complains about the function needing a use server; base idiom is right --- apps/website/app/components/auth/CreateGroup.tsx | 7 +------ apps/website/app/components/auth/GroupInvite.tsx | 6 +----- apps/website/app/components/auth/GroupMemberList.tsx | 6 +----- apps/website/app/components/auth/JoinGroup.tsx | 7 +------ 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/apps/website/app/components/auth/CreateGroup.tsx b/apps/website/app/components/auth/CreateGroup.tsx index 657194be6..c140c93be 100644 --- a/apps/website/app/components/auth/CreateGroup.tsx +++ b/apps/website/app/components/auth/CreateGroup.tsx @@ -41,12 +41,7 @@ export const CreateGroup = ({

Group created successfully.

)} {error &&

{error}

} -
{ - void createGroupAction(formData); - }} - className="flex gap-2" - > +
diff --git a/apps/website/app/components/auth/GroupInvite.tsx b/apps/website/app/components/auth/GroupInvite.tsx index fb13bb6b5..65507840f 100644 --- a/apps/website/app/components/auth/GroupInvite.tsx +++ b/apps/website/app/components/auth/GroupInvite.tsx @@ -37,11 +37,7 @@ export const GroupInvite = ({ )}
-
{ - void createToken(formData); - }} - > +