Skip to content

Commit 2b08704

Browse files
authored
ENG-1719 List groups I'm a member of (#1011)
1 parent bb196b3 commit 2b08704

7 files changed

Lines changed: 281 additions & 3 deletions

File tree

apps/obsidian/src/components/AdminPanelSettings.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ export const AdminPanelSettings = () => {
6363
new Notice("Failed to connect to the database", 3000);
6464
return;
6565
}
66-
if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank");
66+
if (data)
67+
window.open(
68+
`${nextRoot()}auth/token?t=${data}&url=/auth/group`,
69+
"_blank",
70+
);
6771
};
6872

6973
return (

apps/roam/src/components/settings/AdminPanel.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,11 @@ const FeatureFlagsTab = (): React.ReactElement => {
325325
});
326326
return;
327327
}
328-
if (data) window.open(`${nextRoot()}/auth/token?t=${data}&url=/`, "_blank");
328+
if (data)
329+
window.open(
330+
`${nextRoot()}auth/token?t=${data}&url=/auth/group`,
331+
"_blank",
332+
);
329333
};
330334

331335
return (
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ListGroups } from "~/components/auth/ListGroups";
2+
3+
const Page = () => (
4+
<main>
5+
<div className="mx-auto max-w-6xl space-y-12 px-6 py-12">
6+
<ListGroups />
7+
</div>
8+
</main>
9+
);
10+
11+
export default Page;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createClient } from "~/utils/supabase/server";
2+
import { getSessionUserData } from "~/utils/supabase/account";
3+
import Link from "next/link";
4+
import { Tables } from "@repo/database/dbTypes";
5+
import internalError from "~/utils/internalErrorSsr";
6+
7+
type GroupData = Tables<"my_groups">;
8+
9+
export const ListGroups = async () => {
10+
let groupData: GroupData[] | null = null;
11+
let adminData: Record<string, boolean> = {};
12+
let userName: string | undefined;
13+
let error: string | undefined;
14+
15+
try {
16+
const client = await createClient();
17+
const userData = await getSessionUserData(client);
18+
if (!userData) {
19+
throw new Error("Not logged in.\nPlease log in from application.");
20+
}
21+
const { name, type, id } = userData;
22+
if (type === "anonymous") userName = "Space " + name;
23+
else if (type === "group") userName = "group " + name;
24+
else if (type === "person") userName = name;
25+
const groupResponse = await client.from("my_groups").select();
26+
if (groupResponse.error) {
27+
internalError({
28+
error: groupResponse.error,
29+
});
30+
throw new Error("Could not access Discourse Graphs");
31+
}
32+
groupData = groupResponse.data;
33+
const membershipReq = await client
34+
.from("group_membership")
35+
.select("group_id,admin")
36+
.eq("member_id", id);
37+
if (membershipReq.error) {
38+
internalError({
39+
error: membershipReq.error,
40+
});
41+
throw new Error("Could not access Discourse Graphs");
42+
}
43+
adminData = Object.fromEntries(
44+
// eslint-disable-next-line @typescript-eslint/naming-convention
45+
membershipReq.data.map(({ group_id, admin }) => [
46+
group_id,
47+
admin || false,
48+
]),
49+
);
50+
} catch (e) {
51+
error = e instanceof Error ? e.message : "An unknown error occured";
52+
}
53+
54+
return (
55+
<>
56+
<div className="text-right text-sm">
57+
{userName ? <p>Logged in as {userName}</p> : ""}
58+
</div>
59+
<div>
60+
{error ? (
61+
"Error: " + error
62+
) : groupData === null ? (
63+
"Error" // we should have had an error in that case
64+
) : groupData.length === 0 ? (
65+
<p>You are not part of any group.</p>
66+
) : (
67+
<>
68+
<p>Your groups:</p>
69+
<ul className="list-inside list-disc space-y-2">
70+
{groupData.map((d) => (
71+
<li key={d.id}>
72+
{adminData[d.id || ""] ? (
73+
<Link href={"/auth/group/" + d.id!}>{d.name}</Link>
74+
) : (
75+
d.name
76+
)}
77+
</li>
78+
))}
79+
</ul>
80+
</>
81+
)}
82+
</div>
83+
</>
84+
);
85+
};

