From 35af361ba2722e4c2f8967e275cdf4088f97090e Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Fri, 29 Aug 2025 11:04:59 -0300 Subject: [PATCH 1/5] Fix account link not attaching to existing account --- apps/web/app/(app)/accounts/AddAccount.tsx | 39 +++++---- .../app/api/outlook/linking/auth-url/route.ts | 10 ++- .../app/api/outlook/linking/callback/route.ts | 87 +++++++++++++++++-- 3 files changed, 106 insertions(+), 30 deletions(-) diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index c1ffa0aa4a..d6c1794061 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { signIn } from "@/utils/auth-client"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -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,29 @@ export function AddAccount() { window.location.href = data.url; }; - const handleConnectMicrosoft = async () => { - await signIn.social({ - provider: "microsoft", - callbackURL: "/accounts", - scopes: [...OUTLOOK_SCOPES], - }); - }; - - const handleMergeMicrosoft = async () => { - const response = await fetch("/api/outlook/linking/auth-url", { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); + 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 data: GetOutlookAuthLinkUrlResponse = await response.json(); window.location.href = data.url; }; + const handleCreateMicrosoft = useCallback( + () => handleConnectMicrosoft("create"), + [], + ); + const handleMergeMicrosoft = useCallback( + () => handleConnectMicrosoft("merge"), + [], + ); + return ( @@ -68,7 +71,7 @@ export function AddAccount() { @@ -146,9 +149,9 @@ function AddEmailAccount({ 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..20a2208862 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,83 @@ 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, + }, + ); + 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: tokens.expires_at + ? new Date(tokens.expires_at * 1000) + : null, + 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) { From d0e3c55cb5d86bc53bd6a2449ae5621e1b9a887b Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Fri, 29 Aug 2025 11:12:38 -0300 Subject: [PATCH 2/5] Minor fix --- apps/web/app/(app)/accounts/AddAccount.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index d6c1794061..b96b53407e 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -149,9 +149,9 @@ function AddEmailAccount({ From 994dc97320dde3c5afc286c80977059ecf0fb53e Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Fri, 29 Aug 2025 11:15:03 -0300 Subject: [PATCH 3/5] Remove hook wrapper --- apps/web/app/(app)/accounts/AddAccount.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index b96b53407e..dad142a6e4 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState } from "react"; import { signIn } from "@/utils/auth-client"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -50,14 +50,8 @@ export function AddAccount() { window.location.href = data.url; }; - const handleCreateMicrosoft = useCallback( - () => handleConnectMicrosoft("create"), - [], - ); - const handleMergeMicrosoft = useCallback( - () => handleConnectMicrosoft("merge"), - [], - ); + const handleCreateMicrosoft = () => handleConnectMicrosoft("create"); + const handleMergeMicrosoft = () => handleConnectMicrosoft("merge"); return ( From 46243499f84c891a627a8297bf204fe84e0c5420 Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Fri, 29 Aug 2025 11:29:05 -0300 Subject: [PATCH 4/5] PR feedback --- apps/web/app/(app)/accounts/AddAccount.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index c1ffa0aa4a..69f6ed2eac 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -46,13 +46,17 @@ export function AddAccount() { }; const handleMergeMicrosoft = async () => { - const response = await fetch("/api/outlook/linking/auth-url", { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); + const response = await fetch("/api/outlook/linking/auth-url"); - const data: GetOutlookAuthLinkUrlResponse = await response.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; }; From 9f62408bd0e42718b6b2fc7b06be70564267ca1b Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Fri, 29 Aug 2025 11:34:35 -0300 Subject: [PATCH 5/5] PR feedback --- apps/web/app/api/outlook/linking/callback/route.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/app/api/outlook/linking/callback/route.ts b/apps/web/app/api/outlook/linking/callback/route.ts index 12c6b74856..9352af7144 100644 --- a/apps/web/app/api/outlook/linking/callback/route.ts +++ b/apps/web/app/api/outlook/linking/callback/route.ts @@ -131,20 +131,17 @@ export const GET = withError(async (request: NextRequest) => { targetUserId, }, ); - // Compute expires_at from tokens + let expiresAt: Date | null = null; if (tokens.expires_at) { - // If expires_at is provided (absolute timestamp) expiresAt = new Date(tokens.expires_at * 1000); } else if (tokens.expires_in) { - // If expires_in is provided (seconds from now) const expiresInSeconds = typeof tokens.expires_in === "string" ? Number.parseInt(tokens.expires_in, 10) : tokens.expires_in; expiresAt = new Date(Date.now() + expiresInSeconds * 1000); } - // If neither exists, expiresAt remains null const newAccount = await prisma.account.create({ data: {