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