Merge branch 'feat/organizations' into feat/organizations-banner
This commit is contained in:
commit
ed87a76c15
|
@ -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}/*",
|
||||
|
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 |
|
@ -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": "You’ve been invited to join a team on {{appName}}",
|
||||
"email_no_user_invite_heading": "You’ve 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": "We’ll walk you through a few short steps and you’ll 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": "We’ll walk you through a few short steps and you’ll 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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -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: "",
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1 +1,5 @@
|
|||
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
|
||||
export { AboutOrganizationForm } from "./AboutOrganizationForm";
|
||||
export { SetPasswordForm } from "./SetPasswordForm";
|
||||
export { AddNewOrgAdminsForm } from "./AddNewOrgAdminsForm";
|
||||
export { AddNewTeamsForm } from "./AddNewTeamsForm";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ export const telemetryEventTypes = {
|
|||
pageView: "website_page_view",
|
||||
},
|
||||
slugReplacementAction: "slug_replacement_action",
|
||||
org_created: "org_created",
|
||||
};
|
||||
|
||||
export function collectPageParameters(
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -32,6 +32,7 @@ const ENDPOINTS = [
|
|||
"saml",
|
||||
"slots",
|
||||
"teams",
|
||||
"organizations",
|
||||
"users",
|
||||
"viewer",
|
||||
"webhook",
|
||||
|
|
|
@ -68,6 +68,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
|||
trialEndsAt: true,
|
||||
metadata: true,
|
||||
role: true,
|
||||
organizationId: true,
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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>;
|
|
@ -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 };
|
||||
};
|
|
@ -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>;
|
|
@ -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 };
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZSetPasswordSchema = z.object({
|
||||
newPassword: z.string(),
|
||||
});
|
||||
|
||||
export type TSetPasswordSchema = z.infer<typeof ZSetPasswordSchema>;
|
|
@ -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 };
|
||||
};
|
|
@ -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>;
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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} </p>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
|
||||
{title.replace(` | ${APP_NAME}`, "")}
|
||||
</p>
|
||||
<p className="text-subtle font-sans text-sm font-normal">{subtitle} </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>
|
||||
);
|
Loading…
Reference in New Issue
Block a user