From c55e432bc3d7e76ba472dc0659b44bf798905fb2 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 12 May 2023 13:52:09 +0100 Subject: [PATCH] Feat/team invite flow (#8804) * completeOnboarding drives invite flow changes * Username prefil - force getting started when acc doesnt exist * Change subtitle text and unique error on multiple invites * User exists and no password set - generate signup link still * Fix callback param * Update packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts * Fix spacing * Add password hints * Fix translations --------- Co-authored-by: Bailey Pumfleet --- .../web/pages/getting-started/[[...step]].tsx | 24 +++++++ apps/web/pages/signup.tsx | 68 +++++++++++-------- apps/web/public/static/locales/en/common.json | 2 + .../emails/src/templates/TeamInviteEmail.tsx | 10 ++- .../viewer/teams/inviteMember.handler.ts | 29 +++++++- 5 files changed, 100 insertions(+), 33 deletions(-) diff --git a/apps/web/pages/getting-started/[[...step]].tsx b/apps/web/pages/getting-started/[[...step]].tsx index 62941f54ac..1c53282b29 100644 --- a/apps/web/pages/getting-started/[[...step]].tsx +++ b/apps/web/pages/getting-started/[[...step]].tsx @@ -80,6 +80,17 @@ const OnboardingPage = (props: IOnboardingPageProps) => { }, ]; + // TODO: Add this in when we have solved the ability to move to tokens accept invite and note invitedto + // Ability to accept other pending invites if any (low priority) + // if (props.hasPendingInvites) { + // headers.unshift( + // props.hasPendingInvites && { + // title: `${t("email_no_user_invite_heading", { appName: APP_NAME })}`, + // subtitle: [], // TODO: come up with some subtitle text here + // } + // ); + // } + const goToIndex = (index: number) => { const newStep = steps[index]; router.push( @@ -196,6 +207,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => allowDynamicBooking: true, defaultScheduleId: true, completedOnboarding: true, + teams: { + select: { + accepted: true, + team: { + select: { + id: true, + name: true, + logo: true, + }, + }, + }, + }, }, }); @@ -214,6 +237,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => ...user, emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), }, + hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false, }, }; }; diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index a454648351..81181ecd2e 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -4,10 +4,12 @@ import { useRouter } from "next/router"; import type { CSSProperties } from "react"; import type { SubmitHandler } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; +import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; @@ -16,7 +18,6 @@ import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@c import PageWrapper from "@components/PageWrapper"; -import { asStringOrNull } from "../lib/asStringOrNull"; import { IS_GOOGLE_LOGIN_ENABLED } from "../server/lib/constants"; import { ssrInit } from "../server/lib/ssr"; @@ -24,11 +25,10 @@ type FormValues = { username: string; email: string; password: string; - passwordcheck: string; apiError: string; }; -export default function Signup({ prepopulateFormValues }: inferSSRProps) { +export default function Signup({ prepopulateFormValues, token }: inferSSRProps) { const { t } = useLocale(); const router = useRouter(); const telemetry = useTelemetry(); @@ -76,7 +76,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps
-
+
{ @@ -109,7 +109,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps {errors.apiError && } -
+
- - value === methods.watch("password") || (t("error_password_mismatch") as string), - })} - />
- - + {!token && ( + + )}
@@ -163,7 +159,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps { const ssr = await ssrInit(ctx); - const token = asStringOrNull(ctx.query.token); + const token = z.string().optional().parse(ctx.query.token); const props = { isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED, @@ -221,11 +217,27 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { }; } + const guessUsernameFromEmail = (email: string) => { + const [username] = email.split("@"); + return username; + }; + + let username = guessUsernameFromEmail(verificationToken.identifier); + + if (!IS_SELF_HOSTED) { + // Im not sure we actually hit this because of next redirects signup to website repo - but just in case this is pretty cool :) + const { available, suggestion } = await checkPremiumUsername(username); + + username = available ? username : suggestion || username; + } + return { props: { ...props, + token, prepopulateFormValues: { email: verificationToken.identifier, + username, }, }, }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 005147efa0..e3bd64ba40 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1626,6 +1626,7 @@ "email_user_cta": "View Invitation", "email_no_user_invite_heading": "You’ve been invited to join a team on {{appName}}", "email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", + "email_user_invite_subheading": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", "email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your team in no time.", "email_no_user_step_one": "Choose your username", "email_no_user_step_two": "Connect your calendar account", @@ -1813,6 +1814,7 @@ "open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.", "need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.", "book_my_cal": "Book my Cal", + "email_not_cal_member_cta": "Join your team", "disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees", "disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.", "disable_host_confirmation_emails": "Disable default confirmation emails for host", diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx index a99b805c51..fa0a288a97 100644 --- a/packages/emails/src/templates/TeamInviteEmail.tsx +++ b/packages/emails/src/templates/TeamInviteEmail.tsx @@ -49,11 +49,17 @@ export const TeamInviteEmail = ( marginTop: "32px", lineHeightStep: "24px", }}> - <>{props.language("email_no_user_invite_subheading", { invitedBy: props.from, appName: APP_NAME })} + <> + {props.language("email_user_invite_subheading", { + invitedBy: props.from, + appName: APP_NAME, + teamName: props.teamName, + })} +

diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts index 9ec7e819a9..c975e0cf6e 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts @@ -37,6 +37,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + // A user can exist even if they have not completed onboarding const invitee = await prisma.user.findFirst({ where: { OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }], @@ -81,7 +82,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = from: ctx.user.name, to: input.usernameOrEmail, teamName: team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/teams`, + joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, // we know that the user has not completed onboarding yet, so we can redirect them to the onboarding flow isCalcomMember: false, }); } @@ -112,13 +113,35 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = } // inform user of membership by email if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { + const inviteTeamOptions = { + joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, + isCalcomMember: true, + }; + /** + * Here we want to redirect to a differnt place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a differnt email template. + * This only changes if the user is a CAL user and has not completed onboarding and has no password + */ + if (!invitee.completedOnboarding && !invitee.password && invitee.identityProvider === "CAL") { + const token = randomBytes(32).toString("hex"); + await prisma.verificationToken.create({ + data: { + identifier: input.usernameOrEmail, + token, + expires: new Date(new Date().setHours(168)), // +1 week + }, + }); + + inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; + inviteTeamOptions.isCalcomMember = false; + } + + await sendTeamInviteEmail({ language: translation, from: ctx.user.name, to: sendTo, teamName: team.name, - joinLink: WEBAPP_URL + "/settings/teams", - isCalcomMember: true, + ...inviteTeamOptions, }); } }