Merge branch 'feat/organizations' into feat/organizations-banner

This commit is contained in:
Leo Giovanetti 2023-06-06 19:55:03 -03:00
commit ed87a76c15
44 changed files with 1577 additions and 51 deletions

5
.vscode/launch.json vendored
View File

@ -5,11 +5,12 @@
"type": "node",
"request": "launch",
"name": "Next.js Node Debug",
"runtimeExecutable": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeExecutable": "${userHome}/.yarn/bin/yarn",
"runtimeArgs": ["dev"],
"env": {
"NODE_OPTIONS": "--inspect"
},
"cwd": "${workspaceFolder}/apps/web",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMapPathOverrides": {
"meteor://💻app/*": "${workspaceFolder}/*",

View File

@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
export default createNextApiHandler(viewerOrganizationsRouter);

View File

@ -0,0 +1,31 @@
import { useRouter } from "next/router";
import { AboutOrganizationForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
const AboutOrganizationPage = () => {
const { t } = useLocale();
const router = useRouter();
if (!router.isReady) return null;
return (
<>
<Meta title={t("about_your_organization")} description={t("about_your_organization_description")} />
<AboutOrganizationForm />
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={3} maxSteps={5}>
{page}
</WizardLayout>
);
};
AboutOrganizationPage.getLayout = LayoutWrapper;
AboutOrganizationPage.PageWrapper = PageWrapper;
export default AboutOrganizationPage;

View File

@ -0,0 +1,37 @@
import type { NextRouter } from "next/router";
import { useRouter } from "next/router";
import { AddNewTeamsForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
const AddNewTeamsPage = () => {
const { t } = useLocale();
const router = useRouter();
if (!router.isReady) return null;
return (
<>
<Meta title={t("create_your_teams")} description={t("create_your_teams_description")} />
<AddNewTeamsForm />
</>
);
};
AddNewTeamsPage.getLayout = (page: React.ReactElement, router: NextRouter) => (
<>
<WizardLayout
currentStep={5}
maxSteps={5}
isOptionalCallback={() => {
router.push(`/getting-started`);
}}>
{page}
</WizardLayout>
</>
);
AddNewTeamsPage.PageWrapper = PageWrapper;
export default AddNewTeamsPage;

View File

@ -0,0 +1,38 @@
import type { NextRouter } from "next/router";
import { useRouter } from "next/router";
import { AddNewOrgAdminsForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
const OnboardTeamMembersPage = () => {
const { t } = useLocale();
const router = useRouter();
if (!router.isReady) return null;
return (
<>
<Meta
title={t("invite_organization_admins")}
description={t("invite_organization_admins_description")}
/>
<AddNewOrgAdminsForm />
</>
);
};
OnboardTeamMembersPage.getLayout = (page: React.ReactElement, router: NextRouter) => (
<WizardLayout
currentStep={4}
maxSteps={5}
isOptionalCallback={() => {
router.push(`/settings/organizations/${router.query.id}/add-teams`);
}}>
{page}
</WizardLayout>
);
OnboardTeamMembersPage.PageWrapper = PageWrapper;
export default OnboardTeamMembersPage;

View File

@ -0,0 +1,31 @@
import { useRouter } from "next/router";
import { SetPasswordForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
const SetPasswordPage = () => {
const { t } = useLocale();
const router = useRouter();
if (!router.isReady) return null;
return (
<>
<Meta title={t("set_a_password")} description={t("set_a_password_description")} />
<SetPasswordForm />
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={2} maxSteps={5}>
{page}
</WizardLayout>
);
};
SetPasswordPage.getLayout = LayoutWrapper;
SetPasswordPage.PageWrapper = PageWrapper;
export default SetPasswordPage;

View File

@ -1,32 +1,27 @@
import Head from "next/head";
import { CreateANewOrganizationForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import WizardLayout from "@components/layouts/WizardLayout";
const CreateNewTeamPage = () => {
const CreateNewOrganizationPage = () => {
const { t } = useLocale();
return (
<>
<Head>
<title>{t("create_new_team")}</title>
<meta name="description" content={t("create_new_team_description")} />
</Head>
<Meta title={t("set_up_your_organization")} description={t("organizations_description")} />
<CreateANewOrganizationForm />
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={1} maxSteps={2}>
<WizardLayout currentStep={1} maxSteps={5}>
{page}
</WizardLayout>
);
};
CreateNewTeamPage.getLayout = LayoutWrapper;
CreateNewTeamPage.PageWrapper = PageWrapper;
CreateNewOrganizationPage.getLayout = LayoutWrapper;
CreateNewOrganizationPage.PageWrapper = PageWrapper;
export default CreateNewTeamPage;
export default CreateNewOrganizationPage;

View File

@ -2,9 +2,9 @@ import Head from "next/head";
import AddNewTeamMembers from "@calcom/features/ee/teams/components/AddNewTeamMembers";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import WizardLayout from "@components/layouts/WizardLayout";
const OnboardTeamMembersPage = () => {
const { t } = useLocale();

View File

@ -2,9 +2,9 @@ import Head from "next/head";
import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import WizardLayout from "@components/layouts/WizardLayout";
const CreateNewTeamPage = () => {
const { t } = useLocale();

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -110,7 +110,7 @@
"team_info": "Team Info",
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
"user_invited_you": "{{user}} invited you to join the team {{team}} on {{appName}}",
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
"hidden_team_member_title": "You are hidden in this team",
"hidden_team_member_message": "Your seat is not paid for, either Upgrade to PRO or let the team owner know they can pay for your seat.",
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
@ -684,6 +684,7 @@
"create_team_to_get_started": "Create a team to get started",
"teams": "Teams",
"team": "Team",
"organization": "Organization",
"team_billing": "Team Billing",
"team_billing_description": "Manage billing for your team",
"upgrade_to_flexible_pro_title": "We've changed billing for teams",
@ -1641,10 +1642,10 @@
"organizer_timezone": "Organizer timezone",
"email_no_user_cta": "Create your account",
"email_user_cta": "View Invitation",
"email_no_user_invite_heading": "Youve been invited to join a team on {{appName}}",
"email_no_user_invite_heading": "Youve been invited to join a {{appName}} {{entity}}",
"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": "Well walk you through a few short steps and youll be enjoying stress free scheduling with your team in no time.",
"email_user_invite_subheading": "{{invitedBy}} has invited you to join their {{entity}} `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your {{entity}} to schedule meetings without the email tennis.",
"email_no_user_invite_steps_intro": "Well walk you through a few short steps and youll be enjoying stress free scheduling with your {{entity}} in no time.",
"email_no_user_step_one": "Choose your username",
"email_no_user_step_two": "Connect your calendar account",
"email_no_user_step_three": "Set your Availability",
@ -1837,7 +1838,7 @@
"book_my_cal": "Book my Cal",
"invite_as":"Invite as",
"form_updated_successfully":"Form updated successfully.",
"email_not_cal_member_cta": "Join your team",
"email_not_cal_member_cta": "Join your {{entity}}",
"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",
@ -1848,9 +1849,39 @@
"google_workspace_admin_tooltip":"You must be a Workspace Admin to use this feature",
"first_event_type_webhook_description": "Create your first webhook for this event type",
"create_for": "Create for",
"setup_organisation": "Setup an Organization",
"organisation_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
"organisation_banner_title": "Manage organizations with multiple teams",
"setup_organization": "Setup an Organization",
"organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.",
"organization_banner_title": "Manage organizations with multiple teams",
"set_up_your_organization": "Set up your organization",
"organizations_description": "Organizations are shared environments where teams can create shared event types, apps, workflows and more.",
"organization_url_taken": "This URL is already taken",
"must_enter_organization_name": "Must enter an organization name",
"must_enter_organization_admin_email": "Must enter your organization email address",
"admin_email": "Your organization email address",
"admin_username": "Administrator's username",
"organization_name": "Organization name",
"organization_url": "Organization URL",
"organization_verify_header" :"Verify your organization email",
"organization_verify_email_body":"Please use the code below to verify your email address to continue setting up your organization.",
"additional_url_parameters": "Additional URL parameters",
"about_your_organization": "About your organization",
"about_your_organization_description": "Organizations are shared environments where you can create multiple teams with shared members, event types, apps, workflows and more.",
"create_your_teams": "Create your teams",
"create_your_teams_description": "Start scheduling together by adding your team members to your organisation",
"invite_organization_admins": "Invite your organization admins",
"invite_organization_admins_description": "These admins will have access to all teams in your organization. You can add team admins and members later.",
"set_a_password": "Set a password",
"set_a_password_description": "This will create a new user account with your organization email and this password.",
"organization_logo": "Organization Logo",
"organization_about_description": "A few sentences about your organization. This will appear on your organization public profile page.",
"ill_do_this_later": "I'll do this later",
"verify_your_email": "Verify your email",
"enter_digit_code": "Enter the 6 digit code we sent to {{email}}",
"verify_email_organization": "Verify your email to create an organization",
"code_provided_invalid": "The code provided is not valid, try again",
"email_already_used": "Email already being used",
"duplicated_slugs_warning": "The following teams couldn't be created due to duplicated slugs: {{slugs}}",
"team_names_empty": "Team names can't be empty",
"team_names_repeated": "Team names can't be repeated",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -23,6 +23,8 @@ import FeedbackEmail from "./templates/feedback-email";
import type { PasswordReset } from "./templates/forgot-password-email";
import ForgotPasswordEmail from "./templates/forgot-password-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import type { OrganizationEmailVerify } from "./templates/organization-email-verification";
import OrganizationEmailVerification from "./templates/organization-email-verification";
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email";
@ -328,3 +330,7 @@ export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, dow
}
await Promise.all(emailsToSend);
};
export const sendOrganizationEmailVerification = async (sendOrgInput: OrganizationEmailVerify) => {
await sendEmail(() => new OrganizationEmailVerification(sendOrgInput));
};

View File

@ -0,0 +1,65 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml } from "../components";
export type OrganizationEmailVerify = {
language: TFunction;
user: {
email: string;
};
code: string;
};
export const OrganisationAccountVerifyEmail = (
props: OrganizationEmailVerify & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("organization_verify_header", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("organization_verify_header")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.email })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("organization_verify_email_body")}</>
</p>
<div style={{ display: "flex" }}>
<div
style={{
borderRadius: "6px",
backgroundColor: "#101010",
padding: "6px 2px 6px 8px",
flexShrink: 1,
}}>
<b style={{ fontWeight: 400, lineHeight: "24px", color: "white", letterSpacing: "6px" }}>
{props.code}
</b>
</div>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")} <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team")}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@ -11,6 +11,7 @@ type TeamInvite = {
teamName: string;
joinLink: string;
isCalcomMember: boolean;
isOrg: boolean;
};
export const TeamInviteEmail = (
@ -22,9 +23,15 @@ export const TeamInviteEmail = (
user: props.from,
team: props.teamName,
appName: APP_NAME,
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>{props.language("email_no_user_invite_heading", { appName: APP_NAME })}</>
<>
{props.language("email_no_user_invite_heading", {
appName: APP_NAME,
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
</>
</p>
<img
style={{
@ -54,12 +61,15 @@ export const TeamInviteEmail = (
invitedBy: props.from,
appName: APP_NAME,
teamName: props.teamName,
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
</>
</p>
<div style={{ display: "flex", justifyContent: "center" }}>
<CallToAction
label={props.language(props.isCalcomMember ? "email_user_cta" : "email_not_cal_member_cta")}
label={props.language(props.isCalcomMember ? "email_user_cta" : "email_not_cal_member_cta", {
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
href={props.joinLink}
endIconName="linkIcon"
/>
@ -72,7 +82,11 @@ export const TeamInviteEmail = (
marginTop: "48px",
lineHeightStep: "24px",
}}>
<>{props.language("email_no_user_invite_steps_intro")}</>
<>
{props.language("email_no_user_invite_steps_intro", {
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
</>
</p>
{!props.isCalcomMember && (
@ -121,7 +135,12 @@ export const TeamInviteEmail = (
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>{props.language("email_no_user_signoff", { appName: APP_NAME })}</>
<>
{props.language("email_no_user_signoff", {
appName: APP_NAME,
entity: props.language(props.isOrg ? "organization" : "team").toLowerCase(),
})}
</>
</p>
</div>

View File

@ -25,3 +25,4 @@ export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelle
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
export * from "@calcom/app-store/routing-forms/emails/components";
export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";

View File

@ -0,0 +1,38 @@
import type { TFunction } from "next-i18next";
import { APP_NAME } from "@calcom/lib/constants";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
export type OrganizationEmailVerify = {
language: TFunction;
user: {
email: string;
};
code: string;
};
export default class OrganizationEmailVerification extends BaseEmail {
orgVerifyInput: OrganizationEmailVerify;
constructor(orgVerifyInput: OrganizationEmailVerify) {
super();
this.name = "SEND_ORG_ACCOUNT_VERIFY_EMAIL";
this.orgVerifyInput = orgVerifyInput;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: this.orgVerifyInput.user.email,
subject: this.orgVerifyInput.language("verify_email_organization"),
html: renderEmail("OrganisationAccountVerifyEmail", this.orgVerifyInput),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `<b>Code:</b> ${this.orgVerifyInput.code}`;
}
}

View File

@ -1,4 +1,4 @@
import { TFunction } from "next-i18next";
import type { TFunction } from "next-i18next";
import { APP_NAME } from "@calcom/lib/constants";
@ -12,6 +12,7 @@ export type TeamInvite = {
teamName: string;
joinLink: string;
isCalcomMember: boolean;
isOrg: boolean;
};
export default class TeamInviteEmail extends BaseEmail {
@ -31,6 +32,9 @@ export default class TeamInviteEmail extends BaseEmail {
user: this.teamInviteEvent.from,
team: this.teamInviteEvent.teamName,
appName: APP_NAME,
entity: this.teamInviteEvent
.language(this.teamInviteEvent.isOrg ? "organization" : "team")
.toLowerCase(),
}),
html: renderEmail("TeamInviteEmail", this.teamInviteEvent),
text: "",

View File

@ -0,0 +1,116 @@
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Form, ImageUploader, Alert, Label, TextAreaField } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
const querySchema = z.object({
id: z.string(),
});
export const AboutOrganizationForm = () => {
const { t } = useLocale();
const router = useRouter();
const { id: orgId } = querySchema.parse(router.query);
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const [image, setImage] = useState("");
const aboutOrganizationFormMethods = useForm<{
logo: string;
bio: string;
}>();
const updateOrganizationMutation = trpc.viewer.organizations.update.useMutation({
onSuccess: (data) => {
if (data.update) {
router.push(`/settings/organizations/${orgId}/onboard-admins`);
}
},
onError: (err) => {
setServerErrorMessage(err.message);
},
});
return (
<>
<Form
form={aboutOrganizationFormMethods}
className="space-y-5"
handleSubmit={(v) => {
if (!updateOrganizationMutation.isLoading) {
setServerErrorMessage(null);
updateOrganizationMutation.mutate({ ...v, orgId });
}
}}>
{serverErrorMessage && (
<div>
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<div>
<Controller
control={aboutOrganizationFormMethods.control}
name="logo"
render={() => (
<>
<Label>{t("organization_logo")}</Label>
<div className="flex items-center">
<Avatar alt="" imageSrc={image || "/org_avatar.png"} size="lg" />
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload")}
handleAvatarChange={(newAvatar: string) => {
setImage(newAvatar);
aboutOrganizationFormMethods.setValue("logo", newAvatar);
}}
imageSrc={image}
/>
</div>
</div>
</>
)}
/>
</div>
<div>
<Controller
control={aboutOrganizationFormMethods.control}
name="bio"
render={({ field: { value } }) => (
<>
<TextAreaField
name="about"
defaultValue={value}
onChange={(e) => {
aboutOrganizationFormMethods.setValue("bio", e?.target.value);
}}
/>
<p className="text-subtle text-sm">{t("organization_about_description")}</p>
</>
)}
/>
</div>
<div className="flex">
<Button
disabled={
aboutOrganizationFormMethods.formState.isSubmitting || updateOrganizationMutation.isLoading
}
color="primary"
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};

View File

@ -0,0 +1,99 @@
import { ArrowRight } from "lucide-react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast, TextAreaField, Form } from "@calcom/ui";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
});
export const AddNewOrgAdminsForm = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const { id: orgId } = querySchema.parse(router.query);
const newAdminsFormMethods = useForm<{
emails: string[];
}>();
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
if (data.sendEmailInvitation) {
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.usernameOrEmail.length,
}),
"success"
);
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
}
router.push(`/settings/organizations/${orgId}/add-teams`);
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<Form
form={newAdminsFormMethods}
handleSubmit={(values) => {
inviteMemberMutation.mutate({
teamId: orgId,
language: i18n.language,
role: MembershipRole.ADMIN,
usernameOrEmail: values.emails,
sendEmailInvitation: true,
isOrg: true,
});
}}>
<div className="flex flex-col rounded-md">
<Controller
name="emails"
control={newAdminsFormMethods.control}
rules={{
required: t("enter_email_or_username"),
}}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
<TextAreaField
name="emails"
label="Invite via email"
rows={4}
autoCorrect="off"
placeholder="john@doe.com, alex@smith.com"
required
value={value}
onChange={(e) => {
const emails = e.target.value.split(",").map((email) => email.trim().toLocaleLowerCase());
return onChange(emails);
}}
/>
{error && <span className="text-sm text-red-800">{error.message}</span>}
</>
)}
/>
<Button
EndIcon={ArrowRight}
color="primary"
type="submit"
className="mt-6 w-full justify-center"
disabled={inviteMemberMutation.isLoading}>
Continue
</Button>
</div>
</Form>
);
};

View File

@ -0,0 +1,107 @@
import { ArrowRight } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast, TextField } from "@calcom/ui";
import { Plus, X } from "@calcom/ui/components/icon";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
});
export const AddNewTeamsForm = () => {
const { t } = useLocale();
const router = useRouter();
const { id: orgId } = querySchema.parse(router.query);
const [counter, setCounter] = useState(1);
const [inputValues, setInputValues] = useState<string[]>([""]);
const handleCounterIncrease = () => {
setCounter((prevCounter) => prevCounter + 1);
setInputValues((prevInputValues) => [...prevInputValues, ""]);
};
const handleInputChange = (index: number, value: string) => {
const newInputValues = [...inputValues];
newInputValues[index] = value;
setInputValues(newInputValues);
};
const handleRemoveInput = (index: number) => {
const newInputValues = [...inputValues];
newInputValues.splice(index, 1);
setInputValues(newInputValues);
setCounter((prevCounter) => prevCounter - 1);
};
const createTeamsMutation = trpc.viewer.organizations.createTeams.useMutation({
async onSuccess(data) {
if (data.duplicatedSlugs.length) {
showToast(t("duplicated_slugs_warning", { slugs: data.duplicatedSlugs.join(", ") }), "warning");
setTimeout(() => {
router.push(`/getting-started`);
}, 3000);
} else {
router.push(`/getting-started`);
}
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<>
{Array.from({ length: counter }, (_, index) => (
<div className="relative" key={index}>
<TextField
key={index}
value={inputValues[index]}
onChange={(e) => handleInputChange(index, e.target.value)}
addOnClassname="bg-transparent p-0 border-l-0"
addOnSuffix={
index > 0 && (
<Button
color="minimal"
className="group/remove mx-2 px-0 hover:bg-transparent"
onClick={() => handleRemoveInput(index)}>
<X className="bg-subtle text group-hover/remove:text-inverted group-hover/remove:bg-inverted h-5 w-5 rounded-full p-1" />
</Button>
)
}
/>
</div>
))}
<Button
StartIcon={Plus}
color="secondary"
disabled={createTeamsMutation.isLoading}
onClick={handleCounterIncrease}>
{t("add_a_team")}
</Button>
<Button
EndIcon={ArrowRight}
color="primary"
className="mt-6 w-full justify-center"
disabled={createTeamsMutation.isLoading}
onClick={() => {
if (inputValues.includes("")) {
showToast(t("team_name_empty"), "error");
} else {
const duplicates = inputValues.filter((item, index) => inputValues.indexOf(item) !== index);
if (duplicates.length) {
showToast("team_names_repeated", "error");
} else {
createTeamsMutation.mutate({ orgId, teamNames: inputValues });
}
}
}}>
{t("continue")}
</Button>
</>
);
};

View File

@ -1 +1,319 @@
export const CreateANewOrganizationForm = () => <></>;
import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import useDigitInput from "react-digit-input";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Form,
TextField,
Alert,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
Label,
Input,
} from "@calcom/ui";
import { ArrowRight, Info } from "@calcom/ui/components/icon";
function extractDomainFromEmail(email: string) {
let out = "";
try {
const match = email.match(/^(?:.*?:\/\/)?.*?(?<root>[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
out = (match && match.groups?.root) ?? "";
} catch (ignore) {}
return out.split(".")[0];
}
export const VerifyCodeDialog = ({
isOpenDialog,
setIsOpenDialog,
email,
onSuccess,
}: {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
email: string;
onSuccess: (isVerified: boolean) => void;
}) => {
const { t } = useLocale();
// Not using the mutation isLoading flag because after verifying we submit the underlying org creation form
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [value, onChange] = useState("");
const digits = useDigitInput({
acceptedCharacters: /^[0-9]$/,
length: 6,
value,
onChange,
});
const verifyCodeMutation = trpc.viewer.organizations.verifyCode.useMutation({
onSuccess: (data) => {
setIsLoading(false);
onSuccess(data);
},
onError: (err) => {
setIsLoading(false);
if (err.message === "invalid_code") {
setError(t("code_provided_invalid"));
}
},
});
const digitClassName = "h-12 w-12 !text-xl text-center";
return (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
onChange("");
setIsOpenDialog(open);
}}>
<DialogContent className="sm:max-w-md">
<div className="flex flex-row">
<div className="w-full">
<DialogHeader title={t("verify_your_email")} subtitle={t("enter_digit_code", { email })} />
<Label htmlFor="code">{t("code")}</Label>
<div className="flex flex-row justify-between">
<Input
className={digitClassName}
name="2fa1"
inputMode="decimal"
{...digits[0]}
autoFocus
autoComplete="one-time-code"
/>
<Input className={digitClassName} name="2fa2" inputMode="decimal" {...digits[1]} />
<Input className={digitClassName} name="2fa3" inputMode="decimal" {...digits[2]} />
<Input className={digitClassName} name="2fa4" inputMode="decimal" {...digits[3]} />
<Input className={digitClassName} name="2fa5" inputMode="decimal" {...digits[4]} />
<Input className={digitClassName} name="2fa6" inputMode="decimal" {...digits[5]} />
</div>
{error && (
<div className="mt-2 flex items-center gap-x-2 text-sm text-red-700">
<div>
<Info className="h-3 w-3" />
</div>
<p>{error}</p>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
disabled={isLoading}
onClick={() => {
setError("");
if (value === "") {
setError("The code is a required field");
} else {
setIsLoading(true);
verifyCodeMutation.mutate({
code: value,
email,
});
}
}}>
{t("verify")}
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export const CreateANewOrganizationForm = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const [showVerifyCode, setShowVerifyCode] = useState(false);
const newOrganizationFormMethods = useForm<{
name: string;
slug: string;
adminEmail: string;
adminUsername: string;
}>();
const watchAdminEmail = newOrganizationFormMethods.watch("adminEmail");
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
onSuccess: async (data) => {
if (data.checked) {
setShowVerifyCode(true);
} else if (data.user) {
telemetry.event(telemetryEventTypes.org_created);
await signIn("credentials", {
redirect: false,
callbackUrl: "/",
email: data.user.email,
password: data.user.password,
});
router.push(`/settings/organizations/${data.user.organizationId}/set-password`);
}
},
onError: (err) => {
if (err.message === "admin_email_taken") {
newOrganizationFormMethods.setError("adminEmail", {
type: "custom",
message: t("email_already_used"),
});
} else if (err.message === "organization_url_taken") {
newOrganizationFormMethods.setError("slug", { type: "custom", message: t("organization_url_taken") });
} else {
setServerErrorMessage(err.message);
}
},
});
return (
<>
<Form
form={newOrganizationFormMethods}
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isLoading) {
setServerErrorMessage(null);
createOrganizationMutation.mutate(v);
}
}}>
<div className="mb-5">
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
<Controller
name="adminEmail"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_admin_email"),
}}
render={({ field: { value } }) => (
<div className="flex">
<TextField
containerClassName="w-full"
placeholder="john@acme.com"
name="adminEmail"
label={t("admin_email")}
defaultValue={value}
onChange={(e) => {
const domain = extractDomainFromEmail(e?.target.value);
newOrganizationFormMethods.setValue("adminEmail", e?.target.value);
newOrganizationFormMethods.setValue("adminUsername", e?.target.value.split("@")[0]);
newOrganizationFormMethods.setValue("slug", domain);
newOrganizationFormMethods.setValue(
"name",
domain.charAt(0).toUpperCase() + domain.slice(1)
);
}}
autoComplete="off"
/>
</div>
)}
/>
</div>
<div className="mb-5">
<Controller
name="name"
control={newOrganizationFormMethods.control}
defaultValue=""
rules={{
required: t("must_enter_organization_name"),
}}
render={({ field: { value } }) => (
<>
<TextField
className="mt-2"
placeholder="Acme"
name="name"
label={t("organization_name")}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("name", e?.target.value);
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
}
}}
autoComplete="off"
/>
</>
)}
/>
</div>
<div className="mb-5">
<Controller
name="slug"
control={newOrganizationFormMethods.control}
rules={{
required: "Must enter organization slug",
}}
render={({ field: { value } }) => (
<TextField
className="mt-2"
name="slug"
label={t("organization_url")}
placeholder="acme"
addOnSuffix={`.${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace("https://", "")?.replace(
"http://",
""
)}`}
defaultValue={value}
onChange={(e) => {
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value), {
shouldTouch: true,
});
newOrganizationFormMethods.clearErrors("slug");
}}
/>
)}
/>
</div>
<input hidden {...newOrganizationFormMethods.register("adminUsername")} />
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.isLoading
}
color="primary"
EndIcon={ArrowRight}
type="submit"
form="createOrg"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
<VerifyCodeDialog
isOpenDialog={showVerifyCode}
setIsOpenDialog={setShowVerifyCode}
email={watchAdminEmail}
onSuccess={(isVerified) => {
if (isVerified) {
createOrganizationMutation.mutate({
...newOrganizationFormMethods.getValues(),
language: i18n.language,
check: false,
});
}
}}
/>
</>
);
};

