diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index c1ffa0aa4a..0e0fa9d008 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -15,7 +15,6 @@ import { Dialog } from "@/components/ui/dialog"; import type { GetAuthLinkUrlResponse } from "@/app/api/google/linking/auth-url/route"; import type { GetOutlookAuthLinkUrlResponse } from "@/app/api/outlook/linking/auth-url/route"; import { SCOPES as GMAIL_SCOPES } from "@/utils/gmail/scopes"; -import { SCOPES as OUTLOOK_SCOPES } from "@/utils/outlook/scopes"; export function AddAccount() { const handleConnectGoogle = async () => { @@ -37,25 +36,31 @@ export function AddAccount() { window.location.href = data.url; }; - const handleConnectMicrosoft = async () => { - await signIn.social({ - provider: "microsoft", - callbackURL: "/accounts", - scopes: [...OUTLOOK_SCOPES], - }); - }; + const handleConnectMicrosoft = async (action: "merge" | "create") => { + const response = await fetch( + `/api/outlook/linking/auth-url?action=${action}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + }, + ); - const handleMergeMicrosoft = async () => { - const response = await fetch("/api/outlook/linking/auth-url", { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); + if (!response.ok) { + toastError({ + title: "Error initiating Microsoft link", + description: "Please try again or contact support", + }); + return; + } const data: GetOutlookAuthLinkUrlResponse = await response.json(); window.location.href = data.url; }; + const handleCreateMicrosoft = () => handleConnectMicrosoft("create"); + const handleMergeMicrosoft = () => handleConnectMicrosoft("merge"); + return ( @@ -68,7 +73,7 @@ export function AddAccount() { diff --git a/apps/web/app/api/outlook/linking/auth-url/route.ts b/apps/web/app/api/outlook/linking/auth-url/route.ts index 0b75588385..4a32086e89 100644 --- a/apps/web/app/api/outlook/linking/auth-url/route.ts +++ b/apps/web/app/api/outlook/linking/auth-url/route.ts @@ -6,8 +6,8 @@ import { OUTLOOK_LINKING_STATE_COOKIE_NAME } from "@/utils/outlook/constants"; export type GetOutlookAuthLinkUrlResponse = { url: string }; -const getAuthUrl = ({ userId }: { userId: string }) => { - const stateObject = { userId, nonce: crypto.randomUUID() }; +const getAuthUrl = ({ userId, action }: { userId: string; action: string }) => { + const stateObject = { userId, action, nonce: crypto.randomUUID() }; const state = Buffer.from(JSON.stringify(stateObject)).toString("base64url"); const baseUrl = getLinkingOAuth2Url(); @@ -18,9 +18,11 @@ const getAuthUrl = ({ userId }: { userId: string }) => { export const GET = withAuth(async (request) => { const userId = request.auth.userId; - const { url, state } = getAuthUrl({ userId }); + const url = new URL(request.url); + const action = url.searchParams.get("action") || "merge"; + const { url: authUrl, state } = getAuthUrl({ userId, action }); - const response = NextResponse.json({ url }); + const response = NextResponse.json({ url: authUrl }); response.cookies.set(OUTLOOK_LINKING_STATE_COOKIE_NAME, state, { httpOnly: true, diff --git a/apps/web/app/api/outlook/linking/callback/route.ts b/apps/web/app/api/outlook/linking/callback/route.ts index dc3fe5dc06..9352af7144 100644 --- a/apps/web/app/api/outlook/linking/callback/route.ts +++ b/apps/web/app/api/outlook/linking/callback/route.ts @@ -32,7 +32,7 @@ export const GET = withError(async (request: NextRequest) => { return NextResponse.redirect(redirectUrl, { headers: response.headers }); } - let decodedState: { userId: string; nonce: string }; + let decodedState: { userId: string; action: string; nonce: string }; try { decodedState = JSON.parse( Buffer.from(storedState, "base64url").toString("utf8"), @@ -46,7 +46,7 @@ export const GET = withError(async (request: NextRequest) => { response.cookies.delete(OUTLOOK_LINKING_STATE_COOKIE_NAME); - const { userId: targetUserId } = decodedState; + const { userId: targetUserId, action } = decodedState; if (!code) { logger.warn("Missing code in Outlook linking callback"); @@ -114,12 +114,93 @@ export const GET = withError(async (request: NextRequest) => { }); if (!existingAccount) { - logger.warn( - "Merge Failed: Microsoft account not found in the system. Cannot merge.", - { email: providerEmail }, - ); - redirectUrl.searchParams.set("error", "account_not_found_for_merge"); - return NextResponse.redirect(redirectUrl, { headers: response.headers }); + if (action === "merge") { + logger.warn( + "Merge Failed: Microsoft account not found in the system. Cannot merge.", + { email: providerEmail }, + ); + redirectUrl.searchParams.set("error", "account_not_found_for_merge"); + return NextResponse.redirect(redirectUrl, { + headers: response.headers, + }); + } else { + logger.info( + "Creating new Microsoft account and linking to current user", + { + email: providerEmail, + targetUserId, + }, + ); + + let expiresAt: Date | null = null; + if (tokens.expires_at) { + expiresAt = new Date(tokens.expires_at * 1000); + } else if (tokens.expires_in) { + const expiresInSeconds = + typeof tokens.expires_in === "string" + ? Number.parseInt(tokens.expires_in, 10) + : tokens.expires_in; + expiresAt = new Date(Date.now() + expiresInSeconds * 1000); + } + + const newAccount = await prisma.account.create({ + data: { + userId: targetUserId, + type: "oidc", + provider: "microsoft", + providerAccountId: profile.id || providerEmail, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: expiresAt, + scope: tokens.scope, + token_type: tokens.token_type, + }, + }); + + let profileImage = null; + try { + const photoResponse = await fetch( + "https://graph.microsoft.com/v1.0/me/photo/$value", + { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }, + ); + + if (photoResponse.ok) { + const photoBuffer = await photoResponse.arrayBuffer(); + const photoBase64 = Buffer.from(photoBuffer).toString("base64"); + profileImage = `data:image/jpeg;base64,${photoBase64}`; + } + } catch (error) { + logger.warn("Failed to fetch profile picture", { error }); + } + + await prisma.emailAccount.create({ + data: { + email: providerEmail, + userId: targetUserId, + accountId: newAccount.id, + name: + profile.displayName || + profile.givenName || + profile.surname || + providerEmail, + image: profileImage, + }, + }); + + logger.info("Successfully created and linked new Microsoft account", { + email: providerEmail, + targetUserId, + accountId: newAccount.id, + }); + redirectUrl.searchParams.set("success", "account_created_and_linked"); + return NextResponse.redirect(redirectUrl, { + headers: response.headers, + }); + } } if (existingAccount.userId === targetUserId) {