Skip to content

Commit 332a675

Browse files
authored
Eng 1721 basic group management UI (#1086)
1 parent 43e8bc3 commit 332a675

7 files changed

Lines changed: 381 additions & 7 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { redirect, notFound } from "next/navigation";
2+
import Link from "next/link";
3+
import { createClient } from "~/utils/supabase/server";
4+
import { getSessionBaseUserData } from "~/utils/supabase/account";
5+
import { GroupMemberList } from "~/components/auth/GroupMemberList";
6+
import { GroupInvite } from "~/components/auth/GroupInvite";
7+
import internalError from "~/utils/internalErrorSsr";
8+
9+
const Page = async ({
10+
params,
11+
searchParams,
12+
}: {
13+
params: Promise<{ groupId: string }>;
14+
searchParams: Promise<{
15+
token?: string;
16+
tokenError?: string;
17+
removeError?: string;
18+
}>;
19+
}) => {
20+
const { groupId } = await params;
21+
const sp = await searchParams;
22+
23+
const client = await createClient();
24+
const userData = await getSessionBaseUserData(client);
25+
if (!userData)
26+
redirect("/auth/error?error=" + encodeURIComponent("Not logged in"));
27+
28+
const membershipReq = await client
29+
.from("group_membership")
30+
.select("admin")
31+
.eq("group_id", groupId)
32+
.eq("member_id", userData.id)
33+
.maybeSingle();
34+
if (membershipReq.error) {
35+
internalError({ error: membershipReq.error });
36+
redirect("/auth/error?error=" + encodeURIComponent("Could not load group"));
37+
}
38+
if (!membershipReq.data) notFound();
39+
const isAdmin = membershipReq.data.admin === true;
40+
41+
const groupReq = await client
42+
.from("my_groups")
43+
.select("name")
44+
.eq("id", groupId)
45+
.maybeSingle();
46+
if (groupReq.error) {
47+
internalError({ error: groupReq.error });
48+
redirect("/auth/error?error=" + encodeURIComponent("Could not load group"));
49+
}
50+
const groupName = groupReq.data?.name ?? groupId;
51+
52+
return (
53+
<main>
54+
<div className="mx-auto max-w-3xl space-y-8 px-6 py-12">
55+
<Link href="/auth/group" className="float-right">
56+
Back to group page
57+
</Link>
58+
<div>
59+
<h1 className="text-2xl font-bold">{groupName}</h1>
60+
</div>
61+
<GroupMemberList
62+
groupId={groupId}
63+
isAdmin={isAdmin}
64+
removeError={sp.removeError}
65+
/>
66+
{isAdmin && (
67+
<GroupInvite
68+
groupId={groupId}
69+
token={sp.token}
70+
tokenError={sp.tokenError}
71+
/>
72+
)}
73+
</div>
74+
</main>
75+
);
76+
};
77+
78+
export default Page;
Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import { ListGroups } from "~/components/auth/ListGroups";
2+
import { JoinGroup } from "~/components/auth/JoinGroup";
3+
import { CreateGroup } from "~/components/auth/CreateGroup";
24

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-
);
5+
const Page = async ({
6+
searchParams,
7+
}: {
8+
searchParams: Promise<{
9+
error?: string;
10+
joined?: string;
11+
createError?: string;
12+
created?: string;
13+
}>;
14+
}) => {
15+
const params = await searchParams;
16+
return (
17+
<main>
18+
<div className="mx-auto max-w-6xl space-y-12 px-6 py-12">
19+
<ListGroups />
20+
<CreateGroup error={params.createError} created={params.created} />
21+
<JoinGroup error={params.error} joined={params.joined === "1"} />
22+
</div>
23+
</main>
24+
);
25+
};
1026

