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 <bailey@pumfleet.co.uk>
This commit is contained in:
co-authored by
Bailey Pumfleet
parent
f47c3cd538
commit
c55e432bc3
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+40
-28
@@ -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<typeof getServerSideProps>) {
|
||||
export default function Signup({ prepopulateFormValues, token }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
@@ -76,7 +76,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div
|
||||
className="bg-muted flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8"
|
||||
className="bg-muted flex min-h-screen flex-col justify-center "
|
||||
style={
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
@@ -95,7 +95,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-default mx-2 px-4 py-8 shadow sm:rounded-lg sm:px-10">
|
||||
<div className="bg-default mx-2 p-6 shadow sm:rounded-lg lg:p-8">
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
@@ -109,7 +109,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
|
||||
}}
|
||||
className="bg-default space-y-6">
|
||||
{errors.apiError && <Alert severity="error" message={errors.apiError?.message} />}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
addOnLeading={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/`}
|
||||
{...register("username")}
|
||||
@@ -125,32 +125,28 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
{...register("password")}
|
||||
hintErrors={["caplow", "min", "num"]}
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
|
||||
/>
|
||||
<PasswordField
|
||||
label={t("confirm_password")}
|
||||
{...register("passwordcheck", {
|
||||
validate: (value) =>
|
||||
value === methods.watch("password") || (t("error_password_mismatch") as string),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button type="submit" loading={isSubmitting} className="w-7/12 justify-center">
|
||||
<Button type="submit" loading={isSubmitting} className="w-full justify-center">
|
||||
{t("create_account")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="w-5/12 justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: router.query.callbackUrl
|
||||
? `${WEBAPP_URL}/${router.query.callbackUrl}`
|
||||
: `${WEBAPP_URL}/getting-started`,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
</Button>
|
||||
{!token && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="w-full justify-center"
|
||||
onClick={() =>
|
||||
signIn("Cal.com", {
|
||||
callbackUrl: router.query.callbackUrl
|
||||
? `${WEBAPP_URL}/${router.query.callbackUrl}`
|
||||
: `${WEBAPP_URL}/getting-started`,
|
||||
})
|
||||
}>
|
||||
{t("login_instead")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
@@ -163,7 +159,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
</>
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<CallToAction
|
||||
label={props.language(props.isCalcomMember ? "email_user_cta" : "email_no_user_cta")}
|
||||
label={props.language(props.isCalcomMember ? "email_user_cta" : "email_not_cal_member_cta")}
|
||||
href={props.joinLink}
|
||||
endIconName="linkIcon"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user