Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/web/app/api/google/threads/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getThreadsBatch, parseGmailApiResponse } from "@/utils/gmail/thread";
import { withError } from "@/utils/middleware";
import { createGmailLabel } from "./archive/controller";
import { getEmailSummary } from "@/utils/langbase";
import { getWorkspaceForUser } from "@/utils/workspace";

export const dynamic = "force-dynamic";

Expand All @@ -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<typeof threadsQuery>;
export type ThreadsResponse = Awaited<ReturnType<typeof getThreads>>;
Expand All @@ -37,6 +40,25 @@ async function getThreads(query: ThreadsQuery) {

if (!email) throw new Error("Not authenticated");

if (query.workspaceId) {
const membership = await getWorkspaceForUser({
userId: session.user.id,
workspaceId: query.workspaceId,
});
if (!membership) throw new Error("Workspace not found");

const emailAccount = query.emailAccountId
? membership.organization.emailAccounts.find(
(account) => account.id === query.emailAccountId,
)
: membership.organization.emailAccounts[0];

if (!emailAccount) return { threads: [] };
if (emailAccount.email.toLowerCase() !== email.toLowerCase()) {
return { threads: [] };
}
}

const gmail = getGmailClient(session);
const token = await getGmailAccessToken(session);
const accessToken = token?.token;
Expand Down Expand Up @@ -134,6 +156,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,
Expand All @@ -142,6 +166,8 @@ export const GET = withError(async (request: Request) => {
isTeam,
isCalendar,
isSent,
workspaceId,
emailAccountId,
});

const threads = await getThreads(query);
Expand Down
49 changes: 49 additions & 0 deletions apps/web/app/api/workspaces/[id]/members/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { MembershipRole } from "@prisma/client";
import { sendWorkspaceInviteEmail } from "@inboxzero/resend";
import { 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 { inviteWorkspaceMember } from "@/utils/workspace";

const inviteMemberBody = z.object({
email: z.string().trim().email(),
name: z.string().trim().max(80).optional(),
role: z.nativeEnum(MembershipRole).default(MembershipRole.USER),
});

export const POST = withError(async (request, { params }) => {
const session = await auth();
if (!session?.user.email) throw new Error("Not authenticated");
if (!params.id) throw new Error("Missing workspace id");

const body = inviteMemberBody.parse(await request.json());
const workspace = await prisma.organization.findUniqueOrThrow({
where: { id: params.id },
select: { id: true, name: true },
});

const invite = await inviteWorkspaceMember({
workspaceId: workspace.id,
inviterUserId: session.user.id,
invitedEmail: body.email,
invitedName: body.name,
role: body.role,
});

await sendWorkspaceInviteEmail({
to: body.email,
emailProps: {
baseUrl: env.NEXT_PUBLIC_BASE_URL,
invitedByName: session.user.name || session.user.email,
workspaceName: workspace.name,
inviteUrl: invite.invitedEmail
? `${env.NEXT_PUBLIC_BASE_URL}/api/workspaces/invitations/${invite.id}/accept`
: `${env.NEXT_PUBLIC_BASE_URL}/mail`,
},
});

return NextResponse.json({ member: invite });
});
22 changes: 22 additions & 0 deletions apps/web/app/api/workspaces/invitations/[id]/accept/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/api/auth/[...nextauth]/auth";
import { env } from "@/env.mjs";
import { withError } from "@/utils/middleware";
import { acceptWorkspaceInvitation } from "@/utils/workspace";

export const GET = withError(async (_request, { params }) => {
const session = await auth();
if (!session?.user.email) {
return NextResponse.redirect(`${env.NEXT_PUBLIC_BASE_URL}/login`);
}

if (!params.id) throw new Error("Missing invitation id");

await acceptWorkspaceInvitation({
invitationId: params.id,
userId: session.user.id,
email: session.user.email,
});

return NextResponse.redirect(`${env.NEXT_PUBLIC_BASE_URL}/mail`);
});
99 changes: 99 additions & 0 deletions apps/web/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { MembershipRole } from "@prisma/client";
import { sendWorkspaceInviteEmail } from "@inboxzero/resend";
import { 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 {
createWorkspaceForUser,
ensurePersonalWorkspaceForUser,
getWorkspacesForUser,
inviteWorkspaceMember,
} from "@/utils/workspace";

const createWorkspaceBody = z.object({
name: z.string().trim().min(1).max(80),
image: z.string().url().nullish(),
inviteEmail: z.string().trim().email().optional().or(z.literal("")),
inviteName: z.string().trim().max(80).optional(),
inviteRole: z.nativeEnum(MembershipRole).optional(),
});

export type WorkspacesResponse = Awaited<ReturnType<typeof getWorkspaces>>;
export type CreateWorkspaceResponse = Awaited<
ReturnType<typeof createWorkspace>
>;

async function getWorkspaces() {
const session = await auth();
if (!session?.user.email) throw new Error("Not authenticated");

await ensurePersonalWorkspaceForUser({
userId: session.user.id,
email: session.user.email,
name: session.user.name,
image: session.user.image,
});

const workspaces = await getWorkspacesForUser(session.user.id);
return { workspaces };
}

async function createWorkspace(options: z.infer<typeof createWorkspaceBody>) {
const session = await auth();
if (!session?.user.email) throw new Error("Not authenticated");

const workspace = await createWorkspaceForUser({
userId: session.user.id,
email: session.user.email,
userName: session.user.name,
name: options.name,
image: options.image || null,
});

if (options.inviteEmail) {
const invite = await inviteWorkspaceMember({
workspaceId: workspace.id,
inviterUserId: session.user.id,
invitedEmail: options.inviteEmail,
invitedName: options.inviteName,
role: options.inviteRole || MembershipRole.USER,
});

await sendWorkspaceInviteEmail({
to: options.inviteEmail,
emailProps: {
baseUrl: env.NEXT_PUBLIC_BASE_URL,
invitedByName: session.user.name || session.user.email,
workspaceName: workspace.name,
inviteUrl: invite.invitedEmail
? `${env.NEXT_PUBLIC_BASE_URL}/api/workspaces/invitations/${invite.id}/accept`
: `${env.NEXT_PUBLIC_BASE_URL}/mail`,
},
});
}

return {
workspace: {
id: workspace.id,
name: workspace.name,
image: workspace.image,
role: MembershipRole.OWNER,
emailAccounts: workspace.emailAccounts.map((account) => ({
id: account.id,
name: account.name,
email: account.email,
})),
},
};
}

export const GET = withError(async () => {
return NextResponse.json(await getWorkspaces());
});

export const POST = withError(async (request: Request) => {
const body = createWorkspaceBody.parse(await request.json());
return NextResponse.json(await createWorkspace(body));
});
61 changes: 52 additions & 9 deletions apps/web/components/mail/components/account-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,65 @@ import {
interface AccountSwitcherProps {
isCollapsed: boolean;
accounts: {
id?: string;
label: string;
email: string;
icon: React.ReactNode;
}[];
selectedAccountId?: string | null;
onSelectAccount?: (accountId: string) => void;
}

export function AccountSwitcher({
isCollapsed,
accounts,
selectedAccountId,
onSelectAccount,
}: AccountSwitcherProps) {
const [selectedAccount, setSelectedAccount] = React.useState<string>(
accounts[0].email,
const fallbackAccountId = accounts[0]?.id || accounts[0]?.email;
const [localSelectedAccount, setLocalSelectedAccount] = React.useState<
string | undefined
>(fallbackAccountId);
const selectedAccount = selectedAccountId || localSelectedAccount;

React.useEffect(() => {
if (!selectedAccount && fallbackAccountId) {
setLocalSelectedAccount(fallbackAccountId);
onSelectAccount?.(fallbackAccountId);
}
}, [fallbackAccountId, onSelectAccount, selectedAccount]);

if (!accounts.length) {
return (
<Select disabled>
<SelectTrigger
className={cn(
"flex items-center gap-2 [&>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",
isCollapsed &&
"flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden",
)}
aria-label="No accounts"
>
<SelectValue placeholder={isCollapsed ? "" : "No accounts"} />
</SelectTrigger>
</Select>
);
}

const selectedValue =
selectedAccount || fallbackAccountId || accounts[0].email;
const selectedAccountDetails = accounts.find(
(account) => (account.id || account.email) === selectedValue,
);

return (
<Select defaultValue={selectedAccount} onValueChange={setSelectedAccount}>
<Select
value={selectedValue}
onValueChange={(accountId) => {
setLocalSelectedAccount(accountId);
onSelectAccount?.(accountId);
}}
>
<SelectTrigger
className={cn(
"flex items-center gap-2 [&>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",
Expand All @@ -39,18 +82,18 @@ export function AccountSwitcher({
aria-label="Select account"
>
<SelectValue placeholder="Select an account">
{accounts.find((account) => account.email === selectedAccount)?.icon}
{selectedAccountDetails?.icon}
<span className={cn("ml-2", isCollapsed && "hidden")}>
{
accounts.find((account) => account.email === selectedAccount)
?.label
}
{selectedAccountDetails?.label}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.email} value={account.email}>
<SelectItem
key={account.id || account.email}
value={account.id || account.email}
>
<div className="flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
{account.icon}
{account.email}
Expand Down
Loading