From cc1e9d27f99392e3cf71970d885092084afcf56b Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 19:55:25 -0400 Subject: [PATCH 01/11] Add persisted workspace creation --- apps/web/app/api/workspaces/route.ts | 97 +++++++++++++++++++ apps/web/components/mail/components/mail.tsx | 59 ++++++++--- .../mail/components/workspace-sidebar.tsx | 66 ++++++++++--- 3 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 apps/web/app/api/workspaces/route.ts diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts new file mode 100644 index 0000000..c6b5911 --- /dev/null +++ b/apps/web/app/api/workspaces/route.ts @@ -0,0 +1,97 @@ +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const createWorkspaceSchema = z.object({ + name: z.string().trim().min(1).max(80), + image: z.string().url().optional().nullable(), +}); + +export async function GET() { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const memberships = await prisma.membership.findMany({ + where: { userId: session.user.id }, + include: { + organization: { + include: { + emailAccounts: { + select: { + id: true, + name: true, + email: true, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }, + }, + orderBy: { id: "asc" }, + }); + + return NextResponse.json({ + workspaces: memberships.map((membership) => ({ + id: membership.organization.id, + name: membership.organization.name, + image: membership.organization.image, + role: membership.role, + emailAccounts: membership.organization.emailAccounts, + })), + }); +} + +export async function POST(request: Request) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const parsed = createWorkspaceSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid workspace payload" }, + { status: 400 }, + ); + } + + const workspace = await prisma.organization.create({ + data: { + name: parsed.data.name, + image: parsed.data.image || null, + membership: { + create: { + role: "OWNER", + userId: session.user.id, + }, + }, + }, + include: { + emailAccounts: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + return NextResponse.json( + { + workspace: { + id: workspace.id, + name: workspace.name, + image: workspace.image, + role: "OWNER", + emailAccounts: workspace.emailAccounts, + }, + }, + { status: 201 }, + ); +} diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index 19cac9b..22dd4df 100644 --- a/apps/web/components/mail/components/mail.tsx +++ b/apps/web/components/mail/components/mail.tsx @@ -38,7 +38,7 @@ import { ResizablePanelGroup, } from "@/components/ui/resizable"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import useSWR from "swr"; +import useSWR, { useSWRConfig } from "swr"; import { configAtom, openComposeAtom, @@ -125,6 +125,10 @@ export function Mail({ const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); const [file, setFile] = React.useState(); const [isUploading, setIsUploading] = React.useState(false); + const [workspaceName, setWorkspaceName] = React.useState(""); + const [workspaceError, setWorkspaceError] = React.useState( + null, + ); const [selectedTab, setSelectedTab] = useAtom(tabAtom); const [composeOpen, setComposeOpen] = useAtom(openComposeAtom); const [stateThreadsData, setStateThreadsData] = useAtom(threadsAtom); @@ -133,6 +137,7 @@ export function Mail({ ); const { edgestore } = useEdgeStore(); + const { mutate } = useSWRConfig(); const mail = useAtomValue(configAtom); @@ -246,7 +251,7 @@ export function Mail({ setCreateWorkspaceOpen(!createWorkspaceOpen)} + onOpenChange={setCreateWorkspaceOpen} > @@ -267,12 +272,21 @@ export function Mail({
- -
-
- - + { + setWorkspaceName(event.target.value); + setWorkspaceError(null); + }} + />
+ {workspaceError ? ( + + {workspaceError} + + ) : null}
@@ -284,8 +298,17 @@ export function Mail({ - + {isLoading ? ( + + ) : workspaces.length > 0 ? ( + workspaces.map((workspace) => ( + + )) + ) : ( + + )}
); } + +function getWorkspaceInitials(name: string) { + const initials = name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); + + return initials || "WS"; +} From 4741af19957c2b9db076e622753615e48ef4207b Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 20:09:16 -0400 Subject: [PATCH 02/11] Expand workspace email account support --- apps/web/app/api/workspaces/route.ts | 17 ++++ .../mail/components/account-switcher.tsx | 24 +++-- apps/web/components/mail/components/mail.tsx | 91 ++++++++++++++++++- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts index c6b5911..81aeccb 100644 --- a/apps/web/app/api/workspaces/route.ts +++ b/apps/web/app/api/workspaces/route.ts @@ -6,6 +6,15 @@ import { z } from "zod"; const createWorkspaceSchema = z.object({ name: z.string().trim().min(1).max(80), image: z.string().url().optional().nullable(), + emailAccounts: z + .array( + z.object({ + name: z.string().trim().min(1).max(80), + email: z.string().trim().email().max(255), + }), + ) + .max(10) + .optional(), }); export async function GET() { @@ -64,6 +73,14 @@ export async function POST(request: Request) { data: { name: parsed.data.name, image: parsed.data.image || null, + emailAccounts: parsed.data.emailAccounts?.length + ? { + create: parsed.data.emailAccounts.map((emailAccount) => ({ + name: emailAccount.name, + email: emailAccount.email, + })), + } + : undefined, membership: { create: { role: "OWNER", diff --git a/apps/web/components/mail/components/account-switcher.tsx b/apps/web/components/mail/components/account-switcher.tsx index 3a94fb6..9d28bb8 100644 --- a/apps/web/components/mail/components/account-switcher.tsx +++ b/apps/web/components/mail/components/account-switcher.tsx @@ -25,11 +25,24 @@ export function AccountSwitcher({ accounts, }: AccountSwitcherProps) { const [selectedAccount, setSelectedAccount] = React.useState( - accounts[0].email, + accounts[0]?.email || "", ); + const selectedAccountDetails = accounts.find( + (account) => account.email === selectedAccount, + ); + + React.useEffect(() => { + if (!accounts.some((account) => account.email === selectedAccount)) { + setSelectedAccount(accounts[0]?.email || ""); + } + }, [accounts, selectedAccount]); + + if (!accounts.length) { + return null; + } return ( - span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0", @@ -39,12 +52,9 @@ export function AccountSwitcher({ aria-label="Select account" > - {accounts.find((account) => account.email === selectedAccount)?.icon} + {selectedAccountDetails?.icon} - { - accounts.find((account) => account.email === selectedAccount) - ?.label - } + {selectedAccountDetails?.label} diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index 22dd4df..aa42c68 100644 --- a/apps/web/components/mail/components/mail.tsx +++ b/apps/web/components/mail/components/mail.tsx @@ -76,12 +76,27 @@ interface MailProps { navCollapsedSize: number; } +interface WorkspaceResponse { + workspaces: { + id: string; + name: string; + emailAccounts: { + id: string; + name: string; + email: string; + }[]; + }[]; +} + export function Mail({ accounts, defaultLayout = [225, 440, 655], defaultCollapsed = false, navCollapsedSize, }: MailProps) { + const { data: workspacesData } = + useSWR("/api/workspaces"); + const { data: threadsData, error: threadsError, @@ -126,6 +141,8 @@ export function Mail({ const [file, setFile] = React.useState(); const [isUploading, setIsUploading] = React.useState(false); const [workspaceName, setWorkspaceName] = React.useState(""); + const [emailAccountName, setEmailAccountName] = React.useState(""); + const [emailAccountEmail, setEmailAccountEmail] = React.useState(""); const [workspaceError, setWorkspaceError] = React.useState( null, ); @@ -141,6 +158,18 @@ export function Mail({ const mail = useAtomValue(configAtom); + const workspaceAccounts = + workspacesData?.workspaces.flatMap((workspace) => + workspace.emailAccounts.map((emailAccount) => ({ + label: emailAccount.name || workspace.name, + email: emailAccount.email, + icon: , + })), + ) ?? []; + const visibleAccounts = workspaceAccounts.length + ? workspaceAccounts + : accounts; + React.useEffect(() => { if (threadsData) { setStateThreadsData(threadsData); @@ -282,6 +311,34 @@ export function Mail({ }} /> +
+
+ + { + setEmailAccountName(event.target.value); + setWorkspaceError(null); + }} + /> +
+
+ + { + setEmailAccountEmail(event.target.value); + setWorkspaceError(null); + }} + /> +
+
{workspaceError ? ( {workspaceError} @@ -304,6 +361,20 @@ export function Mail({ return; } + const accountName = emailAccountName.trim(); + const accountEmail = emailAccountEmail.trim(); + if (accountName && !accountEmail) { + setWorkspaceError("Email account address is required."); + return; + } + if ( + accountEmail && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(accountEmail) + ) { + setWorkspaceError("Enter a valid email account address."); + return; + } + setWorkspaceError(null); setIsUploading(true); let image: string | undefined; @@ -322,7 +393,18 @@ export function Mail({ const response = await fetch("/api/workspaces", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, image }), + body: JSON.stringify({ + name, + image, + emailAccounts: accountEmail + ? [ + { + name: accountName || accountEmail, + email: accountEmail, + }, + ] + : undefined, + }), }); if (!response.ok) { @@ -333,6 +415,8 @@ export function Mail({ await mutate("/api/workspaces"); setWorkspaceName(""); + setEmailAccountName(""); + setEmailAccountEmail(""); setFile(undefined); setIsUploading(false); setCreateWorkspaceOpen(false); @@ -370,7 +454,10 @@ export function Mail({ isCollapsed ? "h-[52px] flex-col" : "px-2", )} > - +
From 9c4cd86a853a99f32478bc7afe47bd09a7951258 Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 20:20:32 -0400 Subject: [PATCH 03/11] Add workspace invite role support --- apps/web/app/api/workspaces/route.ts | 60 +++++++++++++- apps/web/components/mail/components/mail.tsx | 87 ++++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts index 81aeccb..70c9066 100644 --- a/apps/web/app/api/workspaces/route.ts +++ b/apps/web/app/api/workspaces/route.ts @@ -6,6 +6,16 @@ import { z } from "zod"; const createWorkspaceSchema = z.object({ name: z.string().trim().min(1).max(80), image: z.string().url().optional().nullable(), + invitedMembers: z + .array( + z.object({ + name: z.string().trim().max(80).optional(), + email: z.string().trim().email().max(255), + role: z.enum(["ADMIN", "USER"]).default("USER"), + }), + ) + .max(10) + .optional(), emailAccounts: z .array( z.object({ @@ -29,6 +39,23 @@ export async function GET() { include: { organization: { include: { + membership: { + select: { + id: true, + role: true, + invitedName: true, + invitedEmail: true, + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: { id: "asc" }, + }, emailAccounts: { select: { id: true, @@ -49,6 +76,7 @@ export async function GET() { name: membership.organization.name, image: membership.organization.image, role: membership.role, + members: membership.organization.membership, emailAccounts: membership.organization.emailAccounts, })), }); @@ -82,13 +110,36 @@ export async function POST(request: Request) { } : undefined, membership: { - create: { - role: "OWNER", - userId: session.user.id, - }, + create: [ + { + role: "OWNER", + userId: session.user.id, + }, + ...(parsed.data.invitedMembers ?? []).map((invitedMember) => ({ + role: invitedMember.role, + invitedName: invitedMember.name || null, + invitedEmail: invitedMember.email, + })), + ], }, }, include: { + membership: { + select: { + id: true, + role: true, + invitedName: true, + invitedEmail: true, + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, emailAccounts: { select: { id: true, @@ -106,6 +157,7 @@ export async function POST(request: Request) { name: workspace.name, image: workspace.image, role: "OWNER", + members: workspace.membership, emailAccounts: workspace.emailAccounts, }, }, diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index aa42c68..6bfc86d 100644 --- a/apps/web/components/mail/components/mail.tsx +++ b/apps/web/components/mail/components/mail.tsx @@ -80,6 +80,18 @@ interface WorkspaceResponse { workspaces: { id: string; name: string; + members: { + id: string; + role: "OWNER" | "ADMIN" | "USER"; + invitedName: string | null; + invitedEmail: string | null; + user: { + id: string; + name: string | null; + email: string | null; + image: string | null; + } | null; + }[]; emailAccounts: { id: string; name: string; @@ -143,6 +155,11 @@ export function Mail({ const [workspaceName, setWorkspaceName] = React.useState(""); const [emailAccountName, setEmailAccountName] = React.useState(""); const [emailAccountEmail, setEmailAccountEmail] = React.useState(""); + const [invitedMemberName, setInvitedMemberName] = React.useState(""); + const [invitedMemberEmail, setInvitedMemberEmail] = React.useState(""); + const [invitedMemberRole, setInvitedMemberRole] = React.useState< + "ADMIN" | "USER" + >("USER"); const [workspaceError, setWorkspaceError] = React.useState( null, ); @@ -339,6 +356,51 @@ export function Mail({ />
+
+
+ + { + setInvitedMemberName(event.target.value); + setWorkspaceError(null); + }} + /> +
+
+ + { + setInvitedMemberEmail(event.target.value); + setWorkspaceError(null); + }} + /> +
+
+ + +
+
{workspaceError ? ( {workspaceError} @@ -363,6 +425,8 @@ export function Mail({ const accountName = emailAccountName.trim(); const accountEmail = emailAccountEmail.trim(); + const inviteName = invitedMemberName.trim(); + const inviteEmail = invitedMemberEmail.trim(); if (accountName && !accountEmail) { setWorkspaceError("Email account address is required."); return; @@ -374,6 +438,17 @@ export function Mail({ setWorkspaceError("Enter a valid email account address."); return; } + if (inviteName && !inviteEmail) { + setWorkspaceError("Invite email address is required."); + return; + } + if ( + inviteEmail && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) + ) { + setWorkspaceError("Enter a valid invite email address."); + return; + } setWorkspaceError(null); setIsUploading(true); @@ -404,6 +479,15 @@ export function Mail({ }, ] : undefined, + invitedMembers: inviteEmail + ? [ + { + name: inviteName || undefined, + email: inviteEmail, + role: invitedMemberRole, + }, + ] + : undefined, }), }); @@ -417,6 +501,9 @@ export function Mail({ setWorkspaceName(""); setEmailAccountName(""); setEmailAccountEmail(""); + setInvitedMemberName(""); + setInvitedMemberEmail(""); + setInvitedMemberRole("USER"); setFile(undefined); setIsUploading(false); setCreateWorkspaceOpen(false); From 160a7ea59c9078fffd2187f4bc5b02c831e7a415 Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 20:26:40 -0400 Subject: [PATCH 04/11] Add workspace member invite API --- .../workspaces/[workspaceId]/members/route.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/web/app/api/workspaces/[workspaceId]/members/route.ts diff --git a/apps/web/app/api/workspaces/[workspaceId]/members/route.ts b/apps/web/app/api/workspaces/[workspaceId]/members/route.ts new file mode 100644 index 0000000..b465e05 --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/members/route.ts @@ -0,0 +1,128 @@ +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const inviteMemberSchema = z.object({ + name: z.string().trim().max(80).optional(), + email: z.string().trim().email().max(255), + role: z.enum(["ADMIN", "USER"]).default("USER"), +}); + +async function getWorkspaceMembership(workspaceId: string, userId: string) { + return prisma.membership.findFirst({ + where: { + organizationId: workspaceId, + userId, + }, + }); +} + +async function canManageWorkspace(workspaceId: string, userId: string) { + const membership = await getWorkspaceMembership(workspaceId, userId); + return membership?.role === "OWNER" || membership?.role === "ADMIN"; +} + +export async function GET( + _request: Request, + { params }: { params: { workspaceId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const membership = await getWorkspaceMembership( + params.workspaceId, + session.user.id, + ); + if (!membership) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const members = await prisma.membership.findMany({ + where: { organizationId: params.workspaceId }, + select: { + id: true, + role: true, + invitedName: true, + invitedEmail: true, + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: { id: "asc" }, + }); + + return NextResponse.json({ members }); +} + +export async function POST( + request: Request, + { params }: { params: { workspaceId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + if (!(await canManageWorkspace(params.workspaceId, session.user.id))) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const parsed = inviteMemberSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid member invite payload" }, + { status: 400 }, + ); + } + + const existingMembership = await prisma.membership.findFirst({ + where: { + organizationId: params.workspaceId, + OR: [ + { invitedEmail: parsed.data.email }, + { user: { email: parsed.data.email } }, + ], + }, + }); + if (existingMembership) { + return NextResponse.json( + { error: "Member already belongs to this workspace" }, + { status: 409 }, + ); + } + + const member = await prisma.membership.create({ + data: { + organizationId: params.workspaceId, + role: parsed.data.role, + invitedName: parsed.data.name || null, + invitedEmail: parsed.data.email, + }, + select: { + id: true, + role: true, + invitedName: true, + invitedEmail: true, + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }); + + return NextResponse.json({ member }, { status: 201 }); +} From f1d03f6a9d61a213b4814478a636218f6ed3d88f Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 20:30:31 -0400 Subject: [PATCH 05/11] Add workspace member role management --- .../[workspaceId]/members/[memberId]/route.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts diff --git a/apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts b/apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts new file mode 100644 index 0000000..6aa5fbf --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts @@ -0,0 +1,125 @@ +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const updateMemberSchema = z.object({ + role: z.enum(["ADMIN", "USER"]), +}); + +async function getOwnerMembership(workspaceId: string, userId: string) { + return prisma.membership.findFirst({ + where: { + organizationId: workspaceId, + userId, + role: "OWNER", + }, + }); +} + +async function getTargetMembership(workspaceId: string, memberId: string) { + return prisma.membership.findFirst({ + where: { + id: memberId, + organizationId: workspaceId, + }, + select: { + id: true, + role: true, + userId: true, + }, + }); +} + +export async function PATCH( + request: Request, + { params }: { params: { workspaceId: string; memberId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + if (!(await getOwnerMembership(params.workspaceId, session.user.id))) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const targetMembership = await getTargetMembership( + params.workspaceId, + params.memberId, + ); + if (!targetMembership) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + if (targetMembership.role === "OWNER") { + return NextResponse.json( + { error: "Owner role cannot be changed" }, + { status: 400 }, + ); + } + + const parsed = updateMemberSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid member payload" }, + { status: 400 }, + ); + } + + const member = await prisma.membership.update({ + where: { id: targetMembership.id }, + data: { role: parsed.data.role }, + select: { + id: true, + role: true, + invitedName: true, + invitedEmail: true, + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }); + + return NextResponse.json({ member }); +} + +export async function DELETE( + _request: Request, + { params }: { params: { workspaceId: string; memberId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + if (!(await getOwnerMembership(params.workspaceId, session.user.id))) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const targetMembership = await getTargetMembership( + params.workspaceId, + params.memberId, + ); + if (!targetMembership) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + if (targetMembership.role === "OWNER") { + return NextResponse.json( + { error: "Owner membership cannot be removed" }, + { status: 400 }, + ); + } + + await prisma.membership.delete({ + where: { id: targetMembership.id }, + }); + + return NextResponse.json({ success: true }); +} From 97661fa3a104b63aaf85b81abac6b036ac8ae6ce Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 20:34:02 -0400 Subject: [PATCH 06/11] Normalize workspace invite emails --- .../workspaces/[workspaceId]/members/route.ts | 7 +++++- apps/web/app/api/workspaces/route.ts | 25 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/workspaces/[workspaceId]/members/route.ts b/apps/web/app/api/workspaces/[workspaceId]/members/route.ts index b465e05..e03b05b 100644 --- a/apps/web/app/api/workspaces/[workspaceId]/members/route.ts +++ b/apps/web/app/api/workspaces/[workspaceId]/members/route.ts @@ -5,7 +5,12 @@ import { z } from "zod"; const inviteMemberSchema = z.object({ name: z.string().trim().max(80).optional(), - email: z.string().trim().email().max(255), + email: z + .string() + .trim() + .email() + .max(255) + .transform((email) => email.toLowerCase()), role: z.enum(["ADMIN", "USER"]).default("USER"), }); diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts index 70c9066..29f895a 100644 --- a/apps/web/app/api/workspaces/route.ts +++ b/apps/web/app/api/workspaces/route.ts @@ -10,20 +10,41 @@ const createWorkspaceSchema = z.object({ .array( z.object({ name: z.string().trim().max(80).optional(), - email: z.string().trim().email().max(255), + email: z + .string() + .trim() + .email() + .max(255) + .transform((email) => email.toLowerCase()), role: z.enum(["ADMIN", "USER"]).default("USER"), }), ) .max(10) + .refine( + (members) => + new Set(members.map((member) => member.email)).size === members.length, + "Invited members must have unique email addresses", + ) .optional(), emailAccounts: z .array( z.object({ name: z.string().trim().min(1).max(80), - email: z.string().trim().email().max(255), + email: z + .string() + .trim() + .email() + .max(255) + .transform((email) => email.toLowerCase()), }), ) .max(10) + .refine( + (accounts) => + new Set(accounts.map((account) => account.email)).size === + accounts.length, + "Email accounts must have unique email addresses", + ) .optional(), }); From 6cc67800fd2bae60429fefc6ba36eb2e2428fb15 Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 20:43:04 -0400 Subject: [PATCH 07/11] Support multiple workspace invites --- apps/web/components/mail/components/mail.tsx | 380 +++++++++++++------ 1 file changed, 270 insertions(+), 110 deletions(-) diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index 6bfc86d..2e37332 100644 --- a/apps/web/components/mail/components/mail.tsx +++ b/apps/web/components/mail/components/mail.tsx @@ -100,6 +100,17 @@ interface WorkspaceResponse { }[]; } +type WorkspaceEmailAccountDraft = { + name: string; + email: string; +}; + +type InvitedMemberDraft = { + name: string; + email: string; + role: "ADMIN" | "USER"; +}; + export function Mail({ accounts, defaultLayout = [225, 440, 655], @@ -153,13 +164,12 @@ export function Mail({ const [file, setFile] = React.useState(); const [isUploading, setIsUploading] = React.useState(false); const [workspaceName, setWorkspaceName] = React.useState(""); - const [emailAccountName, setEmailAccountName] = React.useState(""); - const [emailAccountEmail, setEmailAccountEmail] = React.useState(""); - const [invitedMemberName, setInvitedMemberName] = React.useState(""); - const [invitedMemberEmail, setInvitedMemberEmail] = React.useState(""); - const [invitedMemberRole, setInvitedMemberRole] = React.useState< - "ADMIN" | "USER" - >("USER"); + const [emailAccountDrafts, setEmailAccountDrafts] = React.useState< + WorkspaceEmailAccountDraft[] + >([{ name: "", email: "" }]); + const [invitedMemberDrafts, setInvitedMemberDrafts] = React.useState< + InvitedMemberDraft[] + >([{ name: "", email: "", role: "USER" }]); const [workspaceError, setWorkspaceError] = React.useState( null, ); @@ -186,6 +196,28 @@ export function Mail({ const visibleAccounts = workspaceAccounts.length ? workspaceAccounts : accounts; + const updateEmailAccountDraft = ( + index: number, + updates: Partial, + ) => { + setEmailAccountDrafts((drafts) => + drafts.map((draft, draftIndex) => + draftIndex === index ? { ...draft, ...updates } : draft, + ), + ); + setWorkspaceError(null); + }; + const updateInvitedMemberDraft = ( + index: number, + updates: Partial, + ) => { + setInvitedMemberDrafts((drafts) => + drafts.map((draft, draftIndex) => + draftIndex === index ? { ...draft, ...updates } : draft, + ), + ); + setWorkspaceError(null); + }; React.useEffect(() => { if (threadsData) { @@ -299,7 +331,7 @@ export function Mail({ open={createWorkspaceOpen} onOpenChange={setCreateWorkspaceOpen} > - + Create Workspace @@ -328,78 +360,159 @@ export function Mail({ }} /> -
-
- - { - setEmailAccountName(event.target.value); - setWorkspaceError(null); - }} - /> -
-
- - { - setEmailAccountEmail(event.target.value); - setWorkspaceError(null); - }} - /> -
+
+ {emailAccountDrafts.map((emailAccount, index) => ( +
+
+ + + updateEmailAccountDraft(index, { + name: event.target.value, + }) + } + /> +
+
+ + + updateEmailAccountDraft(index, { + email: event.target.value, + }) + } + /> +
+
+ +
+
+ ))} +
-
-
- - { - setInvitedMemberName(event.target.value); - setWorkspaceError(null); - }} - /> -
-
- - { - setInvitedMemberEmail(event.target.value); - setWorkspaceError(null); - }} - /> -
-
- - -
+
+ + + updateInvitedMemberDraft(index, { + name: event.target.value, + }) + } + /> +
+
+ + + updateInvitedMemberDraft(index, { + email: event.target.value, + }) + } + /> +
+
+ + +
+
+ +
+
+ ))} +
{workspaceError ? ( @@ -423,30 +536,82 @@ export function Mail({ return; } - const accountName = emailAccountName.trim(); - const accountEmail = emailAccountEmail.trim(); - const inviteName = invitedMemberName.trim(); - const inviteEmail = invitedMemberEmail.trim(); - if (accountName && !accountEmail) { + const emailAccounts = emailAccountDrafts + .map((emailAccount) => ({ + name: emailAccount.name.trim(), + email: emailAccount.email.trim(), + })) + .filter( + (emailAccount) => + emailAccount.name || emailAccount.email, + ); + const invitedMembers = invitedMemberDrafts + .map((invitedMember) => ({ + name: invitedMember.name.trim(), + email: invitedMember.email.trim(), + role: invitedMember.role, + })) + .filter( + (invitedMember) => + invitedMember.name || invitedMember.email, + ); + + if ( + emailAccounts.some( + (emailAccount) => !emailAccount.email, + ) + ) { setWorkspaceError("Email account address is required."); return; } if ( - accountEmail && - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(accountEmail) + emailAccounts.some( + (emailAccount) => + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + emailAccount.email, + ), + ) + ) { + setWorkspaceError("Enter valid email account addresses."); + return; + } + if ( + new Set( + emailAccounts.map((emailAccount) => + emailAccount.email.toLowerCase(), + ), + ).size !== emailAccounts.length ) { - setWorkspaceError("Enter a valid email account address."); + setWorkspaceError("Email account addresses must be unique."); return; } - if (inviteName && !inviteEmail) { + if ( + invitedMembers.some( + (invitedMember) => !invitedMember.email, + ) + ) { setWorkspaceError("Invite email address is required."); return; } if ( - inviteEmail && - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) + invitedMembers.some( + (invitedMember) => + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + invitedMember.email, + ), + ) + ) { + setWorkspaceError("Enter valid invite email addresses."); + return; + } + if ( + new Set( + invitedMembers.map((invitedMember) => + invitedMember.email.toLowerCase(), + ), + ).size !== invitedMembers.length ) { - setWorkspaceError("Enter a valid invite email address."); + setWorkspaceError("Invite email addresses must be unique."); return; } @@ -471,22 +636,18 @@ export function Mail({ body: JSON.stringify({ name, image, - emailAccounts: accountEmail - ? [ - { - name: accountName || accountEmail, - email: accountEmail, - }, - ] + emailAccounts: emailAccounts.length + ? emailAccounts.map((emailAccount) => ({ + name: emailAccount.name || emailAccount.email, + email: emailAccount.email, + })) : undefined, - invitedMembers: inviteEmail - ? [ - { - name: inviteName || undefined, - email: inviteEmail, - role: invitedMemberRole, - }, - ] + invitedMembers: invitedMembers.length + ? invitedMembers.map((invitedMember) => ({ + name: invitedMember.name || undefined, + email: invitedMember.email, + role: invitedMember.role, + })) : undefined, }), }); @@ -499,11 +660,10 @@ export function Mail({ await mutate("/api/workspaces"); setWorkspaceName(""); - setEmailAccountName(""); - setEmailAccountEmail(""); - setInvitedMemberName(""); - setInvitedMemberEmail(""); - setInvitedMemberRole("USER"); + setEmailAccountDrafts([{ name: "", email: "" }]); + setInvitedMemberDrafts([ + { name: "", email: "", role: "USER" }, + ]); setFile(undefined); setIsUploading(false); setCreateWorkspaceOpen(false); From ec7b80dcb3c047ee5bda5644a71bf4a3edf28648 Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 20:55:40 -0400 Subject: [PATCH 08/11] Add workspace email account management API --- .../email-accounts/[emailAccountId]/route.ts | 133 ++++++++++++++++++ .../[workspaceId]/email-accounts/route.ts | 120 ++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts create mode 100644 apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts diff --git a/apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts new file mode 100644 index 0000000..c4c385b --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/[emailAccountId]/route.ts @@ -0,0 +1,133 @@ +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const updateEmailAccountSchema = z.object({ + name: z.string().trim().min(1).max(80).optional(), + email: z + .string() + .trim() + .email() + .max(255) + .transform((email) => email.toLowerCase()) + .optional(), +}); + +async function canManageWorkspace(workspaceId: string, userId: string) { + const membership = await prisma.membership.findFirst({ + where: { + organizationId: workspaceId, + userId, + }, + }); + + return membership?.role === "OWNER" || membership?.role === "ADMIN"; +} + +async function getEmailAccount(workspaceId: string, emailAccountId: string) { + return prisma.emailAccount.findFirst({ + where: { + id: emailAccountId, + organizationId: workspaceId, + }, + select: { + id: true, + email: true, + }, + }); +} + +export async function PATCH( + request: Request, + { params }: { params: { workspaceId: string; emailAccountId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + if (!(await canManageWorkspace(params.workspaceId, session.user.id))) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const targetEmailAccount = await getEmailAccount( + params.workspaceId, + params.emailAccountId, + ); + if (!targetEmailAccount) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 404 }, + ); + } + + const parsed = updateEmailAccountSchema.safeParse(await request.json()); + if (!parsed.success || (!parsed.data.name && !parsed.data.email)) { + return NextResponse.json( + { error: "Invalid email account payload" }, + { status: 400 }, + ); + } + + if (parsed.data.email && parsed.data.email !== targetEmailAccount.email) { + const existingEmailAccount = await prisma.emailAccount.findFirst({ + where: { + organizationId: params.workspaceId, + email: parsed.data.email, + NOT: { id: targetEmailAccount.id }, + }, + }); + if (existingEmailAccount) { + return NextResponse.json( + { error: "Email account already exists in this workspace" }, + { status: 409 }, + ); + } + } + + const emailAccount = await prisma.emailAccount.update({ + where: { id: targetEmailAccount.id }, + data: parsed.data, + select: { + id: true, + name: true, + email: true, + }, + }); + + return NextResponse.json({ emailAccount }); +} + +export async function DELETE( + _request: Request, + { params }: { params: { workspaceId: string; emailAccountId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + if (!(await canManageWorkspace(params.workspaceId, session.user.id))) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const targetEmailAccount = await getEmailAccount( + params.workspaceId, + params.emailAccountId, + ); + if (!targetEmailAccount) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 404 }, + ); + } + + await prisma.emailAccount.delete({ + where: { id: targetEmailAccount.id }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts new file mode 100644 index 0000000..d97bfaa --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/email-accounts/route.ts @@ -0,0 +1,120 @@ +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const emailAccountSchema = z.object({ + name: z.string().trim().min(1).max(80), + email: z + .string() + .trim() + .email() + .max(255) + .transform((email) => email.toLowerCase()), +}); + +async function getWorkspaceMembership(workspaceId: string, userId: string) { + return prisma.membership.findFirst({ + where: { + organizationId: workspaceId, + userId, + }, + }); +} + +async function canManageWorkspace(workspaceId: string, userId: string) { + const membership = await getWorkspaceMembership(workspaceId, userId); + return membership?.role === "OWNER" || membership?.role === "ADMIN"; +} + +export async function GET( + _request: Request, + { params }: { params: { workspaceId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const membership = await getWorkspaceMembership( + params.workspaceId, + session.user.id, + ); + if (!membership) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const emailAccounts = await prisma.emailAccount.findMany({ + where: { organizationId: params.workspaceId }, + select: { + id: true, + name: true, + email: true, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ emailAccounts }); +} + +export async function POST( + request: Request, + { params }: { params: { workspaceId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + if (!(await canManageWorkspace(params.workspaceId, session.user.id))) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const parsed = emailAccountSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid email account payload" }, + { status: 400 }, + ); + } + + const accountCount = await prisma.emailAccount.count({ + where: { organizationId: params.workspaceId }, + }); + if (accountCount >= 10) { + return NextResponse.json( + { error: "A workspace can have up to 10 email accounts" }, + { status: 400 }, + ); + } + + const existingEmailAccount = await prisma.emailAccount.findFirst({ + where: { + organizationId: params.workspaceId, + email: parsed.data.email, + }, + }); + if (existingEmailAccount) { + return NextResponse.json( + { error: "Email account already exists in this workspace" }, + { status: 409 }, + ); + } + + const emailAccount = await prisma.emailAccount.create({ + data: { + organizationId: params.workspaceId, + name: parsed.data.name, + email: parsed.data.email, + }, + select: { + id: true, + name: true, + email: true, + }, + }); + + return NextResponse.json({ emailAccount }, { status: 201 }); +} From d22b78fbe26b8f3c87fb3ac3dc780dcd805f2578 Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 21:02:37 -0400 Subject: [PATCH 09/11] Add workspace email account management UI --- .../mail/components/workspace-sidebar.tsx | 484 +++++++++++++++--- 1 file changed, 424 insertions(+), 60 deletions(-) diff --git a/apps/web/components/mail/components/workspace-sidebar.tsx b/apps/web/components/mail/components/workspace-sidebar.tsx index 43629aa..be02fc8 100644 --- a/apps/web/components/mail/components/workspace-sidebar.tsx +++ b/apps/web/components/mail/components/workspace-sidebar.tsx @@ -1,27 +1,52 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { openCreateWorkspaceOpenAtom } from "@/utils/store"; import { useSetAtom } from "jotai"; -import { Building2, Loader2, Plus } from "lucide-react"; +import { Building2, Loader2, Mail, Plus, Save, Trash2 } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; +import * as React from "react"; import useSWR from "swr"; +type EmailAccount = { + id: string; + name: string; + email: string; +}; + type Workspace = { id: string; name: string; image: string | null; + role: "OWNER" | "ADMIN" | "USER"; + emailAccounts: EmailAccount[]; }; type WorkspacesResponse = { workspaces: Workspace[]; }; +type EmailAccountDraft = { + name: string; + email: string; +}; + const userNavigation = [ { name: "Usage", href: "/usage" }, { @@ -34,76 +59,415 @@ const userNavigation = [ export function WorkspaceSidebar() { const { data: session, status } = useSession(); const setCreateWorkspaceOpen = useSetAtom(openCreateWorkspaceOpenAtom); - const { data, isLoading } = useSWR( + const { data, isLoading, mutate } = useSWR( status === "authenticated" ? "/api/workspaces" : null, ); const workspaces = data?.workspaces ?? []; + const [managingWorkspaceId, setManagingWorkspaceId] = React.useState< + string | null + >(null); + const [emailAccountDraft, setEmailAccountDraft] = + React.useState({ name: "", email: "" }); + const [editingEmailAccounts, setEditingEmailAccounts] = React.useState< + Record + >({}); + const [emailAccountError, setEmailAccountError] = React.useState< + string | null + >(null); + const [isSavingEmailAccount, setIsSavingEmailAccount] = + React.useState(false); + const managingWorkspace = + workspaces.find((workspace) => workspace.id === managingWorkspaceId) ?? + null; + + React.useEffect(() => { + if (!managingWorkspace) { + return; + } + + setEditingEmailAccounts( + Object.fromEntries( + managingWorkspace.emailAccounts.map((emailAccount) => [ + emailAccount.id, + { + name: emailAccount.name, + email: emailAccount.email, + }, + ]), + ), + ); + setEmailAccountDraft({ name: "", email: "" }); + setEmailAccountError(null); + }, [managingWorkspace]); + + const addEmailAccount = async () => { + if (!managingWorkspace) { + return; + } + + const name = emailAccountDraft.name.trim(); + const email = emailAccountDraft.email.trim(); + if (!name || !email) { + setEmailAccountError("Name and email address are required."); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setEmailAccountError("Enter a valid email account address."); + return; + } + if ( + managingWorkspace.emailAccounts.some( + (emailAccount) => + emailAccount.email.toLowerCase() === email.toLowerCase(), + ) + ) { + setEmailAccountError("Email account already exists in this workspace."); + return; + } + + setIsSavingEmailAccount(true); + setEmailAccountError(null); + const response = await fetch( + `/api/workspaces/${managingWorkspace.id}/email-accounts`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email }), + }, + ); + setIsSavingEmailAccount(false); + + if (!response.ok) { + setEmailAccountError("Could not add email account."); + return; + } + + setEmailAccountDraft({ name: "", email: "" }); + await mutate(); + }; + + const updateEmailAccount = async (emailAccount: EmailAccount) => { + if (!managingWorkspace) { + return; + } + + const draft = editingEmailAccounts[emailAccount.id] ?? emailAccount; + const name = draft.name.trim(); + const email = draft.email.trim(); + if (!name || !email) { + setEmailAccountError("Name and email address are required."); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setEmailAccountError("Enter a valid email account address."); + return; + } + if ( + managingWorkspace.emailAccounts.some( + (account) => + account.id !== emailAccount.id && + account.email.toLowerCase() === email.toLowerCase(), + ) + ) { + setEmailAccountError("Email account already exists in this workspace."); + return; + } + + setIsSavingEmailAccount(true); + setEmailAccountError(null); + const response = await fetch( + `/api/workspaces/${managingWorkspace.id}/email-accounts/${emailAccount.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email }), + }, + ); + setIsSavingEmailAccount(false); + + if (!response.ok) { + setEmailAccountError("Could not update email account."); + return; + } + + await mutate(); + }; + + const deleteEmailAccount = async (emailAccount: EmailAccount) => { + if (!managingWorkspace) { + return; + } + + setIsSavingEmailAccount(true); + setEmailAccountError(null); + const response = await fetch( + `/api/workspaces/${managingWorkspace.id}/email-accounts/${emailAccount.id}`, + { method: "DELETE" }, + ); + setIsSavingEmailAccount(false); + + if (!response.ok) { + setEmailAccountError("Could not remove email account."); + return; + } + + await mutate(); + }; return ( -
-
-
- {isLoading ? ( - - ) : workspaces.length > 0 ? ( - workspaces.map((workspace) => ( - + ) : workspaces.length > 0 ? ( + workspaces.map((workspace) => { + const canManageEmailAccounts = + workspace.role === "OWNER" || workspace.role === "ADMIN"; + + return ( + + + + + + {workspace.name} + { + event.preventDefault(); + if (canManageEmailAccounts) { + setManagingWorkspaceId(workspace.id); + } + }} + > + + Manage email accounts + + {workspace.emailAccounts.length ? ( + <> + + {workspace.emailAccounts.slice(0, 3).map((account) => ( + + {account.email} + + ))} + + ) : null} + + + ); + }) + ) : ( + - )) - ) : ( - +
+ + {session && session.user && ( +
+ + + + + + {session?.user.name ? session.user.name[0] : ""} + + + + + {session.user.email} + {userNavigation.map((item) => ( + + + {item.name} + + + ))} + + +
)} -
+
+ { + if (!open) { + setManagingWorkspaceId(null); + } + }} + > + + + {managingWorkspace?.name} email accounts + + Add Gmail or Google Workspace accounts that belong to this + workspace. + + +
+ {managingWorkspace?.emailAccounts.length ? ( +
+ {managingWorkspace.emailAccounts.map((emailAccount) => { + const draft = editingEmailAccounts[emailAccount.id] ?? { + name: emailAccount.name, + email: emailAccount.email, + }; - {session && session.user && ( -
- - - - - - {session?.user.name ? session.user.name[0] : ""} - - - - - {session.user.email} - {userNavigation.map((item) => ( - - - {item.name} - - - ))} - - + return ( +
+
+ + { + setEditingEmailAccounts((drafts) => ({ + ...drafts, + [emailAccount.id]: { + ...draft, + name: event.target.value, + }, + })); + setEmailAccountError(null); + }} + /> +
+
+ + { + setEditingEmailAccounts((drafts) => ({ + ...drafts, + [emailAccount.id]: { + ...draft, + email: event.target.value, + }, + })); + setEmailAccountError(null); + }} + /> +
+
+ +
+
+ +
+
+ ); + })} +
+ ) : ( + + This workspace does not have email accounts yet. + + )} +
+
+ + { + setEmailAccountDraft((draft) => ({ + ...draft, + name: event.target.value, + })); + setEmailAccountError(null); + }} + /> +
+
+ + { + setEmailAccountDraft((draft) => ({ + ...draft, + email: event.target.value, + })); + setEmailAccountError(null); + }} + /> +
+
+ +
+
+ {emailAccountError ? ( + + {emailAccountError} + + ) : null}
- )} -
- +
+
+ ); } From f339ef7fc6cd712ff812ee5ed0e2fd25307d2518 Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 21:09:58 -0400 Subject: [PATCH 10/11] Add workspace member management UI --- .../mail/components/workspace-sidebar.tsx | 567 ++++++++++++++---- 1 file changed, 437 insertions(+), 130 deletions(-) diff --git a/apps/web/components/mail/components/workspace-sidebar.tsx b/apps/web/components/mail/components/workspace-sidebar.tsx index be02fc8..62d5810 100644 --- a/apps/web/components/mail/components/workspace-sidebar.tsx +++ b/apps/web/components/mail/components/workspace-sidebar.tsx @@ -19,7 +19,15 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { openCreateWorkspaceOpenAtom } from "@/utils/store"; import { useSetAtom } from "jotai"; -import { Building2, Loader2, Mail, Plus, Save, Trash2 } from "lucide-react"; +import { + Building2, + Loader2, + Mail, + Plus, + Save, + Trash2, + UserPlus, +} from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import * as React from "react"; import useSWR from "swr"; @@ -30,11 +38,25 @@ type EmailAccount = { email: string; }; +type WorkspaceMember = { + id: string; + role: "OWNER" | "ADMIN" | "USER"; + invitedName: string | null; + invitedEmail: string | null; + user: { + id: string; + name: string | null; + email: string | null; + image: string | null; + } | null; +}; + type Workspace = { id: string; name: string; image: string | null; role: "OWNER" | "ADMIN" | "USER"; + members: WorkspaceMember[]; emailAccounts: EmailAccount[]; }; @@ -47,6 +69,12 @@ type EmailAccountDraft = { email: string; }; +type MemberDraft = { + name: string; + email: string; + role: "ADMIN" | "USER"; +}; + const userNavigation = [ { name: "Usage", href: "/usage" }, { @@ -76,6 +104,16 @@ export function WorkspaceSidebar() { >(null); const [isSavingEmailAccount, setIsSavingEmailAccount] = React.useState(false); + const [memberDraft, setMemberDraft] = React.useState({ + name: "", + email: "", + role: "USER", + }); + const [editingMembers, setEditingMembers] = React.useState< + Record + >({}); + const [memberError, setMemberError] = React.useState(null); + const [isSavingMember, setIsSavingMember] = React.useState(false); const managingWorkspace = workspaces.find((workspace) => workspace.id === managingWorkspaceId) ?? null; @@ -96,8 +134,20 @@ export function WorkspaceSidebar() { ]), ), ); + setEditingMembers( + Object.fromEntries( + managingWorkspace.members.map((member) => [ + member.id, + { + role: member.role === "OWNER" ? "USER" : member.role, + }, + ]), + ), + ); setEmailAccountDraft({ name: "", email: "" }); + setMemberDraft({ name: "", email: "", role: "USER" }); setEmailAccountError(null); + setMemberError(null); }, [managingWorkspace]); const addEmailAccount = async () => { @@ -146,6 +196,105 @@ export function WorkspaceSidebar() { await mutate(); }; + const addMember = async () => { + if (!managingWorkspace) { + return; + } + + const name = memberDraft.name.trim(); + const email = memberDraft.email.trim(); + if (!email) { + setMemberError("Invite email address is required."); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setMemberError("Enter a valid invite email address."); + return; + } + if ( + managingWorkspace.members.some( + (member) => + getMemberEmail(member)?.toLowerCase() === email.toLowerCase(), + ) + ) { + setMemberError("Member already belongs to this workspace."); + return; + } + + setIsSavingMember(true); + setMemberError(null); + const response = await fetch( + `/api/workspaces/${managingWorkspace.id}/members`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name || undefined, + email, + role: memberDraft.role, + }), + }, + ); + setIsSavingMember(false); + + if (!response.ok) { + setMemberError("Could not invite member."); + return; + } + + setMemberDraft({ name: "", email: "", role: "USER" }); + await mutate(); + }; + + const updateMemberRole = async (member: WorkspaceMember) => { + if (!managingWorkspace || member.role === "OWNER") { + return; + } + + const draft = editingMembers[member.id] ?? { + role: member.role === "OWNER" ? "USER" : member.role, + }; + setIsSavingMember(true); + setMemberError(null); + const response = await fetch( + `/api/workspaces/${managingWorkspace.id}/members/${member.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: draft.role }), + }, + ); + setIsSavingMember(false); + + if (!response.ok) { + setMemberError("Could not update member role."); + return; + } + + await mutate(); + }; + + const deleteMember = async (member: WorkspaceMember) => { + if (!managingWorkspace || member.role === "OWNER") { + return; + } + + setIsSavingMember(true); + setMemberError(null); + const response = await fetch( + `/api/workspaces/${managingWorkspace.id}/members/${member.id}`, + { method: "DELETE" }, + ); + setIsSavingMember(false); + + if (!response.ok) { + setMemberError("Could not remove member."); + return; + } + + await mutate(); + }; + const updateEmailAccount = async (emailAccount: EmailAccount) => { if (!managingWorkspace) { return; @@ -225,7 +374,7 @@ export function WorkspaceSidebar() { ) : workspaces.length > 0 ? ( workspaces.map((workspace) => { - const canManageEmailAccounts = + const canManageWorkspace = workspace.role === "OWNER" || workspace.role === "ADMIN"; return ( @@ -250,16 +399,16 @@ export function WorkspaceSidebar() { > {workspace.name} { event.preventDefault(); - if (canManageEmailAccounts) { + if (canManageWorkspace) { setManagingWorkspaceId(workspace.id); } }} > - - Manage email accounts + + Manage workspace {workspace.emailAccounts.length ? ( <> @@ -331,139 +480,289 @@ export function WorkspaceSidebar() { > - {managingWorkspace?.name} email accounts + {managingWorkspace?.name} workspace - Add Gmail or Google Workspace accounts that belong to this - workspace. + Manage team members and connected Gmail or Google Workspace + accounts.
- {managingWorkspace?.emailAccounts.length ? ( -
- {managingWorkspace.emailAccounts.map((emailAccount) => { - const draft = editingEmailAccounts[emailAccount.id] ?? { - name: emailAccount.name, - email: emailAccount.email, - }; - - return ( -
-
- - { - setEditingEmailAccounts((drafts) => ({ - ...drafts, - [emailAccount.id]: { - ...draft, - name: event.target.value, - }, - })); - setEmailAccountError(null); - }} - /> -
-
- - { - setEditingEmailAccounts((drafts) => ({ - ...drafts, - [emailAccount.id]: { - ...draft, - email: event.target.value, - }, - })); - setEmailAccountError(null); - }} - /> -
-
- -
-
- -
-
- ); - })} +
+
+ + Team members
- ) : ( - - This workspace does not have email accounts yet. - - )} -
-
- - { - setEmailAccountDraft((draft) => ({ - ...draft, - name: event.target.value, - })); - setEmailAccountError(null); - }} - /> + {managingWorkspace?.members.length ? ( +
+ {managingWorkspace.members.map((member) => { + const draft = editingMembers[member.id] ?? { + role: member.role === "OWNER" ? "USER" : member.role, + }; + const canEditMember = + managingWorkspace.role === "OWNER" && + member.role !== "OWNER"; + + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ ); + })} +
+ ) : null} +
+
+ + { + setMemberDraft((draft) => ({ + ...draft, + name: event.target.value, + })); + setMemberError(null); + }} + /> +
+
+ + { + setMemberDraft((draft) => ({ + ...draft, + email: event.target.value, + })); + setMemberError(null); + }} + /> +
+
+ + +
+
+ +
-
- - { - setEmailAccountDraft((draft) => ({ - ...draft, - email: event.target.value, - })); - setEmailAccountError(null); - }} - /> + {memberError ? ( + + {memberError} + + ) : null} +
+
+
+ + Email accounts
-
- + {managingWorkspace?.emailAccounts.length ? ( +
+ {managingWorkspace.emailAccounts.map((emailAccount) => { + const draft = editingEmailAccounts[emailAccount.id] ?? { + name: emailAccount.name, + email: emailAccount.email, + }; + + return ( +
+
+ + { + setEditingEmailAccounts((drafts) => ({ + ...drafts, + [emailAccount.id]: { + ...draft, + name: event.target.value, + }, + })); + setEmailAccountError(null); + }} + /> +
+
+ + { + setEditingEmailAccounts((drafts) => ({ + ...drafts, + [emailAccount.id]: { + ...draft, + email: event.target.value, + }, + })); + setEmailAccountError(null); + }} + /> +
+
+ +
+
+ +
+
+ ); + })} +
+ ) : ( + + This workspace does not have email accounts yet. + + )} +
+
+ + { + setEmailAccountDraft((draft) => ({ + ...draft, + name: event.target.value, + })); + setEmailAccountError(null); + }} + /> +
+
+ + { + setEmailAccountDraft((draft) => ({ + ...draft, + email: event.target.value, + })); + setEmailAccountError(null); + }} + /> +
+
+ +
+ {emailAccountError ? ( + + {emailAccountError} + + ) : null}
- {emailAccountError ? ( - - {emailAccountError} - - ) : null}
@@ -471,6 +770,14 @@ export function WorkspaceSidebar() { ); } +function getMemberName(member: WorkspaceMember) { + return member.user?.name || member.invitedName || "Pending invite"; +} + +function getMemberEmail(member: WorkspaceMember) { + return member.user?.email || member.invitedEmail; +} + function getWorkspaceInitials(name: string) { const initials = name .split(/\s+/) From bee98fcb1ea1cf7fa1c22e0b8667c5b1aa9fffa0 Mon Sep 17 00:00:00 2001 From: VexCode24 Date: Mon, 25 May 2026 21:29:08 -0400 Subject: [PATCH 11/11] Add workspace details management --- .../app/api/workspaces/[workspaceId]/route.ts | 59 ++++++++++ .../mail/components/workspace-sidebar.tsx | 104 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/web/app/api/workspaces/[workspaceId]/route.ts diff --git a/apps/web/app/api/workspaces/[workspaceId]/route.ts b/apps/web/app/api/workspaces/[workspaceId]/route.ts new file mode 100644 index 0000000..119ebed --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/route.ts @@ -0,0 +1,59 @@ +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const updateWorkspaceSchema = z.object({ + name: z.string().trim().min(1).max(80), + image: z.string().trim().url().optional().nullable(), +}); + +async function canManageWorkspace(workspaceId: string, userId: string) { + const membership = await prisma.membership.findFirst({ + where: { + organizationId: workspaceId, + userId, + }, + select: { role: true }, + }); + + return membership?.role === "OWNER" || membership?.role === "ADMIN"; +} + +export async function PATCH( + request: Request, + { params }: { params: { workspaceId: string } }, +) { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + if (!(await canManageWorkspace(params.workspaceId, session.user.id))) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + const parsed = updateWorkspaceSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid workspace payload" }, + { status: 400 }, + ); + } + + const workspace = await prisma.organization.update({ + where: { id: params.workspaceId }, + data: { + name: parsed.data.name, + image: parsed.data.image || null, + }, + select: { + id: true, + name: true, + image: true, + }, + }); + + return NextResponse.json({ workspace }); +} diff --git a/apps/web/components/mail/components/workspace-sidebar.tsx b/apps/web/components/mail/components/workspace-sidebar.tsx index 62d5810..62c5937 100644 --- a/apps/web/components/mail/components/workspace-sidebar.tsx +++ b/apps/web/components/mail/components/workspace-sidebar.tsx @@ -69,6 +69,11 @@ type EmailAccountDraft = { email: string; }; +type WorkspaceDraft = { + name: string; + image: string; +}; + type MemberDraft = { name: string; email: string; @@ -104,6 +109,12 @@ export function WorkspaceSidebar() { >(null); const [isSavingEmailAccount, setIsSavingEmailAccount] = React.useState(false); + const [workspaceDraft, setWorkspaceDraft] = + React.useState({ name: "", image: "" }); + const [workspaceError, setWorkspaceError] = React.useState( + null, + ); + const [isSavingWorkspace, setIsSavingWorkspace] = React.useState(false); const [memberDraft, setMemberDraft] = React.useState({ name: "", email: "", @@ -134,6 +145,10 @@ export function WorkspaceSidebar() { ]), ), ); + setWorkspaceDraft({ + name: managingWorkspace.name, + image: managingWorkspace.image ?? "", + }); setEditingMembers( Object.fromEntries( managingWorkspace.members.map((member) => [ @@ -146,10 +161,48 @@ export function WorkspaceSidebar() { ); setEmailAccountDraft({ name: "", email: "" }); setMemberDraft({ name: "", email: "", role: "USER" }); + setWorkspaceError(null); setEmailAccountError(null); setMemberError(null); }, [managingWorkspace]); + const updateWorkspace = async () => { + if (!managingWorkspace) { + return; + } + + const name = workspaceDraft.name.trim(); + const image = workspaceDraft.image.trim(); + if (!name) { + setWorkspaceError("Workspace name is required."); + return; + } + if (image) { + try { + new URL(image); + } catch { + setWorkspaceError("Enter a valid workspace image URL."); + return; + } + } + + setIsSavingWorkspace(true); + setWorkspaceError(null); + const response = await fetch(`/api/workspaces/${managingWorkspace.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, image: image || null }), + }); + setIsSavingWorkspace(false); + + if (!response.ok) { + setWorkspaceError("Could not update workspace details."); + return; + } + + await mutate(); + }; + const addEmailAccount = async () => { if (!managingWorkspace) { return; @@ -487,6 +540,57 @@ export function WorkspaceSidebar() {
+
+
+ + Workspace details +
+
+
+ + { + setWorkspaceDraft((draft) => ({ + ...draft, + name: event.target.value, + })); + setWorkspaceError(null); + }} + /> +
+
+ + { + setWorkspaceDraft((draft) => ({ + ...draft, + image: event.target.value, + })); + setWorkspaceError(null); + }} + /> +
+
+ +
+
+ {workspaceError ? ( + + {workspaceError} + + ) : null} +