diff --git a/apps/web/app/api/google/threads/route.ts b/apps/web/app/api/google/threads/route.ts index 37d7642..95a38b4 100644 --- a/apps/web/app/api/google/threads/route.ts +++ b/apps/web/app/api/google/threads/route.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import he from "he"; import { NextResponse } from "next/server"; +import type { Rule } from "@prisma/client"; import { parseMessages } from "@/utils/mail"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; @@ -12,7 +13,7 @@ import { getCategory } from "@/utils/redis/category"; import { getThreadsBatch, parseGmailApiResponse } from "@/utils/gmail/thread"; import { withError } from "@/utils/middleware"; import { createGmailLabel } from "./archive/controller"; -import { getEmailSummary } from "@/utils/langbase"; +import { getWorkspaceEmailAccountsForUser } from "@/utils/workspace"; export const dynamic = "force-dynamic"; @@ -26,6 +27,8 @@ const threadsQuery = z.object({ isTeam: z.coerce.boolean().nullish(), isCalendar: z.coerce.boolean().nullish(), isSent: z.coerce.boolean().nullish(), + workspaceId: z.string().nullish(), + emailAccountId: z.string().nullish(), }); export type ThreadsQuery = z.infer; export type ThreadsResponse = Awaited>; @@ -33,13 +36,37 @@ export type ThreadsResponse = Awaited>; async function getThreads(query: ThreadsQuery) { const session = await auth(); const email = session?.user.email; + const userId = session?.user.id; const emailDomain = email?.split("@")[1]; - if (!email) throw new Error("Not authenticated"); + if (!email || !userId) throw new Error("Not authenticated"); - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - const accessToken = token?.token; + let gmail = getGmailClient(session); + let token = await getGmailAccessToken(session); + let accessToken = token?.token; + + if (query.workspaceId) { + const emailAccounts = await getWorkspaceEmailAccountsForUser({ + userId, + organizationId: query.workspaceId, + }); + + if (!emailAccounts) throw new Error("Workspace not found"); + if (emailAccounts.length === 0) return { threads: [] }; + + const emailAccount = query.emailAccountId + ? emailAccounts.find((account) => account.id === query.emailAccountId) + : emailAccounts.find((account) => account.email === email) ?? + emailAccounts[0]; + + if (!emailAccount) return { threads: [] }; + + if (emailAccount.email !== email) { + if (!emailAccount.gmailAccessToken) return { threads: [] }; + gmail = getGmailClient({ accessToken: emailAccount.gmailAccessToken }); + accessToken = emailAccount.gmailAccessToken; + } + } if (!accessToken) throw new Error("Missing access token"); @@ -83,7 +110,9 @@ async function getThreads(query: ThreadsQuery) { maxResults: query.limit || 50, q: query.isTeam || query.isCalendar ? buildQuery() : undefined, }), - prisma.rule.findMany({ where: { userId: session.user.id } }), + prisma.rule.findMany({ where: { userId: session.user.id } }) as Promise< + Rule[] + >, ]); // may have been faster not using batch method, but doing 50 getMessages in parallel @@ -134,6 +163,8 @@ export const GET = withError(async (request: Request) => { const isTeam = searchParams.get("isTeam"); const isCalendar = searchParams.get("isCalendar"); const isSent = searchParams.get("isSent"); + const workspaceId = searchParams.get("workspaceId"); + const emailAccountId = searchParams.get("emailAccountId"); const query = threadsQuery.parse({ limit, fromEmail, @@ -142,6 +173,8 @@ export const GET = withError(async (request: Request) => { isTeam, isCalendar, isSent, + workspaceId, + emailAccountId, }); const threads = await getThreads(query); diff --git a/apps/web/app/api/workspaces/[id]/members/route.ts b/apps/web/app/api/workspaces/[id]/members/route.ts new file mode 100644 index 0000000..6be4c73 --- /dev/null +++ b/apps/web/app/api/workspaces/[id]/members/route.ts @@ -0,0 +1,98 @@ +import { MembershipRole } from "@prisma/client"; +import { sendWorkspaceInviteEmail } from "@inboxzero/resend"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { env } from "@/env.mjs"; +import { withError } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; +import { canManageWorkspace, getWorkspacesForUser } from "@/utils/workspace"; + +const inviteMemberBody = z.object({ + email: z.string().trim().email().max(255), + name: z.string().trim().max(80).optional(), + role: z.nativeEnum(MembershipRole).default(MembershipRole.USER), +}); + +export const POST = withError( + async ( + request: NextRequest, + { params }: { params: { id?: string | undefined } }, + ) => { + const session = await auth(); + if (!session?.user.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const organizationId = params.id; + if (!organizationId) { + return NextResponse.json( + { error: "Missing workspace id" }, + { status: 400 }, + ); + } + + const canManage = await canManageWorkspace({ + userId: session.user.id, + organizationId, + }); + if (!canManage) { + return NextResponse.json( + { error: "You do not have permission to manage this workspace" }, + { status: 403 }, + ); + } + + const body = inviteMemberBody.parse(await request.json()); + const workspace = await prisma.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + + if (!workspace) { + return NextResponse.json( + { error: "Workspace not found" }, + { status: 404 }, + ); + } + + const membership = await prisma.membership.upsert({ + where: { + organizationId_invitedEmail: { + organizationId, + invitedEmail: body.email, + }, + }, + update: { + invitedName: body.name, + role: body.role, + }, + create: { + organizationId, + invitedEmail: body.email, + invitedName: body.name, + role: body.role, + }, + select: { id: true }, + }); + + const acceptUrl = new URL( + `/api/workspaces/invitations/${membership.id}/accept`, + env.NEXT_PUBLIC_BASE_URL, + ).toString(); + + await sendWorkspaceInviteEmail({ + to: body.email, + emailProps: { + acceptUrl, + invitedByName: session.user.name ?? session.user.email, + workspaceName: workspace.name, + }, + }); + + return NextResponse.json({ + workspaces: await getWorkspacesForUser(session.user.id), + }); + }, +); diff --git a/apps/web/app/api/workspaces/invitations/[id]/accept/route.ts b/apps/web/app/api/workspaces/invitations/[id]/accept/route.ts new file mode 100644 index 0000000..837f271 --- /dev/null +++ b/apps/web/app/api/workspaces/invitations/[id]/accept/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; + +export const GET = withError( + async ( + request: NextRequest, + { params }: { params: { id?: string | undefined } }, + ) => { + const inviteId = params.id; + const mailUrl = new URL("/mail", request.url); + + if (!inviteId) { + return NextResponse.redirect(mailUrl); + } + + const session = await auth(); + if (!session?.user.id || !session.user.email) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("next", request.url); + return NextResponse.redirect(loginUrl); + } + + const membership = await prisma.membership.findUnique({ + where: { id: inviteId }, + select: { + id: true, + invitedEmail: true, + organizationId: true, + }, + }); + + if (!membership?.invitedEmail) { + return NextResponse.redirect(mailUrl); + } + + if ( + membership.invitedEmail.toLowerCase() !== session.user.email.toLowerCase() + ) { + return NextResponse.json( + { error: "This invitation was sent to a different email address" }, + { status: 403 }, + ); + } + + const existingMembership = await prisma.membership.findFirst({ + where: { + organizationId: membership.organizationId, + userId: session.user.id, + }, + select: { id: true }, + }); + + if (existingMembership && existingMembership.id !== membership.id) { + await prisma.membership.delete({ where: { id: membership.id } }); + } else { + await prisma.membership.update({ + where: { id: membership.id }, + data: { + userId: session.user.id, + invitedEmail: null, + invitedName: null, + }, + }); + } + + mailUrl.searchParams.set("workspaceId", membership.organizationId); + return NextResponse.redirect(mailUrl); + }, +); diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts new file mode 100644 index 0000000..4e7f093 --- /dev/null +++ b/apps/web/app/api/workspaces/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { + createWorkspaceForUser, + getWorkspacesForUser, +} from "@/utils/workspace"; + +const createWorkspaceBody = z.object({ + name: z.string().trim().min(1).max(80), + image: z.string().url().nullish(), +}); + +export type WorkspacesResponse = Awaited>; + +async function workspacesResponse(userId: string, workspaceId?: string) { + return { workspaces: await getWorkspacesForUser(userId), workspaceId }; +} + +export const GET = withError(async () => { + const session = await auth(); + if (!session?.user.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + return NextResponse.json(await workspacesResponse(session.user.id)); +}); + +export const POST = withError(async (request: NextRequest) => { + const session = await auth(); + if (!session?.user.id) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const body = createWorkspaceBody.parse(await request.json()); + const workspaceId = await createWorkspaceForUser({ + userId: session.user.id, + name: body.name, + image: body.image, + email: session.user.email, + userName: session.user.name, + }); + + return NextResponse.json( + await workspacesResponse(session.user.id, workspaceId), + ); +}); diff --git a/apps/web/components/mail/components/account-switcher.tsx b/apps/web/components/mail/components/account-switcher.tsx index 3a94fb6..e71714b 100644 --- a/apps/web/components/mail/components/account-switcher.tsx +++ b/apps/web/components/mail/components/account-switcher.tsx @@ -14,22 +14,45 @@ import { interface AccountSwitcherProps { isCollapsed: boolean; accounts: { + id?: string; label: string; email: string; icon: React.ReactNode; }[]; + selectedAccountId?: string | null; + onAccountChange?: (accountId: string) => void; } export function AccountSwitcher({ isCollapsed, accounts, + selectedAccountId, + onAccountChange, }: AccountSwitcherProps) { - const [selectedAccount, setSelectedAccount] = React.useState( - accounts[0].email, + const fallbackAccountId = accounts[0]?.id ?? accounts[0]?.email ?? ""; + const [internalAccountId, setInternalAccountId] = + React.useState(fallbackAccountId); + const selectedAccount = + selectedAccountId ?? internalAccountId ?? fallbackAccountId; + const selectedAccountOption = accounts.find( + (account) => (account.id ?? account.email) === selectedAccount, ); + React.useEffect(() => { + const selectedAccountExists = accounts.some( + (account) => (account.id ?? account.email) === internalAccountId, + ); + if (!selectedAccountExists) setInternalAccountId(fallbackAccountId); + }, [accounts, fallbackAccountId, internalAccountId]); + return ( - { + setInternalAccountId(value); + onAccountChange?.(value); + }} + > 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,18 +62,18 @@ export function AccountSwitcher({ aria-label="Select account" > - {accounts.find((account) => account.email === selectedAccount)?.icon} + {selectedAccountOption?.icon} - { - accounts.find((account) => account.email === selectedAccount) - ?.label - } + {selectedAccountOption?.label} {accounts.map((account) => ( - +
{account.icon} {account.email} diff --git a/apps/web/components/mail/components/mail.tsx b/apps/web/components/mail/components/mail.tsx index 19cac9b..c49a37b 100644 --- a/apps/web/components/mail/components/mail.tsx +++ b/apps/web/components/mail/components/mail.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; import { - AlertCircle, Archive, ArchiveX, Building, @@ -11,15 +10,12 @@ import { Gauge, Inbox as InboxIcon, Loader2, - MessagesSquare, Newspaper, Pencil, Plus, Send, - ShoppingCart, Star, Trash2, - Users2, } from "lucide-react"; import { AccountSwitcher } from "./account-switcher"; @@ -32,38 +28,44 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { useAtom } from "jotai"; +import useSWR, { useSWRConfig } from "swr"; import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import useSWR from "swr"; -import { - configAtom, openComposeAtom, openCreateWorkspaceOpenAtom, + selectedEmailAccountIdAtom, + selectedWorkspaceIdAtom, tabAtom, threadsAtom, } from "@/utils/store"; -import { ProfileDropdown } from "@/components/TopNav"; import { Inbox } from "@/components/mail/components/inbox"; import { Newsletters } from "@/components/mail/components/newsletters"; import { MailStats } from "@/components/mail/components/mail-stats"; -import { Button, ButtonLoader } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { WorkspaceSidebar } from "./workspace-sidebar"; import { Dialog, DialogContent, DialogFooter, DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; -import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { SingleImageDropzone } from "@/components/ui/single-image-dropzone"; import { useEdgeStore } from "@/utils/edgestore"; import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; +import type { WorkspacesResponse } from "@/app/api/workspaces/route"; + +type InviteRole = "ADMIN" | "USER"; interface MailProps { accounts: { @@ -82,11 +84,93 @@ export function Mail({ defaultCollapsed = false, navCollapsedSize, }: MailProps) { + const [selectedWorkspaceId, setSelectedWorkspaceId] = useAtom( + selectedWorkspaceIdAtom, + ); + const [selectedEmailAccountId, setSelectedEmailAccountId] = useAtom( + selectedEmailAccountIdAtom, + ); + const { data: workspacesData } = + useSWR("/api/workspaces"); + const workspaces = React.useMemo( + () => workspacesData?.workspaces ?? [], + [workspacesData?.workspaces], + ); + const selectedWorkspace = React.useMemo( + () => + workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? + workspaces[0], + [selectedWorkspaceId, workspaces], + ); + const workspaceAccounts = React.useMemo( + () => + selectedWorkspace?.emailAccounts.map((account) => ({ + id: account.id, + label: account.name, + email: account.email, + icon: , + })) ?? [], + [selectedWorkspace?.emailAccounts], + ); + const fallbackAccounts = React.useMemo( + () => + accounts.map((account) => ({ + ...account, + id: account.email, + })), + [accounts], + ); + const accountOptions = selectedWorkspace + ? workspaceAccounts + : fallbackAccounts; + + React.useEffect(() => { + if (!selectedWorkspace && !selectedWorkspaceId) return; + + const nextWorkspaceId = selectedWorkspace?.id ?? null; + if (nextWorkspaceId && nextWorkspaceId !== selectedWorkspaceId) { + setSelectedWorkspaceId(nextWorkspaceId); + } + }, [selectedWorkspace, selectedWorkspaceId, setSelectedWorkspaceId]); + + React.useEffect(() => { + if (!workspaceAccounts.length) { + setSelectedEmailAccountId(null); + return; + } + + const selectedAccountExists = workspaceAccounts.some( + (account) => account.id === selectedEmailAccountId, + ); + if (!selectedEmailAccountId || !selectedAccountExists) { + setSelectedEmailAccountId(workspaceAccounts[0].id); + } + }, [selectedEmailAccountId, setSelectedEmailAccountId, workspaceAccounts]); + + const threadsKey = React.useCallback( + (params?: Record) => { + if (!selectedWorkspaceId) return null; + + const searchParams = new URLSearchParams({ + workspaceId: selectedWorkspaceId, + }); + if (selectedEmailAccountId) { + searchParams.set("emailAccountId", selectedEmailAccountId); + } + Object.entries(params ?? {}).forEach(([key, value]) => { + searchParams.set(key, value); + }); + + return `/api/google/threads?${searchParams.toString()}`; + }, + [selectedEmailAccountId, selectedWorkspaceId], + ); + const { data: threadsData, error: threadsError, isLoading: threadsLoading, - } = useSWR("/api/google/threads", { + } = useSWR(threadsKey(), { keepPreviousData: true, }); @@ -94,7 +178,7 @@ export function Mail({ data: doneEmailsData, error: doneEmailsError, isLoading: doneEmailsLoading, - } = useSWR("/api/google/threads?isDone=true", { + } = useSWR(threadsKey({ isDone: "true" }), { keepPreviousData: true, }); @@ -102,7 +186,7 @@ export function Mail({ data: teamEmailsData, error: teamEmailsError, isLoading: teamEmailsLoading, - } = useSWR("/api/google/threads?isTeam=true", { + } = useSWR(threadsKey({ isTeam: "true" }), { keepPreviousData: true, }); @@ -110,7 +194,7 @@ export function Mail({ data: calendarEmailsData, error: calendarEmailsError, isLoading: calendarEmailsLoading, - } = useSWR("/api/google/threads?isCalendar=true", { + } = useSWR(threadsKey({ isCalendar: "true" }), { keepPreviousData: true, }); @@ -118,23 +202,28 @@ export function Mail({ data: sentEmailsData, error: sentEmailsError, isLoading: sentEmailsLoading, - } = useSWR("/api/google/threads?isSent=true", { + } = useSWR(threadsKey({ isSent: "true" }), { keepPreviousData: true, }); 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 [inviteEmail, setInviteEmail] = React.useState(""); + const [inviteRole, setInviteRole] = React.useState("USER"); const [selectedTab, setSelectedTab] = useAtom(tabAtom); - const [composeOpen, setComposeOpen] = useAtom(openComposeAtom); + const [, setComposeOpen] = useAtom(openComposeAtom); const [stateThreadsData, setStateThreadsData] = useAtom(threadsAtom); const [createWorkspaceOpen, setCreateWorkspaceOpen] = useAtom( openCreateWorkspaceOpenAtom, ); const { edgestore } = useEdgeStore(); - - const mail = useAtomValue(configAtom); + const { mutate } = useSWRConfig(); React.useEffect(() => { if (threadsData) { @@ -149,6 +238,81 @@ export function Mail({ setIsCollapsed(!isCollapsed); }; + const resetWorkspaceDialog = () => { + setFile(undefined); + setWorkspaceName(""); + setInviteEmail(""); + setInviteRole("USER"); + setWorkspaceError(null); + }; + + const createWorkspace = async () => { + const name = workspaceName.trim(); + if (!name) return; + + try { + setIsUploading(true); + setWorkspaceError(null); + + let image: string | null = null; + if (file) { + const upload = await edgestore.publicFiles.upload({ file }); + image = upload.url; + } + + const response = await fetch("/api/workspaces", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, image }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error?.error || "Could not create workspace"); + } + + let data = (await response.json()) as WorkspacesResponse; + const createdWorkspace = data.workspaces?.find( + (workspace: { name: string }) => workspace.name === name, + ); + const workspaceId = data.workspaceId ?? createdWorkspace?.id; + + if (workspaceId && inviteEmail.trim()) { + const inviteResponse = await fetch( + `/api/workspaces/${encodeURIComponent(workspaceId)}/members`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: inviteEmail.trim(), + role: inviteRole, + }), + }, + ); + + if (!inviteResponse.ok) { + const error = await inviteResponse.json(); + throw new Error(error?.error || "Could not invite teammate"); + } + + data = (await inviteResponse.json()) as WorkspacesResponse; + } + + if (workspaceId) { + setSelectedWorkspaceId(workspaceId); + window.localStorage.setItem("caley:selected-workspace", workspaceId); + } + + await mutate("/api/workspaces", data, { revalidate: false }); + setCreateWorkspaceOpen(false); + resetWorkspaceDialog(); + } catch (error) { + setWorkspaceError((error as Error).message); + } finally { + setIsUploading(false); + } + }; + const returnTab = () => { switch (selectedTab) { case "Inbox": @@ -246,7 +410,10 @@ export function Mail({ setCreateWorkspaceOpen(!createWorkspaceOpen)} + onOpenChange={(open) => { + setCreateWorkspaceOpen(open); + if (!open) resetWorkspaceDialog(); + }} > @@ -267,12 +434,47 @@ export function Mail({
- + setWorkspaceName(event.target.value)} + placeholder="Acme Support" + />
-
- - +
+
+ + setInviteEmail(event.target.value)} + placeholder="teammate@example.com" + /> +
+
+ + +
+ {workspaceError && ( +

{workspaceError}

+ )}
@@ -283,24 +485,8 @@ export function Mail({ Cancel
diff --git a/apps/web/components/mail/components/workspace-sidebar.tsx b/apps/web/components/mail/components/workspace-sidebar.tsx index a8d6cbc..a2b8599 100644 --- a/apps/web/components/mail/components/workspace-sidebar.tsx +++ b/apps/web/components/mail/components/workspace-sidebar.tsx @@ -6,11 +6,21 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { openCreateWorkspaceOpenAtom } from "@/utils/store"; -import { useSetAtom } from "jotai"; -import { Plus } from "lucide-react"; +import type { WorkspacesResponse } from "@/app/api/workspaces/route"; +import { + openCreateWorkspaceOpenAtom, + selectedWorkspaceIdAtom, +} from "@/utils/store"; +import { cn } from "@/utils"; +import { useAtom, useSetAtom } from "jotai"; +import { Loader2, Plus } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import Image from "next/image"; +import { useSearchParams } from "next/navigation"; +import * as React from "react"; +import useSWR from "swr"; + +const SELECTED_WORKSPACE_STORAGE_KEY = "caley:selected-workspace"; const userNavigation = [ { name: "Usage", href: "/usage" }, @@ -22,23 +32,101 @@ const userNavigation = [ ]; export function WorkspaceSidebar() { - const { data: session, status } = useSession(); + const { data: session } = useSession(); + const searchParams = useSearchParams(); + const workspaceIdFromUrl = searchParams.get("workspaceId"); const setCreateWorkspaceOpen = useSetAtom(openCreateWorkspaceOpenAtom); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useAtom( + selectedWorkspaceIdAtom, + ); + const { data, isLoading } = useSWR("/api/workspaces"); + const workspaces = React.useMemo( + () => data?.workspaces ?? [], + [data?.workspaces], + ); + + React.useEffect(() => { + if (workspaceIdFromUrl) { + setSelectedWorkspaceId(workspaceIdFromUrl); + window.localStorage.setItem( + SELECTED_WORKSPACE_STORAGE_KEY, + workspaceIdFromUrl, + ); + return; + } + + const storedWorkspaceId = window.localStorage.getItem( + SELECTED_WORKSPACE_STORAGE_KEY, + ); + + if (storedWorkspaceId) setSelectedWorkspaceId(storedWorkspaceId); + }, [setSelectedWorkspaceId, workspaceIdFromUrl]); + + React.useEffect(() => { + if (!workspaces.length) return; + + const selectedWorkspaceExists = workspaces.some( + (workspace) => workspace.id === selectedWorkspaceId, + ); + + if (!selectedWorkspaceId || !selectedWorkspaceExists) { + const nextWorkspaceId = workspaces[0].id; + setSelectedWorkspaceId(nextWorkspaceId); + window.localStorage.setItem( + SELECTED_WORKSPACE_STORAGE_KEY, + nextWorkspaceId, + ); + } + }, [selectedWorkspaceId, setSelectedWorkspaceId, workspaces]); + + const selectWorkspace = (workspaceId: string) => { + setSelectedWorkspaceId(workspaceId); + window.localStorage.setItem(SELECTED_WORKSPACE_STORAGE_KEY, workspaceId); + }; + return (
- - + {isLoading && ( +
+ +
+ )} + {workspaces.map((workspace) => { + const initials = workspace.name + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + const isSelected = workspace.id === selectedWorkspaceId; + + return ( + + ); + })} + + + If you were not expecting this invitation, you can ignore this + email. + + + + + + + ); +} + +const paragraph = { + fontSize: "14px", + lineHeight: "24px", +}; diff --git a/packages/resend/src/index.tsx b/packages/resend/src/index.tsx index 4f4b82a..d961b1b 100644 --- a/packages/resend/src/index.tsx +++ b/packages/resend/src/index.tsx @@ -2,6 +2,9 @@ import { JSXElementConstructor, ReactElement } from "react"; import { Resend } from "resend"; import { nanoid } from "nanoid"; import StatsUpdateEmail, { StatsUpdateEmailProps } from "../emails/stats"; +import WorkspaceInviteEmail, { + WorkspaceInviteEmailProps, +} from "../emails/workspace-invite"; const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) @@ -71,3 +74,26 @@ export const sendStatsEmail = async ({ ], }); }; + +export const sendWorkspaceInviteEmail = async ({ + to, + test, + emailProps, +}: { + to: string; + test?: boolean; + emailProps: WorkspaceInviteEmailProps; +}) => { + return sendEmail({ + to, + subject: `Join ${emailProps.workspaceName} on Caley.io`, + react: , + test, + tags: [ + { + name: "category", + value: "workspace_invite", + }, + ], + }); +};