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
74 changes: 36 additions & 38 deletions src/app/(app)/settings/_components/EmailAccountsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,50 +95,48 @@ export function EmailAccountsSection({

{initial.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-surface p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-petrol-50">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
</div>
<h2 className="m-0 mb-1 text-[15px] font-medium text-ink">
Connect Your Gmail
Connect Your Inbox
</h2>
<p className="m-0 mb-5 text-[13px] text-gray-500">
Read and send email from your Gmail inbox without leaving the portal.
Read and send email from the portal using your existing inbox.
</p>
<a
href="/api/oauth/google/start"
className="inline-flex h-9 items-center gap-2 rounded-md btn-primary px-4 text-[13px] font-medium text-white"
>
Connect Gmail
</a>
<div className="flex items-center justify-center gap-2">
<a
href="/api/oauth/google/start"
className="inline-flex h-9 items-center gap-2 rounded-md btn-primary px-4 text-[13px] font-medium text-white"
>
Connect Gmail
</a>
<a
href="/api/oauth/microsoft/start"
className="inline-flex h-9 items-center gap-2 rounded-md border border-gray-200 bg-white px-4 text-[13px] font-medium text-ink hover:border-petrol-300"
>
Connect Outlook
</a>
</div>
</div>
) : (
initial.map((acct) => (
<AccountBlock key={acct.id} acct={acct} />
))
<>
{initial.map((acct) => (
<AccountBlock key={acct.id} acct={acct} />
))}
<div className="mt-4 flex items-center justify-end gap-2">
<a
href="/api/oauth/google/start"
className="text-[12px] text-petrol-500 hover:text-petrol-700"
>
Connect Another Gmail
</a>
<span className="text-[12px] text-gray-300">·</span>
<a
href="/api/oauth/microsoft/start"
className="text-[12px] text-petrol-500 hover:text-petrol-700"
>
Connect Outlook
</a>
</div>
</>
)}
</section>
);
Expand Down
119 changes: 119 additions & 0 deletions src/app/api/oauth/microsoft/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createServiceClient } from "@/lib/supabase/service";
import {
exchangeCodeForTokens,
getUserInfo,
} from "@/lib/email/microsoft-oauth";
import { encryptToken } from "@/lib/email/crypto";
import { syncOutlookAccount } from "@/lib/email/sync";

export const dynamic = "force-dynamic";
export const maxDuration = 300;

export async function GET(req: NextRequest) {
const url = req.nextUrl;
const code = url.searchParams.get("code");
const stateFromQuery = url.searchParams.get("state");
const error = url.searchParams.get("error");
const stateCookie = req.cookies.get("microsoft_oauth_state")?.value;

if (error) {
return NextResponse.redirect(
new URL(`/settings?email_connect=error&reason=${error}`, req.url)
);
}
if (
!code ||
!stateFromQuery ||
!stateCookie ||
stateFromQuery !== stateCookie
) {
return NextResponse.redirect(
new URL("/settings?email_connect=error&reason=state_mismatch", req.url)
);
}

const sb = await createClient();
const {
data: { user },
} = await sb.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL("/login", req.url));
}

const { data: profile } = await sb
.from("profiles")
.select("org_id")
.eq("id", user.id)
.maybeSingle();
if (!profile?.org_id) {
return NextResponse.redirect(
new URL("/settings?email_connect=error&reason=no_org", req.url)
);
}

let tokens, userInfo;
try {
tokens = await exchangeCodeForTokens({ code, origin: url.origin });
userInfo = await getUserInfo(tokens.access_token);
} catch (e) {
console.error("MS OAuth exchange failed", e);
return NextResponse.redirect(
new URL(
"/settings?email_connect=error&reason=exchange_failed",
req.url
)
);
}

const svc = createServiceClient();
const expiresAt = new Date(
Date.now() + tokens.expires_in * 1000
).toISOString();

const { error: upsertErr } = await svc.from("channel_accounts").upsert(
{
org_id: profile.org_id,
user_id: user.id,
provider: "outlook",
address: userInfo.email,
display_name: userInfo.name ?? null,
access_token_encrypted: encryptToken(tokens.access_token),
refresh_token_encrypted: tokens.refresh_token
? encryptToken(tokens.refresh_token)
: null,
token_expires_at: expiresAt,
status: "active",
},
{ onConflict: "user_id,provider,address" }
);

