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.

+ ) : ( + + )} +
+ ); +}; 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" + > + + +
+
+ ); +}; diff --git a/apps/website/app/utils/supabase/account.ts b/apps/website/app/utils/supabase/account.ts index b6f0fbc56..8a683ad86 100644 --- a/apps/website/app/utils/supabase/account.ts +++ b/apps/website/app/utils/supabase/account.ts @@ -114,12 +114,28 @@ export const acceptGroupInvitation = async ( export const createGroup = async ( client: DGSupabaseClient, name: string, -): Promise => { +): Promise<{ groupId: string | null; error: string | null }> => { + // eslint-disable-next-line @typescript-eslint/naming-convention const result = await client.functions.invoke<{ group_id: string }>( "create-group", { body: { name } }, ); - return result.data?.group_id || null; + if (result.error) { + let message = + typeof result.error === "string" + ? result.error + : (result.error as { message: string }).message; + try { + const body = (await ( + result.error as { context?: Response } + ).context?.json()) as { msg?: string } | undefined; + if (body?.msg) message = body.msg; + } catch { + // ignore parse errors + } + return { groupId: null, error: message }; + } + return { groupId: result.data?.group_id ?? null, error: null }; }; export const removeFromGroup = async ({ diff --git a/apps/website/test/integration/groupInvitation.test.ts b/apps/website/test/integration/groupInvitation.test.ts index 669942087..97c7dd773 100644 --- a/apps/website/test/integration/groupInvitation.test.ts +++ b/apps/website/test/integration/groupInvitation.test.ts @@ -93,7 +93,11 @@ describe( it("executes the full invitation flow", async () => { // Step 1: user1 creates a group - const groupId = await createGroup(client1, "vitest-invite-group"); + const { groupId, error: createError } = await createGroup( + client1, + "vitest-invite-group", + ); + expect(createError, "createGroup should not error").toBeNull(); expect(groupId, "createGroup should return a group ID").toBeTruthy(); createdGroupId = groupId; diff --git a/apps/website/test/integration/leaveGroup.test.ts b/apps/website/test/integration/leaveGroup.test.ts index bb7880ee8..109e02dd0 100644 --- a/apps/website/test/integration/leaveGroup.test.ts +++ b/apps/website/test/integration/leaveGroup.test.ts @@ -103,7 +103,11 @@ describe("leave group flow", { tags: ["database"] }, () => { it("lists group members", async () => { // Step 1: user1 creates a group - const groupId = await createGroup(client1, "vitest-invite-group"); + const { groupId, error: createError } = await createGroup( + client1, + "vitest-invite-group", + ); + assert(createError === null, createError!); assert(groupId !== null, "createGroup should return a group ID"); createdGroupId = groupId; diff --git a/apps/website/test/integration/listGroupMembers.test.ts b/apps/website/test/integration/listGroupMembers.test.ts index 915f10596..5e01b073e 100644 --- a/apps/website/test/integration/listGroupMembers.test.ts +++ b/apps/website/test/integration/listGroupMembers.test.ts @@ -1,7 +1,7 @@ import assert from "assert"; import { describe, it, beforeAll, afterAll } from "vitest"; import { createClient } from "@supabase/supabase-js"; -import type { Database } from "@repo/database/dbTypes"; +import type { Database, Tables } from "@repo/database/dbTypes"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import { fetchOrCreateSpaceDirect, @@ -14,7 +14,7 @@ const ANON_KEY = process.env.SUPABASE_PUBLISHABLE_KEY!; const SERVICE_KEY = process.env.SUPABASE_SECRET_KEY!; const PASSWORD = "abcdefgh"; -type GroupSpaceInfo = Database["public"]["CompositeTypes"]["group_space_info"]; +type PseudoAccountInfo = Tables<"my_pseudo_accounts">; const freshClient = (): DGSupabaseClient => createClient(SUPABASE_URL, ANON_KEY); @@ -105,7 +105,11 @@ describe("list group members flow", { tags: ["database"] }, () => { it("lists group members", async () => { // Step 1: user1 creates a group - const groupId = await createGroup(client1, "vitest-invite-group"); + const { groupId, error: createError } = await createGroup( + client1, + "vitest-invite-group", + ); + assert(createError === null, createError!); assert(groupId !== null, "createGroup should return a group ID"); createdGroupId = groupId; @@ -121,19 +125,18 @@ describe("list group members flow", { tags: ["database"] }, () => { const expectedSpaceIds = [spaceId1, spaceId2]; // Step 3: user1 lists group members - const { data: data1, error: error1 } = await client1.rpc( - "spaces_in_group", - { - p_group_id: createdGroupId, // eslint-disable-line @typescript-eslint/naming-convention - }, - ); + const { data: data1, error: error1 } = await client1 + .from("my_pseudo_accounts") + .select() + .eq("group_id", createdGroupId); assert(error1 === null, error1 ? error1.message : ""); assert(data1 !== null, "group spaces should not be empty"); assert(data1.length === 2, "There should be two spaces"); + console.log(data1); const spacesSeenBy1 = Object.fromEntries( - data1.filter((gm) => gm.id !== null).map((gm) => [gm.id, gm]), - ) as Record; + data1.filter((gm) => gm.space_id !== null).map((gm) => [gm.space_id, gm]), + ) as Record; assert( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expectedSpaceIds.every((id) => spacesSeenBy1[id] !== undefined), @@ -145,17 +148,15 @@ describe("list group members flow", { tags: ["database"] }, () => { ), ); // Step 4: user2 lists group members - const { data: data2, error: error2 } = await client2.rpc( - "spaces_in_group", - { - p_group_id: createdGroupId, // eslint-disable-line @typescript-eslint/naming-convention - }, - ); + const { data: data2, error: error2 } = await client2 + .from("my_pseudo_accounts") + .select() + .eq("group_id", createdGroupId); assert(error2 === null, error2 ? error2.message : ""); assert(data2 !== null, "group spaces should not be empty"); assert(data2.length === 2, "There should be two spaces"); const spacesSeenBy2 = new Set( - data2.map((gm) => gm.id).filter((id) => id !== null), + data2.map((gm) => gm.space_id).filter((id) => id !== null), ); assert( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -173,19 +174,19 @@ describe("list group members flow", { tags: ["database"] }, () => { }); assert(!errorPublishSpace2); // Step 6: that space is now seen as published by 1. - const { data: data1b, error: error1b } = await client1.rpc( - "spaces_in_group", - { - p_group_id: createdGroupId, // eslint-disable-line @typescript-eslint/naming-convention - }, - ); + const { data: data1b, error: error1b } = await client1 + .from("my_pseudo_accounts") + .select() + .eq("group_id", createdGroupId); assert(error1b === null, error1b ? error1b.message : ""); assert(data1b !== null, "group spaces should not be empty"); assert(data1b.length === 2, "There should be two spaces"); const spacesSeenBy1b = Object.fromEntries( - data1b.filter((gm) => gm.id !== null).map((gm) => [gm.id, gm]), - ) as Record; + data1b + .filter((gm) => gm.space_id !== null) + .map((gm) => [gm.space_id, gm]), + ) as Record; assert( spacesSeenBy1b[spaceId2]?.sharing_permissions, "Second space should now be seen as shared", diff --git a/apps/website/test/integration/listMyGroups.test.ts b/apps/website/test/integration/listMyGroups.test.ts index 8f4dbd33c..6149da568 100644 --- a/apps/website/test/integration/listMyGroups.test.ts +++ b/apps/website/test/integration/listMyGroups.test.ts @@ -103,7 +103,11 @@ describe("list group members flow", { tags: ["database"] }, () => { it("lists group members", async () => { // Step 1: user1 creates a group - const groupId = await createGroup(client1, "vitest-invite-group"); + const { groupId, error: createError } = await createGroup( + client1, + "vitest-invite-group", + ); + assert(createError === null, createError!); assert(groupId !== null, "createGroup should return a group ID"); createdGroupId = groupId; diff --git a/packages/database/src/dbTypes.ts b/packages/database/src/dbTypes.ts index c8b39d975..ef568af06 100644 --- a/packages/database/src/dbTypes.ts +++ b/packages/database/src/dbTypes.ts @@ -1322,7 +1322,9 @@ export type Database = { } my_pseudo_accounts: { Row: { + admin: boolean | null dg_account: string | null + group_id: string | null id: number | null name: string | null platform: Database["public"]["Enums"]["Platform"] | null @@ -1332,6 +1334,13 @@ export type Database = { space_id: number | null } Relationships: [ + { + foreignKeyName: "group_membership_group_id_fkey" + columns: ["group_id"] + isOneToOne: false + referencedRelation: "my_groups" + referencedColumns: ["id"] + }, { foreignKeyName: "PlatformAccount_dg_account_fkey" columns: ["dg_account"] @@ -1803,16 +1812,6 @@ export type Database = { isSetofReturn: true } } - spaces_in_group: { - Args: { p_group_id: string } - Returns: Database["public"]["CompositeTypes"]["group_space_info"][] - SetofOptions: { - from: "*" - to: "group_space_info" - isOneToOne: false - isSetofReturn: true - } - } unowned_account_in_shared_space: { Args: { p_account_id: number } Returns: boolean @@ -1983,15 +1982,6 @@ export type Database = { | Database["public"]["CompositeTypes"]["account_local_input"] | null } - group_space_info: { - id: number | null - name: string | null - platform: Database["public"]["Enums"]["Platform"] | null - sharing_permissions: - | Database["public"]["Enums"]["SpaceAccessPermissions"] - | null - admin: boolean | null - } inline_embedding_input: { model: string | null vector: number[] | null @@ -2178,3 +2168,4 @@ export const Constants = { }, }, } as const + diff --git a/packages/database/supabase/migrations/20260525003723_correct_pseudo_account.sql b/packages/database/supabase/migrations/20260525003723_correct_pseudo_account.sql new file mode 100644 index 000000000..f4b980832 --- /dev/null +++ b/packages/database/supabase/migrations/20260525003723_correct_pseudo_account.sql @@ -0,0 +1,22 @@ +DROP VIEW public.my_pseudo_accounts; + +CREATE VIEW public.my_pseudo_accounts AS +SELECT + pa.id, + pa.platform, + pa.dg_account, + gm.group_id, + gm.admin, + sa.space_id, + sp.name, + grpsa.permissions AS sharing_permissions +FROM public."PlatformAccount" AS pa + JOIN public.group_membership AS gm ON (member_id = pa.dg_account) + JOIN public.group_membership AS gm2 ON (gm2.member_id = auth.uid() AND gm2.group_id = gm.group_id) + JOIN public."SpaceAccess" AS sa ON (sa.account_uid = pa.dg_account) + JOIN public."Space" AS sp ON (sp.id = sa.space_id) + LEFT OUTER JOIN public."SpaceAccess" AS grpsa ON (grpsa.account_uid = gm.group_id AND grpsa.space_id = sp.id); + +DROP FUNCTION public.spaces_in_group; + +DROP TYPE public.group_space_info; diff --git a/packages/database/supabase/schemas/account.sql b/packages/database/supabase/schemas/account.sql index bd751b161..1c6e7a836 100644 --- a/packages/database/supabase/schemas/account.sql +++ b/packages/database/supabase/schemas/account.sql @@ -463,16 +463,18 @@ SELECT pa.id, pa.platform, pa.dg_account, + gm.group_id, + gm.admin, sa.space_id, sp.name, - mysa.permissions AS sharing_permissions + grpsa.permissions AS sharing_permissions FROM public."PlatformAccount" AS pa - JOIN public.group_membership AS gm ON (member_id = dg_account) + JOIN public.group_membership AS gm ON (member_id = pa.dg_account) JOIN public.group_membership AS gm2 ON (gm2.member_id = auth.uid() AND gm2.group_id = gm.group_id) - JOIN public."SpaceAccess" AS sa ON (sa.account_uid = dg_account) + JOIN public."SpaceAccess" AS sa ON (sa.account_uid = pa.dg_account) JOIN public."Space" AS sp ON (sp.id = sa.space_id) - LEFT OUTER JOIN public."SpaceAccess" AS mysa ON (mysa.account_uid = gm.group_id AND mysa.space_id = sp.id) -WHERE pa.agent_type = 'anonymous' AND sa.permissions = 'editor'; + LEFT OUTER JOIN public."SpaceAccess" AS grpsa ON (grpsa.account_uid = gm.group_id AND grpsa.space_id = sp.id); + CREATE TYPE public.group_space_info AS ( id BIGINT, @@ -486,10 +488,9 @@ CREATE OR REPLACE FUNCTION public.spaces_in_group(p_group_id UUID) RETURNS SETOF STABLE SET search_path = '' LANGUAGE sql AS $$ - SELECT pa.space_id as id, pa.name, pa.platform, pa.sharing_permissions, gm.admin - FROM public.my_pseudo_accounts AS pa - JOIN public.group_membership AS gm ON (gm.member_id = pa.dg_account) - WHERE gm.group_id = p_group_id; + SELECT space_id as id, name, platform, sharing_permissions, admin + FROM public.my_pseudo_accounts + WHERE group_id = p_group_id; $$; CREATE OR REPLACE FUNCTION public.accept_group_invitation(token varchar) RETURNS boolean 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 };