1127
export default Page;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { redirect } from "next/navigation";
2+
import { createClient } from "~/utils/supabase/server";
3+
import { createGroup } from "~/utils/supabase/account";
4+
import { Button } from "@repo/ui/components/ui/button";
5+
import { Input } from "@repo/ui/components/ui/input";
6+
7+
export const CreateGroup = ({
8+
error,
9+
created,
10+
}: {
11+
error?: string;
12+
created?: string;
13+
}) => {
14+
const createGroupAction = async (formData: FormData) => {
15+
"use server";
16+
const name = formData.get("name");
17+
if (typeof name !== "string" || !name.trim()) {
18+
redirect(
19+
"/auth/group?createError=" +
20+
encodeURIComponent("Please enter a group name"),
21+
);
22+
}
23+
const client = await createClient();
24+
const { groupId, error: err } = await createGroup(client, name.trim());
25+
if (err) {
26+
redirect("/auth/group?createError=" + encodeURIComponent(err));
27+
}
28+
if (!groupId) {
29+
redirect(
30+
"/auth/group?createError=" +
31+
encodeURIComponent("Failed to create group"),
32+
);
33+
}
34+
redirect("/auth/group?created=" + encodeURIComponent(groupId));
35+
};
36+
37+
return (
38+
<section className="space-y-3">
39+
<h2 className="text-lg font-semibold">Create a group</h2>
40+
{created && (
41+
<p className="text-sm text-green-600">Group created successfully.</p>
42+
)}
43+
{error && <p className="text-destructive text-sm">{error}</p>}
44+
<form action={createGroupAction} className="flex gap-2">
45+
<Input name="name" placeholder="Group name" className="max-w-sm" />
46+
<Button type="submit">Create Group</Button>
47+
</form>
48+
</section>
49+
);
50+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { redirect } from "next/navigation";
2+
import { createClient } from "~/utils/supabase/server";
3+
import { createGroupInvitation } from "~/utils/supabase/account";
4+
import { Button } from "@repo/ui/components/ui/button";
5+
6+
export const GroupInvite = ({
7+
groupId,
8+
token,
9+
tokenError,
10+
}: {
11+
groupId: string;
12+
token?: string;
13+
tokenError?: string;
14+
}) => {
15+
const createToken = async (formData: FormData) => {
16+
"use server";
17+
const admin = formData.get("admin") === "true";
18+
const client = await createClient();
19+
const t = await createGroupInvitation({ client, groupId, admin });
20+
if (!t) {
21+
redirect(
22+
`/auth/group/${groupId}?tokenError=` +
23+
encodeURIComponent("Could not create invitation token"),
24+
);
25+
}
26+
redirect(`/auth/group/${groupId}?token=` + encodeURIComponent(t));
27+
};
28+
29+
return (
30+
<section className="space-y-3">
31+
<h2 className="text-lg font-semibold">Invite to group</h2>
32+
{tokenError && <p className="text-destructive text-sm">{tokenError}</p>}
33+
{token && (
34+
<div className="bg-muted rounded-md border p-3 text-sm">
35+
<p className="mb-1 font-medium">Invitation token (valid 60 days):</p>
36+
<code className="break-all">{token}</code>
37+
</div>
38+
)}
39+
<div className="flex gap-2">
40+
<form action={createToken}>
41+
<input type="hidden" name="admin" value="false" />
42+
<Button type="submit" variant="outline">
43+
Create member token
44+
</Button>
45+
</form>
46+
</div>
47+
</section>
48+
);
49+
};
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { redirect } from "next/navigation";
2+
import { createClient } from "~/utils/supabase/server";
3+
import {
4+
removeFromGroup,
5+
getSessionBaseUserData,
6+
} from "~/utils/supabase/account";
7+
import { Button } from "@repo/ui/components/ui/button";
8+
import internalError from "~/utils/internalErrorSsr";
9+
10+
export const GroupMemberList = async ({
11+
groupId,
12+
isAdmin,
13+
removeError,
14+
}: {
15+
groupId: string;
16+
isAdmin: boolean;
17+
removeError?: string;
18+
}) => {
19+
const client = await createClient();
20+
const clientData = await getSessionBaseUserData(client);
21+
const myUserId = clientData?.id;
22+
if (!myUserId) {
23+
internalError({ error: "Not logged in" });
24+
redirect(
25+
"/auth/error?error=" +
26+
encodeURIComponent("Not logged in.\nPlease log in from application."),
27+
);
28+
}
29+
30+
const pseudoAccountReq = await client
31+
.from("my_pseudo_accounts")
32+
.select()
33+
.eq("group_id", groupId);
34+
35+
if (pseudoAccountReq.error) {
36+
internalError({ error: pseudoAccountReq.error });
37+
redirect(
38+
"/auth/error?error=" + encodeURIComponent("Could not load group members"),
39+
);
40+
}
41+
const pseudoAccountInfo = pseudoAccountReq.data ?? [];
42+
const numAdmins = pseudoAccountInfo
43+
.map((pa) => (pa.admin ? 1 : 0) as number)
44+
.reduce((acc, cur) => acc + cur, 0);
45+
46+
const removeSpace = async (formData: FormData) => {
47+
"use server";
48+
const memberId = formData.get("memberId");
49+
if (typeof memberId !== "string") return;
50+
const c = await createClient();
51+
const error = await removeFromGroup({ client: c, groupId, memberId });
52+
if (error) {
53+
redirect(
54+
`/auth/group/${groupId}?removeError=` + encodeURIComponent(error),
55+
);
56+
}
57+
redirect(`/auth/group/${groupId}`);
58+
};
59+
60+
return (
61+
<section className="space-y-3">
62+
<h2 className="text-lg font-semibold">Member spaces</h2>
63+
{removeError && <p className="text-destructive text-sm">{removeError}</p>}
64+
{pseudoAccountInfo.length === 0 ? (
65+
<p className="text-muted-foreground text-sm">No spaces yet.</p>
66+
) : (
67+
<ul className="divide-y rounded-md border">
68+
{pseudoAccountInfo.map((pseudoAccount) => {
69+
const memberId = pseudoAccount.dg_account;
70+
return (
71+
<li
72+
key={`${pseudoAccount.id}-${pseudoAccount.space_id}`}
73+
className="flex items-center justify-between px-4 py-2"
74+
>
75+
<span>
76+
{pseudoAccount.name}
77+
<span className="text-muted-foreground ml-2 text-xs">
78+
({pseudoAccount.platform})
79+
</span>
80+
{pseudoAccount.dg_account === myUserId && (
81+
<span className="ml-2 rounded bg-blue-300 px-1.5 py-0.5 text-xs text-blue-900">
82+
me
83+
</span>
84+
)}
85+
{pseudoAccount.admin && (
86+
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
87+
admin
88+
</span>
89+
)}
90+
</span>
91+
{memberId &&
92+
// allow admins to remove others
93+
// admins should not remove self, unless there's another admin
94+
((isAdmin &&
95+
(numAdmins > 1 || pseudoAccount.dg_account !== myUserId)) ||
96+
// non-admins can remove self.
97+
pseudoAccount.dg_account === myUserId) && (
98+
<form action={removeSpace}>
99+
<input type="hidden" name="memberId" value={memberId} />
100+
<Button type="submit" variant="destructive" size="sm">
101+
Remove
102+
</Button>
103+
</form>
104+
)}
105+
</li>
106+
);
107+
})}
108+
</ul>
109+
)}
110+
</section>
111+
);
112+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { redirect } from "next/navigation";
2+
import { createClient } from "~/utils/supabase/server";
3+
import { acceptGroupInvitation } from "~/utils/supabase/account";
4+
import { Button } from "@repo/ui/components/ui/button";
5+
import { Input } from "@repo/ui/components/ui/input";
6+
7+
export const JoinGroup = ({
8+
error,
9+
joined,
10+
}: {
11+
error?: string;
12+
joined?: boolean;
13+
}) => {
14+
const joinGroup = async (formData: FormData) => {
15+
"use server";
16+
const token = formData.get("token");
17+
if (typeof token !== "string" || !token.trim()) {
18+
redirect(
19+
"/auth/group?error=" + encodeURIComponent("Please enter a token"),
20+
);
21+
}
22+
const client = await createClient();
23+
const err = await acceptGroupInvitation(client, token.trim());
24+
if (err) {
25+
redirect("/auth/group?error=" + encodeURIComponent(err));
26+
}
27+
redirect("/auth/group?joined=1");
28+
};
29+
30+
return (
31+
<section className="space-y-3">
32+
<h2 className="text-lg font-semibold">Join a group</h2>
33+
{joined && (
34+
<p className="text-sm text-green-600">Successfully joined the group.</p>
35+
)}
36+
{error && <p className="text-destructive text-sm">{error}</p>}
37+
<form action={joinGroup} className="flex gap-2">
38+
<Input
39+
name="token"
40+
placeholder="Paste your invitation token"
41+
className="max-w-sm"
42+
/>
43+
<Button type="submit">Join Group</Button>
44+
</form>
45+
</section>
46+
);
47+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as React from "react";
2+
3+
import { cn } from "@repo/ui/lib/utils";
4+
5+
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
6+
({ className, type, ...props }, ref) => {
7+
return (
8+
<input
9+
type={type}
10+
className={cn(
11+
"border-input bg-background ring-offset-background file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12+
className,
13+
)}
14+
ref={ref}
15+
{...props}
16+
/>
17+
);
18+
},
19+
);
20+
Input.displayName = "Input";
21+
22+
export { Input };

0 commit comments

Comments
 (0)