View File

@ -0,0 +1,109 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Form, Alert, PasswordField } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
const querySchema = z.object({
id: z.string(),
});
const formSchema = z.object({
password: z.string().superRefine((data, ctx) => {
const isStrict = true;
const result = isPasswordValid(data, true, isStrict);
Object.keys(result).map((key: string) => {
if (!result[key as keyof typeof result]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: key,
});
}
});
}),
});
export const SetPasswordForm = () => {
const { t } = useLocale();
const router = useRouter();
const { id: orgId } = querySchema.parse(router.query);
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const setPasswordFormMethods = useForm<{
password: string;
}>({
resolver: zodResolver(formSchema),
});
const setPasswordMutation = trpc.viewer.organizations.setPassword.useMutation({
onSuccess: (data) => {
if (data.update) {
router.push(`/settings/organizations/${orgId}/about`);
}
},
onError: (err) => {
setServerErrorMessage(err.message);
},
});
return (
<>
<Form
form={setPasswordFormMethods}
handleSubmit={(v) => {
if (!setPasswordMutation.isLoading) {
setServerErrorMessage(null);
setPasswordMutation.mutate({ newPassword: v.password });
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
</div>
<div className="mb-5">
<Controller
name="password"
control={setPasswordFormMethods.control}
render={({ field: { onBlur, onChange, value } }) => (
<PasswordField
value={value || ""}
onBlur={onBlur}
onChange={async (e) => {
onChange(e.target.value);
setPasswordFormMethods.setValue("password", e.target.value);
await setPasswordFormMethods.trigger("password");
}}
hintErrors={["caplow", "admin_min", "num"]}
name="password"
autoComplete="off"
/>
)}
/>
</div>
<div className="flex">
<Button
disabled={setPasswordFormMethods.formState.isSubmitting || setPasswordMutation.isLoading}
color="primary"
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};

View File

@ -1 +1,5 @@
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
export { AboutOrganizationForm } from "./AboutOrganizationForm";
export { SetPasswordForm } from "./SetPasswordForm";
export { AddNewOrgAdminsForm } from "./AddNewOrgAdminsForm";
export { AddNewTeamsForm } from "./AddNewTeamsForm";

View File

@ -1,18 +1,6 @@
import type { MembershipRole } from "@calcom/prisma/enums";
export interface NewOrganizationFormValues {
name: string;
slug: string;
temporarySlug: string;
logo: string;
}
export interface PendingMember {
name: string | null;
email: string;
id?: number;
username: string | null;
role: MembershipRole;
avatar: string | null;
sendInviteEmail?: boolean;
adminEmail: string;
}

View File

@ -23,6 +23,7 @@ export const telemetryEventTypes = {
pageView: "website_page_view",
},
slugReplacementAction: "slug_replacement_action",
org_created: "org_created",
};
export function collectPageParameters(

View File

@ -306,6 +306,7 @@ export const teamMetadataSchema = z
paymentId: z.string(),
subscriptionId: z.string().nullable(),
subscriptionItemId: z.string().nullable(),
isOrganization: z.boolean().nullable(),
})
.partial()
.nullable();

View File

@ -32,6 +32,7 @@ const ENDPOINTS = [
"saml",
"slots",
"teams",
"organizations",
"users",
"viewer",
"webhook",

View File

@ -68,6 +68,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
trialEndsAt: true,
metadata: true,
role: true,
organizationId: true,
allowDynamicBooking: true,
},
});

