2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { trpc } from "@calcom/trpc/react";
import { Alert, Avatar, Button, Form, Icon, ImageUploader, Label, TextAreaField } from "@calcom/ui";
const querySchema = z.object({
id: z.string(),
});
export const AboutOrganizationForm = () => {
const { t } = useLocale();
const router = useRouter();
const routerQuery = useRouterQuery();
const { id: orgId } = querySchema.parse(routerQuery);
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-members`);
}
},
onError: (err) => {
setServerErrorMessage(err.message);
},
});
return (
<>
<Form
form={aboutOrganizationFormMethods}
className="space-y-5"
handleSubmit={(v) => {
if (!updateOrganizationMutation.isPending) {
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=""
fallback={<Icon name="plus" className="text-subtle h-6 w-6" />}
className="items-center"
imageSrc={image}
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.isPending
}
color="primary"
EndIcon="arrow-right"
type="submit"
className="w-full justify-center">
{t("continue")}
</Button>
</div>
</Form>
</>
);
};

View File

@@ -0,0 +1,256 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { IS_TEAM_BILLING_ENABLED_CLIENT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button, CheckboxField, Form, Icon, showToast, TextField } from "@calcom/ui";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
});
const schema = z.object({
teams: z.array(
z.object({
name: z.string().trim(),
})
),
moveTeams: z.array(
z.object({
id: z.number(),
shouldMove: z.boolean(),
newSlug: z.string().optional(),
})
),
});
export const AddNewTeamsForm = () => {
const { data: teams } = trpc.viewer.teams.list.useQuery();
const routerQuery = useRouterQuery();
const { id: orgId } = querySchema.parse(routerQuery);
const { data: org } = trpc.viewer.teams.get.useQuery({ teamId: orgId, isOrg: true });
if (!teams || !org) {
return null;
}
const orgWithRequestedSlug = {
...org,
requestedSlug: org.metadata.requestedSlug ?? null,
};
const regularTeams = teams.filter((team) => !team.parentId);
return <AddNewTeamsFormChild org={orgWithRequestedSlug} teams={regularTeams} />;
};
const AddNewTeamsFormChild = ({
teams,
org,
}: {
org: { id: number; slug: string | null; requestedSlug: string | null };
teams: { id: number; name: string; slug: string | null }[];
}) => {
const { t } = useLocale();
const router = useRouter();
const [counter, setCounter] = useState(1);
const form = useForm({
defaultValues: {
teams: [{ name: "" }],
moveTeams: teams.map((team) => ({
id: team.id,
shouldMove: false,
newSlug: getSuggestedSlug({ teamSlug: team.slug, orgSlug: org.slug || org.requestedSlug }),
})),
}, // Set initial values
resolver: async (data) => {
try {
const validatedData = await schema.parseAsync(data);
return { values: validatedData, errors: {} };
} catch (error) {
if (error instanceof z.ZodError) {
return {
values: {
teams: [],
},
errors: error.formErrors.fieldErrors,
};
}
return { values: {}, errors: { teams: { message: "Error validating input" } } };
}
},
});
const session = useSession();
const isAdmin =
session.data?.user?.role === UserPermissionRole.ADMIN ||
session.data?.user?.impersonatedBy?.role === UserPermissionRole.ADMIN;
const allowWizardCompletionWithoutUpgrading = !IS_TEAM_BILLING_ENABLED_CLIENT || isAdmin;
const { register, control, watch, getValues } = form;
const { fields, append, remove } = useFieldArray({
control,
name: "teams",
});
const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const handleCounterIncrease = () => {
if (counter >= 0 && counter < 5) {
setCounter((prevCounter) => prevCounter + 1);
append({ name: "" });
}
};
const handleRemoveInput = (index: number) => {
remove(index);
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");
// Server will return array of duplicated slugs, so we need to wait for user to read the warning
// before pushing to next page
setTimeout(() => handleSuccessRedirect, 3000);
} else {
handleSuccessRedirect();
}
function handleSuccessRedirect() {
if (allowWizardCompletionWithoutUpgrading) {
router.push(`/event-types`);
return;
}
// mutate onSuccess will take care of routing to the correct place.
publishOrgMutation.mutate();
}
},
onError: (error) => {
showToast(t(error.message), "error");
},
});
const handleFormSubmit = () => {
const fields = getValues("teams");
const moveTeams = getValues("moveTeams");
createTeamsMutation.mutate({ orgId: org.id, moveTeams, teamNames: fields.map((field) => field.name) });
};
const moveTeams = watch("moveTeams");
return (
<>
<Form form={form} handleSubmit={handleFormSubmit}>
{moveTeams.length ? (
<>
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">
Move existing teams
</label>
<ul className="mb-8 space-y-4">
{moveTeams.map((team, index) => {
return (
<li key={team.id}>
<Controller
name={`moveTeams.${index}.shouldMove`}
render={({ field: { value, onChange } }) => (
<CheckboxField
defaultValue={value}
checked={value}
onChange={onChange}
description={teams.find((t) => t.id === team.id)?.name ?? ""}
/>
)}
/>
{moveTeams[index].shouldMove ? (
<TextField
placeholder="New Slug"
defaultValue={teams.find((t) => t.id === team.id)?.slug ?? ""}
{...register(`moveTeams.${index}.newSlug`)}
className="mt-2"
label=""
/>
) : null}
</li>
);
})}
</ul>
</>
) : null}
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">Add New Teams</label>
{fields.map((field, index) => (
<div className={classNames("relative", index > 0 ? "mb-2" : "")} key={field.id}>
<TextField
key={field.id}
{...register(`teams.${index}.name`)}
label=""
addOnClassname="bg-transparent p-0 border-l-0"
className={index > 0 ? "mb-2" : ""}
placeholder={t(`org_team_names_example_${index + 1}`)}
addOnSuffix={
index > 0 && (
<Button
color="minimal"
className="group/remove mx-2 px-0 hover:bg-transparent"
onClick={() => handleRemoveInput(index)}
aria-label="Remove Team">
<Icon
name="x"
className="bg-subtle text group-hover/remove:text-inverted group-hover/remove:bg-inverted h-5 w-5 rounded-full p-1"
/>
</Button>
)
}
minLength={2}
maxLength={63}
/>
</div>
))}
{counter === 5 && <p className="text-subtle my-2 text-sm">{t("org_max_team_warnings")}</p>}
{counter < 5 && (
<Button
StartIcon="plus"
color="secondary"
disabled={createTeamsMutation.isPending}
onClick={handleCounterIncrease}
aria-label={t("add_a_team")}
className="my-1">
{t("add_a_team")}
</Button>
)}
<Button
EndIcon="arrow-right"
color="primary"
className="mt-6 w-full justify-center"
data-testId="continue_or_checkout"
disabled={createTeamsMutation.isPending || createTeamsMutation.isSuccess}
onClick={handleFormSubmit}>
{allowWizardCompletionWithoutUpgrading ? t("continue") : t("checkout")}
</Button>
</Form>
</>
);
};
const getSuggestedSlug = ({ teamSlug, orgSlug }: { teamSlug: string | null; orgSlug: string | null }) => {
// If there is no orgSlug, we can't suggest a slug
if (!teamSlug || !orgSlug) {
return teamSlug;
}
return teamSlug.replace(`${orgSlug}-`, "").replace(`-${orgSlug}`, "");
};

View File

@@ -0,0 +1,340 @@
import type { SessionContextValue } from "next-auth/react";
import { signIn, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import classNames from "@calcom/lib/classNames";
import { MINIMUM_NUMBER_OF_ORG_SEATS } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Alert, Button, Form, Label, RadioGroup as RadioArea, TextField, ToggleGroup } from "@calcom/ui";
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 CreateANewOrganizationForm = () => {
const session = useSession();
if (!session.data) {
return null;
}
return <CreateANewOrganizationFormChild session={session} />;
};
enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
const CreateANewOrganizationFormChild = ({
session,
}: {
session: Ensure<SessionContextValue, "data">;
isPlatformOrg?: boolean;
}) => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
const defaultOrgOwnerEmail = session.data.user.email ?? "";
const newOrganizationFormMethods = useForm<{
name: string;
seats: number;
billingPeriod: BillingPeriod;
pricePerSeat: number;
slug: string;
orgOwnerEmail: string;
}>({
defaultValues: {
billingPeriod: BillingPeriod.MONTHLY,
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
},
});
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
onSuccess: async (data) => {
telemetry.event(telemetryEventTypes.org_created);
// This is necessary so that server token has the updated upId
await session.update({
upId: data.upId,
});
if (isAdmin && data.userId !== session.data?.user.id) {
// Impersonate the user chosen as the organization owner(if the admin user isn't the owner himself), so that admin can now configure the organisation on his behalf.
// He won't need to have access to the org directly in this way.
signIn("impersonation-auth", {
username: data.email,
callbackUrl: `/settings/organizations/${data.organizationId}/about`,
});
}
router.push(`/settings/organizations/${data.organizationId}/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(err.message);
}
},
});
return (
<>
<Form
form={newOrganizationFormMethods}
className="space-y-5"
id="createOrg"
handleSubmit={(v) => {
if (!createOrganizationMutation.isPending) {
setServerErrorMessage(null);
createOrganizationMutation.mutate(v);
}
}}>
<div>
{serverErrorMessage && (
<div className="mb-4">
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
{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;
const slug = deriveSlugFromEmail(email);
newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim());
if (newOrganizationFormMethods.getValues("slug") === "") {
newOrganizationFormMethods.setValue("slug", slug);
}
newOrganizationFormMethods.setValue("name", deriveOrgNameFromEmail(email));
}}
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
className="mt-2"
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>
{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="30"
name="seats"
type="number"
label="Seats (optional)"
min={isAdmin ? 1 : MINIMUM_NUMBER_OF_ORG_SEATS}
defaultValue={value || MINIMUM_NUMBER_OF_ORG_SEATS}
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 visuall purposes */}
{!isAdmin && (
<>
<div className="bg-subtle space-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>
<p>{t("organization_price_per_user_month")}</p>
</RadioArea.Item>
</RadioArea.Group>
</div>
</>
)}
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.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);
}

