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<string, unknown>` 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 <apk@cognition.ai> * refactor: revert pure formatting and import reordering changes Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.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 <apk@cognition.ai> * 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 <apk@cognition.ai> * chore: remove profileId related tests --------- Co-authored-by: amrit <iamamrit27@gmail.com> Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Sean Brydon <sean@cal.com> Co-authored-by: bot_apk <apk@cognition.ai>
This commit is contained in:
co-authored by
bot_apk
amrit
Devanshu Sharma
Devin AI
Sean Brydon
parent
b7340f7151
commit
e2add3f2c9
+4
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LoginType>();
|
||||
|
||||
@@ -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 = () => (
|
||||
<img className="mr-2 w-4 h-4 text-subtle" src="/google-icon-colored.svg" alt="Continue with Google Icon" />
|
||||
const MicrosoftIcon = () => (
|
||||
<img className="size-4" src="/microsoft-logo.svg" alt="" />
|
||||
);
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<img className="size-4" src="/google-icon-colored.svg" alt="" />
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden">
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
fill="none"
|
||||
className="[--grid-fill:#f7f7f7] [--grid-stroke:rgba(34,42,53,0.08)] dark:[--grid-fill:#1f1f1f] dark:[--grid-stroke:rgba(255,255,255,0.08)]">
|
||||
<defs>
|
||||
<radialGradient id="gridFade" cx="50%" cy="50%" rx="70%" ry="70%">
|
||||
<stop offset="20%" stopColor="white" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<mask id="gridMask">
|
||||
<rect width={width} height={height} fill="url(#gridFade)" />
|
||||
</mask>
|
||||
<filter id="gridShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="4" floodColor="rgba(34,42,53,0.05)" />
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="rgba(19,19,22,0.03)" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g mask="url(#gridMask)">
|
||||
{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 (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={size}
|
||||
height={size}
|
||||
rx={radius}
|
||||
fill="var(--grid-fill)"
|
||||
stroke="var(--grid-stroke)"
|
||||
strokeWidth="1"
|
||||
filter="url(#gridShadow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
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<string | null>(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 = (
|
||||
<Link href={signupUrl} className="font-medium text-brand-500">
|
||||
{t("dont_have_an_account")}
|
||||
</Link>
|
||||
useEffect(
|
||||
function refactorMeWithoutEffect() {
|
||||
if (error) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
},
|
||||
[error]
|
||||
);
|
||||
|
||||
const TwoFactorFooter = (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (twoFactorLostAccess) {
|
||||
setTwoFactorLostAccess(false);
|
||||
methods.setValue("backupCode", "");
|
||||
} else {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
StartIcon="arrow-left"
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
{!twoFactorLostAccess ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(true);
|
||||
setErrorMessage(null);
|
||||
methods.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon="lock"
|
||||
color="minimal">
|
||||
{t("lost_access")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const ExternalTotpFooter = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.replace("/");
|
||||
}}
|
||||
color="minimal">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
);
|
||||
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 (
|
||||
<div className="text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
|
||||
<AuthContainer
|
||||
showLogo
|
||||
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
|
||||
footerText={
|
||||
twoFactorRequired
|
||||
? !totpEmail
|
||||
? TwoFactorFooter
|
||||
: ExternalTotpFooter
|
||||
: process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true" && searchParams?.get("register") !== "false"
|
||||
? LoginFooter
|
||||
: null
|
||||
}>
|
||||
<FormProvider {...methods}>
|
||||
{!twoFactorRequired && (
|
||||
<>
|
||||
<div className="stack-y-3">
|
||||
{isGoogleLoginEnabled && (
|
||||
<Button
|
||||
color="primary"
|
||||
className="justify-center w-full"
|
||||
disabled={formState.isSubmitting}
|
||||
data-testid="google"
|
||||
CustomStartIcon={<GoogleIcon />}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setLastUsed("google");
|
||||
await signIn("google", {
|
||||
callbackUrl,
|
||||
});
|
||||
}}>
|
||||
<span>{t("signin_with_google")}</span>
|
||||
{lastUsed === "google" && <LastUsed />}
|
||||
</Button>
|
||||
)}
|
||||
{displaySSOLogin && (
|
||||
<SAMLLogin
|
||||
disabled={formState.isSubmitting}
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isGoogleLoginEnabled || displaySSOLogin) && (
|
||||
<div className="my-8">
|
||||
<div className="flex relative items-center">
|
||||
<div className="border-t border-subtle grow" />
|
||||
<span className="mx-2 text-sm font-normal leading-none text-subtle shrink">
|
||||
{t("or").toLocaleLowerCase()}
|
||||
</span>
|
||||
<div className="border-t border-subtle grow" />
|
||||
</div>
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-default/80 px-4 py-10">
|
||||
<BackgroundGrid />
|
||||
|
||||
<div className="relative z-10 flex w-full max-w-md flex-col items-center">
|
||||
{/* Main Card */}
|
||||
<div className="w-full rounded-xl border border-subtle bg-default p-10 shadow-sm">
|
||||
{/* Logo */}
|
||||
<div className="mb-2 text-center">
|
||||
<h1 className="font-cal text-xl font-bold text-emphasis">Cal.com</h1>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<p className="mb-8 text-center text-sm text-subtle" data-testid="login-subtitle">
|
||||
{twoFactorRequired ? t("2fa_code") : t("welcome_back_sign_in")}
|
||||
</p>
|
||||
|
||||
<FormProvider {...methods}>
|
||||
{/* Social Login Buttons */}
|
||||
{!twoFactorRequired && showSocialLogin && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{isGoogleLoginEnabled && (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={formState.isSubmitting}
|
||||
data-testid="google"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setLastUsed("google");
|
||||
await signIn("google", {
|
||||
callbackUrl,
|
||||
});
|
||||
}}>
|
||||
<GoogleIcon />
|
||||
<span>{t("signin_with_google")}</span>
|
||||
{lastUsed === "google" && <LastUsed />}
|
||||
</Button>
|
||||
)}
|
||||
{isOutlookLoginEnabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
data-testid="microsoft"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setLastUsed("microsoft");
|
||||
await signIn("azure-ad", {
|
||||
callbackUrl,
|
||||
});
|
||||
}}>
|
||||
<MicrosoftIcon />
|
||||
<span>{t("signin_with_microsoft")}</span>
|
||||
{lastUsed === "microsoft" && <LastUsed />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-6 flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-sm text-zinc-400">{t("or").toLowerCase()}</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
|
||||
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...register("csrfToken")} />
|
||||
|
||||
{!twoFactorRequired && (
|
||||
<div className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<Field>
|
||||
<FieldLabel>{t("email")}</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
defaultValue={totpEmail || (searchParams?.get("email") as string)}
|
||||
autoComplete="email"
|
||||
{...register("email")}
|
||||
/>
|
||||
{formState.errors.email && (
|
||||
<p data-testid="field-error" className="text-destructive-foreground text-xs">
|
||||
{formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Password Field */}
|
||||
<Field>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<FieldLabel>{t("password")}</FieldLabel>
|
||||
<Link href="/auth/forgot-password" className="text-sm text-subtle hover:text-emphasis">
|
||||
{t("forgot")}
|
||||
</Link>
|
||||
</div>
|
||||
<InputGroup className="overflow-hidden">
|
||||
<InputGroupInput
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...register("password")}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? t("hide_password") : t("show_password")}>
|
||||
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{formState.errors.password && (
|
||||
<p data-testid="field-error" className="text-destructive-foreground text-xs">
|
||||
{formState.errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
|
||||
<div>
|
||||
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...register("csrfToken")} />
|
||||
</div>
|
||||
<div className="stack-y-6">
|
||||
<div
|
||||
className={classNames("stack-y-6", {
|
||||
hidden: twoFactorRequired,
|
||||
})}>
|
||||
<EmailField
|
||||
id="email"
|
||||
label={t("email_address")}
|
||||
defaultValue={totpEmail || (searchParams?.get("email") as string)}
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
{...register("email")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<PasswordField
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
required={!totpEmail}
|
||||
className="mb-0"
|
||||
{...register("password")}
|
||||
/>
|
||||
<div className="absolute -top-[2px] ltr:right-0 rtl:left-0">
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-default">
|
||||
{t("forgot")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* Two Factor */}
|
||||
{twoFactorRequired && (
|
||||
<div className="space-y-4">
|
||||
{!twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
|
||||
{/* Error Message */}
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} className="mt-4" />}
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
disabled={formState.isSubmitting}
|
||||
className="justify-center w-full">
|
||||
<span>{twoFactorRequired ? t("submit") : t("sign_in")}</span>
|
||||
{lastUsed === "credentials" && !twoFactorRequired && <LastUsed className="text-gray-600" />}
|
||||
variant="outline"
|
||||
className="mt-8 w-full"
|
||||
disabled={formState.isSubmitting}>
|
||||
{twoFactorRequired ? t("submit") : t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</AuthContainer>
|
||||
</form>
|
||||
|
||||
{/* Two Factor Footer */}
|
||||
{twoFactorRequired && (
|
||||
<div className="mt-4 flex justify-center gap-4">
|
||||
{!totpEmail ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (twoFactorLostAccess) {
|
||||
setTwoFactorLostAccess(false);
|
||||
methods.setValue("backupCode", "");
|
||||
} else {
|
||||
setTwoFactorRequired(false);
|
||||
methods.setValue("totpCode", "");
|
||||
}
|
||||
setErrorMessage(null);
|
||||
}}>
|
||||
<Icon name="arrow-left" className="mr-2 size-4" />
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
{!twoFactorLostAccess && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(true);
|
||||
setErrorMessage(null);
|
||||
methods.setValue("totpCode", "");
|
||||
}}>
|
||||
<Icon name="lock" className="mr-2 size-4" />
|
||||
{t("lost_access")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.location.replace("/");
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
{!twoFactorRequired && (
|
||||
<div className="mt-6 flex items-center justify-center gap-4 text-center">
|
||||
{showSignupLink && (
|
||||
<Link
|
||||
href={
|
||||
callbackUrl
|
||||
? `${WEBSITE_URL}/signup?redirect=${encodeURIComponent(callbackUrl)}`
|
||||
: `${WEBSITE_URL}/signup`
|
||||
}
|
||||
className="text-sm font-medium text-emphasis hover:underline">
|
||||
{t("create_account")}
|
||||
</Link>
|
||||
)}
|
||||
{displaySSOLogin && (
|
||||
<>
|
||||
{showSignupLink && <span className="text-subtle">·</span>}
|
||||
<SAMLLogin
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
setErrorMessage={setErrorMessage}
|
||||
color="minimal"
|
||||
StartIcon={undefined}
|
||||
className="text-sm font-medium text-emphasis hover:underline p-0 h-auto"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddToHomescreen />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Button
|
||||
color="primary"
|
||||
loading={isGoogleLoading}
|
||||
disabled={isMicrosoftLoading}
|
||||
CustomStartIcon={
|
||||
<>
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
@@ -684,8 +687,8 @@ export default function Signup({
|
||||
searchQueryParams.set("username", prepopulateFormValues.username);
|
||||
localStorage.setItem("username", prepopulateFormValues.username);
|
||||
}
|
||||
if (token) {
|
||||
searchQueryParams.set("email", prepopulateFormValues?.email);
|
||||
if (token && prepopulateFormValues?.email) {
|
||||
searchQueryParams.set("email", prepopulateFormValues.email);
|
||||
}
|
||||
const url = searchQueryParams.toString()
|
||||
? `${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`
|
||||
@@ -698,7 +701,59 @@ export default function Signup({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleLoginEnabled && (
|
||||
{isOutlookLoginEnabled && (
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
color="secondary"
|
||||
loading={isMicrosoftLoading}
|
||||
disabled={isGoogleLoading}
|
||||
CustomStartIcon={
|
||||
<>
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<img
|
||||
className={classNames(
|
||||
"text-subtle mr-2 h-4 w-4",
|
||||
premiumUsername && "opacity-50"
|
||||
)}
|
||||
src="/microsoft-logo.svg"
|
||||
alt="Continue with Microsoft Icon"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
className={classNames("w-full justify-center rounded-md text-center")}
|
||||
data-testid="continue-with-microsoft-button"
|
||||
onClick={async () => {
|
||||
posthog.capture("signup_microsoft_button_clicked", {
|
||||
has_token: !!token,
|
||||
is_org_invite: isOrgInviteByLink,
|
||||
org_slug: orgSlug,
|
||||
has_prepopulated_username: !!prepopulateFormValues?.username,
|
||||
});
|
||||
setIsSamlSignup(false);
|
||||
setIsMicrosoftLoading(true);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
const MICROSOFT_AUTH_URL = `${baseUrl}/auth/sso/microsoft`;
|
||||
const searchQueryParams = new URLSearchParams();
|
||||
if (prepopulateFormValues?.username) {
|
||||
// If username is present we save it in query params to check for premium
|
||||
searchQueryParams.set("username", prepopulateFormValues.username);
|
||||
localStorage.setItem("username", prepopulateFormValues.username);
|
||||
}
|
||||
if (token && prepopulateFormValues?.email) {
|
||||
searchQueryParams.set("email", prepopulateFormValues.email);
|
||||
}
|
||||
const url = searchQueryParams.toString()
|
||||
? `${MICROSOFT_AUTH_URL}?${searchQueryParams.toString()}`
|
||||
: MICROSOFT_AUTH_URL;
|
||||
|
||||
router.push(url);
|
||||
}}>
|
||||
{t("continue_with_microsoft")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isGoogleLoginEnabled || isOutlookLoginEnabled) && (
|
||||
<div>
|
||||
<div className="relative flex items-center">
|
||||
<div className="border-subtle grow border-t" />
|
||||
@@ -714,7 +769,7 @@ export default function Signup({
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isGoogleLoading}
|
||||
disabled={isGoogleLoading || isMicrosoftLoading}
|
||||
className={classNames("w-full justify-center rounded-md text-center")}
|
||||
onClick={() => {
|
||||
posthog.capture("signup_email_button_clicked", {
|
||||
|
||||
@@ -19,14 +19,9 @@ test.describe("unauthorized user sees correct translations (de)", async () => {
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=Willkommen zurück");
|
||||
const locator = page.getByText("Willkommen zurück", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -44,14 +39,9 @@ test.describe("unauthorized user sees correct translations (ar)", async () => {
|
||||
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=أهلاً بك من جديد");
|
||||
const locator = page.getByText("أهلاً بك من جديد", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -69,14 +59,9 @@ test.describe("unauthorized user sees correct translations (zh)", async () => {
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=欢迎回来");
|
||||
const locator = page.getByText("欢迎回来", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -94,14 +79,9 @@ test.describe("unauthorized user sees correct translations (zh-CN)", async () =>
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=欢迎回来");
|
||||
const locator = page.getByText("欢迎回来", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -119,14 +99,9 @@ test.describe("unauthorized user sees correct translations (zh-TW)", async () =>
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=歡迎回來");
|
||||
const locator = page.getByText("歡迎回來", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -144,14 +119,9 @@ test.describe("unauthorized user sees correct translations (pt)", async () => {
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=Olá novamente");
|
||||
const locator = page.getByText("Olá novamente", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -169,14 +139,9 @@ test.describe("unauthorized user sees correct translations (pt-br)", async () =>
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=Bem-vindo(a) novamente");
|
||||
const locator = page.getByText("Bem-vindo(a) novamente", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -195,14 +160,9 @@ test.describe("unauthorized user sees correct translations (es-419)", async () =
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
await page.waitForSelector("text=Bienvenido de nuevo");
|
||||
const locator = page.getByText("Bienvenido de nuevo", { exact: true });
|
||||
expect(await locator.count()).toEqual(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Welcome back", { exact: true });
|
||||
expect(await locator.count()).toEqual(0);
|
||||
const subtitle = page.getByTestId("login-subtitle");
|
||||
await expect(subtitle).toBeVisible();
|
||||
await expect(subtitle).not.toHaveText(/Welcome back/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { IS_GOOGLE_LOGIN_ENABLED, IS_SAML_LOGIN_ENABLED } from "../server/lib/constants";
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants";
|
||||
|
||||
test("Should display Google Login button", async ({ page }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
@@ -13,7 +15,7 @@ test("Should display Google Login button", async ({ page }) => {
|
||||
|
||||
test("Should display SAML Login button", async ({ page }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML Login is installed");
|
||||
test.skip(!isSAMLLoginEnabled, "It should only run if SAML Login is installed");
|
||||
|
||||
// TODO: Fix this later
|
||||
// Button is visible only if there is a SAML connection exists (self-hosted)
|
||||
|
||||
@@ -243,7 +243,8 @@ test.describe("OAuth Provider", () => {
|
||||
);
|
||||
|
||||
// check if user is redirected to login page
|
||||
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Cal.com" })).toBeVisible();
|
||||
await expect(page.getByTestId("login-subtitle")).toBeVisible();
|
||||
await page.locator("#email").fill(user.email);
|
||||
await page.locator("#password").fill(user.username || "");
|
||||
await page.locator('[type="submit"]').click();
|
||||
|
||||
@@ -48,4 +48,5 @@ test.describe("Onboarding", () => {
|
||||
testOnboarding(IdentityProvider.GOOGLE);
|
||||
testOnboarding(IdentityProvider.CAL);
|
||||
testOnboarding(IdentityProvider.SAML);
|
||||
testOnboarding(IdentityProvider.AZUREAD);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="100" height="100" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="microsoft-logo-title microsoft-logo-desc">
|
||||
<title id="microsoft-logo-title">Microsoft</title>
|
||||
<desc id="microsoft-logo-desc">Microsoft logo</desc>
|
||||
<rect x="0" y="0" width="240" height="240" fill="#F25022"/>
|
||||
<rect x="272" y="0" width="240" height="240" fill="#7FBA00"/>
|
||||
<rect x="0" y="272" width="240" height="240" fill="#00A4EF"/>
|
||||
<rect x="272" y="272" width="240" height="240" fill="#FFB900"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
@@ -3,7 +3,8 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { IS_OUTLOOK_LOGIN_ENABLED } from "@calcom/features/auth/lib/outlook";
|
||||
import { isSAMLLoginEnabled, samlTenantID, samlProductID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -90,6 +91,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
props: {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
|
||||
isOutlookLoginEnabled: IS_OUTLOOK_LOGIN_ENABLED,
|
||||
isSAMLLoginEnabled,
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
|
||||
@@ -3,4 +3,3 @@ export const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET
|
||||
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
|
||||
export const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
|
||||
export const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
|
||||
export const IS_SAML_LOGIN_ENABLED = !!(process.env.SAML_DATABASE_URL && process.env.SAML_ADMINS);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockUploadAvatar = vi.fn();
|
||||
const mockResizeBase64Image = vi.fn();
|
||||
const mockUpdateUserAvatarUrl = vi.fn();
|
||||
const mockLoggerInfo = vi.fn();
|
||||
const mockLoggerError = vi.fn();
|
||||
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
info: (...args: unknown[]) => mockLoggerInfo(...args),
|
||||
error: (...args: unknown[]) => mockLoggerError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/avatar", () => ({
|
||||
uploadAvatar: (...args: unknown[]) => mockUploadAvatar(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/resizeBase64Image", () => ({
|
||||
resizeBase64Image: (...args: unknown[]) => mockResizeBase64Image(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/updateUserAvatarUrl", () => ({
|
||||
updateUserAvatarUrl: (...args: unknown[]) => mockUpdateUserAvatarUrl(...args),
|
||||
}));
|
||||
|
||||
import { updateProfilePhotoMicrosoft } from "./updateProfilePhotoMicrosoft";
|
||||
|
||||
function createFetchResponse({
|
||||
ok = true,
|
||||
status = 200,
|
||||
statusText = "OK",
|
||||
body = new ArrayBuffer(8),
|
||||
contentType = "image/png",
|
||||
}: {
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
body?: ArrayBuffer;
|
||||
contentType?: string | null;
|
||||
} = {}) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText,
|
||||
arrayBuffer: () => Promise.resolve(body),
|
||||
headers: {
|
||||
get: (name: string) => (name === "content-type" ? contentType : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("updateProfilePhotoMicrosoft", () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
originalFetch = globalThis.fetch;
|
||||
mockResizeBase64Image.mockImplementation((img: string) => Promise.resolve(img));
|
||||
mockUploadAvatar.mockResolvedValue("/api/avatar/test-key.png");
|
||||
mockUpdateUserAvatarUrl.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("fetches Microsoft Graph photo endpoint with correct Bearer header", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(createFetchResponse());
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("my-token", 1);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://graph.microsoft.com/v1.0/me/photo/$value", {
|
||||
headers: { Authorization: "Bearer my-token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("converts response to base64 with content-type from header", async () => {
|
||||
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createFetchResponse({ body: imageBytes.buffer as ArrayBuffer, contentType: "image/png" })
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 1);
|
||||
|
||||
const expectedBase64 = `data:image/png;base64,${Buffer.from(imageBytes).toString("base64")}`;
|
||||
expect(mockResizeBase64Image).toHaveBeenCalledWith(expectedBase64);
|
||||
});
|
||||
|
||||
it("defaults content-type to image/jpeg when header is missing", async () => {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createFetchResponse({ contentType: null })) as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 1);
|
||||
|
||||
expect(mockResizeBase64Image).toHaveBeenCalledWith(expect.stringContaining("data:image/jpeg;base64,"));
|
||||
});
|
||||
|
||||
it("resizes, uploads, and updates avatarUrl on success", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(createFetchResponse()) as unknown as typeof fetch;
|
||||
mockResizeBase64Image.mockResolvedValue("resized-image");
|
||||
mockUploadAvatar.mockResolvedValue("/api/avatar/new-key.png");
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 42);
|
||||
|
||||
expect(mockUploadAvatar).toHaveBeenCalledWith({ avatar: "resized-image", userId: 42 });
|
||||
expect(mockUpdateUserAvatarUrl).toHaveBeenCalledWith({ id: 42, avatarUrl: "/api/avatar/new-key.png" });
|
||||
});
|
||||
|
||||
it("returns silently on 404 without uploading", async () => {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createFetchResponse({ ok: false, status: 404, statusText: "Not Found" })
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 1);
|
||||
|
||||
expect(mockUploadAvatar).not.toHaveBeenCalled();
|
||||
expect(mockUpdateUserAvatarUrl).not.toHaveBeenCalled();
|
||||
expect(mockLoggerInfo).toHaveBeenCalledWith("Microsoft profile photo not found for user");
|
||||
});
|
||||
|
||||
it("returns silently on 500 without uploading", async () => {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createFetchResponse({ ok: false, status: 500, statusText: "Internal Server Error" })
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 1);
|
||||
|
||||
expect(mockUploadAvatar).not.toHaveBeenCalled();
|
||||
expect(mockUpdateUserAvatarUrl).not.toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
"Failed to fetch Microsoft profile photo",
|
||||
expect.objectContaining({ status: 500 })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns silently on 403 without uploading", async () => {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createFetchResponse({ ok: false, status: 403, statusText: "Forbidden" })
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 1);
|
||||
|
||||
expect(mockUploadAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches network errors and logs without throwing", async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network timeout")) as unknown as typeof fetch;
|
||||
|
||||
await expect(updateProfilePhotoMicrosoft("token", 1)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
"Error updating avatarUrl from Microsoft sign-in",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it("catches upload failures and logs without throwing", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(createFetchResponse()) as unknown as typeof fetch;
|
||||
mockUploadAvatar.mockRejectedValue(new Error("Upload failed"));
|
||||
|
||||
await expect(updateProfilePhotoMicrosoft("token", 1)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
"Error updating avatarUrl from Microsoft sign-in",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it("catches updateUserAvatarUrl failures and logs without throwing", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(createFetchResponse()) as unknown as typeof fetch;
|
||||
mockUpdateUserAvatarUrl.mockRejectedValue(new Error("DB error"));
|
||||
|
||||
await expect(updateProfilePhotoMicrosoft("token", 1)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
"Error updating avatarUrl from Microsoft sign-in",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it("logs info at the start of the function", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(createFetchResponse()) as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 1);
|
||||
|
||||
expect(mockLoggerInfo).toHaveBeenCalledWith("updateProfilePhotoMicrosoft called", { hasToken: true });
|
||||
});
|
||||
|
||||
it("logs success message on completion", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(createFetchResponse()) as unknown as typeof fetch;
|
||||
|
||||
await updateProfilePhotoMicrosoft("token", 1);
|
||||
|
||||
expect(mockLoggerInfo).toHaveBeenCalledWith("Microsoft profile photo updated successfully");
|
||||
});
|
||||
|
||||
it("does not throw when function completes successfully", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(createFetchResponse()) as unknown as typeof fetch;
|
||||
|
||||
await expect(updateProfilePhotoMicrosoft("token", 1)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { uploadAvatar } from "@calcom/lib/server/avatar";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
import { updateUserAvatarUrl } from "@calcom/lib/server/updateUserAvatarUrl";
|
||||
|
||||
export async function updateProfilePhotoMicrosoft(accessToken: string, userId: number) {
|
||||
logger.info("updateProfilePhotoMicrosoft called", { hasToken: !!accessToken });
|
||||
try {
|
||||
const response = await fetch("https://graph.microsoft.com/v1.0/me/photo/$value", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// in case user might not have a profile photo set
|
||||
if (response.status === 404) {
|
||||
logger.info("Microsoft profile photo not found for user");
|
||||
return;
|
||||
}
|
||||
logger.error("Failed to fetch Microsoft profile photo", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
const base64Image = `data:${contentType};base64,${buffer.toString("base64")}`;
|
||||
|
||||
const resizedAvatarUrl = await uploadAvatar({
|
||||
avatar: await resizeBase64Image(base64Image),
|
||||
userId,
|
||||
});
|
||||
|
||||
await updateUserAvatarUrl({ id: userId, avatarUrl: resizedAvatarUrl });
|
||||
logger.info("Microsoft profile photo updated successfully");
|
||||
} catch (error) {
|
||||
logger.error("Error updating avatarUrl from Microsoft sign-in", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getIdentityProvider, NEXTAUTH_TO_IDENTITY_PROVIDER } from "./identityProviders";
|
||||
|
||||
describe("identityProviders", () => {
|
||||
describe("NEXTAUTH_TO_IDENTITY_PROVIDER", () => {
|
||||
it("contains exactly the expected mapping keys", () => {
|
||||
expect(Object.keys(NEXTAUTH_TO_IDENTITY_PROVIDER).sort()).toEqual(
|
||||
["azure-ad", "cal", "google", "saml", "saml-idp"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIdentityProvider", () => {
|
||||
it("maps 'azure-ad' to AZUREAD", () => {
|
||||
expect(getIdentityProvider("azure-ad")).toBe(IdentityProvider.AZUREAD);
|
||||
});
|
||||
|
||||
it("maps 'google' to GOOGLE", () => {
|
||||
expect(getIdentityProvider("google")).toBe(IdentityProvider.GOOGLE);
|
||||
});
|
||||
|
||||
it("maps 'saml' to SAML", () => {
|
||||
expect(getIdentityProvider("saml")).toBe(IdentityProvider.SAML);
|
||||
});
|
||||
|
||||
it("maps 'saml-idp' to SAML", () => {
|
||||
expect(getIdentityProvider("saml-idp")).toBe(IdentityProvider.SAML);
|
||||
});
|
||||
|
||||
it("maps 'cal' to CAL", () => {
|
||||
expect(getIdentityProvider("cal")).toBe(IdentityProvider.CAL);
|
||||
});
|
||||
|
||||
it("returns null for unknown provider", () => {
|
||||
expect(getIdentityProvider("unknown-provider")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(getIdentityProvider("")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
/**
|
||||
* Maps NextAuth provider names to IdentityProvider enum values.
|
||||
* Includes aliases (e.g., "saml-idp" -> SAML).
|
||||
*/
|
||||
export const NEXTAUTH_TO_IDENTITY_PROVIDER: Record<string, IdentityProvider> = {
|
||||
"azure-ad": IdentityProvider.AZUREAD,
|
||||
google: IdentityProvider.GOOGLE,
|
||||
saml: IdentityProvider.SAML,
|
||||
"saml-idp": IdentityProvider.SAML,
|
||||
cal: IdentityProvider.CAL,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get IdentityProvider enum from NextAuth provider name.
|
||||
* Returns null for unknown providers so callers can reject the login gracefully.
|
||||
*/
|
||||
export const getIdentityProvider = (nextAuthProvider: string): IdentityProvider | null => {
|
||||
return NEXTAUTH_TO_IDENTITY_PROVIDER[nextAuthProvider] ?? null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,16 @@
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { OAuth2Client } from "googleapis-common";
|
||||
import type { AuthOptions, Account, Session, User } from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import { encode } from "next-auth/jwt";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
|
||||
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
|
||||
import {
|
||||
createGoogleCalendarServiceWithGoogleType,
|
||||
type GoogleCalendar,
|
||||
} from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { updateProfilePhotoMicrosoft } from "@calcom/app-store/_utils/oauth/updateProfilePhotoMicrosoft";
|
||||
import { createGoogleCalendarServiceWithGoogleType } from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
|
||||
import { getIdentityProvider } from "@calcom/features/auth/lib/identityProviders";
|
||||
import {
|
||||
OUTLOOK_CLIENT_ID,
|
||||
OUTLOOK_CLIENT_SECRET,
|
||||
OUTLOOK_LOGIN_ENABLED,
|
||||
} from "@calcom/features/auth/lib/outlook";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { buildCredentialCreateData } from "@calcom/features/credentials/services/CredentialDataService";
|
||||
import type { TrackingData } from "@calcom/lib/tracking";
|
||||
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
|
||||
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
|
||||
import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg";
|
||||
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
||||
@@ -30,12 +22,15 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito
|
||||
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import {
|
||||
ENABLE_PROFILE_SWITCHER,
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
GOOGLE_OAUTH_SCOPES,
|
||||
HOSTED_CAL_FEATURES,
|
||||
IS_CALCOM,
|
||||
IS_TEAM_BILLING_ENABLED,
|
||||
MICROSOFT_CALENDAR_SCOPES,
|
||||
WEBAPP_URL,
|
||||
} from "@calcom/lib/constants";
|
||||
import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||
import { isENVDev } from "@calcom/lib/env";
|
||||
@@ -44,24 +39,40 @@ import { randomString } from "@calcom/lib/random";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import type { TrackingData } from "@calcom/lib/tracking";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Membership, Team } from "@calcom/prisma/client";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { CreationSource, IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { OAuth2Client } from "googleapis-common";
|
||||
import type { Account, AuthOptions, Profile, Session, User } from "next-auth";
|
||||
import type { AdapterUser } from "next-auth/adapters";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
import { encode } from "next-auth/jwt";
|
||||
import AzureADProvider from "next-auth/providers/azure-ad";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import type { Provider } from "next-auth/providers/index";
|
||||
import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail";
|
||||
import { ErrorCode } from "./ErrorCode";
|
||||
import { dub } from "./dub";
|
||||
import { validateSamlAccountConversion } from "./samlAccountLinking";
|
||||
import { ErrorCode } from "./ErrorCode";
|
||||
import CalComAdapter from "./next-auth-custom-adapter";
|
||||
import { validateSamlAccountConversion } from "./samlAccountLinking";
|
||||
import { verifyPassword } from "./verifyPassword";
|
||||
import { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
type UserWithProfiles = NonNullable<
|
||||
Awaited<ReturnType<UserRepository["findByEmailAndIncludeProfilesAndPassword"]>>
|
||||
>;
|
||||
|
||||
interface ExtendedOAuthProfile extends Profile {
|
||||
email_verified?: boolean; // Google/OIDC standard
|
||||
xms_edov?: boolean | string | number; // Azure AD specific
|
||||
}
|
||||
|
||||
// This adapts our internal user model to what NextAuth expects
|
||||
// NextAuth core requires id to be a string, so we handle that here
|
||||
const AdapterUserPresenter = {
|
||||
@@ -126,7 +137,8 @@ export const checkIfUserBelongsToActiveTeam = <T extends UserTeams>(user: T) =>
|
||||
|
||||
const checkIfUserShouldBelongToOrg = async (idP: IdentityProvider, email: string) => {
|
||||
const [orgUsername, apexDomain] = email.split("@");
|
||||
if (!ORGANIZATIONS_AUTOLINK || idP !== "GOOGLE") return { orgUsername, orgId: undefined };
|
||||
if (!ORGANIZATIONS_AUTOLINK || (idP !== "GOOGLE" && idP !== "AZUREAD"))
|
||||
return { orgUsername, orgId: undefined };
|
||||
const existingOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
organizationSettings: {
|
||||
@@ -270,10 +282,26 @@ export const CalComCredentialsProvider = CredentialsProvider({
|
||||
name: "Cal.com",
|
||||
type: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
|
||||
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
|
||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
email: {
|
||||
label: "Email Address",
|
||||
type: "email",
|
||||
placeholder: "john.doe@example.com",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
placeholder: "Your super secure password",
|
||||
},
|
||||
totpCode: {
|
||||
label: "Two-factor Code",
|
||||
type: "input",
|
||||
placeholder: "Code from authenticator app",
|
||||
},
|
||||
backupCode: {
|
||||
label: "Backup Code",
|
||||
type: "input",
|
||||
placeholder: "Two-factor backup code",
|
||||
},
|
||||
},
|
||||
authorize: authorizeCredentials,
|
||||
});
|
||||
@@ -470,6 +498,32 @@ if (isSAMLLoginEnabled) {
|
||||
);
|
||||
}
|
||||
|
||||
if (OUTLOOK_LOGIN_ENABLED && OUTLOOK_CLIENT_ID && OUTLOOK_CLIENT_SECRET) {
|
||||
providers.push(
|
||||
AzureADProvider({
|
||||
clientId: OUTLOOK_CLIENT_ID,
|
||||
clientSecret: OUTLOOK_CLIENT_SECRET,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: ["openid", "profile", "email", ...MICROSOFT_CALENDAR_SCOPES].join(" "),
|
||||
prompt: "consent",
|
||||
},
|
||||
},
|
||||
// Azure AD returns base64-encoded picture data (~9KB) that bloats the JWT cookie.
|
||||
// we exclude it here and fetch the profile photo separately via Microsoft Graph API.
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
type: "email",
|
||||
@@ -485,16 +539,6 @@ function isNumber(n: string) {
|
||||
|
||||
const calcomAdapter = CalComAdapter(prisma);
|
||||
|
||||
const mapIdentityProvider = (providerName: string) => {
|
||||
switch (providerName) {
|
||||
case "saml-idp":
|
||||
case "saml":
|
||||
return IdentityProvider.SAML;
|
||||
default:
|
||||
return IdentityProvider.GOOGLE;
|
||||
}
|
||||
};
|
||||
|
||||
export const getOptions = ({
|
||||
getDubId,
|
||||
getTrackingData,
|
||||
@@ -505,7 +549,6 @@ export const getOptions = ({
|
||||
getTrackingData: () => TrackingData;
|
||||
}): AuthOptions => ({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
adapter: calcomAdapter,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
@@ -627,7 +670,7 @@ export const getOptions = ({
|
||||
orgRole = membership?.role;
|
||||
}
|
||||
|
||||
return {
|
||||
const autoMergeJwt = {
|
||||
...existingUserWithoutTeamsField,
|
||||
...token,
|
||||
profileId: profile.id,
|
||||
@@ -650,6 +693,7 @@ export const getOptions = ({
|
||||
}
|
||||
: null,
|
||||
} as JWT;
|
||||
return autoMergeJwt;
|
||||
};
|
||||
if (!user) {
|
||||
return await autoMergeIdentities();
|
||||
@@ -693,9 +737,19 @@ export const getOptions = ({
|
||||
if (account.type === "oauth") {
|
||||
log.debug("callbacks:jwt:accountType:oauth", safeStringify({ account }));
|
||||
if (!account.provider || !account.providerAccountId) {
|
||||
return { ...token, upId: user.profile?.upId ?? token.upId ?? null } as JWT;
|
||||
return {
|
||||
...token,
|
||||
upId: user.profile?.upId ?? token.upId ?? null,
|
||||
} as JWT;
|
||||
}
|
||||
const idP = getIdentityProvider(account.provider);
|
||||
|
||||
if (!idP) {
|
||||
log.warn("callbacks:jwt:accountType:oauth - unknown provider, falling back to auto-merge", {
|
||||
provider: account.provider,
|
||||
});
|
||||
return await autoMergeIdentities();
|
||||
}
|
||||
const idP = account.provider === "saml" ? IdentityProvider.SAML : IdentityProvider.GOOGLE;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -773,9 +827,81 @@ export const getOptions = ({
|
||||
}
|
||||
await updateProfilePhotoGoogle(oAuth2Client, Number(user.id));
|
||||
}
|
||||
|
||||
// Installing Outlook Calendar by default for Microsoft/Azure AD sign-in
|
||||
// Note: offline_access is requested but not returned in scope list by Microsoft
|
||||
const microsoftCalendarScopesToCheck = MICROSOFT_CALENDAR_SCOPES.filter(
|
||||
(scope) => scope !== "offline_access"
|
||||
);
|
||||
if (
|
||||
account.provider === "azure-ad" &&
|
||||
!(await CredentialRepository.findFirstByAppIdAndUserId({
|
||||
userId: Number(user.id),
|
||||
appId: "office365-calendar",
|
||||
})) &&
|
||||
microsoftCalendarScopesToCheck.every((scope) => grantedScopes.includes(scope))
|
||||
) {
|
||||
const credentialKey = {
|
||||
access_token: account.access_token,
|
||||
refresh_token: account.refresh_token,
|
||||
email: user.email,
|
||||
// convert seconds to milliseconds — OAuthManager compares expiry_date against Date.now()
|
||||
expiry_date: account.expires_at ? account.expires_at * 1000 : undefined,
|
||||
};
|
||||
|
||||
const outlookCredentialData = buildCredentialCreateData({
|
||||
userId: Number(user.id),
|
||||
key: credentialKey,
|
||||
appId: "office365-calendar",
|
||||
type: "office365_calendar",
|
||||
});
|
||||
const outlookCredential = await CredentialRepository.create(outlookCredentialData);
|
||||
|
||||
// Fetch default calendar from Microsoft Graph API
|
||||
try {
|
||||
const calendarResponse = await fetch(
|
||||
"https://graph.microsoft.com/v1.0/me/calendars?$select=id,isDefaultCalendar",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${account.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (calendarResponse.ok) {
|
||||
const calendarData = await calendarResponse.json();
|
||||
const defaultCalendar = calendarData.value?.find(
|
||||
(cal: { isDefaultCalendar?: boolean }) => cal.isDefaultCalendar
|
||||
);
|
||||
|
||||
if (defaultCalendar?.id) {
|
||||
await prisma.selectedCalendar.create({
|
||||
data: {
|
||||
userId: Number(user.id),
|
||||
integration: "office365_calendar",
|
||||
externalId: defaultCalendar.id,
|
||||
credentialId: outlookCredential.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch default calendar for Microsoft sign-in", error);
|
||||
}
|
||||
|
||||
// Update profile photo for Microsoft/Azure AD sign-in
|
||||
if (account.access_token) {
|
||||
await updateProfilePhotoMicrosoft(account.access_token, Number(user.id));
|
||||
}
|
||||
} else if (account.provider === "azure-ad" && account.access_token) {
|
||||
// Update profile photo even if calendar wasn't installed
|
||||
await updateProfilePhotoMicrosoft(account.access_token, Number(user.id));
|
||||
}
|
||||
|
||||
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(existingUser);
|
||||
const { upId } = determineProfile({ profiles: allProfiles, token });
|
||||
log.debug("callbacks:jwt:accountType:oauth:existingUser", safeStringify({ existingUser, upId }));
|
||||
log.debug("callbacks:jwt:accountType:oauth:existingUser", safeStringify({ userId: existingUser.id, upId }));
|
||||
return {
|
||||
...token,
|
||||
upId,
|
||||
@@ -783,9 +909,10 @@ export const getOptions = ({
|
||||
name: existingUser.name,
|
||||
username: existingUser.username,
|
||||
email: existingUser.email,
|
||||
avatarUrl: existingUser.avatarUrl,
|
||||
role: existingUser.role,
|
||||
impersonatedBy: token.impersonatedBy,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
impersonatedBy: token.impersonatedBy,
|
||||
org: token?.org,
|
||||
orgAwareUsername: token.orgAwareUsername,
|
||||
locale: existingUser.locale,
|
||||
@@ -798,7 +925,10 @@ export const getOptions = ({
|
||||
|
||||
log.warn(
|
||||
"callbacks:jwt - unknown account type",
|
||||
safeStringify({ accountType: account.type, accountProvider: account.provider })
|
||||
safeStringify({
|
||||
accountType: account.type,
|
||||
accountProvider: account.provider,
|
||||
})
|
||||
);
|
||||
return token;
|
||||
},
|
||||
@@ -828,18 +958,14 @@ export const getOptions = ({
|
||||
};
|
||||
return calendsoSession;
|
||||
},
|
||||
async signIn(params): Promise<boolean | string> {
|
||||
const {
|
||||
/**
|
||||
* Available when Credentials provider is used - Has the value returned by authorize callback
|
||||
*/
|
||||
user,
|
||||
/**
|
||||
* Available when Credentials provider is used - Has the value submitted as the body of the HTTP POST submission
|
||||
*/
|
||||
profile,
|
||||
account,
|
||||
} = params;
|
||||
async signIn(params: {
|
||||
user: User | AdapterUser;
|
||||
account: Account | null;
|
||||
profile?: Profile;
|
||||
email?: { verificationRequest?: boolean };
|
||||
credentials?: Record<string, unknown>;
|
||||
}): Promise<boolean | string> {
|
||||
const { user, account, profile } = params;
|
||||
|
||||
log.debug("callbacks:signin", safeStringify(params));
|
||||
|
||||
@@ -876,7 +1002,9 @@ export const getOptions = ({
|
||||
}
|
||||
}
|
||||
if (!user.email) {
|
||||
log.warn("callbacks:signIn - user email is missing", { provider: account?.provider });
|
||||
log.warn("callbacks:signIn - user email is missing", {
|
||||
provider: account?.provider,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -888,12 +1016,38 @@ export const getOptions = ({
|
||||
return false;
|
||||
}
|
||||
if (account?.provider) {
|
||||
const idP: IdentityProvider = mapIdentityProvider(account.provider);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error TODO validate email_verified key on profile
|
||||
user.email_verified = user.email_verified || !!user.emailVerified || profile.email_verified;
|
||||
const idP = getIdentityProvider(account.provider);
|
||||
|
||||
if (!user.email_verified) {
|
||||
if (!idP) {
|
||||
log.warn("callbacks:signIn - unknown provider, rejecting login", {
|
||||
provider: account.provider,
|
||||
});
|
||||
return "/auth/error?error=unknown-provider";
|
||||
}
|
||||
// Use optional chaining for safety, especially with AdapterUser potentially having different structure initially.
|
||||
const isEmailVerified = user.emailVerified || (profile as ExtendedOAuthProfile)?.email_verified;
|
||||
|
||||
// For Azure AD, check xms_edov (Email Domain Owner Verified) claim
|
||||
// xms_edov returns inconsistent types: boolean for work/school, string "1" for personal accounts
|
||||
const xmsEdov = (profile as ExtendedOAuthProfile)?.xms_edov;
|
||||
const isAzureEmailDomainVerified =
|
||||
xmsEdov === true || xmsEdov === "true" || xmsEdov === "1" || xmsEdov === 1;
|
||||
|
||||
// Azure AD never sets email_verified in the token profile, so isEmailVerified is always
|
||||
// falsy for AZUREAD logins. Use isAzureEmailDomainVerified (xms_edov) as the equivalent
|
||||
// proof of ownership so the auto-merge path treats Azure AD the same as other verified IdPs.
|
||||
const isVerified =
|
||||
isEmailVerified || (idP === IdentityProvider.AZUREAD && isAzureEmailDomainVerified);
|
||||
|
||||
if (idP === IdentityProvider.AZUREAD && !isAzureEmailDomainVerified) {
|
||||
log.error(
|
||||
"Azure AD email domain not verified (xms_edov claim)",
|
||||
safeStringify({ emailDomain: user.email?.split("@")[1], xmsEdov })
|
||||
);
|
||||
return "/auth/error?error=unverified-email";
|
||||
}
|
||||
|
||||
if (!isEmailVerified && idP !== IdentityProvider.AZUREAD) {
|
||||
log.error("Attention: SAML/Google User email is not verified in the IdP", safeStringify({ user }));
|
||||
return "/auth/error?error=unverified-email";
|
||||
}
|
||||
@@ -986,7 +1140,10 @@ export const getOptions = ({
|
||||
});
|
||||
|
||||
if (!userWithNewEmail) {
|
||||
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
|
||||
await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: { email: user.email },
|
||||
});
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUser.email);
|
||||
} else {
|
||||
@@ -1019,11 +1176,7 @@ export const getOptions = ({
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
// if self-hosted then we can allow auto-merge of identity providers if email is verified
|
||||
if (
|
||||
!hostedCal &&
|
||||
existingUserWithEmail.emailVerified &&
|
||||
existingUserWithEmail.identityProvider !== IdentityProvider.CAL
|
||||
) {
|
||||
if (!hostedCal && isVerified && existingUserWithEmail.identityProvider !== IdentityProvider.CAL) {
|
||||
// Verify SAML IdP is authoritative before auto-merge
|
||||
if (idP === IdentityProvider.SAML) {
|
||||
const samlTenant = getSamlTenant();
|
||||
@@ -1083,10 +1236,12 @@ export const getOptions = ({
|
||||
}
|
||||
}
|
||||
|
||||
// User signs up with email/password and then tries to login with Google/SAML using the same email
|
||||
// User signs up with email/password and then tries to login with Google/SAML/AzureAD using the same email
|
||||
if (
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
|
||||
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
|
||||
(idP === IdentityProvider.GOOGLE ||
|
||||
idP === IdentityProvider.SAML ||
|
||||
idP === IdentityProvider.AZUREAD)
|
||||
) {
|
||||
// Prevent account pre-hijacking: block OAuth linking for unverified accounts
|
||||
if (!existingUserWithEmail.emailVerified) {
|
||||
@@ -1121,13 +1276,43 @@ export const getOptions = ({
|
||||
return `/auth/error?error=wrong-provider&provider=${existingUserWithEmail.identityProvider}`;
|
||||
} else if (
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
|
||||
idP === IdentityProvider.SAML
|
||||
(idP === IdentityProvider.SAML || idP === IdentityProvider.AZUREAD)
|
||||
) {
|
||||
// Verify SAML IdP is authoritative before converting account
|
||||
const samlTenant = getSamlTenant();
|
||||
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Google→SAML");
|
||||
if (!validation.allowed) {
|
||||
return validation.errorUrl;
|
||||
if (idP === IdentityProvider.SAML) {
|
||||
const samlTenant = getSamlTenant();
|
||||
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Google→SAML");
|
||||
if (!validation.allowed) {
|
||||
return validation.errorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email: existingUserWithEmail.email },
|
||||
// also update email to the IdP email
|
||||
data: {
|
||||
email: user.email.toLowerCase(),
|
||||
identityProvider: idP,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail.email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.AZUREAD &&
|
||||
(idP === IdentityProvider.SAML || idP === IdentityProvider.GOOGLE)
|
||||
) {
|
||||
// Verify SAML IdP is authoritative before converting account
|
||||
if (idP === IdentityProvider.SAML) {
|
||||
const samlTenant = getSamlTenant();
|
||||
const validation = await validateSamlAccountConversion(samlTenant, user.email, "AzureAD→SAML");
|
||||
if (!validation.allowed) {
|
||||
return validation.errorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
@@ -1157,7 +1342,7 @@ export const getOptions = ({
|
||||
return `/auth/error?error=wrong-provider&provider=${existingUserWithEmail.identityProvider}`;
|
||||
}
|
||||
|
||||
// Associate with organization if enabled by flag and idP is Google (for now)
|
||||
// Associate with organization if enabled by flag and idP is Google or Azure AD
|
||||
const { orgUsername, orgId } = await checkIfUserShouldBelongToOrg(idP, user.email);
|
||||
|
||||
try {
|
||||
@@ -1170,14 +1355,18 @@ export const getOptions = ({
|
||||
emailVerified: new Date(Date.now()),
|
||||
name: user.name,
|
||||
...(user.image && { avatarUrl: user.image }),
|
||||
email: user.email,
|
||||
email: user.email!,
|
||||
identityProvider: idP,
|
||||
identityProviderId: account.providerAccountId,
|
||||
...(orgId && {
|
||||
verified: true,
|
||||
organization: { connect: { id: orgId } },
|
||||
teams: {
|
||||
create: { role: MembershipRole.MEMBER, accepted: true, team: { connect: { id: orgId } } },
|
||||
create: {
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
team: { connect: { id: orgId } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
@@ -1190,6 +1379,11 @@ export const getOptions = ({
|
||||
);
|
||||
await calcomAdapter.linkAccount(linkAccountNewUserData);
|
||||
|
||||
// Update profile photo for new Microsoft/Azure AD users
|
||||
if (account.provider === "azure-ad" && account.access_token) {
|
||||
await updateProfilePhotoMicrosoft(account.access_token, newUser.id);
|
||||
}
|
||||
|
||||
waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
@@ -1225,7 +1419,7 @@ export const getOptions = ({
|
||||
})()
|
||||
);
|
||||
|
||||
if (account.twoFactorEnabled) {
|
||||
if (newUser.twoFactorEnabled) {
|
||||
return loginWithTotp(newUser.email);
|
||||
} else {
|
||||
return true;
|
||||
@@ -1310,7 +1504,7 @@ const determineProfile = ({
|
||||
|
||||
if (token.upId) {
|
||||
// Otherwise use what's in the token
|
||||
return { profileId: token.profileId, upId: token.upId as string };
|
||||
return { id: token.profileId ?? null, upId: token.upId as string };
|
||||
}
|
||||
|
||||
// If there is just one profile it has to be the one we want to log into.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import process from "node:process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("outlook", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env completely
|
||||
for (const key of ["MS_GRAPH_CLIENT_ID", "MS_GRAPH_CLIENT_SECRET", "OUTLOOK_LOGIN_ENABLED"]) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = originalEnv[key];
|
||||
}
|
||||
}
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("reads OUTLOOK_CLIENT_ID from MS_GRAPH_CLIENT_ID", async () => {
|
||||
process.env.MS_GRAPH_CLIENT_ID = "test-client-id";
|
||||
const { OUTLOOK_CLIENT_ID } = await import("./outlook");
|
||||
expect(OUTLOOK_CLIENT_ID).toBe("test-client-id");
|
||||
});
|
||||
|
||||
it("reads OUTLOOK_CLIENT_SECRET from MS_GRAPH_CLIENT_SECRET", async () => {
|
||||
process.env.MS_GRAPH_CLIENT_SECRET = "test-secret";
|
||||
const { OUTLOOK_CLIENT_SECRET } = await import("./outlook");
|
||||
expect(OUTLOOK_CLIENT_SECRET).toBe("test-secret");
|
||||
});
|
||||
|
||||
it("OUTLOOK_LOGIN_ENABLED is true only for exact string 'true'", async () => {
|
||||
process.env.OUTLOOK_LOGIN_ENABLED = "true";
|
||||
const mod = await import("./outlook");
|
||||
expect(mod.OUTLOOK_LOGIN_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("OUTLOOK_LOGIN_ENABLED is false for other values", async () => {
|
||||
process.env.OUTLOOK_LOGIN_ENABLED = "1";
|
||||
const mod = await import("./outlook");
|
||||
expect(mod.OUTLOOK_LOGIN_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("IS_OUTLOOK_LOGIN_ENABLED is true when all vars are set", async () => {
|
||||
process.env.MS_GRAPH_CLIENT_ID = "client-id";
|
||||
process.env.MS_GRAPH_CLIENT_SECRET = "client-secret";
|
||||
process.env.OUTLOOK_LOGIN_ENABLED = "true";
|
||||
const { IS_OUTLOOK_LOGIN_ENABLED } = await import("./outlook");
|
||||
expect(IS_OUTLOOK_LOGIN_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("IS_OUTLOOK_LOGIN_ENABLED is false when client ID is missing", async () => {
|
||||
delete process.env.MS_GRAPH_CLIENT_ID;
|
||||
process.env.MS_GRAPH_CLIENT_SECRET = "client-secret";
|
||||
process.env.OUTLOOK_LOGIN_ENABLED = "true";
|
||||
const { IS_OUTLOOK_LOGIN_ENABLED } = await import("./outlook");
|
||||
expect(IS_OUTLOOK_LOGIN_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("IS_OUTLOOK_LOGIN_ENABLED is false when client secret is missing", async () => {
|
||||
process.env.MS_GRAPH_CLIENT_ID = "client-id";
|
||||
delete process.env.MS_GRAPH_CLIENT_SECRET;
|
||||
process.env.OUTLOOK_LOGIN_ENABLED = "true";
|
||||
const { IS_OUTLOOK_LOGIN_ENABLED } = await import("./outlook");
|
||||
expect(IS_OUTLOOK_LOGIN_ENABLED).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export const OUTLOOK_CLIENT_ID = process.env.MS_GRAPH_CLIENT_ID;
|
||||
export const OUTLOOK_CLIENT_SECRET = process.env.MS_GRAPH_CLIENT_SECRET;
|
||||
export const OUTLOOK_LOGIN_ENABLED = process.env.OUTLOOK_LOGIN_ENABLED === "true";
|
||||
export const IS_OUTLOOK_LOGIN_ENABLED = !!(
|
||||
OUTLOOK_CLIENT_ID &&
|
||||
OUTLOOK_CLIENT_SECRET &&
|
||||
process.env.OUTLOOK_LOGIN_ENABLED === "true"
|
||||
);
|
||||
@@ -276,3 +276,10 @@ export const IS_API_V2_E2E = process.env.IS_E2E === "true";
|
||||
|
||||
export const ENABLE_ASYNC_TASKER =
|
||||
process.env.ENABLE_ASYNC_TASKER === "true" && !process.env.NEXT_PUBLIC_IS_E2E && !IS_API_V2_E2E;
|
||||
|
||||
export const MICROSOFT_CALENDAR_SCOPES = [
|
||||
"User.Read",
|
||||
"Calendars.Read",
|
||||
"Calendars.ReadWrite",
|
||||
"offline_access",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockUpsert = vi.fn();
|
||||
const mockUuidv4 = vi.fn();
|
||||
const mockConvertSvgToPng = vi.fn();
|
||||
|
||||
vi.mock("uuid", () => ({
|
||||
v4: (...args: unknown[]) => mockUuidv4(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {
|
||||
avatar: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
upsert: (...args: unknown[]) => mockUpsert(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./imageUtils", () => ({
|
||||
convertSvgToPng: (...args: unknown[]) => mockConvertSvgToPng(...args),
|
||||
}));
|
||||
|
||||
import { uploadAvatar } from "./avatar";
|
||||
|
||||
describe("uploadAvatar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConvertSvgToPng.mockImplementation((data: string) => Promise.resolve(`processed_${data}`));
|
||||
mockUpsert.mockResolvedValue(undefined);
|
||||
mockUuidv4.mockReturnValue("generated-uuid-1234");
|
||||
});
|
||||
|
||||
it("generates new objectKey via uuidv4 when no existing avatar", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await uploadAvatar({ userId: 1, avatar: "image-data" });
|
||||
|
||||
expect(mockUuidv4).toHaveBeenCalled();
|
||||
expect(result).toBe("/api/avatar/generated-uuid-1234.png");
|
||||
});
|
||||
|
||||
it("reuses existing objectKey and does NOT call uuidv4", async () => {
|
||||
mockFindUnique.mockResolvedValue({ objectKey: "existing-key-5678" });
|
||||
|
||||
const result = await uploadAvatar({ userId: 1, avatar: "image-data" });
|
||||
|
||||
expect(mockUuidv4).not.toHaveBeenCalled();
|
||||
expect(result).toBe("/api/avatar/existing-key-5678.png");
|
||||
});
|
||||
|
||||
it("includes objectKey in create clause but NOT in update clause", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
await uploadAvatar({ userId: 1, avatar: "image-data" });
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({ objectKey: "generated-uuid-1234" }),
|
||||
update: expect.not.objectContaining({ objectKey: expect.anything() }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns /api/avatar/{objectKey}.png", async () => {
|
||||
mockFindUnique.mockResolvedValue({ objectKey: "my-key" });
|
||||
|
||||
const result = await uploadAvatar({ userId: 5, avatar: "data" });
|
||||
|
||||
expect(result).toBe("/api/avatar/my-key.png");
|
||||
});
|
||||
|
||||
it("uses correct compound unique key for lookup and upsert", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
await uploadAvatar({ userId: 42, avatar: "data" });
|
||||
|
||||
const expectedWhere = {
|
||||
teamId_userId_isBanner: { teamId: 0, userId: 42, isBanner: false },
|
||||
};
|
||||
|
||||
expect(mockFindUnique).toHaveBeenCalledWith(expect.objectContaining({ where: expectedWhere }));
|
||||
expect(mockUpsert).toHaveBeenCalledWith(expect.objectContaining({ where: expectedWhere }));
|
||||
});
|
||||
|
||||
it("calls convertSvgToPng on input data", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
await uploadAvatar({ userId: 1, avatar: "svg-data-here" });
|
||||
|
||||
expect(mockConvertSvgToPng).toHaveBeenCalledWith("svg-data-here");
|
||||
});
|
||||
|
||||
it("passes processed data to upsert create and update", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
await uploadAvatar({ userId: 1, avatar: "raw-data" });
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({ data: "processed_raw-data" }),
|
||||
update: expect.objectContaining({ data: "processed_raw-data" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sets isBanner to false in create clause", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
await uploadAvatar({ userId: 1, avatar: "data" });
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({ isBanner: false }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,22 @@ import { prisma } from "@calcom/prisma";
|
||||
import { convertSvgToPng } from "./imageUtils";
|
||||
|
||||
export const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
|
||||
const objectKey = uuidv4();
|
||||
const processedData = await convertSvgToPng(data);
|
||||
|
||||
// Check if avatar already exists to preserve the objectKey
|
||||
const existing = await prisma.avatar.findUnique({
|
||||
where: {
|
||||
teamId_userId_isBanner: {
|
||||
teamId: 0,
|
||||
userId,
|
||||
isBanner: false,
|
||||
},
|
||||
},
|
||||
select: { objectKey: true },
|
||||
});
|
||||
|
||||
const objectKey = existing?.objectKey ?? uuidv4();
|
||||
|
||||
await prisma.avatar.upsert({
|
||||
where: {
|
||||
teamId_userId_isBanner: {
|
||||
@@ -24,7 +37,7 @@ export const uploadAvatar = async ({ userId, avatar: data }: { userId: number; a
|
||||
},
|
||||
update: {
|
||||
data: processedData,
|
||||
objectKey,
|
||||
// Don't update objectKey - keep existing to preserve URLs
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockUpdateMany = vi.fn();
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
user: {
|
||||
updateMany: (...args: unknown[]) => mockUpdateMany(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { updateUserAvatarUrl } from "./updateUserAvatarUrl";
|
||||
|
||||
describe("updateUserAvatarUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUpdateMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
it("calls updateMany with correct where clause requiring null avatarUrl", async () => {
|
||||
await updateUserAvatarUrl({ id: 42, avatarUrl: "/api/avatar/abc.png" });
|
||||
|
||||
expect(mockUpdateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 42,
|
||||
avatarUrl: { equals: null },
|
||||
},
|
||||
data: { avatarUrl: "/api/avatar/abc.png" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes avatarUrl unchanged", async () => {
|
||||
const url = "https://example.com/photo.jpg";
|
||||
await updateUserAvatarUrl({ id: 1, avatarUrl: url });
|
||||
|
||||
expect(mockUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { avatarUrl: url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined (void)", async () => {
|
||||
const result = await updateUserAvatarUrl({ id: 1, avatarUrl: "/test.png" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates prisma errors", async () => {
|
||||
mockUpdateMany.mockRejectedValue(new Error("DB connection failed"));
|
||||
|
||||
await expect(updateUserAvatarUrl({ id: 1, avatarUrl: "/test.png" })).rejects.toThrow(
|
||||
"DB connection failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the provided user id in the where clause", async () => {
|
||||
await updateUserAvatarUrl({ id: 999, avatarUrl: "/photo.png" });
|
||||
|
||||
expect(mockUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ id: 999 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const updateUserAvatarUrl = async ({ id, avatarUrl }: { id: number; avatarUrl: string }) => {
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
id,
|
||||
avatarUrl: { equals: null },
|
||||
},
|
||||
data: { avatarUrl },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "IdentityProvider" ADD VALUE 'AZUREAD';
|
||||
@@ -349,6 +349,7 @@ enum IdentityProvider {
|
||||
CAL
|
||||
GOOGLE
|
||||
SAML
|
||||
AZUREAD
|
||||
}
|
||||
|
||||
model DestinationCalendar {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
|
||||
const {
|
||||
mockFindAllProfilesForUserIncludingMovedUser,
|
||||
mockEnrichUserWithTheProfile,
|
||||
mockFindUnique,
|
||||
mockFindMany,
|
||||
mockGetTeamIdsWithPermission,
|
||||
MockUserRepository,
|
||||
MockPermissionCheckService,
|
||||
} = vi.hoisted(() => {
|
||||
const mockFindAllProfilesForUserIncludingMovedUser = vi.fn();
|
||||
const mockEnrichUserWithTheProfile = vi.fn();
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockFindMany = vi.fn();
|
||||
const mockGetTeamIdsWithPermission = vi.fn();
|
||||
|
||||
class MockUserRepository {
|
||||
enrichUserWithTheProfile = (...args: unknown[]) => mockEnrichUserWithTheProfile(...args);
|
||||
}
|
||||
|
||||
class MockPermissionCheckService {
|
||||
getTeamIdsWithPermission = (...args: unknown[]) => mockGetTeamIdsWithPermission(...args);
|
||||
}
|
||||
|
||||
return {
|
||||
mockFindAllProfilesForUserIncludingMovedUser,
|
||||
mockEnrichUserWithTheProfile,
|
||||
mockFindUnique,
|
||||
mockFindMany,
|
||||
mockGetTeamIdsWithPermission,
|
||||
MockUserRepository,
|
||||
MockPermissionCheckService,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
|
||||
ProfileRepository: {
|
||||
findAllProfilesForUserIncludingMovedUser: (...args: unknown[]) =>
|
||||
mockFindAllProfilesForUserIncludingMovedUser(...args),
|
||||
buildPersonalProfileFromUser: vi.fn(() => ({ upId: "usr_1" })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
|
||||
UserRepository: MockUserRepository,
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
|
||||
PermissionCheckService: MockPermissionCheckService,
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/getAvatarUrl", () => ({
|
||||
getUserAvatarUrl: vi.fn(() => "https://avatar.example.com/1"),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
secondaryEmail: {
|
||||
findMany: (...args: unknown[]) => mockFindMany(...args),
|
||||
},
|
||||
account: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { getHandler } from "./get.handler";
|
||||
|
||||
describe("getHandler - identity provider email lookup", () => {
|
||||
const baseUser = {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
username: "testuser",
|
||||
emailVerified: new Date(),
|
||||
bufferTime: 0,
|
||||
locale: "en",
|
||||
timeFormat: 12,
|
||||
timeZone: "UTC",
|
||||
avatar: null,
|
||||
avatarUrl: null,
|
||||
createdDate: new Date(),
|
||||
trialEndsAt: null,
|
||||
defaultScheduleId: null,
|
||||
completedOnboarding: true,
|
||||
twoFactorEnabled: false,
|
||||
disableImpersonation: false,
|
||||
brandColor: "#000000",
|
||||
darkBrandColor: "#ffffff",
|
||||
bio: null,
|
||||
weekStart: "Monday",
|
||||
theme: null,
|
||||
appTheme: null,
|
||||
hideBranding: false,
|
||||
metadata: null,
|
||||
defaultBookerLayouts: null,
|
||||
allowDynamicBooking: true,
|
||||
allowSEOIndexing: true,
|
||||
receiveMonthlyDigestEmail: true,
|
||||
requiresBookerEmailVerification: false,
|
||||
role: "USER",
|
||||
organization: null,
|
||||
teams: [],
|
||||
};
|
||||
|
||||
function createCtx(overrides: Partial<typeof baseUser> = {}) {
|
||||
return {
|
||||
user: { ...baseUser, ...overrides } as unknown as NonNullable<TrpcSessionUser>,
|
||||
session: { upId: "usr_1" } as any,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockFindAllProfilesForUserIncludingMovedUser.mockResolvedValue([]);
|
||||
mockEnrichUserWithTheProfile.mockImplementation(({ user }) => ({
|
||||
...user,
|
||||
profile: null,
|
||||
}));
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
mockGetTeamIdsWithPermission.mockResolvedValue([]);
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("maps AZUREAD identity provider to 'azure-ad' in account lookup", async () => {
|
||||
const ctx = createCtx({
|
||||
identityProvider: IdentityProvider.AZUREAD,
|
||||
identityProviderId: "azure-provider-id-123",
|
||||
} as any);
|
||||
mockEnrichUserWithTheProfile.mockImplementation(({ user }) => ({
|
||||
...user,
|
||||
identityProvider: IdentityProvider.AZUREAD,
|
||||
identityProviderId: "azure-provider-id-123",
|
||||
profile: null,
|
||||
}));
|
||||
mockFindUnique.mockResolvedValue({ providerEmail: "azure@example.com" });
|
||||
|
||||
const result = await getHandler({ ctx, input: {} });
|
||||
|
||||
expect(mockFindUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: "azure-ad",
|
||||
providerAccountId: "azure-provider-id-123",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.identityProviderEmail).toBe("azure@example.com");
|
||||
});
|
||||
|
||||
it("maps GOOGLE identity provider to 'google' in account lookup", async () => {
|
||||
const ctx = createCtx({
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
identityProviderId: "google-id-456",
|
||||
} as any);
|
||||
mockEnrichUserWithTheProfile.mockImplementation(({ user }) => ({
|
||||
...user,
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
identityProviderId: "google-id-456",
|
||||
profile: null,
|
||||
}));
|
||||
mockFindUnique.mockResolvedValue({ providerEmail: "google@example.com" });
|
||||
|
||||
const result = await getHandler({ ctx, input: {} });
|
||||
|
||||
expect(mockFindUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: "google",
|
||||
providerAccountId: "google-id-456",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.identityProviderEmail).toBe("google@example.com");
|
||||
});
|
||||
|
||||
it("maps CAL identity provider to 'cal' in account lookup", async () => {
|
||||
const ctx = createCtx({
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
identityProviderId: "cal-id-789",
|
||||
} as any);
|
||||
mockEnrichUserWithTheProfile.mockImplementation(({ user }) => ({
|
||||
...user,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
identityProviderId: "cal-id-789",
|
||||
profile: null,
|
||||
}));
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getHandler({ ctx, input: {} });
|
||||
|
||||
expect(mockFindUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: "cal",
|
||||
providerAccountId: "cal-id-789",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.identityProviderEmail).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when no account found", async () => {
|
||||
const ctx = createCtx({
|
||||
identityProvider: IdentityProvider.AZUREAD,
|
||||
identityProviderId: "nonexistent-id",
|
||||
} as any);
|
||||
mockEnrichUserWithTheProfile.mockImplementation(({ user }) => ({
|
||||
...user,
|
||||
identityProvider: IdentityProvider.AZUREAD,
|
||||
identityProviderId: "nonexistent-id",
|
||||
profile: null,
|
||||
}));
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getHandler({ ctx, input: {} });
|
||||
|
||||
expect(result.identityProviderEmail).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,10 @@ export const getHandler = async ({ ctx, input }: MeOptions) => {
|
||||
const account = await prisma.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: user.identityProvider.toLocaleLowerCase(),
|
||||
provider:
|
||||
user.identityProvider === IdentityProvider.AZUREAD
|
||||
? "azure-ad"
|
||||
: user.identityProvider.toLowerCase(),
|
||||
providerAccountId: user.identityProviderId,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"CALCOM_ADMIN_API_KEY",
|
||||
"NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED",
|
||||
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
|
||||
"OUTLOOK_LOGIN_ENABLED",
|
||||
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
|
||||
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
|
||||
"SLOTS_CACHE_TTL",
|
||||
|
||||
Reference in New Issue
Block a user