if (upsertErr) {
console.error("channel_accounts upsert failed", upsertErr);
return NextResponse.redirect(
new URL("/settings?email_connect=error&reason=db_write", req.url)
);
}

const { data: justConnected } = await svc
.from("channel_accounts")
.select("id")
.eq("user_id", user.id)
.eq("provider", "outlook")
.eq("address", userInfo.email)
.maybeSingle();
if (justConnected?.id) {
try {
await syncOutlookAccount(justConnected.id as string);
} catch (e) {
console.error("initial outlook sync failed", e);
}
}

const redirect = NextResponse.redirect(
new URL("/settings?email_connect=success#email-accounts", req.url)
);
redirect.cookies.delete("microsoft_oauth_state");
return redirect;
}
29 changes: 29 additions & 0 deletions src/app/api/oauth/microsoft/start/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { createClient } from "@/lib/supabase/server";
import { buildAuthorizeUrl } from "@/lib/email/microsoft-oauth";

export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
const sb = await createClient();
const {
data: { user },
} = await sb.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL("/login", req.url));
}

const state = randomBytes(24).toString("hex");
const origin = req.nextUrl.origin;

const res = NextResponse.redirect(buildAuthorizeUrl({ origin, state }));
res.cookies.set("microsoft_oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 600,
path: "/",
});
return res;
}
121 changes: 121 additions & 0 deletions src/lib/email/microsoft-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
const OUTLOOK_SCOPES = [
"offline_access",
"openid",
"email",
"profile",
"User.Read",
"Mail.Read",
"Mail.Send",
"Mail.ReadWrite",
];

const TENANT = "common";

export function getRedirectUri(origin: string): string {
return `${origin}/api/oauth/microsoft/callback`;
}

export function buildAuthorizeUrl(opts: {
origin: string;
state: string;
}): string {
const clientId = process.env.MICROSOFT_CLIENT_ID;
if (!clientId) throw new Error("MICROSOFT_CLIENT_ID is not set.");

const params = new URLSearchParams({
client_id: clientId,
redirect_uri: getRedirectUri(opts.origin),
response_type: "code",
response_mode: "query",
scope: OUTLOOK_SCOPES.join(" "),
state: opts.state,
prompt: "consent",
});
return `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/authorize?${params.toString()}`;
}

export type MicrosoftTokenResponse = {
access_token: string;
refresh_token?: string;
expires_in: number;
scope: string;
token_type: string;
id_token?: string;
};

export async function exchangeCodeForTokens(opts: {
code: string;
origin: string;
}): Promise<MicrosoftTokenResponse> {
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("MICROSOFT_CLIENT_ID/SECRET not set.");
}
const res = await fetch(
`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code: opts.code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: getRedirectUri(opts.origin),
grant_type: "authorization_code",
scope: OUTLOOK_SCOPES.join(" "),
}),
}
);
if (!res.ok) {
throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`);
}
return (await res.json()) as MicrosoftTokenResponse;
}

export async function refreshAccessToken(opts: {
refreshToken: string;
}): Promise<MicrosoftTokenResponse> {
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("MICROSOFT_CLIENT_ID/SECRET not set.");
}
const res = await fetch(
`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: opts.refreshToken,
grant_type: "refresh_token",
scope: OUTLOOK_SCOPES.join(" "),
}),
}
);
if (!res.ok) {
throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`);
}
return (await res.json()) as MicrosoftTokenResponse;
}

export async function getUserInfo(
accessToken: string
): Promise<{ email: string; name?: string }> {
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
throw new Error(`Graph /me failed: ${res.status} ${await res.text()}`);
}
const body = (await res.json()) as {
mail?: string | null;
userPrincipalName?: string | null;
displayName?: string | null;
};
const email = (body.mail ?? body.userPrincipalName ?? "").toLowerCase();
if (!email) throw new Error("Graph /me returned no email address");
return { email, name: body.displayName ?? undefined };
}
Loading
Loading