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>
<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>
<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;
55 changes: 55 additions & 0 deletions apps/website/app/components/auth/CreateGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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={(formData) => {
void createGroupAction(formData);
}}
className="flex gap-2"
>
<Input name="name" placeholder="Group name" className="max-w-sm" />
<Button type="submit">Create Group</Button>
</form>
</section>
);
};
53 changes: 53 additions & 0 deletions apps/website/app/components/auth/GroupInvite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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={(formData) => {
void createToken(formData);
}}
>
<input type="hidden" name="admin" value="false" />
<Button type="submit" variant="outline">
Create member token
</Button>
</form>
</div>
</section>
);
};
110 changes: 110 additions & 0 deletions apps/website/app/components/auth/GroupMemberList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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}
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) && (
<form
action={(formData) => {
void removeSpace(formData);
}}
>
<input type="hidden" name="memberId" value={memberId} />
<Button type="submit" variant="destructive" size="sm">
Remove
</Button>
</form>
)}
</li>
);
})}
</ul>
)}
</section>
);
};
52 changes: 52 additions & 0 deletions apps/website/app/components/auth/JoinGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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={(formData) => {
void joinGroup(formData);
}}
className="flex gap-2"
>
<Input
name="token"
placeholder="Paste your invitation token"
className="max-w-sm"
/>
<Button type="submit">Join Group</Button>
</form>
</section>
);
};
Loading
Loading