apps/website/app/utils/supabase/account.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,53 @@
1+
import type { Database } from "@repo/database/dbTypes";
12
import type { DGSupabaseClient } from "@repo/database/lib/client";
23

4+
type AgentType = Database["public"]["Enums"]["AgentType"] | "group";
5+
6+
export const getSessionUserData = async (
7+
client: DGSupabaseClient,
8+
): Promise<{
9+
id: string;
10+
name: string;
11+
type: AgentType;
12+
email?: string;
13+
} | null> => {
14+
const { data, error } = await client.auth.getUser();
15+
if (error || !data?.user) return null;
16+
const userData = data.user;
17+
if (typeof userData.id !== "string") return null;
18+
const { id, email }: { id: string; email?: string } = userData;
19+
if (email) {
20+
const [name, host] = email.split("@") as [string, string];
21+
if (host === "database.discoursegraphs.com" && name.endsWith("-anon")) {
22+
const parts = name.split("-");
23+
const spaceId = Number.parseInt(parts[1]!);
24+
if (Number.isNaN(spaceId)) return null;
25+
const spaceReq = await client
26+
.from("Space")
27+
.select("name")
28+
.eq("id", spaceId)
29+
.maybeSingle();
30+
if (spaceReq.error || !spaceReq.data) {
31+
return null;
32+
}
33+
return { name: spaceReq.data.name, id, type: "anonymous", email };
34+
}
35+
if (host === "groups.discoursegraphs.com") {
36+
return { name, id, email, type: "group" };
37+
}
38+
}
39+
const accountReq = await client
40+
.from("PlatformAccount")
41+
.select("name")
42+
.eq("dg_account", id)
43+
.eq("agent_type", "person")
44+
.maybeSingle();
45+
if (accountReq.error || !accountReq.data) {
46+
return null;
47+
}
48+
return { id, name: accountReq.data.name, type: "person", email };
49+
};
50+
351
export const createGroup = async (
452
client: DGSupabaseClient,
553
name: string,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import assert from "assert";
2+
import { describe, it, beforeAll, afterAll } from "vitest";
3+
import { createClient } from "@supabase/supabase-js";
4+
import type { Database } from "@repo/database/dbTypes";
5+
import type { DGSupabaseClient } from "@repo/database/lib/client";
6+
import {
7+
fetchOrCreateSpaceDirect,
8+
spaceAnonUserEmail,
9+
} from "@repo/database/lib/contextFunctions";
10+
import { createGroup } from "../../app/utils/supabase/account";
11+
12+
const SUPABASE_URL = process.env.SUPABASE_URL!;
13+
const ANON_KEY = process.env.SUPABASE_PUBLISHABLE_KEY!;
14+
const SERVICE_KEY = process.env.SUPABASE_SECRET_KEY!;
15+
const PASSWORD = "abcdefgh";
16+
17+
const freshClient = (): DGSupabaseClient =>
18+
createClient<Database, "public">(SUPABASE_URL, ANON_KEY);
19+
20+
const serviceClient = () =>
21+
createClient<Database, "public">(SUPABASE_URL, SERVICE_KEY);
22+
23+
const signedInClient = async (spaceId: number): Promise<DGSupabaseClient> => {
24+
const client = freshClient();
25+
const { error } = await client.auth.signInWithPassword({
26+
email: spaceAnonUserEmail("Roam", spaceId),
27+
password: PASSWORD,
28+
});
29+
if (error) throw new Error(`Sign-in failed: ${error.message}`);
30+
return client;
31+
};
32+
33+
describe("list group members flow", { tags: ["database"] }, () => {
34+
let spaceId1: number;
35+
let spaceId2: number;
36+
let spaceAccountUuid1: string;
37+
let spaceAccountUuid2: string;
38+
let client1: DGSupabaseClient;
39+
let client2: DGSupabaseClient;
40+
let createdGroupId: string | null = null;
41+
42+
beforeAll(async () => {
43+
const s1 = await fetchOrCreateSpaceDirect({
44+
name: "vitest-s1",
45+
url: "https://roamresearch.com/#/app/vitest-s1",
46+
platform: "Roam",
47+
password: PASSWORD,
48+
});
49+
if (!s1.data)
50+
throw new Error(`Failed to create space 1: ${s1.error?.message}`);
51+
spaceId1 = s1.data.id;
52+
client1 = await signedInClient(spaceId1);
53+
assert(client1);
54+
const accountReq1 = await client1
55+
.from("PlatformAccount")
56+
.select("id,dg_account")
57+
.eq(
58+
"account_local_id",
59+
`roam-${spaceId1}-anon@database.discoursegraphs.com`,
60+
)
61+
.maybeSingle();
62+
assert(!accountReq1.error);
63+
assert(accountReq1.data);
64+
assert(accountReq1.data.dg_account);
65+
spaceAccountUuid1 = accountReq1.data.dg_account;
66+
const s2 = await fetchOrCreateSpaceDirect({
67+
name: "vitest-s2",
68+
url: "https://roamresearch.com/#/app/vitest-s2",
69+
platform: "Roam",
70+
password: PASSWORD,
71+
});
72+
if (!s2.data)
73+
throw new Error(`Failed to create space 2: ${s2.error?.message}`);
74+
spaceId2 = s2.data.id;
75+
client2 = await signedInClient(spaceId2);
76+
assert(client2);
77+
const accountReq2 = await client2
78+
.from("PlatformAccount")
79+
.select("id,dg_account")
80+
.eq(
81+
"account_local_id",
82+
`roam-${spaceId2}-anon@database.discoursegraphs.com`,
83+
)
84+
.maybeSingle();
85+
assert(!accountReq2.error);
86+
assert(accountReq2.data);
87+
assert(accountReq2.data.dg_account);
88+
spaceAccountUuid2 = accountReq2.data.dg_account;
89+
});
90+
91+
afterAll(async () => {
92+
if (createdGroupId)
93+
await serviceClient().auth.admin.deleteUser(createdGroupId);
94+
if (spaceAccountUuid1)
95+
await serviceClient().auth.admin.deleteUser(spaceAccountUuid1);
96+
if (spaceAccountUuid2)
97+
await serviceClient().auth.admin.deleteUser(spaceAccountUuid2);
98+
if (spaceId1)
99+
await serviceClient().from("Space").delete().eq("id", spaceId1);
100+
if (spaceId2)
101+
await serviceClient().from("Space").delete().eq("id", spaceId2);
102+
});
103+
104+
it("lists group members", async () => {
105+
// Step 1: user1 creates a group
106+
const groupId = await createGroup(client1, "vitest-invite-group");
107+
assert(groupId !== null, "createGroup should return a group ID");
108+
createdGroupId = groupId;
109+
110+
// Step 2: Add another member
111+
const { error: errorAddMember } = await client1
112+
.from("group_membership")
113+
.insert({
114+
member_id: spaceAccountUuid2, // eslint-disable-line @typescript-eslint/naming-convention
115+
group_id: groupId, // eslint-disable-line @typescript-eslint/naming-convention
116+
admin: false,
117+
});
118+
assert(!errorAddMember);
119+
120+
const groupResponse = await client2.from("my_groups").select();
121+
assert(!groupResponse.error);
122+
assert(groupResponse.data !== null);
123+
assert(groupResponse.data.length === 1);
124+
assert(groupResponse.data[0]!.id === groupId);
125+
});
126+
});

packages/utils/src/execContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const nextRoot = (): string => {
55
process.env.NEXT_API_ROOT !== undefined &&
66
process.env.NEXT_API_ROOT !== ""
77
)
8-
return process.env.NEXT_API_ROOT.split("/").slice(0, 3).join("/");
8+
return process.env.NEXT_API_ROOT.split("/").slice(0, 3).join("/") + "/";
99
return IS_DEV ? "http://localhost:3000/" : "https://discoursegraphs.com/";
1010
};
1111

0 commit comments

Comments
 (0)