diff --git a/apps/obsidian/src/components/AdminPanelSettings.tsx b/apps/obsidian/src/components/AdminPanelSettings.tsx index 0b1fbe87f..67e7e9f89 100644 --- a/apps/obsidian/src/components/AdminPanelSettings.tsx +++ b/apps/obsidian/src/components/AdminPanelSettings.tsx @@ -65,7 +65,11 @@ export const AdminPanelSettings = () => { new Notice("Failed to connect to the database", 3000); return; } - if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank"); + if (data) + window.open( + `${nextRoot()}auth/token?t=${data}&url=/auth/group`, + "_blank", + ); }; return ( diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index 329e048f9..dd5b39ea9 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -326,7 +326,11 @@ const FeatureFlagsTab = (): React.ReactElement => { }); return; } - if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank"); + if (data) + window.open( + `${nextRoot()}auth/token?t=${data}&url=/auth/group`, + "_blank", + ); }; return ( diff --git a/apps/website/app/(home)/auth/group/page.tsx b/apps/website/app/(home)/auth/group/page.tsx new file mode 100644 index 000000000..7d31fa17b --- /dev/null +++ b/apps/website/app/(home)/auth/group/page.tsx @@ -0,0 +1,11 @@ +import { ListGroups } from "~/components/auth/ListGroups"; + +const Page = () => ( +
+
+ +
+
+); + +export default Page; diff --git a/apps/website/app/components/auth/ListGroups.tsx b/apps/website/app/components/auth/ListGroups.tsx new file mode 100644 index 000000000..791e2e333 --- /dev/null +++ b/apps/website/app/components/auth/ListGroups.tsx @@ -0,0 +1,85 @@ +import { createClient } from "~/utils/supabase/server"; +import { getSessionUserData } from "~/utils/supabase/account"; +import Link from "next/link"; +import { Tables } from "@repo/database/dbTypes"; +import internalError from "~/utils/internalErrorSsr"; + +type GroupData = Tables<"my_groups">; + +export const ListGroups = async () => { + let groupData: GroupData[] | null = null; + let adminData: Record = {}; + let userName: string | undefined; + let error: string | undefined; + + try { + const client = await createClient(); + const userData = await getSessionUserData(client); + if (!userData) { + throw new Error("Not logged in.\nPlease log in from application."); + } + const { name, type, id } = userData; + if (type === "anonymous") userName = "Space " + name; + else if (type === "group") userName = "group " + name; + else if (type === "person") userName = name; + const groupResponse = await client.from("my_groups").select(); + if (groupResponse.error) { + internalError({ + error: groupResponse.error, + }); + throw new Error("Could not access Discourse Graphs"); + } + groupData = groupResponse.data; + const membershipReq = await client + .from("group_membership") + .select("group_id,admin") + .eq("member_id", id); + if (membershipReq.error) { + internalError({ + error: membershipReq.error, + }); + throw new Error("Could not access Discourse Graphs"); + } + adminData = Object.fromEntries( + // eslint-disable-next-line @typescript-eslint/naming-convention + membershipReq.data.map(({ group_id, admin }) => [ + group_id, + admin || false, + ]), + ); + } catch (e) { + error = e instanceof Error ? e.message : "An unknown error occured"; + } + + return ( + <> +
+ {userName ?

Logged in as {userName}

: ""} +
+
+ {error ? ( + "Error: " + error + ) : groupData === null ? ( + "Error" // we should have had an error in that case + ) : groupData.length === 0 ? ( +

You are not part of any group.

+ ) : ( + <> +

Your groups:

+ + + )} +
+ + ); +}; diff --git a/apps/website/app/utils/supabase/account.ts b/apps/website/app/utils/supabase/account.ts index a4a0ee36a..fa9444d62 100644 --- a/apps/website/app/utils/supabase/account.ts +++ b/apps/website/app/utils/supabase/account.ts @@ -1,5 +1,53 @@ +import type { Database } from "@repo/database/dbTypes"; import type { DGSupabaseClient } from "@repo/database/lib/client"; +type AgentType = Database["public"]["Enums"]["AgentType"] | "group"; + +export const getSessionUserData = async ( + client: DGSupabaseClient, +): Promise<{ + id: string; + name: string; + type: AgentType; + email?: string; +} | null> => { + const { data, error } = await client.auth.getUser(); + if (error || !data?.user) return null; + const userData = data.user; + if (typeof userData.id !== "string") return null; + const { id, email }: { id: string; email?: string } = userData; + if (email) { + const [name, host] = email.split("@") as [string, string]; + if (host === "database.discoursegraphs.com" && name.endsWith("-anon")) { + const parts = name.split("-"); + const spaceId = Number.parseInt(parts[1]!); + if (Number.isNaN(spaceId)) return null; + const spaceReq = await client + .from("Space") + .select("name") + .eq("id", spaceId) + .maybeSingle(); + if (spaceReq.error || !spaceReq.data) { + return null; + } + return { name: spaceReq.data.name, id, type: "anonymous", email }; + } + if (host === "groups.discoursegraphs.com") { + return { name, id, email, type: "group" }; + } + } + const accountReq = await client + .from("PlatformAccount") + .select("name") + .eq("dg_account", id) + .eq("agent_type", "person") + .maybeSingle(); + if (accountReq.error || !accountReq.data) { + return null; + } + return { id, name: accountReq.data.name, type: "person", email }; +}; + export const createGroup = async ( client: DGSupabaseClient, name: string, diff --git a/apps/website/test/integration/listMyGroups.test.ts b/apps/website/test/integration/listMyGroups.test.ts new file mode 100644 index 000000000..8f4dbd33c --- /dev/null +++ b/apps/website/test/integration/listMyGroups.test.ts @@ -0,0 +1,126 @@ +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 { DGSupabaseClient } from "@repo/database/lib/client"; +import { + fetchOrCreateSpaceDirect, + spaceAnonUserEmail, +} from "@repo/database/lib/contextFunctions"; +import { createGroup } from "../../app/utils/supabase/account"; + +const SUPABASE_URL = process.env.SUPABASE_URL!; +const ANON_KEY = process.env.SUPABASE_PUBLISHABLE_KEY!; +const SERVICE_KEY = process.env.SUPABASE_SECRET_KEY!; +const PASSWORD = "abcdefgh"; + +const freshClient = (): DGSupabaseClient => + createClient(SUPABASE_URL, ANON_KEY); + +const serviceClient = () => + createClient(SUPABASE_URL, SERVICE_KEY); + +const signedInClient = async (spaceId: number): Promise => { + const client = freshClient(); + const { error } = await client.auth.signInWithPassword({ + email: spaceAnonUserEmail("Roam", spaceId), + password: PASSWORD, + }); + if (error) throw new Error(`Sign-in failed: ${error.message}`); + return client; +}; + +describe("list group members flow", { tags: ["database"] }, () => { + let spaceId1: number; + let spaceId2: number; + let spaceAccountUuid1: string; + let spaceAccountUuid2: string; + let client1: DGSupabaseClient; + let client2: DGSupabaseClient; + let createdGroupId: string | null = null; + + beforeAll(async () => { + const s1 = await fetchOrCreateSpaceDirect({ + name: "vitest-s1", + url: "https://roamresearch.com/#/app/vitest-s1", + platform: "Roam", + password: PASSWORD, + }); + if (!s1.data) + throw new Error(`Failed to create space 1: ${s1.error?.message}`); + spaceId1 = s1.data.id; + client1 = await signedInClient(spaceId1); + assert(client1); + const accountReq1 = await client1 + .from("PlatformAccount") + .select("id,dg_account") + .eq( + "account_local_id", + `roam-${spaceId1}-anon@database.discoursegraphs.com`, + ) + .maybeSingle(); + assert(!accountReq1.error); + assert(accountReq1.data); + assert(accountReq1.data.dg_account); + spaceAccountUuid1 = accountReq1.data.dg_account; + const s2 = await fetchOrCreateSpaceDirect({ + name: "vitest-s2", + url: "https://roamresearch.com/#/app/vitest-s2", + platform: "Roam", + password: PASSWORD, + }); + if (!s2.data) + throw new Error(`Failed to create space 2: ${s2.error?.message}`); + spaceId2 = s2.data.id; + client2 = await signedInClient(spaceId2); + assert(client2); + const accountReq2 = await client2 + .from("PlatformAccount") + .select("id,dg_account") + .eq( + "account_local_id", + `roam-${spaceId2}-anon@database.discoursegraphs.com`, + ) + .maybeSingle(); + assert(!accountReq2.error); + assert(accountReq2.data); + assert(accountReq2.data.dg_account); + spaceAccountUuid2 = accountReq2.data.dg_account; + }); + + afterAll(async () => { + if (createdGroupId) + await serviceClient().auth.admin.deleteUser(createdGroupId); + if (spaceAccountUuid1) + await serviceClient().auth.admin.deleteUser(spaceAccountUuid1); + if (spaceAccountUuid2) + await serviceClient().auth.admin.deleteUser(spaceAccountUuid2); + if (spaceId1) + await serviceClient().from("Space").delete().eq("id", spaceId1); + if (spaceId2) + await serviceClient().from("Space").delete().eq("id", spaceId2); + }); + + it("lists group members", async () => { + // Step 1: user1 creates a group + const groupId = await createGroup(client1, "vitest-invite-group"); + assert(groupId !== null, "createGroup should return a group ID"); + createdGroupId = groupId; + + // Step 2: Add another member + const { error: errorAddMember } = await client1 + .from("group_membership") + .insert({ + member_id: spaceAccountUuid2, // eslint-disable-line @typescript-eslint/naming-convention + group_id: groupId, // eslint-disable-line @typescript-eslint/naming-convention + admin: false, + }); + assert(!errorAddMember); + + const groupResponse = await client2.from("my_groups").select(); + assert(!groupResponse.error); + assert(groupResponse.data !== null); + assert(groupResponse.data.length === 1); + assert(groupResponse.data[0]!.id === groupId); + }); +}); diff --git a/packages/utils/src/execContext.ts b/packages/utils/src/execContext.ts index 19ee5ddae..5b9b06cae 100644 --- a/packages/utils/src/execContext.ts +++ b/packages/utils/src/execContext.ts @@ -5,7 +5,7 @@ export const nextRoot = (): string => { process.env.NEXT_API_ROOT !== undefined && process.env.NEXT_API_ROOT !== "" ) - return process.env.NEXT_API_ROOT.split("/").slice(0, 3).join("/"); + return process.env.NEXT_API_ROOT.split("/").slice(0, 3).join("/") + "/"; return IS_DEV ? "http://localhost:3000/" : "https://discoursegraphs.com/"; };