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
45 changes: 39 additions & 6 deletions apps/web/app/api/google/threads/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -26,20 +27,46 @@ 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>>;

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");

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -142,6 +173,8 @@ export const GET = withError(async (request: Request) => {
isTeam,
isCalendar,
isSent,
workspaceId,
emailAccountId,
});

const threads = await getThreads(query);
Expand Down
98 changes: 98 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,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),
});
},
);
72 changes: 72 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,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);
},
);
49 changes: 49 additions & 0 deletions apps/web/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof workspacesResponse>>;

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),
);
});
41 changes: 32 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,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<string>(
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 (
<Select defaultValue={selectedAccount} onValueChange={setSelectedAccount}>
<Select
value={selectedAccount}
onValueChange={(value) => {
setInternalAccountId(value);
onAccountChange?.(value);
}}
>
<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 +62,18 @@ export function AccountSwitcher({
aria-label="Select account"
>
<SelectValue placeholder="Select an account">
{accounts.find((account) => account.email === selectedAccount)?.icon}
{selectedAccountOption?.icon}
<span className={cn("ml-2", isCollapsed && "hidden")}>
{
accounts.find((account) => account.email === selectedAccount)
?.label
}
{selectedAccountOption?.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