-
Notifications
You must be signed in to change notification settings - Fork 5
Eng 1721 basic group management UI #1086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: eng-1799-add-better-error-message-to-creategroup
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <main> | ||
| <div className="mx-auto max-w-3xl space-y-8 px-6 py-12"> | ||
| <Link href="/auth/group" className="float-right"> | ||
| Back to group page | ||
| </Link> | ||
| <div> | ||
| <h1 className="text-2xl font-bold">{groupName}</h1> | ||
| </div> | ||
| <GroupMemberList | ||
| groupId={groupId} | ||
| isAdmin={isAdmin} | ||
| removeError={sp.removeError} | ||
| /> | ||
| {isAdmin && ( | ||
| <GroupInvite | ||
| groupId={groupId} | ||
| token={sp.token} | ||
| tokenError={sp.tokenError} | ||
| /> | ||
| )} | ||
| </div> | ||
| </main> | ||
| ); | ||
| }; | ||
|
|
||
| export default Page; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,27 @@ | ||
| import { ListGroups } from "~/components/auth/ListGroups"; | ||
| import { JoinGroup } from "~/components/auth/JoinGroup"; | ||
| import { CreateGroup } from "~/components/auth/CreateGroup"; | ||
|
|
||
| const Page = () => ( | ||
| <main> | ||
| <div className="mx-auto max-w-6xl space-y-12 px-6 py-12"> | ||
| <ListGroups /> | ||
| </div> | ||
| </main> | ||
| ); | ||
| const Page = async ({ | ||
| searchParams, | ||
| }: { | ||
| searchParams: Promise<{ | ||
| error?: string; | ||
| joined?: string; | ||
| createError?: string; | ||
| created?: string; | ||
| }>; | ||
| }) => { | ||
| const params = await searchParams; | ||
| return ( | ||
| <main> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: shouldn't need |
||
| <div className="mx-auto max-w-6xl space-y-12 px-6 py-12"> | ||
| <ListGroups /> | ||
| <CreateGroup error={params.createError} created={params.created} /> | ||
| <JoinGroup error={params.error} joined={params.joined === "1"} /> | ||
| </div> | ||
| </main> | ||
| ); | ||
| }; | ||
|
|
||
| export default Page; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| 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 ( | ||
| <section className="space-y-3"> | ||
| <h2 className="text-lg font-semibold">Create a group</h2> | ||
| {created && ( | ||
| <p className="text-sm text-green-600">Group created successfully.</p> | ||
| )} | ||
| {error && <p className="text-destructive text-sm">{error}</p>} | ||
| <form action={createGroupAction} className="flex gap-2"> | ||
| <Input name="name" placeholder="Group name" className="max-w-sm" /> | ||
| <Button type="submit">Create Group</Button> | ||
| </form> | ||
| </section> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| 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 ( | ||
| <section className="space-y-3"> | ||
| <h2 className="text-lg font-semibold">Invite to group</h2> | ||
| {tokenError && <p className="text-destructive text-sm">{tokenError}</p>} | ||
| {token && ( | ||
| <div className="bg-muted rounded-md border p-3 text-sm"> | ||
| <p className="mb-1 font-medium">Invitation token (valid 60 days):</p> | ||
| <code className="break-all">{token}</code> | ||
| </div> | ||
| )} | ||
| <div className="flex gap-2"> | ||
| <form action={createToken}> | ||
| <input type="hidden" name="admin" value="false" /> | ||
| <Button type="submit" variant="outline"> | ||
| Create member token | ||
| </Button> | ||
| </form> | ||
| </div> | ||
| </section> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| 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 ( | ||
| <section className="space-y-3"> | ||
| <h2 className="text-lg font-semibold">Member spaces</h2> | ||
| {removeError && <p className="text-destructive text-sm">{removeError}</p>} | ||
| {pseudoAccountInfo.length === 0 ? ( | ||
| <p className="text-muted-foreground text-sm">No spaces yet.</p> | ||
| ) : ( | ||
| <ul className="divide-y rounded-md border"> | ||
| {pseudoAccountInfo.map((pseudoAccount) => { | ||
| const memberId = pseudoAccount.dg_account; | ||
| return ( | ||
| <li | ||
| key={pseudoAccount.space_id} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using Useful? React with 👍 / 👎. |
||
| className="flex items-center justify-between px-4 py-2" | ||
| > | ||
| <span> | ||
| {pseudoAccount.name} | ||
| <span className="text-muted-foreground ml-2 text-xs"> | ||
| ({pseudoAccount.platform}) | ||
| </span> | ||
| {pseudoAccount.dg_account === myUserId && ( | ||
| <span className="ml-2 rounded bg-blue-300 px-1.5 py-0.5 text-xs text-blue-900"> | ||
| me | ||
| </span> | ||
| )} | ||
| {pseudoAccount.admin && ( | ||
| <span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700"> | ||
| admin | ||
| </span> | ||
| )} | ||
| </span> | ||
| {memberId && | ||
| // allow admins to remove others, non-admins to remove self. | ||
| // admins should not remove self (unless there's another admin? tbd) | ||
| isAdmin !== (pseudoAccount.dg_account === myUserId) && ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The current predicate hides the Remove button for the signed-in admin unconditionally, so admins can never leave a group through this UI even when other admins exist. This creates a management dead-end for legitimate admin users; notably, the backend policy already permits self-removal ( Useful? React with 👍 / 👎. |
||
| <form action={removeSpace}> | ||
| <input type="hidden" name="memberId" value={memberId} /> | ||
| <Button type="submit" variant="destructive" size="sm"> | ||
| Remove | ||
| </Button> | ||
| </form> | ||
| )} | ||
| </li> | ||
| ); | ||
| })} | ||
| </ul> | ||
| )} | ||
| </section> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| 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 ( | ||
| <section className="space-y-3"> | ||
| <h2 className="text-lg font-semibold">Join a group</h2> | ||
| {joined && ( | ||
| <p className="text-sm text-green-600">Successfully joined the group.</p> | ||
| )} | ||
| {error && <p className="text-destructive text-sm">{error}</p>} | ||
| <form action={joinGroup} className="flex gap-2"> | ||
| <Input | ||
| name="token" | ||
| placeholder="Paste your invitation token" | ||
| className="max-w-sm" | ||
| /> | ||
| <Button type="submit">Join Group</Button> | ||
| </form> | ||
| </section> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import * as React from "react"; | ||
|
|
||
| import { cn } from "@repo/ui/lib/utils"; | ||
|
|
||
| const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( | ||
| ({ className, type, ...props }, ref) => { | ||
| return ( | ||
| <input | ||
| type={type} | ||
| className={cn( | ||
| "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", | ||
| className, | ||
| )} | ||
| ref={ref} | ||
| {...props} | ||
| /> | ||
| ); | ||
| }, | ||
| ); | ||
| Input.displayName = "Input"; | ||
|
|
||
| export { Input }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: shouldn't need
<main>and<div>, just use<main>