9424649d6f
Users with personal emails (e.g. Gmail) could enter the org creation flow at /settings/organizations/new but only hit the company email error at checkout, with no way to fix it from the form. After changing their email in settings, the cached store email was still used. - Add server-side isCompanyEmail check in page.tsx to redirect early - Add client-side check in CreateANewOrganizationForm with clear messaging - Always use current session email for non-admin users (not cached store) - Add tests for isCompanyEmail utility Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
|
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import slugify from "@calcom/lib/slugify";
|
|
import { BillingPeriod, CreationSource, UserPermissionRole } from "@calcom/prisma/enums";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
import type { Ensure } from "@calcom/types/utils";
|
|
import classNames from "@calcom/ui/classNames";
|
|
import { Alert } from "@calcom/ui/components/alert";
|
|
import { Button } from "@calcom/ui/components/button";
|
|
import { Form, Label, TextField, ToggleGroup } from "@calcom/ui/components/form";
|
|
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
|
|
import { useOnboarding } from "@calcom/web/modules/ee/organizations/lib/onboardingStore";
|
|
import { useRouter } from "next/navigation";
|
|
import type { SessionContextValue } from "next-auth/react";
|
|
import { useSession } from "next-auth/react";
|
|
import { useState } from "react";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
|
|
function extractDomainFromEmail(email: string) {
|
|
const match = email.match(/^(?:.*?:\/\/)?.*?([\w-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[/?#:]|$)/);
|
|
const out = (match && match[1]) ?? "";
|
|
return out.split(".")[0];
|
|
}
|
|
|
|
export const CreateANewOrganizationForm = () => {
|
|
const session = useSession();
|
|
|
|
const { isLoadingOrgOnboarding } = useOnboarding();
|
|
if (!session.data || isLoadingOrgOnboarding) {
|
|
return null;
|
|
}
|
|
|
|
return <CreateANewOrganizationFormChild session={session} />;
|
|
};
|
|
|
|
const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
|
|
const { t } = useLocale();
|
|
const router = useRouter();
|
|
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
|
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
|
// Let self-hosters create an organization with their own email. Hosted's Admin already has an organization for their email
|
|
const defaultOrgOwnerEmail = (!isAdmin || IS_SELF_HOSTED ? session.data.user.email : null) ?? "";
|
|
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
|
const { slug, name, orgOwnerEmail, billingPeriod, pricePerSeat, seats, onboardingId, reset } =
|
|
useOnboardingStore();
|
|
|
|
// For non-admin users, always use the current session email to prevent stale cached email issues.
|
|
// This handles the case where a user changes their email in settings and returns to org creation.
|
|
const effectiveOrgOwnerEmail = !isAdmin ? defaultOrgOwnerEmail : orgOwnerEmail || defaultOrgOwnerEmail;
|
|
const userHasCompanyEmail = isAdmin || isCompanyEmail(effectiveOrgOwnerEmail);
|
|
|
|
const newOrganizationFormMethods = useForm<{
|
|
name: string;
|
|
seats: number | null;
|
|
billingPeriod: BillingPeriod;
|
|
pricePerSeat: number | null;
|
|
slug: string;
|
|
orgOwnerEmail: string;
|
|
}>({
|
|
defaultValues: {
|
|
billingPeriod: billingPeriod ?? BillingPeriod.MONTHLY,
|
|
slug: slug ?? (!isAdmin ? deriveSlugFromEmail(effectiveOrgOwnerEmail) : undefined),
|
|
orgOwnerEmail: effectiveOrgOwnerEmail,
|
|
name: name ?? (!isAdmin ? deriveOrgNameFromEmail(effectiveOrgOwnerEmail) : undefined),
|
|
seats: seats ?? null,
|
|
pricePerSeat: pricePerSeat ?? null,
|
|
},
|
|
});
|
|
|
|
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
|
|
onSuccess: async (data) => {
|
|
reset({
|
|
onboardingId: data.organizationOnboardingId,
|
|
billingPeriod: data.billingPeriod,
|
|
pricePerSeat: data.pricePerSeat ?? null,
|
|
seats: data.seats ?? null,
|
|
orgOwnerEmail: data.orgOwnerEmail,
|
|
name: data.name,
|
|
slug: data.slug,
|
|
});
|
|
|
|
// Small delay to ensure Zustand persist middleware has time to write to localStorage
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
if (data.handoverUrl) {
|
|
// Admin handover flow - redirect to handover page
|
|
router.push("/settings/organizations/new/handover");
|
|
} else if (data.organizationId) {
|
|
// Self-hosted flow - org already created, redirect to organizations list
|
|
router.push("/settings/organizations");
|
|
} else {
|
|
// Regular flow - continue to next step
|
|
router.push("/settings/organizations/new/about");
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
if (err.message === "organization_url_taken") {
|
|
newOrganizationFormMethods.setError("slug", { type: "custom", message: t("url_taken") });
|
|
} else if (err.message === "domain_taken_team" || err.message === "domain_taken_project") {
|
|
newOrganizationFormMethods.setError("slug", {
|
|
type: "custom",
|
|
message: t("problem_registering_domain"),
|
|
});
|
|
} else {
|
|
setServerErrorMessage(t(err.message));
|
|
}
|
|
},
|
|
});
|
|
|
|
const needToCreateOnboarding = !onboardingId;
|
|
|
|
if (!userHasCompanyEmail) {
|
|
return (
|
|
<div className="stack-y-5">
|
|
<Alert
|
|
severity="warning"
|
|
title={t("use_company_email_to_create_an_organization")}
|
|
message={t("update_email_organization_description")}
|
|
actions={
|
|
<Button href="/settings/my-account/profile" color="secondary" className="mt-2">
|
|
{t("update_email_address")}
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Form
|
|
form={newOrganizationFormMethods}
|
|
className="stack-y-5"
|
|
id="createOrg"
|
|
handleSubmit={async (v) => {
|
|
if (!needToCreateOnboarding) {
|
|
// Resuming existing onboarding - just navigate to next step
|
|
router.push("/settings/organizations/new/about");
|
|
} else {
|
|
// Check if this is admin handover flow based on the submitted form value
|
|
const isAdminHandoverFlow = isAdmin && v.orgOwnerEmail !== session.data.user.email;
|
|
|
|
if (isAdminHandoverFlow) {
|
|
// Admin creating for someone else - submit immediately with just Step 1 data
|
|
if (!intentToCreateOrgMutation.isPending) {
|
|
setServerErrorMessage(null);
|
|
intentToCreateOrgMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP });
|
|
}
|
|
} else {
|
|
// Regular user or admin creating for self - store locally and continue
|
|
reset({
|
|
billingPeriod: v.billingPeriod,
|
|
pricePerSeat: v.pricePerSeat,
|
|
seats: v.seats,
|
|
orgOwnerEmail: v.orgOwnerEmail,
|
|
name: v.name,
|
|
slug: v.slug,
|
|
});
|
|
router.push("/settings/organizations/new/about");
|
|
}
|
|
}
|
|
}}>
|
|
<div>
|
|
{serverErrorMessage && (
|
|
<div className="mb-4">
|
|
<Alert severity="error" message={serverErrorMessage} />
|
|
</div>
|
|
)}
|
|
{isBillingEnabled && isAdmin && (
|
|
<div className="mb-5">
|
|
<Controller
|
|
name="billingPeriod"
|
|
control={newOrganizationFormMethods.control}
|
|
render={({ field: { value, onChange } }) => (
|
|
<>
|
|
<Label htmlFor="billingPeriod">Billing Period</Label>
|
|
<ToggleGroup
|
|
isFullWidth
|
|
id="billingPeriod"
|
|
value={value}
|
|
onValueChange={(e: BillingPeriod) => {
|
|
if ([BillingPeriod.ANNUALLY, BillingPeriod.MONTHLY].includes(e)) {
|
|
onChange(e);
|
|
}
|
|
}}
|
|
options={[
|
|
{
|
|
value: "MONTHLY",
|
|
label: "Monthly",
|
|
},
|
|
{
|
|
value: "ANNUALLY",
|
|
label: "Annually",
|
|
},
|
|
]}
|
|
/>
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<Controller
|
|
name="orgOwnerEmail"
|
|
control={newOrganizationFormMethods.control}
|
|
rules={{
|
|
required: t("must_enter_organization_admin_email"),
|
|
}}
|
|
render={({ field: { value } }) => (
|
|
<div className="flex">
|
|
<TextField
|
|
containerClassName="w-full"
|
|
placeholder="john@acme.com"
|
|
name="orgOwnerEmail"
|
|
disabled={!isAdmin}
|
|
label={t("admin_email")}
|
|
defaultValue={value}
|
|
onChange={(e) => {
|
|
const email = e?.target.value;
|
|
newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim());
|
|
if (newOrganizationFormMethods.getValues("slug") === "") {
|
|
const slug = deriveSlugFromEmail(email);
|
|
newOrganizationFormMethods.setValue("slug", slug);
|
|
}
|
|
const name = deriveOrgNameFromEmail(email);
|
|
newOrganizationFormMethods.setValue("name", name);
|
|
}}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<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.trim());
|
|
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
|
|
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
|
|
}
|
|
}}
|
|
autoComplete="off"
|
|
/>
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Controller
|
|
name="slug"
|
|
control={newOrganizationFormMethods.control}
|
|
rules={{
|
|
required: "Must enter organization slug",
|
|
}}
|
|
render={({ field: { value } }) => (
|
|
<TextField
|
|
name="slug"
|
|
label={t("organization_url")}
|
|
placeholder="acme"
|
|
addOnSuffix={`.${subdomainSuffix()}`}
|
|
defaultValue={value}
|
|
onChange={(e) => {
|
|
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value), {
|
|
shouldTouch: true,
|
|
});
|
|
newOrganizationFormMethods.clearErrors("slug");
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{isBillingEnabled && isAdmin && (
|
|
<>
|
|
<section className="grid grid-cols-2 gap-2">
|
|
<div className="w-full">
|
|
<Controller
|
|
name="seats"
|
|
control={newOrganizationFormMethods.control}
|
|
render={({ field: { value, onChange } }) => (
|
|
<div className="flex">
|
|
<TextField
|
|
containerClassName="w-full"
|
|
placeholder="1"
|
|
name="seats"
|
|
type="number"
|
|
label="Seats (optional)"
|
|
min={1}
|
|
defaultValue={value || 1}
|
|
onChange={(e) => {
|
|
onChange(+e.target.value);
|
|
}}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="w-full">
|
|
<Controller
|
|
name="pricePerSeat"
|
|
control={newOrganizationFormMethods.control}
|
|
render={({ field: { value, onChange } }) => (
|
|
<div className="flex">
|
|
<TextField
|
|
containerClassName="w-full"
|
|
placeholder="30"
|
|
name="pricePerSeat"
|
|
type="number"
|
|
addOnSuffix="$"
|
|
label="Price per seat (optional)"
|
|
defaultValue={value ?? ""}
|
|
onChange={(e) => {
|
|
onChange(+e.target.value);
|
|
}}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{/* This radio group does nothing - its just for visual purposes */}
|
|
{isBillingEnabled && !isAdmin && (
|
|
<>
|
|
<div className="bg-subtle stack-y-5 rounded-lg p-5">
|
|
<h3 className="font-cal text-default text-lg font-semibold leading-4">
|
|
Upgrade to Organizations
|
|
</h3>
|
|
<RadioArea.Group className={classNames("mt-1 flex flex-col gap-4")} value="ORGANIZATION">
|
|
<RadioArea.Item
|
|
className={classNames("bg-default w-full text-sm opacity-70")}
|
|
value="TEAMS"
|
|
disabled>
|
|
<strong className="mb-1 block">{t("teams")}</strong>
|
|
<p>{t("your_current_plan")}</p>
|
|
</RadioArea.Item>
|
|
<RadioArea.Item className={classNames("bg-default w-full text-sm")} value="ORGANIZATION">
|
|
<strong className="mb-1 block">{t("organization")}</strong>
|
|
{pricePerSeat && seats ? (
|
|
<p>{`$${pricePerSeat} per user per month ${
|
|
billingPeriod === BillingPeriod.ANNUALLY ? "(billed annually)" : ""
|
|
}`}</p>
|
|
) : (
|
|
<p>{t("organization_price_per_user_month")}</p>
|
|
)}
|
|
</RadioArea.Item>
|
|
</RadioArea.Group>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex space-x-2 rtl:space-x-reverse">
|
|
<Button
|
|
loading={newOrganizationFormMethods.formState.isSubmitting || intentToCreateOrgMutation.isPending}
|
|
color="primary"
|
|
EndIcon="arrow-right"
|
|
type="submit"
|
|
form="createOrg"
|
|
className="w-full justify-center">
|
|
{t("continue")}
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export function deriveSlugFromEmail(email: string) {
|
|
const domain = extractDomainFromEmail(email);
|
|
|
|
return domain;
|
|
}
|
|
|
|
export function deriveOrgNameFromEmail(email: string) {
|
|
const domain = extractDomainFromEmail(email);
|
|
|
|
return domain.charAt(0).toUpperCase() + domain.slice(1);
|
|
}
|