Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Microsoft login & Update login/signup pages #16873

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
4 changes: 3 additions & 1 deletion apps/web/lib/signup/getServerSideProps.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";

import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/features/auth/lib/google";
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";
Expand All @@ -10,7 +12,6 @@ import { emailSchema } from "@calcom/lib/emailSchema";
import slugify from "@calcom/lib/slugify";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";

import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
import { ssrInit } from "@server/lib/ssr";

const checkValidEmail = (email: string) => emailSchema.safeParse(email).success;
Expand Down Expand Up @@ -44,6 +45,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const props = {
redirectUrl,
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
isOutlookLoginEnabled: IS_OUTLOOK_LOGIN_ENABLED,
isSAMLLoginEnabled,
prepopulateFormValues: undefined,
emailVerificationEnabled,
Expand Down
58 changes: 44 additions & 14 deletions apps/web/modules/auth/login-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useLastUsed, LastUsed } from "@calcom/lib/hooks/useLastUsed";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
import { Alert, Button, EmailField, Icon, PasswordField } from "@calcom/ui";

import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { WithNonceProps } from "@lib/withNonce";
Expand All @@ -42,10 +42,18 @@ interface LoginValues {
const GoogleIcon = () => (
<img className="text-subtle mr-2 h-4 w-4" src="/google-icon-colored.svg" alt="Continue with Google Icon" />
);
const MicrosoftIcon = () => (
<img
className="text-subtle mr-2 h-4 w-4"
src="/microsoft-icon-colored.svg"
alt="Continue with Microsoft Icon"
/>
);
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Login({
csrfToken,
isGoogleLoginEnabled,
isMicrosoftLoginEnabled,
isSAMLLoginEnabled,
samlTenantID,
samlProductID,
Expand Down Expand Up @@ -96,12 +104,6 @@ PageProps & WithNonceProps<{}>) {

callbackUrl = safeCallbackUrl || "";

const LoginFooter = (
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</Link>
);

const TwoFactorFooter = (
<>
<Button
Expand Down Expand Up @@ -179,6 +181,23 @@ PageProps & WithNonceProps<{}>) {
? true
: isSAMLLoginEnabled && !isPending && data?.connectionExists;

const LoginFooter = (
<div className="flex w-full flex-row items-center justify-center">
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("create_an_account")}
</Link>
{displaySSOLogin && <Icon name="circle" className="mx-5 h-2 w-2 fill-[#d9d9d9]" color="#d9d9d9" />}
{displaySSOLogin && (
<SAMLLogin
disabled={formState.isSubmitting}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
setErrorMessage={setErrorMessage}
/>
)}
</div>
);

return (
<div className="dark:bg-brand dark:text-brand-contrast 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
Expand Down Expand Up @@ -217,16 +236,27 @@ PageProps & WithNonceProps<{}>) {
{lastUsed === "google" && <LastUsed />}
</Button>
)}
{displaySSOLogin && (
<SAMLLogin
{/* TODO replace true with isMicrosoftLoginEnabled */}
{true && (
<Button
color="primary"
className="w-full justify-center"
disabled={formState.isSubmitting}
samlTenantID={samlTenantID}
samlProductID={samlProductID}
setErrorMessage={setErrorMessage}
/>
data-testid="microsoft"
CustomStartIcon={<MicrosoftIcon />}
onClick={async (e) => {
e.preventDefault();
setLastUsed("microsoft");
await signIn("microsoft", {
callbackUrl,
});
}}>
<span>{t("signin_with_microsoft")}</span>
{lastUsed === "microsoft" && <LastUsed />}
</Button>
)}
</div>
{(isGoogleLoginEnabled || displaySSOLogin) && (
{(isGoogleLoginEnabled || isMicrosoftLoginEnabled) && (
<div className="my-8">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
Expand Down
108 changes: 70 additions & 38 deletions apps/web/modules/signup-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export default function Signup({
token,
orgSlug,
isGoogleLoginEnabled,
isMicrosoftLoginEnabled,
isSAMLLoginEnabled,
orgAutoAcceptEmail,
redirectUrl,
Expand All @@ -184,6 +185,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 [displayEmailForm, setDisplayEmailForm] = useState(token);
const searchParams = useCompatSearchParams();
const telemetry = useTelemetry();
Expand Down Expand Up @@ -235,6 +237,33 @@ export default function Signup({

const isPlatformUser = redirectUrl?.includes("platform") && redirectUrl?.includes("new");

const handleOAuthClick = async (provider: "google" | "microsoft") => {
if (!provider) {
return;
}
setIsSamlSignup(false);
if (provider === "google") {
setIsGoogleLoading(true);
}
if (provider === "microsoft") {
setIsMicrosoftLoading(true);
}
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
const AUTH_URL = `${baseUrl}/auth/sso/${provider}`;
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) {
searchQueryParams.set("email", prepopulateFormValues?.email);
}
const url = searchQueryParams.toString() ? `${AUTH_URL}?${searchQueryParams.toString()}` : AUTH_URL;

router.push(url);
};

const signUp: SubmitHandler<FormValues> = async (_data) => {
const { cfToken, ...data } = _data;
await fetch("/api/auth/signup", {
Expand Down Expand Up @@ -510,46 +539,49 @@ export default function Signup({
{!displayEmailForm && (
<div className="mt-12">
{/* Upper Row */}
<div className="mt-6 flex flex-col gap-2 md:flex-row">
{isGoogleLoginEnabled ? (
<Button
color="primary"
loading={isGoogleLoading}
CustomStartIcon={
<img
className={classNames("text-subtle mr-2 h-4 w-4", premiumUsername && "opacity-50")}
src="/google-icon-colored.svg"
alt="Continue with Google Icon"
/>
}
className={classNames("w-full justify-center rounded-md text-center")}
data-testid="continue-with-google-button"
onClick={async () => {
setIsSamlSignup(false);
setIsGoogleLoading(true);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
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);
<div className="mt-6 flex flex-col gap-3">
<>
{isGoogleLoginEnabled ? (
<Button
color="primary"
loading={isGoogleLoading}
disabled={isMicrosoftLoading}
CustomStartIcon={
<img
className="text-subtle mr-2 h-4 w-4"
src="/google-icon-colored.svg"
alt="Continue with Google Icon"
/>
}
if (token) {
searchQueryParams.set("email", prepopulateFormValues?.email);
className="w-full justify-center rounded-md text-center"
data-testid="continue-with-google-button"
onClick={() => handleOAuthClick("google")}>
{t("continue_with_google")}
</Button>
) : null}
{/* TODO replace true with isMicrosoftLoginEnabled */}
{true ? (
<Button
color="primary"
loading={isMicrosoftLoading}
disabled={isGoogleLoading}
CustomStartIcon={
<img
className="text-subtle mr-2 h-4 w-4"
src="/microsoft-icon-colored.svg"
alt="Continue with Microsoft Icon"
/>
}
const url = searchQueryParams.toString()
? `${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`
: GOOGLE_AUTH_URL;

router.push(url);
}}>
{t("continue_with_google")}
</Button>
) : null}
className="w-full justify-center rounded-md text-center"
data-testid="continue-with-microsoft-button"
onClick={() => handleOAuthClick("microsoft")}>
{t("continue_with_microsoft")}
</Button>
) : null}
</>
</div>

{isGoogleLoginEnabled && (
{(isGoogleLoginEnabled || isMicrosoftLoginEnabled) && (
<div className="mt-6">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
Expand All @@ -565,7 +597,7 @@ export default function Signup({
<div className="mt-6 flex flex-col gap-2">
<Button
color="secondary"
disabled={isGoogleLoading}
disabled={isGoogleLoading || isMicrosoftLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
setDisplayEmailForm(true);
Expand All @@ -578,7 +610,7 @@ export default function Signup({
<Button
data-testid="continue-with-saml-button"
color="minimal"
disabled={isGoogleLoading}
disabled={isGoogleLoading || isMicrosoftLoading}
className={classNames("w-full justify-center rounded-md text-center")}
onClick={() => {
setDisplayEmailForm(true);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/playwright/login.oauth.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";

import { IS_GOOGLE_LOGIN_ENABLED, IS_SAML_LOGIN_ENABLED } from "../server/lib/constants";
import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/features/auth/lib/google";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";

test("Should display Google Login button", async ({ page }) => {
// eslint-disable-next-line playwright/no-skipped-test
Expand All @@ -13,7 +14,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)
Expand Down
6 changes: 6 additions & 0 deletions apps/web/public/microsoft-icon-colored.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -975,11 +975,15 @@
"account_created_with_identity_provider": "Your account was created using an Identity Provider.",
"account_managed_by_identity_provider": "Your account is managed by {{provider}}",
"account_managed_by_identity_provider_description": "To change your email, password, enable two-factor authentication and more, please visit your {{provider}} account settings.",
"signup_with_google": "Sign up with Google",
"signup_with_microsoft": "Sign up with Microsoft",
"signin_with_google": "Sign in with Google",
"signin_with_microsoft": "Sign in with Microsoft",
"signin_with_saml": "Sign in with SAML",
"signin_with_saml_oidc": "Sign in with SAML/OIDC",
"continue_with_email": "Continue with email",
"continue_with_google": "Continue with Google",
"continue_with_microsoft": "Continue with Microsoft",
"last_used": "Last used",
"you_will_need_to_generate": "You will need to generate an access token from your old scheduling tool.",
"import": "Import",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/server/lib/auth/login/getServerSideProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";

import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/features/auth/lib/google";
import { IS_OUTLOOK_LOGIN_ENABLED } from "@calcom/features/auth/lib/outlook";
import { isSAMLLoginEnabled, samlProductID, samlTenantID } 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";

import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
import { ssrInit } from "@server/lib/ssr";

export async function getServerSideProps(context: GetServerSidePropsContext) {
Expand Down Expand Up @@ -93,6 +94,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
csrfToken: await getCsrfToken(context),
trpcState: ssr.dehydrate(),
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
isOutlookLoginEnabled: IS_OUTLOOK_LOGIN_ENABLED,
isSAMLLoginEnabled,
samlTenantID,
samlProductID,
Expand Down
7 changes: 3 additions & 4 deletions packages/features/auth/SAMLLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ export function SAMLLogin({

return (
<Button
StartIcon="lock"
color="secondary"
color="minimal"
data-testid="samlAndOidc"
className="flex w-full justify-center"
className="text-brand-500 h-auto p-0 font-medium transition-none"
onClick={async (event) => {
event.preventDefault();

Expand All @@ -73,7 +72,7 @@ export function SAMLLogin({
});
}}
{...buttonProps}>
<span>{t("signin_with_saml_oidc")}</span>
<span>{t("signin_with_saml")}</span>
{lastUsed === "saml" && <LastUsed />}
</Button>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
6 changes: 1 addition & 5 deletions packages/features/auth/lib/next-auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,12 @@ import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";

import { ErrorCode } from "./ErrorCode";
import { dub } from "./dub";
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "./google";
import { isPasswordValid } from "./isPasswordValid";
import CalComAdapter from "./next-auth-custom-adapter";
import { verifyPassword } from "./verifyPassword";

const log = logger.getSubLogger({ prefix: ["next-auth-options"] });
const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}";
const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } =
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
const ORGANIZATIONS_AUTOLINK =
process.env.ORGANIZATIONS_AUTOLINK === "1" || process.env.ORGANIZATIONS_AUTOLINK === "true";

Expand Down
9 changes: 9 additions & 0 deletions packages/features/auth/lib/outlook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const OUTLOOK_API_CREDENTIALS = process.env.OUTLOOK_API_CREDENTIALS || "{}";
export const { client_id: OUTLOOK_CLIENT_ID, client_secret: OUTLOOK_CLIENT_SECRET } =
JSON.parse(OUTLOOK_API_CREDENTIALS)?.web || {};
export const OUTLOOK_LOGIN_ENABLED = process.env.OUTLOOK_LOGIN_ENABLED === "true";
export const IS_OUTLOOK_LOGIN_ENABLED = !!(
OUTLOOK_CLIENT_ID &&
OUTLOOK_CLIENT_SECRET &&
OUTLOOK_LOGIN_ENABLED
);
Loading
Loading