View File

@@ -0,0 +1,50 @@
import { useRouter } from "next/navigation";
import { useIsPlatform } from "@calcom/atoms/monorepo";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { showToast, TopBanner } from "@calcom/ui";
export type OrgUpgradeBannerProps = {
data: RouterOutputs["viewer"]["getUserTopBanners"]["orgUpgradeBanner"];
};
export function OrgUpgradeBanner({ data }: OrgUpgradeBannerProps) {
const { t } = useLocale();
const router = useRouter();
const isPlatform = useIsPlatform();
const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
onError: (error) => {
showToast(error.message, "error");
},
});
if (!data) return null;
const [membership] = data;
if (!membership) return null;
// TODO: later figure out how to not show this banner on platform since platform is different to orgs (it just uses the same code)
if (isPlatform) return null;
return (
<TopBanner
text={t("org_upgrade_banner_description", { teamName: membership.team.name })}
variant="warning"
actions={
<button
data-testid="upgrade_org_banner_button"
className="border-b border-b-black"
onClick={() => {
publishOrgMutation.mutate();
}}>
{t("upgrade_banner_action")}
</button>
}
/>
);
}

View File

@@ -0,0 +1,96 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import type { RouterOutputs } from "@calcom/trpc";
import { Avatar, TextField } from "@calcom/ui";
type TeamInviteFromOrgProps = PropsWithChildren<{
selectedEmails?: string | string[];
handleOnChecked: (usersEmail: string) => void;
orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"];
}>;
const keysToCheck = ["name", "email", "username"] as const; // array of keys to check
export default function TeamInviteFromOrg({
handleOnChecked,
selectedEmails,
orgMembers,
}: TeamInviteFromOrgProps) {
const [searchQuery, setSearchQuery] = useState("");
const filteredMembers = orgMembers?.filter((member) => {
if (!searchQuery) {
return true; // return all members if searchQuery is empty
}
const { user } = member ?? {}; // destructuring with default value in case member is undefined
return keysToCheck.some((key) => user?.[key]?.toLowerCase().includes(searchQuery.toLowerCase()));
});
return (
<div className="bg-muted border-subtle flex flex-col rounded-md border p-4">
<div className="-my-1">
<TextField placeholder="Search..." onChange={(e) => setSearchQuery(e.target.value)} />
</div>
<hr className="border-subtle -mx-4 mt-2" />
<div className="scrollbar min-h-48 flex max-h-48 flex-col space-y-0.5 overflow-y-scroll pt-2">
<>
{filteredMembers &&
filteredMembers.map((member) => {
const isSelected = Array.isArray(selectedEmails)
? selectedEmails.includes(member.user.email)
: selectedEmails === member.user.email;
return (
<UserToInviteItem
key={member.user.id}
member={member}
isSelected={isSelected}
onChange={() => handleOnChecked(member.user.email)}
/>
);
})}
</>
</div>
</div>
);
}
function UserToInviteItem({
member,
isSelected,
onChange,
}: {
member: RouterOutputs["viewer"]["organizations"]["getMembers"][number];
isSelected: boolean;
onChange: () => void;
}) {
return (
<div
key={member.userId}
onClick={() => onChange()} // We handle this on click on the div also - for a11y we handle it with label and checkbox below
className={classNames(
"flex cursor-pointer items-center rounded-md px-2 py-1",
isSelected ? "bg-emphasis" : "hover:bg-subtle "
)}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<Avatar size="sm" alt="Users avatar" asChild imageSrc={member.user.avatarUrl} />
<label
htmlFor={`${member.user.id}`}
className="text-emphasis cursor-pointer text-sm font-medium leading-none">
{member.user.name || member.user.email || "Nameless User"}
</label>
</div>
<div className="ml-auto">
<input
id={`${member.user.id}`}
checked={isSelected}
type="checkbox"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default hover:bg-subtle inline-flex h-4 w-4 place-self-center justify-self-end rounded checked:bg-gray-800"
onChange={() => {
onChange();
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
export { AboutOrganizationForm } from "./AboutOrganizationForm";
export { AddNewTeamsForm } from "./AddNewTeamsForm";