From e2add3f2c921e61b1b08021cb336d84313f20dbb Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Thu, 5 Mar 2026 16:47:40 +0530 Subject: [PATCH] feat: enable microsoft sign ups (#28080) * fix: trigger lingo.dev by removing duplicate value * under progress * wow this worked * migrate schema * fix types * fix import for google login * Add onboarding tests for Azure (Microsoft sign up) * add comments back * fix failing test * fix: update Outlook login configuration and improve type safety in authentication adapter - Set OUTLOOK_LOGIN_ENABLED to false in .env.example - Refactor getServerSideProps to directly use samlTenantID and samlProductID - Update linkAccount method in next-auth-custom-adapter for better type handling - Remove redundant comment in next-auth-options related to Azure AD email verification * remove log * remove debug log from signin callback in next-auth options * fixup * chore: standardize naming * chore: add primary calendar for outlook, verify email and auto link org for outlook * chore: helper fns * chore: implement cubic feedback * cleanup * chore: implement cubic feedback again * WIP design# * feat: login design * fix: map identity provider names correctly * 32px of mt * fix: login UI * fix: type check * fix: fix type check again * chore: update OAuth login tests * fixup * fix: bad import * chore: update tests * fixup * fix: locales test * chore: implement PR feedback and fix minor issues * fix: revert token spreading change * fix: merge conflicts * chore: revert signup view changes * fixup: bring back reverted changes because of merge conflicts * fix: disable email input when microsoft sign in is in progress * chore: implement cubic feedback * cleanup: unused variables * fix: address Cubic AI review feedback (confidence >= 9/10) - Remove userId (PII) from log payloads in updateProfilePhotoMicrosoft.ts - Replace text selectors with data-testid in locale.e2e.ts and oauth-provider.e2e.ts - Restore callbackUrl redirect parameter in signup link in login-view.tsx - Add data-testid='login-subtitle' to login page subtitle element Co-Authored-By: unknown <> * fix: use empty alt for decorative icon images in login view MicrosoftIcon and GoogleIcon are decorative (adjacent to text labels), so they should have empty alt attributes per accessibility best practices. Co-Authored-By: unknown <> * chore: implement cubic feedback * cleanup * fixup * chore: implement PR feedback * chore: implement feedback * fix: address PR review feedback - type safety and centralize constants - Replace non-null assertions (!) with proper null checks for OUTLOOK_CLIENT_ID/SECRET - Replace `as any` casting with `Record` for OAuth profile claims - Remove non-null assertion on account.access_token by adding conditional check - Centralize Outlook env constants in @calcom/lib/constants alongside MICROSOFT_CALENDAR_SCOPES - Add explanatory comment for getNextAuthProviderName usage in get.handler.ts Co-Authored-By: unknown <> * Revert "fix: address PR review feedback - type safety and centralize constants" This reverts commit 91ace141e6a28a23deea5897f7f9d6ad80319d84. * chore: implement feedback * chore: cleanup * chore: implement feedback * fix: merge conflicts * fix: revert formatting-only changes in packages/lib/constants.ts Co-Authored-By: unknown <> * fix: revert IdentityProvider enum location change in schema.prisma Co-Authored-By: unknown <> * chore: implement more PR feedback * fix: restore database-derived profileId from determineProfile in OAuth JWT The profileId regression was identified by Cubic AI (confidence 9/10). Previously, determineProfile's returned id was used to set profileId in the JWT via 'profileResult.id ?? token.profileId ?? null'. A recent refactor changed this to 'token.profileId ?? null', which drops the database-derived profile ID. On first OAuth login (or when profile switcher is disabled), token.profileId is likely null, so profileId would incorrectly be set to null even though determineProfile returned a valid profile with an id. This commit restores the correct priority chain: visitorProfileId ?? token.profileId ?? null Co-Authored-By: bot_apk * refactor: revert pure formatting and import reordering changes Co-Authored-By: rajiv@cal.com * fix: normalize determineProfile return type to use consistent 'id' field The determineProfile function returned a union type where one branch used 'id' and the other used 'profileId'. This caused TS2339 when destructuring 'id' from the result. Normalize the token.upId branch to also return 'id' (mapped from token.profileId) so the return type is consistent. Co-Authored-By: bot_apk * chore: add tests * reveret: profileId changes should be in a separate PR * fix: avoid logging entire existingUser object in OAuth JWT callback Revert to logging only { userId, upId } instead of the full existingUser object, which contains PII (email, name, identity provider details). This restores the previous safe logging pattern. Co-Authored-By: bot_apk * chore: remove profileId related tests --------- Co-authored-by: amrit Co-authored-by: Devanshu Sharma Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Sean Brydon Co-authored-by: bot_apk --- .env.example | 5 +- apps/web/lib/signup/getServerSideProps.tsx | 2 + apps/web/modules/auth/hooks/useLastUsed.tsx | 2 +- apps/web/modules/auth/login-view.tsx | 491 +++++--- apps/web/modules/auth/sso/provider-view.tsx | 2 + apps/web/modules/signup-view.tsx | 63 +- apps/web/playwright/locale.e2e.ts | 88 +- apps/web/playwright/login.oauth.e2e.ts | 6 +- apps/web/playwright/oauth-provider.e2e.ts | 3 +- apps/web/playwright/onboarding.e2e.ts | 1 + apps/web/public/microsoft-logo.svg | 8 + .../lib/auth/login/getServerSideProps.tsx | 4 +- apps/web/server/lib/constants.ts | 1 - .../oauth/updateProfilePhotoMicrosoft.test.ts | 214 ++++ .../oauth/updateProfilePhotoMicrosoft.ts | 43 + .../auth/lib/identityProviders.test.ts | 43 + .../features/auth/lib/identityProviders.ts | 21 + .../auth/lib/next-auth-options.test.ts | 1058 ++++++++++++++++- .../features/auth/lib/next-auth-options.ts | 358 ++++-- packages/features/auth/lib/outlook.test.ts | 66 + packages/features/auth/lib/outlook.ts | 8 + packages/lib/constants.ts | 7 + packages/lib/server/avatar.test.ts | 119 ++ packages/lib/server/avatar.ts | 17 +- .../lib/server/updateUserAvatarUrl.test.ts | 66 + packages/lib/server/updateUserAvatarUrl.ts | 11 + .../20250413115818_add_azure/migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../routers/viewer/me/get.handler.test.ts | 233 ++++ .../server/routers/viewer/me/get.handler.ts | 5 +- turbo.json | 1 + 31 files changed, 2602 insertions(+), 347 deletions(-) create mode 100644 apps/web/public/microsoft-logo.svg create mode 100644 packages/app-store/_utils/oauth/updateProfilePhotoMicrosoft.test.ts create mode 100644 packages/app-store/_utils/oauth/updateProfilePhotoMicrosoft.ts create mode 100644 packages/features/auth/lib/identityProviders.test.ts create mode 100644 packages/features/auth/lib/identityProviders.ts create mode 100644 packages/features/auth/lib/outlook.test.ts create mode 100644 packages/features/auth/lib/outlook.ts create mode 100644 packages/lib/server/avatar.test.ts create mode 100644 packages/lib/server/updateUserAvatarUrl.test.ts create mode 100644 packages/lib/server/updateUserAvatarUrl.ts create mode 100644 packages/prisma/migrations/20250413115818_add_azure/migration.sql create mode 100644 packages/trpc/server/routers/viewer/me/get.handler.test.ts diff --git a/.env.example b/.env.example index cef10a0cf0..350c86e9c1 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,10 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= NEXT_PUBLIC_FRESHCHAT_HOST= +# Microsoft OAuth credentials +OUTLOOK_LOGIN_ENABLED=false + + # For holiday feature: # Step-by-step: Get a Google Calendar API Key # 1. Go to Google Cloud Console: https://console.cloud.google.com/ @@ -127,7 +131,6 @@ NEXT_PUBLIC_FRESHCHAT_HOST= # 3. Enable Google Calendar API (APIs & Services → Library , Search for Google Calendar API) # 4. Create the API Key (APIs & Services → Credentials) GOOGLE_CALENDAR_API_KEY= - # Google OAuth credentials # To enable Login with Google you need to: # 1. Set `GOOGLE_API_CREDENTIALS` below diff --git a/apps/web/lib/signup/getServerSideProps.tsx b/apps/web/lib/signup/getServerSideProps.tsx index 0bb15253fa..d2014c5ba0 100644 --- a/apps/web/lib/signup/getServerSideProps.tsx +++ b/apps/web/lib/signup/getServerSideProps.tsx @@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { IS_OUTLOOK_LOGIN_ENABLED } from "@calcom/features/auth/lib/outlook"; import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; @@ -59,6 +60,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { const props = { redirectUrl, isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED, + isOutlookLoginEnabled: IS_OUTLOOK_LOGIN_ENABLED, isSAMLLoginEnabled, prepopulateFormValues: undefined, emailVerificationEnabled, diff --git a/apps/web/modules/auth/hooks/useLastUsed.tsx b/apps/web/modules/auth/hooks/useLastUsed.tsx index 0ab3a23f0a..93cef616ea 100644 --- a/apps/web/modules/auth/hooks/useLastUsed.tsx +++ b/apps/web/modules/auth/hooks/useLastUsed.tsx @@ -4,7 +4,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; import classNames from "@calcom/ui/classNames"; -type LoginType = "saml" | "google" | "credentials"; +type LoginType = "saml" | "google" | "microsoft" | "credentials"; export function useLastUsed() { const [lastUsed, setLastUsed] = useState(); diff --git a/apps/web/modules/auth/login-view.tsx b/apps/web/modules/auth/login-view.tsx index 5edc63d02d..bf7ac4fc18 100644 --- a/apps/web/modules/auth/login-view.tsx +++ b/apps/web/modules/auth/login-view.tsx @@ -1,17 +1,6 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import classNames from "classnames"; -import { signIn } from "next-auth/react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { FormProvider, useForm } from "react-hook-form"; -import { z } from "zod"; - -import { SAMLLogin } from "@calcom/web/modules/auth/components/SAMLLogin"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; -import { LastUsed, useLastUsed } from "@calcom/web/modules/auth/hooks/useLastUsed"; import { HOSTED_CAL_FEATURES, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import { emailRegex } from "@calcom/lib/emailSchema"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; @@ -19,17 +8,27 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Alert } from "@calcom/ui/components/alert"; -import { Button } from "@calcom/ui/components/button"; -import { EmailField, PasswordField } from "@calcom/ui/components/form"; - -import type { inferSSRProps } from "@lib/types/inferSSRProps"; - +import { Icon } from "@calcom/ui/components/icon"; +import { SAMLLogin } from "@calcom/web/modules/auth/components/SAMLLogin"; +import { LastUsed, useLastUsed } from "@calcom/web/modules/auth/hooks/useLastUsed"; import AddToHomescreen from "@components/AddToHomescreen"; import BackupCode from "@components/auth/BackupCode"; import TwoFactor from "@components/auth/TwoFactor"; -import AuthContainer from "@components/ui/AuthContainer"; - +import { Button } from "@coss/ui/components/button"; +import { Field, FieldLabel } from "@coss/ui/components/field"; +import { Input } from "@coss/ui/components/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@coss/ui/components/input-group"; +import { Separator } from "@coss/ui/components/separator"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { inferSSRProps } from "@lib/types/inferSSRProps"; import type { getServerSideProps } from "@server/lib/auth/login/getServerSideProps"; +import { Eye, EyeOff } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; interface LoginValues { email: string; @@ -39,13 +38,75 @@ interface LoginValues { csrfToken: string; } -const GoogleIcon = () => ( - Continue with Google Icon +const MicrosoftIcon = () => ( + ); + +const GoogleIcon = () => ( + +); + +function BackgroundGrid() { + const rows = 9; + const cols = 18; + const size = 60; + const gap = 8; + const radius = 8; + const width = cols * size + (cols - 1) * gap; + const height = rows * size + (rows - 1) * gap; + + return ( +
+ + + + + + + + + + + + + + + + {Array.from({ length: rows * cols }).map((_, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + const x = col * (size + gap); + const y = row * (size + gap); + return ( + + ); + })} + + +
+ ); +} export type PageProps = inferSSRProps; export default function Login({ csrfToken, isGoogleLoginEnabled, + isOutlookLoginEnabled, isSAMLLoginEnabled, samlTenantID, samlProductID, @@ -70,6 +131,7 @@ export default function Login({ const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [lastUsed, setLastUsed] = useLastUsed(); + const [showPassword, setShowPassword] = useState(false); const errorMessages: { [key: string]: string } = { // [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), @@ -93,57 +155,20 @@ export default function Login({ callbackUrl = safeCallbackUrl || ""; - const signupUrl = callbackUrl - ? `${WEBSITE_URL}/signup?redirect=${encodeURIComponent(callbackUrl)}` - : `${WEBSITE_URL}/signup`; + const { data, isPending, error } = trpc.viewer.public.ssoConnections.useQuery(); - const LoginFooter = ( - - {t("dont_have_an_account")} - + useEffect( + function refactorMeWithoutEffect() { + if (error) { + setErrorMessage(error.message); + } + }, + [error] ); - const TwoFactorFooter = ( - <> - - {!twoFactorLostAccess ? ( - - ) : null} - - ); - - const ExternalTotpFooter = ( - - ); + const displaySSOLogin = HOSTED_CAL_FEATURES + ? true + : isSAMLLoginEnabled && !isPending && data?.connectionExists; const onSubmit = async (values: LoginValues) => { setErrorMessage(null); @@ -165,132 +190,232 @@ export default function Login({ else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); }; - const { data, isPending, error } = trpc.viewer.public.ssoConnections.useQuery(); - - useEffect( - function refactorMeWithoutEffect() { - if (error) { - setErrorMessage(error.message); - } - }, - [error] - ); - - const displaySSOLogin = HOSTED_CAL_FEATURES - ? true - : isSAMLLoginEnabled && !isPending && data?.connectionExists; + const showSocialLogin = isGoogleLoginEnabled || isOutlookLoginEnabled; + const showSignupLink = + process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true" && searchParams?.get("register") !== "false"; return ( -
- - - {!twoFactorRequired && ( - <> -
- {isGoogleLoginEnabled && ( - - )} - {displaySSOLogin && ( - - )} -
- {(isGoogleLoginEnabled || displaySSOLogin) && ( -
-
-
- - {t("or").toLocaleLowerCase()} - -
-
+
+ + +
+ {/* Main Card */} +
+ {/* Logo */} +
+

Cal.com

+
+ + {/* Heading */} +

+ {twoFactorRequired ? t("2fa_code") : t("welcome_back_sign_in")} +

+ + + {/* Social Login Buttons */} + {!twoFactorRequired && showSocialLogin && ( + <> +
+ {isGoogleLoginEnabled && ( + + )} + {isOutlookLoginEnabled && ( + + )} +
+ + {/* Divider */} +
+ + {t("or").toLowerCase()} + +
+ + )} + +
+ + + {!twoFactorRequired && ( +
+ {/* Email Field */} + + {t("email")} + + {formState.errors.email && ( +

+ {formState.errors.email.message} +

+ )} +
+ + {/* Password Field */} + +
+ {t("password")} + + {t("forgot")} + +
+ + + + + + + {formState.errors.password && ( +

+ {formState.errors.password.message} +

+ )} +
)} - - )} - -
- -
-
-
- -
- -
- - {t("forgot")} - -
+ {/* Two Factor */} + {twoFactorRequired && ( +
+ {!twoFactorLostAccess ? : }
-
+ )} - {twoFactorRequired ? !twoFactorLostAccess ? : : null} + {/* Error Message */} + {errorMessage && } - {errorMessage && } + {/* Submit Button */} -
- - - + + + {/* Two Factor Footer */} + {twoFactorRequired && ( +
+ {!totpEmail ? ( + <> + + {!twoFactorLostAccess && ( + + )} + + ) : ( + + )} +
+ )} + +
+ + {/* Footer Links */} + {!twoFactorRequired && ( +
+ {showSignupLink && ( + + {t("create_account")} + + )} + {displaySSOLogin && ( + <> + {showSignupLink && ·} + + + )} +
+ )} +
+
); diff --git a/apps/web/modules/auth/sso/provider-view.tsx b/apps/web/modules/auth/sso/provider-view.tsx index bc9d5a5964..aebc25fa7c 100644 --- a/apps/web/modules/auth/sso/provider-view.tsx +++ b/apps/web/modules/auth/sso/provider-view.tsx @@ -32,6 +32,8 @@ export default function Provider(props: SSOProviderPageProps) { signIn("saml", {}, { tenant: props.tenant, product: props.product }); } else if (props.provider === "google" && email) { signIn("google", {}, { login_hint: email }); + } else if (props.provider === "microsoft") { + signIn("azure-ad"); } else { signIn(props.provider); } diff --git a/apps/web/modules/signup-view.tsx b/apps/web/modules/signup-view.tsx index e72fbde1b3..f315c9461f 100644 --- a/apps/web/modules/signup-view.tsx +++ b/apps/web/modules/signup-view.tsx @@ -187,6 +187,7 @@ export default function Signup({ token, orgSlug, isGoogleLoginEnabled, + isOutlookLoginEnabled, isSAMLLoginEnabled, orgAutoAcceptEmail, redirectUrl, @@ -198,6 +199,7 @@ export default function Signup({ const [premiumUsername, setPremiumUsername] = useState(false); const [usernameTaken, setUsernameTaken] = useState(false); const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const [isMicrosoftLoading, setIsMicrosoftLoading] = useState(false); const [accountUnderReview, setAccountUnderReview] = useState(false); const [displayEmailForm, setDisplayEmailForm] = useState(token); const [turnstileKey, setTurnstileKey] = useState(0); @@ -652,6 +654,7 @@ export default function Signup({
)} - {isGoogleLoginEnabled && ( + {isOutlookLoginEnabled && ( +
+ +
+ )} + + {(isGoogleLoginEnabled || isOutlookLoginEnabled) && (
@@ -714,7 +769,7 @@ export default function Signup({