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:
Deepak Prabhakara 2022-02-02 18:33:27 +00:00 committed by GitHub
parent bf74fab0d3
commit 7b65942de2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1532 additions and 1176 deletions

View file

@ -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

View file

@ -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 (

View file

@ -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>

View file

@ -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",
}; };
/** /**

View file

@ -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"
} }
} }

View file

@ -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 &&

View file

@ -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 {

View file

@ -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,

View file

@ -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,
}, },
}; };
} }

View file

@ -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
);
});
}); });

View file

@ -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,

2451
yarn.lock

File diff suppressed because it is too large Load diff