Feature/sso signup (#1555)
* updated saml-jackson * if logged in redirect to getting-started page with username in the query param * fixed issue with mixed up Google login, profile.id is undefined and this is causing the first record to be retrieved instead of the AND query failing * updated updated saml-jackson * document PGSSLMODE for Heroku * tweaks to PGSSLMODE doc * for self-hosted instance just allow user to signin with any identity (as long as email matches) * fixed submitting flag * added username to onboarding flow (if requested during signup) * added telemetry for google login, saml login, saml config * check if firstName and lastName are defined * convert mutation to an async op * added e2e test to ensure username query param gets picked up during onboarding * fixed minor typo and added note about configuring Google integration as an Internal app when self-hosting * cleaned up unnecessary ssr in sso signup routes * renamed function * Revert "cleaned up unnecessary ssr in sso signup routes" This reverts commit 3607ffef79542d8ca4277a64be38d35bd9457960. * moved client side code to useEffect hook * - format - fixed Save button in SAML config component Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									bf74fab0d3
								
							
						
					
					
						commit
						7b65942de2
					
				
					 12 changed files with 1532 additions and 1176 deletions
				
			
		|  | @ -10,13 +10,15 @@ NEXT_PUBLIC_LICENSE_CONSENT='' | ||||||
| # DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>' | # DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>' | ||||||
| DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" | DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" | ||||||
| 
 | 
 | ||||||
| # Needed to enable Google Calendar integrationa and Login with Google | # Needed to enable Google Calendar integration and Login with Google | ||||||
| # @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials | # @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials | ||||||
| GOOGLE_API_CREDENTIALS='{}' | GOOGLE_API_CREDENTIALS='{}' | ||||||
| 
 | 
 | ||||||
| # To enable Login with Google you need to: | # To enable Login with Google you need to: | ||||||
| # 1. Set `GOOGLE_API_CREDENTIALS` above | # 1. Set `GOOGLE_API_CREDENTIALS` above | ||||||
| # 2. Set `GOOGLE_LOGIN_ENABLED` to `true` | # 2. Set `GOOGLE_LOGIN_ENABLED` to `true` | ||||||
|  | # When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance | ||||||
|  | # @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications | ||||||
| GOOGLE_LOGIN_ENABLED=false | GOOGLE_LOGIN_ENABLED=false | ||||||
| 
 | 
 | ||||||
| BASE_URL='http://localhost:3000' | BASE_URL='http://localhost:3000' | ||||||
|  | @ -30,6 +32,9 @@ PLAYWRIGHT_SECRET= | ||||||
| # @see https://github.com/calendso/calendso/tree/main/ee#setting-up-saml-login | # @see https://github.com/calendso/calendso/tree/main/ee#setting-up-saml-login | ||||||
| # SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml" | # SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml" | ||||||
| # SAML_ADMINS='pro@example.com' | # SAML_ADMINS='pro@example.com' | ||||||
|  | # If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line. | ||||||
|  | # @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js | ||||||
|  | ##PGSSLMODE='no-verify' | ||||||
| 
 | 
 | ||||||
| # @see: https://github.com/calendso/calendso/issues/263 | # @see: https://github.com/calendso/calendso/issues/263 | ||||||
| # Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL | # Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL | ||||||
|  |  | ||||||
|  | @ -17,7 +17,9 @@ const NavTabs: FC<Props> = ({ tabs, linkProps }) => { | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <nav className="-mb-px flex rtl:space-x-reverse space-x-2 sm:rtl:space-x-reverse space-x-5" aria-label="Tabs"> |       <nav | ||||||
|  |         className="flex -mb-px space-x-2 space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse" | ||||||
|  |         aria-label="Tabs"> | ||||||
|         {tabs.map((tab) => { |         {tabs.map((tab) => { | ||||||
|           const isCurrent = router.asPath === tab.href; |           const isCurrent = router.asPath === tab.href; | ||||||
|           return ( |           return ( | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from "react"; | ||||||
| 
 | 
 | ||||||
| import { useLocale } from "@lib/hooks/useLocale"; | import { useLocale } from "@lib/hooks/useLocale"; | ||||||
| import showToast from "@lib/notification"; | import showToast from "@lib/notification"; | ||||||
|  | import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; | ||||||
| import { trpc } from "@lib/trpc"; | import { trpc } from "@lib/trpc"; | ||||||
| 
 | 
 | ||||||
| import { Dialog, DialogTrigger } from "@components/Dialog"; | import { Dialog, DialogTrigger } from "@components/Dialog"; | ||||||
|  | @ -22,6 +23,8 @@ export default function SAMLConfiguration({ | ||||||
| 
 | 
 | ||||||
|   const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]); |   const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]); | ||||||
| 
 | 
 | ||||||
|  |   const telemetry = useTelemetry(); | ||||||
|  | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const data = query.data; |     const data = query.data; | ||||||
|     setIsSAMLLoginEnabled(data?.isSAMLLoginEnabled ?? false); |     setIsSAMLLoginEnabled(data?.isSAMLLoginEnabled ?? false); | ||||||
|  | @ -66,8 +69,11 @@ export default function SAMLConfiguration({ | ||||||
| 
 | 
 | ||||||
|     const rawMetadata = samlConfigRef.current.value; |     const rawMetadata = samlConfigRef.current.value; | ||||||
| 
 | 
 | ||||||
|  |     // track Google logins. Without personal data/payload
 | ||||||
|  |     telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.samlConfig, collectPageParameters())); | ||||||
|  | 
 | ||||||
|     mutation.mutate({ |     mutation.mutate({ | ||||||
|       rawMetadata: rawMetadata, |       encodedRawMetadata: Buffer.from(rawMetadata).toString("base64"), | ||||||
|       teamId, |       teamId, | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  | @ -88,14 +94,14 @@ export default function SAMLConfiguration({ | ||||||
|       {isSAMLLoginEnabled ? ( |       {isSAMLLoginEnabled ? ( | ||||||
|         <> |         <> | ||||||
|           <div className="mt-6"> |           <div className="mt-6"> | ||||||
|             <h2 className="font-cal text-lg leading-6 font-medium text-gray-900"> |             <h2 className="text-lg font-medium leading-6 text-gray-900 font-cal"> | ||||||
|               {t("saml_configuration")} |               {t("saml_configuration")} | ||||||
|               <Badge className="text-xs ml-2" variant={samlConfig ? "success" : "gray"}> |               <Badge className="ml-2 text-xs" variant={samlConfig ? "success" : "gray"}> | ||||||
|                 {samlConfig ? t("enabled") : t("disabled")} |                 {samlConfig ? t("enabled") : t("disabled")} | ||||||
|               </Badge> |               </Badge> | ||||||
|               {samlConfig ? ( |               {samlConfig ? ( | ||||||
|                 <> |                 <> | ||||||
|                   <Badge className="text-xs ml-2" variant={"success"}> |                   <Badge className="ml-2 text-xs" variant={"success"}> | ||||||
|                     {samlConfig ? samlConfig : ""} |                     {samlConfig ? samlConfig : ""} | ||||||
|                   </Badge> |                   </Badge> | ||||||
|                 </> |                 </> | ||||||
|  | @ -104,7 +110,7 @@ export default function SAMLConfiguration({ | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|           {samlConfig ? ( |           {samlConfig ? ( | ||||||
|             <div className="mt-2 flex"> |             <div className="flex mt-2"> | ||||||
|               <Dialog> |               <Dialog> | ||||||
|                 <DialogTrigger asChild> |                 <DialogTrigger asChild> | ||||||
|                   <Button |                   <Button | ||||||
|  | @ -147,11 +153,7 @@ export default function SAMLConfiguration({ | ||||||
|             /> |             /> | ||||||
| 
 | 
 | ||||||
|             <div className="flex justify-end py-8"> |             <div className="flex justify-end py-8"> | ||||||
|               <button |               <Button type="submit">{t("save")}</Button> | ||||||
|                 type="submit" |  | ||||||
|                 className="ltr:ml-2 rtl:mr-2bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"> |  | ||||||
|                 {t("save")} |  | ||||||
|               </button> |  | ||||||
|             </div> |             </div> | ||||||
|             <hr className="mt-4" /> |             <hr className="mt-4" /> | ||||||
|           </form> |           </form> | ||||||
|  |  | ||||||
|  | @ -10,6 +10,9 @@ export const telemetryEventTypes = { | ||||||
|   bookingConfirmed: "booking_confirmed", |   bookingConfirmed: "booking_confirmed", | ||||||
|   bookingCancelled: "booking_cancelled", |   bookingCancelled: "booking_cancelled", | ||||||
|   importSubmitted: "import_submitted", |   importSubmitted: "import_submitted", | ||||||
|  |   googleLogin: "google_login", | ||||||
|  |   samlLogin: "saml_login", | ||||||
|  |   samlConfig: "saml_config", | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  |  | ||||||
|  | @ -36,7 +36,7 @@ | ||||||
|     "yarn": ">=1.19.0 < 2.0.0" |     "yarn": ">=1.19.0 < 2.0.0" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@boxyhq/saml-jackson": "0.3.3", |     "@boxyhq/saml-jackson": "0.3.6", | ||||||
|     "@daily-co/daily-js": "^0.21.0", |     "@daily-co/daily-js": "^0.21.0", | ||||||
|     "@headlessui/react": "^1.4.2", |     "@headlessui/react": "^1.4.2", | ||||||
|     "@heroicons/react": "^1.0.5", |     "@heroicons/react": "^1.0.5", | ||||||
|  | @ -168,4 +168,4 @@ | ||||||
|   "prisma": { |   "prisma": { | ||||||
|     "seed": "ts-node ./prisma/seed.ts" |     "seed": "ts-node ./prisma/seed.ts" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | @ -9,7 +9,7 @@ import { ErrorCode, verifyPassword } from "@lib/auth"; | ||||||
| import { symmetricDecrypt } from "@lib/crypto"; | import { symmetricDecrypt } from "@lib/crypto"; | ||||||
| import prisma from "@lib/prisma"; | import prisma from "@lib/prisma"; | ||||||
| import { randomString } from "@lib/random"; | import { randomString } from "@lib/random"; | ||||||
| import { isSAMLLoginEnabled, samlLoginUrl } from "@lib/saml"; | import { isSAMLLoginEnabled, samlLoginUrl, hostedCal } from "@lib/saml"; | ||||||
| import slugify from "@lib/slugify"; | import slugify from "@lib/slugify"; | ||||||
| 
 | 
 | ||||||
| import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants"; | import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants"; | ||||||
|  | @ -124,10 +124,10 @@ if (isSAMLLoginEnabled) { | ||||||
|     profile: (profile) => { |     profile: (profile) => { | ||||||
|       return { |       return { | ||||||
|         id: profile.id || "", |         id: profile.id || "", | ||||||
|         firstName: profile.first_name || "", |         firstName: profile.firstName || "", | ||||||
|         lastName: profile.last_name || "", |         lastName: profile.lastName || "", | ||||||
|         email: profile.email || "", |         email: profile.email || "", | ||||||
|         name: `${profile.firstName} ${profile.lastName}`, |         name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(), | ||||||
|         email_verified: true, |         email_verified: true, | ||||||
|       }; |       }; | ||||||
|     }, |     }, | ||||||
|  | @ -151,8 +151,28 @@ export default NextAuth({ | ||||||
|   providers, |   providers, | ||||||
|   callbacks: { |   callbacks: { | ||||||
|     async jwt({ token, user, account }) { |     async jwt({ token, user, account }) { | ||||||
|       if (!user) { |       const autoMergeIdentities = async () => { | ||||||
|  |         if (!hostedCal) { | ||||||
|  |           const existingUser = await prisma.user.findFirst({ | ||||||
|  |             where: { email: token.email! }, | ||||||
|  |           }); | ||||||
|  | 
 | ||||||
|  |           if (!existingUser) { | ||||||
|  |             return token; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           return { | ||||||
|  |             id: existingUser.id, | ||||||
|  |             username: existingUser.username, | ||||||
|  |             email: existingUser.email, | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         return token; |         return token; | ||||||
|  |       }; | ||||||
|  | 
 | ||||||
|  |       if (!user) { | ||||||
|  |         return await autoMergeIdentities(); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (account && account.type === "credentials") { |       if (account && account.type === "credentials") { | ||||||
|  | @ -185,7 +205,7 @@ export default NextAuth({ | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         if (!existingUser) { |         if (!existingUser) { | ||||||
|           return token; |           return await autoMergeIdentities(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return { |         return { | ||||||
|  | @ -274,6 +294,11 @@ export default NextAuth({ | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         if (existingUserWithEmail) { |         if (existingUserWithEmail) { | ||||||
|  |           // if self-hosted then we can allow auto-merge of identity providers if email is verified
 | ||||||
|  |           if (!hostedCal && existingUserWithEmail.emailVerified) { | ||||||
|  |             return true; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|           // check if user was invited
 |           // check if user was invited
 | ||||||
|           if ( |           if ( | ||||||
|             !existingUserWithEmail.password && |             !existingUserWithEmail.password && | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ import { ErrorCode, getSession } from "@lib/auth"; | ||||||
| import { WEBSITE_URL } from "@lib/config/constants"; | import { WEBSITE_URL } from "@lib/config/constants"; | ||||||
| import { useLocale } from "@lib/hooks/useLocale"; | import { useLocale } from "@lib/hooks/useLocale"; | ||||||
| import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml"; | import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml"; | ||||||
|  | import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; | ||||||
| import { trpc } from "@lib/trpc"; | import { trpc } from "@lib/trpc"; | ||||||
| import { inferSSRProps } from "@lib/types/inferSSRProps"; | import { inferSSRProps } from "@lib/types/inferSSRProps"; | ||||||
| 
 | 
 | ||||||
|  | @ -44,6 +45,8 @@ export default function Login({ | ||||||
|     [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), |     [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   const telemetry = useTelemetry(); | ||||||
|  | 
 | ||||||
|   const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/"; |   const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/"; | ||||||
| 
 | 
 | ||||||
|   async function handleSubmit(e: React.SyntheticEvent) { |   async function handleSubmit(e: React.SyntheticEvent) { | ||||||
|  | @ -177,7 +180,16 @@ export default function Login({ | ||||||
|               color="secondary" |               color="secondary" | ||||||
|               className="flex justify-center w-full" |               className="flex justify-center w-full" | ||||||
|               data-testid={"google"} |               data-testid={"google"} | ||||||
|               onClick={async () => await signIn("google")}> |               onClick={async (event) => { | ||||||
|  |                 event.preventDefault(); | ||||||
|  | 
 | ||||||
|  |                 // track Google logins. Without personal data/payload
 | ||||||
|  |                 telemetry.withJitsu((jitsu) => | ||||||
|  |                   jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters()) | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|  |                 await signIn("google"); | ||||||
|  |               }}> | ||||||
|               {" "} |               {" "} | ||||||
|               {t("signin_with_google")} |               {t("signin_with_google")} | ||||||
|             </Button> |             </Button> | ||||||
|  | @ -192,6 +204,11 @@ export default function Login({ | ||||||
|               onClick={async (event) => { |               onClick={async (event) => { | ||||||
|                 event.preventDefault(); |                 event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|  |                 // track SAML logins. Without personal data/payload
 | ||||||
|  |                 telemetry.withJitsu((jitsu) => | ||||||
|  |                   jitsu.track(telemetryEventTypes.samlLogin, collectPageParameters()) | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|                 if (!hostedCal) { |                 if (!hostedCal) { | ||||||
|                   await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID }); |                   await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID }); | ||||||
|                 } else { |                 } else { | ||||||
|  |  | ||||||
|  | @ -1,35 +1,40 @@ | ||||||
| import { GetServerSidePropsContext } from "next"; | import { GetServerSidePropsContext } from "next"; | ||||||
| import { signIn } from "next-auth/react"; | import { signIn } from "next-auth/react"; | ||||||
| import { useRouter } from "next/router"; | import { useRouter } from "next/router"; | ||||||
|  | import { useEffect } from "react"; | ||||||
| 
 | 
 | ||||||
| import { asStringOrNull } from "@lib/asStringOrNull"; | import { asStringOrNull } from "@lib/asStringOrNull"; | ||||||
|  | import { getSession } from "@lib/auth"; | ||||||
| import prisma from "@lib/prisma"; | import prisma from "@lib/prisma"; | ||||||
| import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID, samlTenantProduct } from "@lib/saml"; | import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID, samlTenantProduct } from "@lib/saml"; | ||||||
| import { inferSSRProps } from "@lib/types/inferSSRProps"; | import { inferSSRProps } from "@lib/types/inferSSRProps"; | ||||||
| 
 | 
 | ||||||
|  | import { ssrInit } from "@server/lib/ssr"; | ||||||
|  | 
 | ||||||
| export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>; | export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>; | ||||||
| 
 | 
 | ||||||
| export default function Type(props: SSOProviderPageProps) { | export default function Provider(props: SSOProviderPageProps) { | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
| 
 | 
 | ||||||
|   if (props.provider === "saml") { |   useEffect(() => { | ||||||
|     const email = typeof router.query?.email === "string" ? router.query?.email : null; |     if (props.provider === "saml") { | ||||||
|  |       const email = typeof router.query?.email === "string" ? router.query?.email : null; | ||||||
| 
 | 
 | ||||||
|     if (!email) { |       if (!email) { | ||||||
|       router.push("/auth/error?error=" + "Email not provided"); |         router.push("/auth/error?error=" + "Email not provided"); | ||||||
|       return null; |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (!props.isSAMLLoginEnabled) { | ||||||
|  |         router.push("/auth/error?error=" + "SAML login not enabled"); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       signIn("saml", {}, { tenant: props.tenant, product: props.product }); | ||||||
|  |     } else { | ||||||
|  |       signIn(props.provider); | ||||||
|     } |     } | ||||||
| 
 |   }, []); | ||||||
|     if (!props.isSAMLLoginEnabled) { |  | ||||||
|       router.push("/auth/error?error=" + "SAML login not enabled"); |  | ||||||
|       return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     signIn("saml", {}, { tenant: props.tenant, product: props.product }); |  | ||||||
|   } else { |  | ||||||
|     signIn(props.provider); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return null; |   return null; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -38,17 +43,32 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => | ||||||
|   // (would be even better to assert them instead of typecasting)
 |   // (would be even better to assert them instead of typecasting)
 | ||||||
|   const providerParam = asStringOrNull(context.query.provider); |   const providerParam = asStringOrNull(context.query.provider); | ||||||
|   const emailParam = asStringOrNull(context.query.email); |   const emailParam = asStringOrNull(context.query.email); | ||||||
|  |   const usernameParam = asStringOrNull(context.query.username); | ||||||
| 
 | 
 | ||||||
|   if (!providerParam) { |   if (!providerParam) { | ||||||
|     throw new Error(`File is not named sso/[provider]`); |     throw new Error(`File is not named sso/[provider]`); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   const { req } = context; | ||||||
|  | 
 | ||||||
|  |   const session = await getSession({ req }); | ||||||
|  |   const ssr = await ssrInit(context); | ||||||
|  | 
 | ||||||
|  |   if (session) { | ||||||
|  |     return { | ||||||
|  |       redirect: { | ||||||
|  |         destination: "/getting-started" + (usernameParam ? `?username=${usernameParam}` : ""), | ||||||
|  |         permanent: false, | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   let error: string | null = null; |   let error: string | null = null; | ||||||
| 
 | 
 | ||||||
|   let tenant = samlTenantID; |   let tenant = samlTenantID; | ||||||
|   let product = samlProductID; |   let product = samlProductID; | ||||||
| 
 | 
 | ||||||
|   if (providerParam === "saml") { |   if (providerParam === "saml" && hostedCal) { | ||||||
|     if (!emailParam) { |     if (!emailParam) { | ||||||
|       error = "Email not provided"; |       error = "Email not provided"; | ||||||
|     } else { |     } else { | ||||||
|  | @ -73,6 +93,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => | ||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     props: { |     props: { | ||||||
|  |       trpcState: ssr.dehydrate(), | ||||||
|       provider: providerParam, |       provider: providerParam, | ||||||
|       isSAMLLoginEnabled, |       isSAMLLoginEnabled, | ||||||
|       hostedCal, |       hostedCal, | ||||||
|  |  | ||||||
|  | @ -17,6 +17,7 @@ import { useForm } from "react-hook-form"; | ||||||
| import TimezoneSelect from "react-timezone-select"; | import TimezoneSelect from "react-timezone-select"; | ||||||
| import * as z from "zod"; | import * as z from "zod"; | ||||||
| 
 | 
 | ||||||
|  | import { asStringOrNull } from "@lib/asStringOrNull"; | ||||||
| import { getSession } from "@lib/auth"; | import { getSession } from "@lib/auth"; | ||||||
| import { DEFAULT_SCHEDULE } from "@lib/availability"; | import { DEFAULT_SCHEDULE } from "@lib/availability"; | ||||||
| import { useLocale } from "@lib/hooks/useLocale"; | import { useLocale } from "@lib/hooks/useLocale"; | ||||||
|  | @ -24,6 +25,7 @@ import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations | ||||||
| import getIntegrations from "@lib/integrations/getIntegrations"; | import getIntegrations from "@lib/integrations/getIntegrations"; | ||||||
| import prisma from "@lib/prisma"; | import prisma from "@lib/prisma"; | ||||||
| import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; | import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; | ||||||
|  | import { trpc } from "@lib/trpc"; | ||||||
| import { inferSSRProps } from "@lib/types/inferSSRProps"; | import { inferSSRProps } from "@lib/types/inferSSRProps"; | ||||||
| import { Schedule as ScheduleType } from "@lib/types/schedule"; | import { Schedule as ScheduleType } from "@lib/types/schedule"; | ||||||
| 
 | 
 | ||||||
|  | @ -46,11 +48,32 @@ type ScheduleFormValues = { | ||||||
|   schedule: ScheduleType; |   schedule: ScheduleType; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | let mutationComplete: ((err: Error | null) => void) | null; | ||||||
|  | 
 | ||||||
| export default function Onboarding(props: inferSSRProps<typeof getServerSideProps>) { | export default function Onboarding(props: inferSSRProps<typeof getServerSideProps>) { | ||||||
|   const { t } = useLocale(); |   const { t } = useLocale(); | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const telemetry = useTelemetry(); |   const telemetry = useTelemetry(); | ||||||
| 
 | 
 | ||||||
|  |   const mutation = trpc.useMutation("viewer.updateProfile", { | ||||||
|  |     onSuccess: async () => { | ||||||
|  |       setSubmitting(true); | ||||||
|  |       setEnteredName(nameRef.current?.value || ""); | ||||||
|  |       if (mutationComplete) { | ||||||
|  |         mutationComplete(null); | ||||||
|  |         mutationComplete = null; | ||||||
|  |       } | ||||||
|  |       setSubmitting(false); | ||||||
|  |     }, | ||||||
|  |     onError: (err) => { | ||||||
|  |       setError(new Error(err.message)); | ||||||
|  |       if (mutationComplete) { | ||||||
|  |         mutationComplete(new Error(err.message)); | ||||||
|  |       } | ||||||
|  |       setSubmitting(false); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|   const DEFAULT_EVENT_TYPES = [ |   const DEFAULT_EVENT_TYPES = [ | ||||||
|     { |     { | ||||||
|       title: t("15min_meeting"), |       title: t("15min_meeting"), | ||||||
|  | @ -128,6 +151,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
| 
 | 
 | ||||||
|   /** Name */ |   /** Name */ | ||||||
|   const nameRef = useRef<HTMLInputElement>(null); |   const nameRef = useRef<HTMLInputElement>(null); | ||||||
|  |   const usernameRef = useRef<HTMLInputElement>(null); | ||||||
|   const bioRef = useRef<HTMLInputElement>(null); |   const bioRef = useRef<HTMLInputElement>(null); | ||||||
|   /** End Name */ |   /** End Name */ | ||||||
|   /** TimeZone */ |   /** TimeZone */ | ||||||
|  | @ -138,7 +162,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
|   const [currentStep, setCurrentStep] = useState(0); |   const [currentStep, setCurrentStep] = useState(0); | ||||||
|   const detectStep = () => { |   const detectStep = () => { | ||||||
|     let step = 0; |     let step = 0; | ||||||
|     const hasSetUserNameOrTimeZone = props.user?.name && props.user?.timeZone; |     const hasSetUserNameOrTimeZone = props.user?.name && props.user?.timeZone && !props.usernameParam; | ||||||
|     if (hasSetUserNameOrTimeZone) { |     if (hasSetUserNameOrTimeZone) { | ||||||
|       step = 1; |       step = 1; | ||||||
|     } |     } | ||||||
|  | @ -170,7 +194,6 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
|       incrementStep(); |       incrementStep(); | ||||||
|       setSubmitting(false); |       setSubmitting(false); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       console.log("handleConfirmStep", error); |  | ||||||
|       setSubmitting(false); |       setSubmitting(false); | ||||||
|       setError(error as Error); |       setError(error as Error); | ||||||
|     } |     } | ||||||
|  | @ -326,6 +349,25 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
|           </div> |           </div> | ||||||
|           <form className="sm:mx-auto sm:w-full"> |           <form className="sm:mx-auto sm:w-full"> | ||||||
|             <section className="space-y-8"> |             <section className="space-y-8"> | ||||||
|  |               {props.usernameParam && ( | ||||||
|  |                 <fieldset> | ||||||
|  |                   <label htmlFor="name" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                     {t("username")} | ||||||
|  |                   </label> | ||||||
|  |                   <input | ||||||
|  |                     ref={usernameRef} | ||||||
|  |                     type="text" | ||||||
|  |                     name="username" | ||||||
|  |                     id="username" | ||||||
|  |                     data-testid="username" | ||||||
|  |                     placeholder={t("username")} | ||||||
|  |                     defaultValue={props.usernameParam ?? ""} | ||||||
|  |                     required | ||||||
|  |                     className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" | ||||||
|  |                   /> | ||||||
|  |                 </fieldset> | ||||||
|  |               )} | ||||||
|  | 
 | ||||||
|               <fieldset> |               <fieldset> | ||||||
|                 <label htmlFor="name" className="block text-sm font-medium text-gray-700"> |                 <label htmlFor="name" className="block text-sm font-medium text-gray-700"> | ||||||
|                   {t("full_name")} |                   {t("full_name")} | ||||||
|  | @ -369,17 +411,26 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
|       showCancel: true, |       showCancel: true, | ||||||
|       cancelText: t("set_up_later"), |       cancelText: t("set_up_later"), | ||||||
|       onComplete: async () => { |       onComplete: async () => { | ||||||
|         try { |         mutationComplete = null; | ||||||
|           setSubmitting(true); |         setError(null); | ||||||
|           await updateUser({ |         const mutationAsync = new Promise((resolve, reject) => { | ||||||
|             name: nameRef.current?.value, |           mutationComplete = (err) => { | ||||||
|             timeZone: selectedTimeZone, |             if (err) { | ||||||
|           }); |               reject(err); | ||||||
|           setEnteredName(nameRef.current?.value || ""); |               return; | ||||||
|           setSubmitting(true); |             } | ||||||
|         } catch (error) { |             resolve(null); | ||||||
|           setError(error as Error); |           }; | ||||||
|           setSubmitting(false); |         }); | ||||||
|  | 
 | ||||||
|  |         mutation.mutate({ | ||||||
|  |           username: usernameRef.current?.value, | ||||||
|  |           name: nameRef.current?.value, | ||||||
|  |           timeZone: selectedTimeZone, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         if (mutationComplete) { | ||||||
|  |           await mutationAsync; | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|  | @ -480,7 +531,6 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
|       onComplete: async () => { |       onComplete: async () => { | ||||||
|         try { |         try { | ||||||
|           setSubmitting(true); |           setSubmitting(true); | ||||||
|           console.log("updating"); |  | ||||||
|           await updateUser({ |           await updateUser({ | ||||||
|             bio: bioRef.current?.value, |             bio: bioRef.current?.value, | ||||||
|           }); |           }); | ||||||
|  | @ -532,7 +582,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
|                 Step {currentStep + 1} of {steps.length} |                 Step {currentStep + 1} of {steps.length} | ||||||
|               </Text> |               </Text> | ||||||
| 
 | 
 | ||||||
|               {error && <Alert severity="error" {...error} />} |               {error && <Alert severity="error" message={error?.message} />} | ||||||
| 
 | 
 | ||||||
|               <section className="flex w-full space-x-2 rtl:space-x-reverse"> |               <section className="flex w-full space-x-2 rtl:space-x-reverse"> | ||||||
|                 {steps.map((s, index) => { |                 {steps.map((s, index) => { | ||||||
|  | @ -591,6 +641,8 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getServerSideProps(context: NextPageContext) { | export async function getServerSideProps(context: NextPageContext) { | ||||||
|  |   const usernameParam = asStringOrNull(context.query.username); | ||||||
|  | 
 | ||||||
|   const session = await getSession(context); |   const session = await getSession(context); | ||||||
| 
 | 
 | ||||||
|   let integrations = []; |   let integrations = []; | ||||||
|  | @ -693,6 +745,7 @@ export async function getServerSideProps(context: NextPageContext) { | ||||||
|       connectedCalendars, |       connectedCalendars, | ||||||
|       eventTypes, |       eventTypes, | ||||||
|       schedules, |       schedules, | ||||||
|  |       usernameParam, | ||||||
|     }, |     }, | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { test } from "@playwright/test"; | import { expect, test } from "@playwright/test"; | ||||||
| 
 | 
 | ||||||
| test.describe("Onboarding", () => { | test.describe("Onboarding", () => { | ||||||
|   test.use({ storageState: "playwright/artifacts/onboardingStorageState.json" }); |   test.use({ storageState: "playwright/artifacts/onboardingStorageState.json" }); | ||||||
|  | @ -11,4 +11,17 @@ test.describe("Onboarding", () => { | ||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  | 
 | ||||||
|  |   const username = "calendso"; | ||||||
|  |   test(`/getting-started?username=${username} shows the first step of onboarding with username field populated`, async ({ | ||||||
|  |     page, | ||||||
|  |   }) => { | ||||||
|  |     await page.goto("/getting-started?username=" + username); | ||||||
|  | 
 | ||||||
|  |     await page.waitForSelector("[data-testid=username]"); | ||||||
|  | 
 | ||||||
|  |     await expect(await page.$eval("[data-testid=username]", (el: HTMLInputElement) => el.value)).toEqual( | ||||||
|  |       username | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -781,17 +781,17 @@ const loggedInViewerRouter = createProtectedRouter() | ||||||
|   }) |   }) | ||||||
|   .mutation("updateSAMLConfig", { |   .mutation("updateSAMLConfig", { | ||||||
|     input: z.object({ |     input: z.object({ | ||||||
|       rawMetadata: z.string(), |       encodedRawMetadata: z.string(), | ||||||
|       teamId: z.union([z.number(), z.null(), z.undefined()]), |       teamId: z.union([z.number(), z.null(), z.undefined()]), | ||||||
|     }), |     }), | ||||||
|     async resolve({ input }) { |     async resolve({ input }) { | ||||||
|       const { rawMetadata, teamId } = input; |       const { encodedRawMetadata, teamId } = input; | ||||||
| 
 | 
 | ||||||
|       const { apiController } = await jackson(); |       const { apiController } = await jackson(); | ||||||
| 
 | 
 | ||||||
|       try { |       try { | ||||||
|         return await apiController.config({ |         return await apiController.config({ | ||||||
|           rawMetadata, |           encodedRawMetadata, | ||||||
|           defaultRedirectUrl: `${process.env.BASE_URL}/api/auth/saml/idp`, |           defaultRedirectUrl: `${process.env.BASE_URL}/api/auth/saml/idp`, | ||||||
|           redirectUrl: JSON.stringify([`${process.env.BASE_URL}/*`]), |           redirectUrl: JSON.stringify([`${process.env.BASE_URL}/*`]), | ||||||
|           tenant: teamId ? tenantPrefix + teamId : samlTenantID, |           tenant: teamId ? tenantPrefix + teamId : samlTenantID, | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue
	
	 Deepak Prabhakara
						Deepak Prabhakara