View File

@ -14,6 +14,7 @@ import { bookingsRouter } from "./bookings/_router";
import { deploymentSetupRouter } from "./deploymentSetup/_router";
import { eventTypesRouter } from "./eventTypes/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { viewerOrganizationsRouter } from "./organizations/_router";
import { paymentsRouter } from "./payments/_router";
import { slotsRouter } from "./slots/_router";
import { ssoRouter } from "./sso/_router";
@ -32,6 +33,7 @@ export const viewerRouter = mergeRouters(
eventTypes: eventTypesRouter,
availability: availabilityRouter,
teams: viewerTeamsRouter,
organizations: viewerOrganizationsRouter,
webhook: webhookRouter,
apiKeys: apiKeysRouter,
slots: slotsRouter,

View File

@ -0,0 +1,101 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZVerifyCodeInputSchema } from "./verifyCode.schema";
type OrganizationsRouterHandlerCache = {
create?: typeof import("./create.handler").createHandler;
update?: typeof import("./update.handler").updateHandler;
verifyCode?: typeof import("./verifyCode.handler").verifyCodeHandler;
createTeams?: typeof import("./createTeams.handler").createTeamsHandler;
setPassword?: typeof import("./setPassword.handler").setPasswordHandler;
};
const UNSTABLE_HANDLER_CACHE: OrganizationsRouterHandlerCache = {};
export const viewerOrganizationsRouter = router({
create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.create) {
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.create) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.create({
ctx,
input,
});
}),
update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.update) {
UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.update) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.update({
ctx,
input,
});
}),
verifyCode: authedProcedure.input(ZVerifyCodeInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.verifyCode) {
UNSTABLE_HANDLER_CACHE.verifyCode = await import("./verifyCode.handler").then(
(mod) => mod.verifyCodeHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.verifyCode) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.verifyCode({
ctx,
input,
});
}),
createTeams: authedProcedure.input(ZCreateTeamsSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.createTeams) {
UNSTABLE_HANDLER_CACHE.createTeams = await import("./createTeams.handler").then(
(mod) => mod.createTeamsHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.createTeams) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.createTeams({
ctx,
input,
});
}),
setPassword: authedProcedure.input(ZSetPasswordSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.setPassword) {
UNSTABLE_HANDLER_CACHE.setPassword = await import("./setPassword.handler").then(
(mod) => mod.setPasswordHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.setPassword) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.setPassword({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,103 @@
import { createHash } from "crypto";
import { totp } from "otplib";
import { sendOrganizationEmailVerification } from "@calcom/emails";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TCreateInputSchema } from "./create.schema";
type CreateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateInputSchema;
};
export const createHandler = async ({ input }: CreateOptions) => {
const { slug, name, adminEmail, adminUsername, check } = input;
const userCollisions = await prisma.user.findFirst({
where: {
email: adminEmail,
},
});
const slugCollisions = await prisma.team.findFirst({
where: {
slug: slug,
metadata: {
path: ["isOrganization"],
equals: true,
},
},
});
if (slugCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "organization_url_taken" });
if (userCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "admin_email_taken" });
const password = createHash("md5")
.update(`${adminEmail}${process.env.CALENDSO_ENCRYPTION_KEY}`)
.digest("hex");
const hashedPassword = await hashPassword(password);
if (check === false) {
const createOwnerOrg = await prisma.user.create({
data: {
username: adminUsername,
email: adminEmail,
emailVerified: new Date(),
password: hashedPassword,
organization: {
create: {
name,
...(!IS_TEAM_BILLING_ENABLED && { slug }),
metadata: {
requestedSlug: slug,
isOrganization: true,
},
},
},
},
});
await prisma.membership.create({
data: {
userId: createOwnerOrg.id,
role: MembershipRole.OWNER,
accepted: true,
teamId: createOwnerOrg.organizationId!,
},
});
return { user: { ...createOwnerOrg, password } };
} else {
const language = await getTranslation(input.language ?? "en", "common");
const secret = createHash("md5")
.update(adminEmail + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
totp.options = { step: 90 };
const code = totp.generate(secret);
await sendOrganizationEmailVerification({
user: {
email: adminEmail,
},
code,
language,
});
}
// Sync Services: Close.com
//closeComUpsertOrganizationUser(createTeam, ctx.user, MembershipRole.OWNER);
return { checked: true };
};

