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 }); +} 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 }); +} 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..e03b05b --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceId]/members/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 inviteMemberSchema = z.object({ + name: z.string().trim().max(80).optional(), + email: z + .string() + .trim() + .email() + .max(255) + .transform((email) => email.toLowerCase()), + 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 }); +} 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/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts new file mode 100644 index 0000000..29f895a --- /dev/null +++ b/apps/web/app/api/workspaces/route.ts @@ -0,0 +1,187 @@ +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(), + invitedMembers: z + .array( + z.object({ + name: z.string().trim().max(80).optional(), + 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) + .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(), +}); + +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: { + 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, + 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, + members: membership.organization.membership, + 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, + emailAccounts: parsed.data.emailAccounts?.length + ? { + create: parsed.data.emailAccounts.map((emailAccount) => ({ + name: emailAccount.name, + email: emailAccount.email, + })), + } + : undefined, + membership: { + 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, + name: true, + email: true, + }, + }, + }, + }); + + return NextResponse.json( + { + workspace: { + id: workspace.id, + name: workspace.name, + image: workspace.image, + role: "OWNER", + members: workspace.membership, + emailAccounts: workspace.emailAccounts, + }, + }, + { status: 201 }, + ); +} 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 19cac9b..2e37332 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, @@ -76,12 +76,50 @@ interface MailProps { navCollapsedSize: number; } +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; + email: string; + }[]; + }[]; +} + +type WorkspaceEmailAccountDraft = { + name: string; + email: string; +}; + +type InvitedMemberDraft = { + name: string; + email: string; + role: "ADMIN" | "USER"; +}; + export function Mail({ accounts, defaultLayout = [225, 440, 655], defaultCollapsed = false, navCollapsedSize, }: MailProps) { + const { data: workspacesData } = + useSWR("/api/workspaces"); + const { data: threadsData, error: threadsError, @@ -125,6 +163,16 @@ 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 [emailAccountDrafts, setEmailAccountDrafts] = React.useState< + WorkspaceEmailAccountDraft[] + >([{ name: "", email: "" }]); + const [invitedMemberDrafts, setInvitedMemberDrafts] = React.useState< + InvitedMemberDraft[] + >([{ name: "", email: "", role: "USER" }]); + const [workspaceError, setWorkspaceError] = React.useState( + null, + ); const [selectedTab, setSelectedTab] = useAtom(tabAtom); const [composeOpen, setComposeOpen] = useAtom(openComposeAtom); const [stateThreadsData, setStateThreadsData] = useAtom(threadsAtom); @@ -133,9 +181,44 @@ export function Mail({ ); const { edgestore } = useEdgeStore(); + const { mutate } = useSWRConfig(); 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; + 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) { setStateThreadsData(threadsData); @@ -246,9 +329,9 @@ export function Mail({ setCreateWorkspaceOpen(!createWorkspaceOpen)} + onOpenChange={setCreateWorkspaceOpen} > - + Create Workspace @@ -267,12 +350,175 @@ export function Mail({
- + { + setWorkspaceName(event.target.value); + setWorkspaceError(null); + }} + />
-
- - +
+ {emailAccountDrafts.map((emailAccount, index) => ( +
+
+ + + updateEmailAccountDraft(index, { + name: event.target.value, + }) + } + /> +
+
+ + + updateEmailAccountDraft(index, { + email: event.target.value, + }) + } + /> +
+
+ +
+
+ ))} + +
+
+ {invitedMemberDrafts.map((invitedMember, index) => ( +
+
+ + + updateInvitedMemberDraft(index, { + name: event.target.value, + }) + } + /> +
+
+ + + updateInvitedMemberDraft(index, { + email: event.target.value, + }) + } + /> +
+
+ + +
+
+ +
+
+ ))} +
+ {workspaceError ? ( + + {workspaceError} + + ) : null}
@@ -284,8 +530,96 @@ export function Mail({
diff --git a/apps/web/components/mail/components/workspace-sidebar.tsx b/apps/web/components/mail/components/workspace-sidebar.tsx index a8d6cbc..62c5937 100644 --- a/apps/web/components/mail/components/workspace-sidebar.tsx +++ b/apps/web/components/mail/components/workspace-sidebar.tsx @@ -1,16 +1,84 @@ 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 { Plus } from "lucide-react"; +import { + Building2, + Loader2, + Mail, + Plus, + Save, + Trash2, + UserPlus, +} from "lucide-react"; import { signOut, useSession } from "next-auth/react"; -import Image from "next/image"; +import * as React from "react"; +import useSWR from "swr"; + +type EmailAccount = { + id: string; + name: string; + 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[]; +}; + +type WorkspacesResponse = { + workspaces: Workspace[]; +}; + +type EmailAccountDraft = { + name: string; + email: string; +}; + +type WorkspaceDraft = { + name: string; + image: string; +}; + +type MemberDraft = { + name: string; + email: string; + role: "ADMIN" | "USER"; +}; const userNavigation = [ { name: "Usage", href: "/usage" }, @@ -24,57 +92,804 @@ const userNavigation = [ export function WorkspaceSidebar() { const { data: session, status } = useSession(); const setCreateWorkspaceOpen = useSetAtom(openCreateWorkspaceOpenAtom); + 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 [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: "", + 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; + + React.useEffect(() => { + if (!managingWorkspace) { + return; + } + + setEditingEmailAccounts( + Object.fromEntries( + managingWorkspace.emailAccounts.map((emailAccount) => [ + emailAccount.id, + { + name: emailAccount.name, + email: emailAccount.email, + }, + ]), + ), + ); + setWorkspaceDraft({ + name: managingWorkspace.name, + image: managingWorkspace.image ?? "", + }); + setEditingMembers( + Object.fromEntries( + managingWorkspace.members.map((member) => [ + member.id, + { + role: member.role === "OWNER" ? "USER" : member.role, + }, + ]), + ), + ); + 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; + } + + 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 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; + } + + 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) => { + const canManageWorkspace = + workspace.role === "OWNER" || workspace.role === "ADMIN"; - {session && session.user && ( -
- - - - - - {session?.user.name ? session.user.name[0] : ""} - - - - - {session.user.email} - {userNavigation.map((item) => ( - - - {item.name} - - - ))} - - + return ( + + + + + + {workspace.name} + { + event.preventDefault(); + if (canManageWorkspace) { + setManagingWorkspaceId(workspace.id); + } + }} + > + + Manage workspace + + {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} workspace + + Manage team members and connected Gmail or Google Workspace + accounts. + + +
+
+
+ + Workspace details +
+
+
+ + { + setWorkspaceDraft((draft) => ({ + ...draft, + name: event.target.value, + })); + setWorkspaceError(null); + }} + /> +
+
+ + { + setWorkspaceDraft((draft) => ({ + ...draft, + image: event.target.value, + })); + setWorkspaceError(null); + }} + /> +
+
+ +
+
+ {workspaceError ? ( + + {workspaceError} + + ) : null} +
+
+
+ + Team members +
+ {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); + }} + /> +
+
+ + +
+
+ +
+
+ {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} +
+
+
+
+ ); } + +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+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); + + return initials || "WS"; +}