feat: Org settings - profile,appearance, child teams, create new child (#9231)

* Initial commit

* Adding feature flag

* Desktop first banner, mobile pending

* Removing dead code and img

* AppInstallButtonBase

* WIP

* Adds Email verification template+translations for organizations (#9202)

* feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209)

* Change scopedMembers to orgMembers

* Change to orgUsers

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Session logic to show org label

* Step 2 done, avatar not working

* List orgs and list teams specific if orgs exist

* Conditionally show org - fix settings layout - add labels for all pages

* Profile Page + update

* Org specific team creation

* appearance page

* Ensure members cant of org cant update settings in UI

* Fix update handler imports

* hide billing on sub teams

* Update profile slug page

* Letting duplicate slugs for teams to support orgs

* Add slug coliisions for org

* Covering null on unique clauses

* Covering null on unique clauses

* Extract to utils

* Update settings to use subdomain path in team url , team + org

* Supporting having the orgId in the session cookie

* Onboarding admins step

* Last step to create teams

* Update handler comments

* Upgrade ORG banner - disabled team banner for child teams

* Handle publishing ORGS

* Fix licenese issue

* Update packages/trpc/server/routers/viewer/teams/create.handler.ts

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* A11ly

* Feedback

* Making an org avatar (temp)

* Add slug colission detection for user and team name

* Fix Import

* Remove update password func

* Fix module import over relative

* feat: organization event type filter (#9253)

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Missing changes to support orgs schema changes

* Remove app install button sa its in 9337

* Remove i18n key not being used

* feat: Onboarding process to create an organization (#9184)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Making sure we check requestedSlug now

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* feat: [CAL-1816] Organization subdomain support (#9345)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* Covering users and subteams, excluding non-org users

* Unpublished teams shows correctly

* Create subdomain in Vercel

* feedback

* Renaming Vercel env vars

* Vercel domain check before creation

* Supporting cal-staging.com

* Change to have vercel detect it

* vercel domain check data message error

* Remove check domain

* Making sure we check requestedSlug now

* Feedback and unneeded code

* Reverting unneeded changes

* Unneeded changes

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Vercel subdomain creation in PROD only

* feat: organization settings general and members page (#9266)

* feat: organization settings general page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* feat: add members page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use invalidate

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: delete mutation

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: remove organization id

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use zod schema

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Type fixes

* Reverting changes

* Update UsernameTextfield.tsx

* More reverts

* Update next-auth-options.ts

* Update common.json

* Type fixes

* Include invite token for orgs

* Update org schema

* Make token settings optional as it isnt used in orgs yet

* Fix missing prop

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
authored and GitHub committed 2023-06-15 10:27:39 -07:00
1 parent 6880f88dbe
commit aab5ee344c
39 files changed
+1555 -48

No files matched your search

@@ -0,0 +1,9 @@
import OrgAppearanceView from "@calcom/features/ee/organizations/pages/settings/appearance";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OrgAppearanceView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -0,0 +1,9 @@
import OrgGeneralView from "@calcom/features/ee/organizations/pages/settings/general";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OrgGeneralView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -0,0 +1,9 @@
import MembersView from "@calcom/features/ee/organizations/pages/settings/members";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = MembersView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -0,0 +1,9 @@
import OrgProfileView from "@calcom/features/ee/organizations/pages/settings/profile";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OrgProfileView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+12 -1
View File
@@ -117,7 +117,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 {{entity}} {{team}} on {{appName}}",
"user_invited_you": "{{user}} invited you to join the team {{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.",
@@ -128,6 +128,9 @@
"team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.",
"team_upgrade_banner_action": "Upgrade here",
"team_upgraded_successfully": "Your team was upgraded successfully!",
"org_upgrade_banner_description": "Thank you for trialing our Organization plan. We noticed your Organization \"{{teamName}}\" needs to be upgraded.",
"org_upgrade_banner_action": "Upgrade here",
"org_upgraded_successfully": "Your Organization was upgraded successfully!",
"use_link_to_reset_password": "Use the link below to reset your password",
"hey_there": "Hey there,",
"forgot_your_password_calcom": "Forgot your password? - {{appName}}",
@@ -243,6 +246,7 @@
"available_apps": "Available Apps",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
"organization_general_description":"Manage settings for your team language and timezone",
"few_sentences_about_yourself": "A few sentences about yourself. This will appear on your personal url page.",
"nearly_there": "Nearly there!",
"nearly_there_instructions": "Last thing, a brief description about you and a photo really help you get bookings and let people know who they’re booking with.",
@@ -540,7 +544,9 @@
"your_team_updated_successfully": "Your team has been updated successfully.",
"about": "About",
"team_description": "A few sentences about your team. This will appear on your team's url page.",
"org_description": "A few sentances about your organization. This will appear on your organization's url page.",
"members": "Members",
"organization_members":"Organisation members",
"member": "Member",
"number_member_one": "{{count}} member",
"number_member_other": "{{count}} members",
@@ -574,6 +580,8 @@
"confirm_all": "Confirm all",
"disband_team": "Disband Team",
"disband_team_confirmation_message": "Are you sure you want to disband this team? Anyone who you've shared this team link with will no longer be able to book using it.",
"disband_org": "Disband Organization",
"disband_org_confirmation_message": "Are you sure you want to disband this Org? All teams and members will be deleted.",
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
"confirm_disband_team": "Yes, disband team",
"confirm_remove_member": "Yes, remove member",
@@ -1299,7 +1307,9 @@
"event_one": "event",
"event_other": "events",
"profile_team_description": "Manage settings for your team profile",
"profile_org_description": "Manage settings for your organization profile",
"members_team_description": "Users that are in the group",
"organization_description": "Manage the admins and members in your organization",
"team_url": "Team URL",
"team_members": "Team members",
"more": "More",
@@ -1444,6 +1454,7 @@
"yearly": "Yearly",
"checkout": "Checkout",
"your_team_disbanded_successfully": "Your team has been disbanded successfully",
"your_org_disbanded_successfully": "Your organization has been disbanded successfully",
"error_creating_team": "Error creating team",
"you": "You",
"send_email": "Send email",
@@ -85,6 +85,11 @@ const providers: Provider[] = [
organizationId: true,
twoFactorEnabled: true,
twoFactorSecret: true,
organization: {
select: {
id: true,
},
},
teams: {
include: {
team: true,
@@ -0,0 +1,39 @@
import { useRouter } from "next/router";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast, TopBanner } from "@calcom/ui";
export function OrgUpgradeBanner() {
const { t } = useLocale();
const router = useRouter();
const { data } = trpc.viewer.organizations.checkIfOrgNeedsUpgrade.useQuery();
const publishTeamMutation = 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;
return (
<TopBanner
text={t("org_upgrade_banner_description", { teamName: membership.team.name })}
variant="warning"
actions={
<button
className="border-b border-b-black"
onClick={() => {
publishTeamMutation.mutate();
}}>
{t("org_upgrade_banner_action")}
</button>
}
/>
);
}
@@ -0,0 +1,13 @@
export 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 extractDomainFromWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
"https://",
""
)?.replace("http://", "") as string;
@@ -0,0 +1,241 @@
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import {
Button,
ColorPicker,
Form,
Meta,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
Switch,
} from "@calcom/ui";
import ThemeLabel from "../../../../settings/ThemeLabel";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mt-6 mb-8 space-y-6">
<div className="flex items-center">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
</div>
<div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
interface OrgAppearanceValues {
hideBranding: boolean;
hideBookATeamMember: boolean;
brandColor: string;
darkBrandColor: string;
theme: string | null | undefined;
}
const OrgAppearanceView = () => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const mutation = trpc.viewer.organizations.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
showToast(t("your_team_updated_successfully"), "success");
},
});
const { data: currentOrg, isLoading } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
onError: () => {
router.push("/settings");
},
});
const form = useForm<OrgAppearanceValues>({
defaultValues: {
theme: currentOrg?.theme,
brandColor: currentOrg?.brandColor,
darkBrandColor: currentOrg?.darkBrandColor,
hideBranding: currentOrg?.hideBranding,
},
});
const isAdmin =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
if (isLoading) {
return <SkeletonLoader title={t("booking_appearance")} description={t("appearance_team_description")} />;
}
return (
<>
<Meta title={t("booking_appearance")} description={t("appearance_team_description")} />
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
mutation.mutate({
...values,
theme: values.theme || null,
});
}}>
<div className="mb-6 flex items-center text-sm">
<div>
<p className="font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
</div>
</div>
<div className="flex flex-col justify-between sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={currentOrg.theme === null}
register={form.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("theme_light")}
defaultChecked={currentOrg.theme === "light"}
register={form.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("theme_dark")}
defaultChecked={currentOrg.theme === "dark"}
register={form.register}
/>
</div>
<hr className="border-subtle my-8" />
<div className="text-default mb-6 flex items-center text-sm">
<div>
<p className="font-semibold">{t("custom_brand_colors")}</p>
<p className="mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
</div>
</div>
<div className="block justify-between sm:flex">
<Controller
name="brandColor"
control={form.control}
defaultValue={currentOrg.brandColor}
render={() => (
<div>
<p className="text-emphasis mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={currentOrg.brandColor || "#292929"}
resetDefaultValue="#292929"
onChange={(value) => form.setValue("brandColor", value, { shouldDirty: true })}
/>
</div>
)}
/>
<Controller
name="darkBrandColor"
control={form.control}
defaultValue={currentOrg.darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-emphasis mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={currentOrg.darkBrandColor || "#fafafa"}
resetDefaultValue="#fafafa"
onChange={(value) => form.setValue("darkBrandColor", value, { shouldDirty: true })}
/>
</div>
)}
/>
</div>
<hr className="border-subtle my-8" />
<div className="flex flex-col gap-8">
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="text-default font-medium">
{t("disable_cal_branding", { appName: APP_NAME })}
</label>
<p className="text-subtle">
{t("team_disable_cal_branding_description", { appName: APP_NAME })}
</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={currentOrg?.hideBranding ?? false}
name="hideBranding"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBranding", isChecked);
}}
/>
)}
/>
</div>
</div>
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="text-default font-medium">
{t("hide_book_a_team_member")}
</label>
<p className="text-subtle">{t("hide_book_a_team_member_description")}</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={currentOrg?.hideBookATeamMember ?? false}
name="hideBookATeamMember"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBookATeamMember", isChecked);
}}
/>
)}
/>
</div>
</div>
</div>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
</Form>
) : (
<div className="border-subtle rounded-md border p-5">
<span className="text-default text-sm">{t("only_owner_change")}</span>
</div>
)}
</>
);
};
OrgAppearanceView.getLayout = getLayout;
export default OrgAppearanceView;
@@ -0,0 +1,194 @@
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { nameOfDay } from "@calcom/lib/weekday";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Form,
Label,
Meta,
Select,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
TimezoneSelect,
} from "@calcom/ui";
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mt-6 mb-8 space-y-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
isAdminOrOwner: boolean;
localeProp: string;
}
const OrgGeneralView = () => {
const { t } = useLocale();
const router = useRouter();
const { data: currentOrg, isLoading } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
onError: () => {
router.push("/settings");
},
});
const { data: user } = trpc.viewer.me.useQuery();
if (isLoading) return <SkeletonLoader title={t("general")} description={t("general_description")} />;
if (!currentOrg) {
return null;
}
const isAdminOrOwner =
currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN;
return (
<GeneralView currentOrg={currentOrg} isAdminOrOwner={isAdminOrOwner} localeProp={user?.locale ?? "en"} />
);
};
const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProps) => {
const { t } = useLocale();
const mutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
reset(getValues());
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const timeFormatOptions = [
{ value: 12, label: t("12_hour") },
{ value: 24, label: t("24_hour") },
];
const weekStartOptions = [
{ value: "Sunday", label: nameOfDay(localeProp, 0) },
{ value: "Monday", label: nameOfDay(localeProp, 1) },
{ value: "Tuesday", label: nameOfDay(localeProp, 2) },
{ value: "Wednesday", label: nameOfDay(localeProp, 3) },
{ value: "Thursday", label: nameOfDay(localeProp, 4) },
{ value: "Friday", label: nameOfDay(localeProp, 5) },
{ value: "Saturday", label: nameOfDay(localeProp, 6) },
];
const formMethods = useForm({
defaultValues: {
timeZone: currentOrg.timeZone || "",
timeFormat: {
value: currentOrg.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === currentOrg.timeFormat)?.label || 12,
},
weekStart: {
value: currentOrg.weekStart,
label:
weekStartOptions.find((option) => option.value === currentOrg.weekStart)?.label ||
nameOfDay(localeProp, 0),
},
},
});
const {
formState: { isDirty, isSubmitting },
reset,
getValues,
} = formMethods;
const isDisabled = isSubmitting || !isDirty || !isAdminOrOwner;
return (
<Form
form={formMethods}
handleSubmit={(values) => {
mutation.mutate({
...values,
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
<Meta title={t("general")} description={t("organization_general_description")} />
<Controller
name="timeZone"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
</>
)}
/>
<Controller
name="timeFormat"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("time_format")}</>
</Label>
<Select
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<div className="text-gray text-default mt-2 flex items-center text-sm">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
render={({ field: { value } }) => (
<>
<Label className="text-emphasis mt-8">
<>{t("start_of_week")}</>
</Label>
<Select
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
}}
/>
</>
)}
/>
<Button disabled={isDisabled} color="primary" type="submit" className="mt-8">
<>{t("update")}</>
</Button>
</Form>
);
};
OrgGeneralView.getLayout = getLayout;
export default OrgGeneralView;
@@ -0,0 +1,174 @@
import { useRouter } from "next/router";
import { useState } from "react";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import MemberListItem from "@calcom/features/ee/teams/components/MemberListItem";
import TeamInviteList from "@calcom/features/ee/teams/components/TeamInviteList";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Meta, TextField, showToast } from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
type Team = RouterOutputs["viewer"]["teams"]["get"];
interface MembersListProps {
team: Team | undefined;
}
const checkIfExist = (comp: string, query: string) =>
comp.toLowerCase().replace(/\s+/g, "").includes(query.toLowerCase().replace(/\s+/g, ""));
function MembersList(props: MembersListProps) {
const { team } = props;
const { t } = useLocale();
const [query, setQuery] = useState<string>("");
const members = team?.members;
const membersList = members
? members && query === ""
? members
: members.filter((member) => {
const email = member.email ? checkIfExist(member.email, query) : false;
const username = member.username ? checkIfExist(member.username, query) : false;
const name = member.name ? checkIfExist(member.name, query) : false;
return email || username || name;
})
: undefined;
return (
<div className="flex flex-col gap-y-3">
<TextField
type="search"
autoComplete="false"
onChange={(e) => setQuery(e.target.value)}
value={query}
placeholder={`${t("search")}...`}
/>
{membersList?.length && team ? (
<ul className="divide-subtle border-subtle divide-y rounded-md border ">
{membersList.map((member) => {
return <MemberListItem key={member.id} team={team} member={member} />;
})}
</ul>
) : null}
</div>
);
}
const MembersView = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const showDialog = router.query.inviteModal === "true";
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
const { data: team, isLoading } = trpc.viewer.organizations.listMembers.useQuery(undefined, {
onError: () => {
router.push("/settings");
},
});
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
await utils.viewer.organizations.listMembers.invalidate();
setShowMemberInvitationModal(false);
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"
);
}
}
},
onError: (error) => {
showToast(error.message, "error");
},
});
const isInviteOpen = !team?.membership.accepted;
const isAdminOrOwner =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
return (
<>
<Meta
title={t("organization_members")}
description={t("organization_description")}
CTA={
isAdminOrOwner ? (
<Button
type="button"
color="primary"
StartIcon={Plus}
className="ml-auto"
onClick={() => setShowMemberInvitationModal(true)}
data-testid="new-organization-member-button">
{t("add")}
</Button>
) : (
<></>
)
}
/>
{!isLoading && (
<>
<div>
{team && (
<>
{isInviteOpen && (
<TeamInviteList
teams={[
{
id: team.id,
accepted: team.membership.accepted || false,
logo: team.logo,
name: team.name,
slug: team.slug,
role: team.membership.role,
},
]}
/>
)}
</>
)}
<MembersList team={team} />
</div>
{showMemberInvitationModal && team && (
<MemberInvitationModal
teamId={team.id}
isOpen={showMemberInvitationModal}
members={team.members}
onExit={() => setShowMemberInvitationModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId: team.id,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
isOrg: true,
});
}}
/>
)}
</>
)}
</>
);
};
MembersView.getLayout = getLayout;
export default MembersView;
@@ -0,0 +1,257 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { Prisma } from "@prisma/client";
import { LinkIcon, Trash2 } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useState, useLayoutEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import {
Avatar,
Button,
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Form,
ImageUploader,
Label,
Meta,
showToast,
TextField,
Editor,
LinkIconButton,
} from "@calcom/ui";
import { getLayout } from "../../../../settings/layouts/SettingsLayout";
import { extractDomainFromWebsiteUrl } from "../../lib/utils";
const regex = new RegExp("^[a-zA-Z0-9-]*$");
const orgProfileFormSchema = z.object({
name: z.string(),
logo: z.string(),
bio: z.string(),
});
const OrgProfileView = () => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const session = useSession();
const [firstRender, setFirstRender] = useState(true);
useLayoutEffect(() => {
document.body.focus();
}, []);
const mutation = trpc.viewer.organizations.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
showToast(t("your_organisation_updated_sucessfully"), "success");
},
});
const form = useForm({
resolver: zodResolver(orgProfileFormSchema),
});
const { data: currentOrganisation, isLoading } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
onError: () => {
router.push("/settings");
},
onSuccess: (org) => {
if (org) {
form.setValue("name", org.name || "");
form.setValue("slug", org.slug || "");
form.setValue("logo", org.logo || "");
form.setValue("bio", org.bio || "");
if (org.slug === null && (org?.metadata as Prisma.JsonObject)?.requestedSlug) {
form.setValue("slug", ((org?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "");
}
}
},
});
const isOrgAdminOrOwner =
currentOrganisation &&
(currentOrganisation.user.role === MembershipRole.OWNER ||
currentOrganisation.user.role === MembershipRole.ADMIN);
const permalink = `${currentOrganisation?.slug}.cal.com`;
const isBioEmpty =
!currentOrganisation ||
!currentOrganisation.bio ||
!currentOrganisation.bio.replace("<p><br></p>", "").length;
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
showToast(t("your_org_disbanded_successfully"), "success");
router.push(`${WEBAPP_URL}/teams`);
},
});
function deleteTeam() {
if (currentOrganisation?.id) deleteTeamMutation.mutate({ teamId: currentOrganisation.id });
}
return (
<>
<Meta title={t("profile")} description={t("profile_org_description")} />
{!isLoading && (
<>
{isOrgAdminOrOwner ? (
<Form
form={form}
handleSubmit={(values) => {
if (currentOrganisation) {
const variables = {
logo: values.logo,
name: values.name,
slug: values.slug,
bio: values.bio,
};
mutation.mutate(variables);
}
}}>
<div className="flex items-center">
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => (
<>
<Avatar
alt=""
imageSrc={getPlaceholderAvatar(value, currentOrganisation?.name as string)}
size="lg"
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo);
}}
imageSrc={value}
/>
</div>
</>
)}
/>
</div>
<hr className="border-subtle my-8" />
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value);
}}
/>
</div>
)}
/>
<div className="mt-8">
<TextField
name="slug"
label={t("team_url")}
value={currentOrganisation.slug ?? ""}
disabled
addOnSuffix={`.${extractDomainFromWebsiteUrl}`}
/>
</div>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<p className="text-default mt-2 text-sm">{t("org_description")}</p>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
</Form>
) : (
<div className="flex">
<div className="flex-grow">
<div>
<Label className="text-emphasis">{t("team_name")}</Label>
<p className="text-default text-sm">{currentOrganisation?.name}</p>
</div>
{currentOrganisation && !isBioEmpty && (
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(currentOrganisation.bio || "") }}
/>
</>
)}
</div>
<div className="">
<LinkIconButton
Icon={LinkIcon}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
</div>
</div>
)}
<hr className="border-subtle my-8 border" />
<div className="text-default mb-3 text-base font-semibold">{t("danger_zone")}</div>
{currentOrganisation?.user.role === "OWNER" ? (
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Trash2}>
{t("disband_org")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_org")}
confirmBtnText={t("confirm")}
onConfirm={deleteTeam}>
{t("disband_org_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
) : null}
{/* LEAVE ORG should go above here ^ */}
</>
)}
</>
);
};
OrgProfileView.getLayout = getLayout;
export default OrgProfileView;
@@ -3,6 +3,7 @@ import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
@@ -100,10 +101,7 @@ export const CreateANewTeamForm = () => {
name="slug"
placeholder="acme"
label={t("team_url")}
addOnLeading={`${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace("https://", "")?.replace(
"http://",
""
)}/team/`}
addOnLeading={`${extractDomainFromWebsiteUrl}/team/`}
defaultValue={value}
onChange={(e) => {
newTeamFormMethods.setValue("slug", slugify(e?.target.value), {
@@ -50,6 +50,8 @@ export default function MemberChangeRoleModal(props: {
const changeRoleMutation = trpc.viewer.teams.changeMemberRole.useMutation({
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
props.onExit();
},
async onError(err) {
@@ -32,7 +32,7 @@ type MemberInvitationModalProps = {
isOpen: boolean;
onExit: () => void;
onSubmit: (values: NewMemberForm) => void;
onSettingsOpen: () => void;
onSettingsOpen?: () => void;
teamId: number;
members: PendingMember[];
token?: string;
@@ -293,7 +293,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
color="minimal"
className="ms-2 me-2"
onClick={() => {
props.onSettingsOpen();
props.onSettingsOpen && props.onSettingsOpen();
newMemberFormMethods.reset();
}}
data-testid="edit-invite-link-button">
@@ -5,6 +5,7 @@ import { useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
@@ -44,6 +45,12 @@ const useCurrentUserId = () => {
return user?.id;
};
const checkIsOrg = (team: Props["team"]) => {
const metadata = teamMetadataSchema.safeParse(team.metadata);
if (metadata.success && metadata.data?.isOrganization) return true;
return false;
};
export default function MemberListItem(props: Props) {
const { t } = useLocale();
@@ -57,6 +64,7 @@ export default function MemberListItem(props: Props) {
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.eventTypes.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
showToast(t("success"), "success");
},
async onError(err) {
@@ -80,7 +88,11 @@ export default function MemberListItem(props: Props) {
})();
const removeMember = () =>
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
removeMemberMutation.mutate({
teamId: props.team?.id,
memberId: props.member.id,
isOrg: checkIsOrg(props.team),
});
const editMode =
(props.team.membership.role === MembershipRole.OWNER &&
@@ -35,6 +35,7 @@ export default function TeamInviteList(props: Props) {
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
async onSuccess() {
await utils.viewer.teams.list.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
},
async onError(err) {
showToast(err.message, "error");
@@ -41,6 +41,7 @@ export default function TeamInviteListItem(props: Props) {
onSuccess: async () => {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.list.invalidate();
await utils.viewer.organizations.listMembers.invalidate();
},
});
@@ -34,6 +34,7 @@ import {
import { ExternalLink, Link as LinkIcon, Trash2, LogOut } from "@calcom/ui/components/icon";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import { extractDomainFromWebsiteUrl } from "../../organizations/lib/utils";
const regex = new RegExp("^[a-zA-Z0-9-]*$");
@@ -216,7 +217,11 @@ const ProfileView = () => {
name="slug"
label={t("team_url")}
value={value}
addOnLeading={`${WEBAPP_URL}/team/`}
addOnLeading={
team.parent
? `${team.parent.slug}.${extractDomainFromWebsiteUrl}/`
: `${WEBAPP_URL}/team/`
}
onChange={(e) => {
form.clearErrors("slug");
form.setValue("slug", e?.target.value);
@@ -29,6 +29,7 @@ import {
ChevronRight,
Plus,
Menu,
Building,
} from "@calcom/ui/components/icon";
const tabs: VerticalTabItemProps[] = [
@@ -74,6 +75,41 @@ const tabs: VerticalTabItemProps[] = [
// { name: "embeds", href: "/v2/settings/developer/embeds" },
],
},
{
name: "organization",
href: "/settings/organizations",
icon: Building,
children: [
{
name: "profile",
href: "/settings/organizations/profile",
},
{
name: "general",
href: "/settings/organizations/general",
},
{
name: "members",
href: "/settings/organizations/members",
},
{
name: "appearance",
href: "/settings/organizations/appearance",
},
{
name: "billing",
href: "/settings/organizations/billing",
},
{
name: "saml_config",
href: "/settings/organizations/sso",
},
{
name: "developer",
href: "/settings/organizations/developer",
},
],
},
{
name: "teams",
href: "/settings/teams",
@@ -104,6 +140,7 @@ tabs.find((tab) => {
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const useTabs = () => {
const session = useSession();
@@ -127,6 +164,8 @@ const useTabs = () => {
// check if name is in adminRequiredKeys
return tabs.filter((tab) => {
if (organizationRequiredKeys.includes(tab.name)) return !!session.data?.user?.organizationId;
if (isAdmin) return true;
return !adminRequiredKeys.includes(tab.name);
});
@@ -298,7 +337,11 @@ const SettingsSidebarContainer = ({
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{(team.role === MembershipRole.OWNER || team.role === MembershipRole.ADMIN) && (
{(team.role === MembershipRole.OWNER ||
team.role === MembershipRole.ADMIN ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
@@ -313,20 +356,25 @@ const SettingsSidebarContainer = ({
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{HOSTED_CAL_FEATURES && (
<VerticalTabItem
name={t("sso_configuration")}
href={`/settings/teams/${team.id}/sso`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{HOSTED_CAL_FEATURES && (
<VerticalTabItem
name={t("saml_config")}
href={`/settings/teams/${team.id}/sso`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
</>
) : null}
</>
)}
</CollapsibleContent>
+2
View File
@@ -12,6 +12,7 @@ import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge";
import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
import { OrgUpgradeBanner } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner";
import { useOrgBrandingValues } from "@calcom/features/ee/organizations/hooks";
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components";
@@ -200,6 +201,7 @@ const Layout = (props: LayoutProps) => {
<div style={{ paddingTop: `${bannersHeight}px` }} className="flex min-h-screen flex-col">
<div ref={bannerRef} className="fixed top-0 z-10 w-full divide-y divide-black">
<TeamsUpgradeBanner />
<OrgUpgradeBanner />
<ImpersonatingBanner />
<AdminPasswordBanner />
<VerifyEmailBanner />
@@ -0,0 +1,34 @@
import prisma from "@calcom/prisma";
// export type OrganisationWithMembers = Awaited<ReturnType<typeof getOrganizationMembers>>;
// also returns team
export async function isOrganisationAdmin(userId: number, orgId: number) {
return (
(await prisma.membership.findFirst({
where: {
userId,
teamId: orgId,
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
},
})) || false
);
}
export async function isOrganisationOwner(userId: number, orgId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId: orgId,
role: "OWNER",
},
}));
}
export async function isOrganisationMember(userId: number, orgId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId: orgId,
},
}));
}
@@ -27,6 +27,8 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
metadata: true,
parent: {
select: {
id: true,
slug: true,
name: true,
logo: true,
},
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "timeFormat" INTEGER,
ADD COLUMN "timeZone" TEXT NOT NULL DEFAULT 'Europe/London',
ADD COLUMN "weekStart" TEXT NOT NULL DEFAULT 'Sunday';
+3
View File
@@ -269,6 +269,9 @@ model Team {
orgUsers User[] @relation("scope")
inviteToken VerificationToken?
webhooks Webhook[]
timeFormat Int?
timeZone String @default("Europe/London")
weekStart String @default("Sunday")
routingForms App_RoutingForms_Form[]
@@unique([slug, parentId])
@@ -73,6 +73,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
allowDynamicBooking: true,
organization: {
select: {
id: true,
slug: true,
metadata: true,
},
@@ -8,10 +8,14 @@ import { ZVerifyCodeInputSchema } from "./verifyCode.schema";
type OrganizationsRouterHandlerCache = {
create?: typeof import("./create.handler").createHandler;
listCurrent?: typeof import("./list.handler").listHandler;
publish?: typeof import("./publish.handler").publishHandler;
checkIfOrgNeedsUpgrade?: typeof import("./checkIfOrgNeedsUpgrade.handler").checkIfOrgNeedsUpgradeHandler;
update?: typeof import("./update.handler").updateHandler;
verifyCode?: typeof import("./verifyCode.handler").verifyCodeHandler;
createTeams?: typeof import("./createTeams.handler").createTeamsHandler;
setPassword?: typeof import("./setPassword.handler").setPasswordHandler;
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
};
@@ -82,6 +86,44 @@ export const viewerOrganizationsRouter = router({
input,
});
}),
listCurrent: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.listCurrent) {
UNSTABLE_HANDLER_CACHE.listCurrent = await import("./list.handler").then((mod) => mod.listHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listCurrent) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listCurrent({
ctx,
});
}),
checkIfOrgNeedsUpgrade: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade) {
UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade = await import("./checkIfOrgNeedsUpgrade.handler").then(
(mod) => mod.checkIfOrgNeedsUpgradeHandler
);
}
if (!UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade({ ctx });
}),
publish: authedProcedure.mutation(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.publish) {
UNSTABLE_HANDLER_CACHE.publish = await import("./publish.handler").then((mod) => mod.publishHandler);
}
if (!UNSTABLE_HANDLER_CACHE.publish) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.publish({ ctx });
}),
setPassword: authedProcedure.input(ZSetPasswordSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.setPassword) {
UNSTABLE_HANDLER_CACHE.setPassword = await import("./setPassword.handler").then(
@@ -99,6 +141,22 @@ export const viewerOrganizationsRouter = router({
input,
});
}),
listMembers: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then(
(mod) => mod.listMembersHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listMembers({
ctx,
});
}),
getBrand: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getBrand) {
UNSTABLE_HANDLER_CACHE.getBrand = await import("./getBrand.handler").then((mod) => mod.getBrandHandler);
@@ -0,0 +1,42 @@
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "../../../trpc";
type GetUpgradeableOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export async function checkIfOrgNeedsUpgradeHandler({ ctx }: GetUpgradeableOptions) {
if (!IS_TEAM_BILLING_ENABLED) return [];
// Get all teams/orgs where the user is an owner
let teams = await prisma.membership.findMany({
where: {
user: {
id: ctx.user.id,
},
role: MembershipRole.OWNER,
team: {
parentId: null, // Since ORGS relay on their parent's subscription, we don't need to return them
},
},
include: {
team: true,
},
});
/** We only need to return teams that don't have a `subscriptionId` on their metadata */
teams = teams.filter((m) => {
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
if (metadata.success && !metadata.data?.isOrganization) return false;
if (metadata.success && metadata.data?.subscriptionId) return false;
return true;
});
return teams;
}
@@ -0,0 +1,38 @@
import type { PrismaClient } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
type ListHandlerInput = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
};
// This functionality is essentially the same as the teams/list.handler.ts but it's easier for SOC to have it in a separate file.
export const listHandler = async ({ ctx }: ListHandlerInput) => {
if (!ctx.user.organization?.id) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You do not belong to an organization" });
}
const membership = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
team: {
id: ctx.user.organization.id,
},
},
include: {
team: true,
},
});
return {
user: {
role: membership?.role,
accepted: membership?.accepted,
},
...membership?.team,
};
};
@@ -0,0 +1,36 @@
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const listMembersHandler = async ({ ctx }: GetOptions) => {
const organizationId = ctx.user.organizationId;
if (!organizationId) {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
}
const team = await getTeamWithMembers(organizationId, undefined, ctx.user.id);
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
}
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
return {
...team,
safeBio: markdownToSafeHTML(team.bio),
membership: {
role: membership?.role as MembershipRole,
accepted: membership?.accepted,
},
};
};
@@ -0,0 +1,83 @@
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
type PublishOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const publishHandler = async ({ ctx }: PublishOptions) => {
const orgId = ctx.user.organizationId;
if (!orgId)
throw new TRPCError({ code: "UNAUTHORIZED", message: "You do not have an organization to upgrade" });
if (!(await isOrganisationAdmin(ctx.user.id, orgId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const prevTeam = await prisma.team.findFirst({
where: {
id: orgId,
},
include: { members: true },
});
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found." });
const metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" });
// Since this is an ORG we neeed to make sure ORG members are scyned with the team. Every time a user is added to the TEAM, we need to add them to the ORG
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({
teamId: prevTeam.id,
seats: prevTeam.members.length,
userId: ctx.user.id,
});
if (!checkoutSession.url)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed retrieving a checkout session URL.",
});
return { url: checkoutSession.url, message: "Payment required to publish organization" };
}
if (!metadata.data?.requestedSlug) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Can't publish organization without `requestedSlug`",
});
}
const { requestedSlug, ...newMetadata } = metadata.data;
let updatedTeam: Awaited<ReturnType<typeof prisma.team.update>>;
try {
updatedTeam = await prisma.team.update({
where: { id: orgId },
data: {
slug: requestedSlug,
metadata: { ...newMetadata },
},
});
} catch (error) {
const { message } = getRequestedSlugError(error, requestedSlug);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
}
// Sync Services: Close.com
closeComUpdateTeam(prevTeam, updatedTeam);
return {
url: `${WEBAPP_URL}/settings/organization/profile`,
message: "Team published successfully",
};
};
@@ -1,5 +1,10 @@
import type { Prisma } from "@prisma/client";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -14,31 +19,83 @@ type UpdateOptions = {
};
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const { logo, bio, orgId } = input;
// A user can only have one org so we pass in their currentOrgId here
const currentOrgId = ctx.user?.organization?.id;
const userMembership = await prisma.membership.findFirst({
if (!currentOrgId) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.slug) {
const userConflict = await prisma.team.findMany({
where: {
slug: input.slug,
parent: {
id: currentOrgId,
},
},
});
if (userConflict.some((t) => t.id !== currentOrgId))
throw new TRPCError({ code: "CONFLICT", message: "Slug already in use." });
}
const prevOrganisation = await prisma.team.findFirst({
where: {
userId: ctx.user.id,
teamId: orgId,
id: currentOrgId,
},
select: {
userId: true,
role: true,
metadata: true,
name: true,
slug: true,
},
});
if (!userMembership || userMembership.role !== MembershipRole.OWNER)
throw new TRPCError({ code: "BAD_REQUEST", message: "not_authorized" });
if (!prevOrganisation) throw new TRPCError({ code: "NOT_FOUND", message: "Organisation not found." });
await prisma.team.update({
where: {
id: orgId,
},
data: {
bio,
logo,
},
const data: Prisma.TeamUpdateArgs["data"] = {
name: input.name,
logo: input.logo,
bio: input.bio,
hideBranding: input.hideBranding,
hideBookATeamMember: input.hideBookATeamMember,
brandColor: input.brandColor,
darkBrandColor: input.darkBrandColor,
theme: input.theme,
timeZone: input.timeZone,
weekStart: input.weekStart,
timeFormat: input.timeFormat,
};
if (
input.slug &&
IS_TEAM_BILLING_ENABLED &&
/** If the team doesn't have a slug we can assume that it hasn't been published yet. */
!prevOrganisation.slug
) {
// Save it on the metadata so we can use it later
data.metadata = {
requestedSlug: input.slug,
};
} else {
data.slug = input.slug;
// If we save slug, we don't need the requestedSlug anymore
const metadataParse = teamMetadataSchema.safeParse(prevOrganisation.metadata);
if (metadataParse.success) {
const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {};
data.metadata = {
...cleanMetadata,
};
}
}
const updatedOrganisation = await prisma.team.update({
where: { id: currentOrgId },
data,
});
return { update: true, userId: userMembership.userId };
// Sync Services: Close.com
if (prevOrganisation) closeComUpdateTeam(prevOrganisation, updatedOrganisation);
return { update: true, userId: ctx.user.id };
};
@@ -1,16 +1,27 @@
import { z } from "zod";
export const ZUpdateInputSchema = z.object({
name: z.string().optional(),
orgId: z
.string()
.regex(/^\d+$/)
.transform((id) => parseInt(id)),
.transform((id) => parseInt(id))
.optional(),
bio: z.string().optional(),
logo: z
.string()
.optional()
.nullable()
.transform((v) => v || null),
slug: z.string().optional(),
hideBranding: z.boolean().optional(),
hideBookATeamMember: z.boolean().optional(),
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),
timeZone: z.string().optional(),
weekStart: z.string().optional(),
timeFormat: z.number().optional(),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
@@ -1,4 +1,5 @@
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -17,10 +18,15 @@ type CreateOptions = {
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { slug, name, logo } = input;
const currentOrgId = ctx.user.organization?.id;
const slugCollisions = await prisma.team.findFirst({
where: {
slug: slug,
parentId: currentOrgId
? {
equals: currentOrgId,
}
: null,
},
});
@@ -45,6 +51,25 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
return duplicatedRequest;
}
let parentId: number | null = null;
// If the user in session is part of an org. check permissions
if (ctx.user.organization?.id) {
if (!isOrganisationAdmin(ctx.user.id, ctx.user.organization.id)) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const nameCollisions = await prisma.user.findFirst({
where: {
organizationId: ctx.user.organization.id,
username: slug,
},
});
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_slug_exists_as_user" });
parentId = ctx.user.organization.id;
}
const createTeam = await prisma.team.create({
data: {
name,
@@ -60,6 +85,9 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
requestedSlug: slug,
},
...(!IS_TEAM_BILLING_ENABLED && { slug }),
parent: {
connect: parentId ? { id: parentId } : undefined,
},
},
});
@@ -12,14 +12,33 @@ type GetUpgradeableOptions = {
export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
if (!IS_TEAM_BILLING_ENABLED) return [];
let { teams } = await prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
include: { teams: { where: { role: MembershipRole.OWNER }, include: { team: true } } },
// Get all teams/orgs where the user is an owner
let teams = await prisma.membership.findMany({
where: {
user: {
id: ctx.user.id,
},
role: MembershipRole.OWNER,
team: {
parentId: null, // Since ORGS relay on their parent's subscription, we don't need to return them
},
},
include: {
team: {
include: {
children: true,
},
},
},
});
/** We only need to return teams that don't have a `subscriptionId` on their metadata */
teams = teams.filter((m) => {
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
return !(metadata.success && metadata.data?.subscriptionId);
if (metadata.success && metadata.data?.subscriptionId) return false;
if (m.team.children.length > 0) return false; // We also dont return ORGs as it will be handled in OrgUpgradeBanner
return true;
});
return teams;
};
@@ -1,3 +1,4 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -10,6 +11,38 @@ type ListOptions = {
};
export const listHandler = async ({ ctx }: ListOptions) => {
if (ctx.user?.organization?.id) {
const membershipsWithoutParent = await prisma.membership.findMany({
where: {
userId: ctx.user.id,
team: {
parent: {
is: {
id: ctx.user?.organization?.id,
},
},
},
},
include: {
team: {
include: {
inviteToken: true,
},
},
},
orderBy: { role: "desc" },
});
const isOrgAdmin = !!(await isOrganisationAdmin(ctx.user.id, ctx.user?.organization?.id ?? -1)); // Org id exists here as we're inside a conditional TS complaining for some reason
return membershipsWithoutParent.map(({ team, ...membership }) => ({
role: membership.role,
accepted: membership.accepted,
isOrgAdmin,
...team,
}));
}
const memberships = await prisma.membership.findMany({
where: {
userId: ctx.user.id,
@@ -25,6 +25,7 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
// Only a team owner can remove another team owner.
if ((await isTeamOwner(input.memberId, input.teamId)) && !(await isTeamOwner(ctx.user.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
if (ctx.user.id === input.memberId && isAdmin)
throw new TRPCError({
code: "FORBIDDEN",
@@ -40,6 +41,23 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
},
});
if (input.isOrg) {
// Deleting membership from all child teams
await prisma.membership.deleteMany({
where: {
team: {
parentId: input.teamId,
},
userId: membership.userId,
},
});
await prisma.user.update({
where: { id: membership.userId },
data: { organizationId: null },
});
}
// Deleted managed event types from this team from this member
await ctx.prisma.eventType.deleteMany({
where: { parent: { teamId: input.teamId }, userId: membership.userId },
@@ -3,6 +3,7 @@ import { z } from "zod";
export const ZRemoveMemberInputSchema = z.object({
teamId: z.number(),
memberId: z.number(),
isOrg: z.boolean().default(false),
});
export type TRemoveMemberInputSchema = z.infer<typeof ZRemoveMemberInputSchema>;