View File

@ -0,0 +1,14 @@
import { z } from "zod";
import slugify from "@calcom/lib/slugify";
export const ZCreateInputSchema = z.object({
name: z.string(),
slug: z.string().transform((val) => slugify(val.trim())),
adminEmail: z.string().email(),
adminUsername: z.string(),
check: z.boolean().default(true),
language: z.string().optional(),
});
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;

View File

@ -0,0 +1,63 @@
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TCreateTeamsSchema } from "./createTeams.schema";
type CreateTeamsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateTeamsSchema;
};
export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => {
const { teamNames, orgId } = input;
const organization = await prisma.team.findFirst({ where: { id: orgId }, select: { metadata: true } });
const metadata = teamMetadataSchema.parse(organization?.metadata);
if (!metadata?.requestedSlug) throw new TRPCError({ code: "BAD_REQUEST", message: "no_organization" });
const userMembership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: orgId,
},
select: {
userId: true,
role: true,
},
});
// TODO test this check works
if (!userMembership || userMembership.role !== MembershipRole.OWNER)
throw new TRPCError({ code: "BAD_REQUEST", message: "not_authorized" });
const [teamSlugs, userSlugs] = await prisma.$transaction([
prisma.team.findMany({ where: { parentId: orgId }, select: { slug: true } }),
prisma.user.findMany({ where: { organizationId: orgId }, select: { username: true } }),
]);
const existingSlugs = teamSlugs
.flatMap((ts) => ts.slug ?? [])
.concat(userSlugs.flatMap((us) => us.username ?? []));
const duplicatedSlugs = existingSlugs.filter((slug) => teamNames.includes(slug));
await prisma.team.createMany({
data: teamNames.flatMap((name) => {
if (!duplicatedSlugs.includes(name)) {
return { name, parentId: orgId, slug: slugify(name) };
} else {
return [];
}
}),
});
return { duplicatedSlugs };
};

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const ZCreateTeamsSchema = z.object({
teamNames: z.string().array(),
orgId: z.number(),
});
export type TCreateTeamsSchema = z.infer<typeof ZCreateTeamsSchema>;

