Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/obsidian/src/components/AdminPanelSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
6 changes: 5 additions & 1 deletion apps/roam/src/components/settings/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
11 changes: 11 additions & 0 deletions apps/website/app/(home)/auth/group/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ListGroups } from "~/components/auth/ListGroups";

const Page = () => (
<main>
Comment thread
maparent marked this conversation as resolved.
<div className="mx-auto max-w-6xl space-y-12 px-6 py-12">
<ListGroups />
</div>
</main>
);

export default Page;
85 changes: 85 additions & 0 deletions apps/website/app/components/auth/ListGroups.tsx
Original file line number Diff line number Diff line change
@@ -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<string, boolean> = {};
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 (
<>
<div className="text-right text-sm">
{userName ? <p>Logged in as {userName}</p> : ""}
</div>
<div>
{error ? (
"Error: " + error
) : groupData === null ? (
"Error" // we should have had an error in that case
) : groupData.length === 0 ? (
<p>You are not part of any group.</p>
) : (
<>
<p>Your groups:</p>
<ul className="list-inside list-disc space-y-2">
{groupData.map((d) => (
<li key={d.id}>
{adminData[d.id || ""] ? (
<Link href={"group/" + d.id!}>{d.name}</Link>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prefix group link with root-relative path

Use a root-relative URL here (for example "/group/" + d.id) instead of "group/" + d.id. In Next.js, this current value is resolved relative to the current page (/auth/group), so admins clicking a group name are sent to /auth/group/group/<id> rather than the intended group route, which results in a broken navigation path for the primary admin action on this screen.

Useful? React with 👍 / 👎.

) : (
d.name
)}
</li>
))}
</ul>
</>
)}
</div>
</>
);
};
48 changes: 48 additions & 0 deletions apps/website/app/utils/supabase/account.ts
Original file line number Diff line number Diff line change
@@ -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]!);
Comment thread
maparent marked this conversation as resolved.
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,
Expand Down
126 changes: 126 additions & 0 deletions apps/website/test/integration/list_my_groups.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database, "public">(SUPABASE_URL, ANON_KEY);

const serviceClient = () =>
createClient<Database, "public">(SUPABASE_URL, SERVICE_KEY);

const signedInClient = async (spaceId: number): Promise<DGSupabaseClient> => {
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);
});
});
2 changes: 1 addition & 1 deletion packages/utils/src/execContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/";
};

Expand Down
Loading