Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
78 changes: 78 additions & 0 deletions apps/website/app/(home)/auth/group/[groupId]/page.tsx
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>
Copy link
Copy Markdown
Member

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>

<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;
30 changes: 23 additions & 7 deletions apps/website/app/(home)/auth/group/page.tsx
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>
Copy link
Copy Markdown
Member

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>

<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;
50 changes: 50 additions & 0 deletions apps/website/app/components/auth/CreateGroup.tsx
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>
);
};
49 changes: 49 additions & 0 deletions apps/website/app/components/auth/GroupInvite.tsx
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>
);
};
106 changes: 106 additions & 0 deletions apps/website/app/components/auth/GroupMemberList.tsx
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}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a unique key for each member-space row

Using space_id as the React key is not stable for this dataset: my_pseudo_accounts can contain multiple rows with the same space_id (for example, different group members who both have access to the same space). Duplicate keys cause incorrect reconciliation, which can mis-associate row state/actions like the Remove form after updates. Use a truly unique key such as id (or a composite like dg_account + space_id).

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) && (
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not block admins from removing their own membership

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 (member_id = auth.uid()), so the UI is stricter than the data model. The condition should allow self-removal when doing so would not orphan admin ownership.

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>
);
};
47 changes: 47 additions & 0 deletions apps/website/app/components/auth/JoinGroup.tsx
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>
);
};
22 changes: 22 additions & 0 deletions packages/ui/src/components/ui/input.tsx
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 };
Loading