View File

@ -0,0 +1,57 @@
import { createHash } from "crypto";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TSetPasswordSchema } from "./setPassword.schema";
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TSetPasswordSchema;
};
export const setPasswordHandler = async ({ ctx, input }: UpdateOptions) => {
const { newPassword } = input;
const user = await prisma.user.findFirst({
where: {
id: ctx.user.id,
},
select: {
password: true,
email: true,
},
});
if (!user) throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" });
if (!user.password) throw new TRPCError({ code: "BAD_REQUEST", message: "Password not set by default" });
const generatedPassword = createHash("md5")
.update(`${user?.email ?? ""}${process.env.CALENDSO_ENCRYPTION_KEY}`)
.digest("hex");
const isCorrectPassword = await verifyPassword(generatedPassword, user?.password);
if (!isCorrectPassword)
throw new TRPCError({
code: "BAD_REQUEST",
message: "The password set by default doesn't match your existing one. Contact an app admin.",
});
const hashedPassword = await hashPassword(newPassword);
await prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
password: hashedPassword,
},
});
return { update: true };
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZSetPasswordSchema = z.object({
newPassword: z.string(),
});
export type TSetPasswordSchema = z.infer<typeof ZSetPasswordSchema>;

View File

@ -0,0 +1,44 @@
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TUpdateInputSchema } from "./update.schema";
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TUpdateInputSchema;
};
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const { logo, bio, orgId } = input;
const userMembership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: orgId,
},
select: {
userId: true,
role: true,
},
});
if (!userMembership || userMembership.role !== MembershipRole.OWNER)
throw new TRPCError({ code: "BAD_REQUEST", message: "not_authorized" });
await prisma.team.update({
where: {
id: orgId,
},
data: {
bio,
logo,
},
});
return { update: true, userId: userMembership.userId };
};

View File

@ -0,0 +1,16 @@
import { z } from "zod";
export const ZUpdateInputSchema = z.object({
orgId: z
.string()
.regex(/^\d+$/)
.transform((id) => parseInt(id)),
bio: z.string().optional(),
logo: z
.string()
.optional()
.nullable()
.transform((v) => v || null),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;

View File

@ -0,0 +1,32 @@
import { createHash } from "crypto";
import { totp } from "otplib";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { ZVerifyCodeInputSchema } from "./verifyCode.schema";
type VerifyCodeOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZVerifyCodeInputSchema;
};
export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
const { email, code } = input;
const { user } = ctx;
if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
const secret = createHash("md5")
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
const isValidToken = totp.check(code, secret);
if (!isValidToken) throw new TRPCError({ code: "BAD_REQUEST", message: "invalid_code" });
return isValidToken;
};

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const ZVerifyCodeInputSchema = z.object({
email: z.string().email(),
code: z.string(),
});
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;

View File

@ -26,7 +26,6 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
const translation = await getTranslation(input.language ?? "en", "common");
const team = await prisma.team.findFirst({
@ -35,7 +34,8 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
},
});
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
if (!team)
throw new TRPCError({ code: "NOT_FOUND", message: `${input.isOrg ? "Organization" : "Team"} not found` });
const emailsToInvite = Array.isArray(input.usernameOrEmail)
? input.usernameOrEmail
@ -48,6 +48,13 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
},
});
if (input.isOrg && invitee) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`,
});
}
if (!invitee) {
// liberal email match
@ -62,6 +69,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
data: {
email: usernameOrEmail,
invitedTo: input.teamId,
...(input.isOrg && { organizationId: input.teamId }),
teams: {
create: {
teamId: input.teamId,
@ -80,14 +88,15 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
expires: new Date(new Date().setHours(168)), // +1 week
},
});
if (ctx?.user?.name && team?.name) {
if (team?.name) {
await sendTeamInviteEmail({
language: translation,
from: ctx.user.name,
from: ctx.user.name || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team.name,
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,
isOrg: input.isOrg,
});
}
} else {
@ -148,6 +157,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
to: sendTo,
teamName: team.name,
...inviteTeamOptions,
isOrg: input.isOrg,
});
}
}

View File

@ -13,6 +13,7 @@ export const ZInviteMemberInputSchema = z.object({
role: z.nativeEnum(MembershipRole),
language: z.string(),
sendEmailInvitation: z.boolean(),
isOrg: z.boolean().default(false),
});
export type TInviteMemberInputSchema = z.infer<typeof ZInviteMemberInputSchema>;

View File

@ -132,3 +132,4 @@ export type { ButtonColor } from "./components/button/Button";
export { CreateButton } from "./components/createButton";
export { useCalcomTheme } from "./styles/useCalcomTheme";
export { ScrollableArea } from "./components/scrollable/ScrollableArea";
export { WizardLayout } from "./layouts/WizardLayout";

View File

@ -3,15 +3,19 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import { StepCard, Steps } from "@calcom/ui";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { StepCard, Steps, Button } from "@calcom/ui";
export default function WizardLayout({
export function WizardLayout({
children,
maxSteps = 2,
currentStep = 0,
isOptionalCallback,
}: {
children: React.ReactNode;
} & { maxSteps?: number; currentStep?: number }) {
} & { maxSteps?: number; currentStep?: number; isOptionalCallback?: () => void }) {
const { t } = useLocale();
const [meta, setMeta] = useState({ title: "", subtitle: " " });
const router = useRouter();
const { title, subtitle } = meta;
@ -35,7 +39,9 @@ export default function WizardLayout({
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
<div className="mx-auto sm:max-w-[520px]">
<header>
<p className="font-cal mb-3 text-[28px] font-medium leading-7">{title}&nbsp;</p>
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
{title.replace(` | ${APP_NAME}`, "")}&nbsp;
</p>
<p className="text-subtle font-sans text-sm font-normal">{subtitle}&nbsp;</p>
</header>
<Steps maxSteps={maxSteps} currentStep={currentStep} navigateToStep={noop} />
@ -43,6 +49,13 @@ export default function WizardLayout({
<StepCard>{children}</StepCard>
</div>
</div>
{isOptionalCallback && (
<div className="mt-4 flex justify-center">
<Button color="minimal" onClick={isOptionalCallback}>
{t("ill_do_this_later")}
</Button>
</div>
)}
</div>
</div>
);