From aab5ee344cc89d633502411d9e4ee838ce7427af Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 15 Jun 2023 18:27:39 +0100 Subject: [PATCH] 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 * 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 * feat: add members page Signed-off-by: Udit Takkar * chore: remove Signed-off-by: Udit Takkar * fix: use invalidate Signed-off-by: Udit Takkar * fix: delete mutation Signed-off-by: Udit Takkar * fix: remove organization id Signed-off-by: Udit Takkar * chore Signed-off-by: Udit Takkar * fix: use zod schema Signed-off-by: Udit Takkar --------- Signed-off-by: Udit Takkar * 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 Co-authored-by: Leo Giovanetti 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 Co-authored-by: Hariom Balhara --- .../settings/organizations/appearance.tsx | 9 + .../pages/settings/organizations/general.tsx | 9 + .../pages/settings/organizations/members.tsx | 9 + .../pages/settings/organizations/profile.tsx | 9 + apps/web/public/org_avatar.png | Bin 0 -> 2019 bytes apps/web/public/static/locales/en/common.json | 13 +- .../features/auth/lib/next-auth-options.ts | 5 + .../components/OrgUpgradeBanner.tsx | 39 +++ .../features/ee/organizations/lib/utils.ts | 13 + .../pages/settings/appearance.tsx | 241 ++++++++++++++++ .../organizations/pages/settings/general.tsx | 194 +++++++++++++ .../organizations/pages/settings/members.tsx | 174 ++++++++++++ .../organizations/pages/settings/profile.tsx | 257 ++++++++++++++++++ .../teams/components/CreateANewTeamForm.tsx | 6 +- .../components/MemberChangeRoleModal.tsx | 2 + .../components/MemberInvitationModal.tsx | 4 +- .../ee/teams/components/MemberListItem.tsx | 14 +- .../ee/teams/components/TeamInviteList.tsx | 1 + .../teams/components/TeamInviteListItem.tsx | 1 + .../ee/teams/pages/team-profile-view.tsx | 7 +- .../settings/layouts/SettingsLayout.tsx | 78 +++++- packages/features/shell/Shell.tsx | 2 + .../lib/server/queries/organisations/index.ts | 34 +++ packages/lib/server/queries/teams/index.ts | 2 + .../migration.sql | 4 + packages/prisma/schema.prisma | 3 + .../server/middlewares/sessionMiddleware.ts | 1 + .../routers/viewer/organizations/_router.tsx | 58 ++++ .../checkIfOrgNeedsUpgrade.handler.ts | 42 +++ .../viewer/organizations/list.handler.ts | 38 +++ .../organizations/listMembers.handler.ts | 36 +++ .../viewer/organizations/publish.handler.ts | 83 ++++++ .../viewer/organizations/update.handler.ts | 93 +++++-- .../viewer/organizations/update.schema.ts | 13 +- .../routers/viewer/teams/create.handler.ts | 30 +- .../viewer/teams/getUpgradeable.handler.ts | 27 +- .../routers/viewer/teams/list.handler.ts | 33 +++ .../viewer/teams/removeMember.handler.ts | 18 ++ .../viewer/teams/removeMember.schema.ts | 1 + 39 files changed, 1555 insertions(+), 48 deletions(-) create mode 100644 apps/web/pages/settings/organizations/appearance.tsx create mode 100644 apps/web/pages/settings/organizations/general.tsx create mode 100644 apps/web/pages/settings/organizations/members.tsx create mode 100644 apps/web/pages/settings/organizations/profile.tsx create mode 100644 apps/web/public/org_avatar.png create mode 100644 packages/features/ee/organizations/components/OrgUpgradeBanner.tsx create mode 100644 packages/features/ee/organizations/lib/utils.ts create mode 100644 packages/features/ee/organizations/pages/settings/appearance.tsx create mode 100644 packages/features/ee/organizations/pages/settings/general.tsx create mode 100644 packages/features/ee/organizations/pages/settings/members.tsx create mode 100644 packages/features/ee/organizations/pages/settings/profile.tsx create mode 100644 packages/lib/server/queries/organisations/index.ts create mode 100644 packages/prisma/migrations/20230601141825_add_locale_timezone_timeformat_in_team/migration.sql create mode 100644 packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/publish.handler.ts diff --git a/apps/web/pages/settings/organizations/appearance.tsx b/apps/web/pages/settings/organizations/appearance.tsx new file mode 100644 index 0000000000..bcfb2614d2 --- /dev/null +++ b/apps/web/pages/settings/organizations/appearance.tsx @@ -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; diff --git a/apps/web/pages/settings/organizations/general.tsx b/apps/web/pages/settings/organizations/general.tsx new file mode 100644 index 0000000000..308eecf72f --- /dev/null +++ b/apps/web/pages/settings/organizations/general.tsx @@ -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; diff --git a/apps/web/pages/settings/organizations/members.tsx b/apps/web/pages/settings/organizations/members.tsx new file mode 100644 index 0000000000..c642078d0c --- /dev/null +++ b/apps/web/pages/settings/organizations/members.tsx @@ -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; diff --git a/apps/web/pages/settings/organizations/profile.tsx b/apps/web/pages/settings/organizations/profile.tsx new file mode 100644 index 0000000000..ef14c3b957 --- /dev/null +++ b/apps/web/pages/settings/organizations/profile.tsx @@ -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; diff --git a/apps/web/public/org_avatar.png b/apps/web/public/org_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..1ad5b29cdec0419aaad20aa69e632636e3742303 GIT binary patch literal 2019 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Xs4mP03*$GQm0V&P`kH}&M1~F9-X7mhF`2ZAT zEDmyaVpw-h<|UBBlJ4m1$iT3%pZiZDD+2?2g{O;SNCo5DyXUimGv$vxe126;>QAHK zR0Wq7uBbxQAOG|3LxR5mT3{m5+`8h&A!8YdbW_`tC+9q_jSbjmu-USst}58~ z)Bfx0tkBIKYjje%(+i$I5JgM$tuOA@DofHy;91l6UgTm5>EKW55Z zd;PUjxem}))*Y#5AMGxBy8gW`znsmAa=ZEBKep@X>Am{<;i2>1`2BT%-~NwoyPe3$ zv{C1PZsxT6Z*OmZUHj`xX5%)s$)0=9&$IpgcE7xw+_l?(>Nc~OCxg}Tn%?hRemQfm z|2!Mb3sa}-$6x#X?Ck7A&ma~qTphmtTK3P4-sk7rum65`clp;}kqOpf3IeB@a#sGl zdH--bKlhuc&)bhwSJ(d)+{SQwBQPYy9<-%xj%@tz_4&Qu{@UMRJ8ZZGI#L-AHQe1% zxcI_6R*oZ^7!nVxi`^ZTah{2o@QdTNF*yBnQ_0I;UtfPUdv8$_8EYTE_@~Ie)31Ji z-WUybj!ehp%a^OfyIuFj*0Q;5{xj?R{J-00rIwbIgye^xzg{DD8t4@->Dqd`veQ6I zBO916fA|0Y>17H}C(uE3i&Stb*_LG^cU@nC37B}^in2XCH`n^>y!rE|>-D-VzI3d_ zYHhjE%s*wnm%TrKJTDTM#<&X<^6u^mecvsv?=`>DuI&iXfwp}4@}=HqcON{Hw)v9V z_4C(foPWNwcb~od?o@ExH)c#bowRX9`QEtcdM58Dm)~UF5u^9|?XRD+%4g^8-tg;R z&6{c9*kWI{`spTJVA%bz21X@~(>+>7%DGx$vo79%1t@$=o9EEp>hG^&*M=?i&b@vc z7*yYZiFN6DB$p-`&Ad`wP!RCz%x6OtV9FqzH9ti?X#DZ-Z}nbz`#PQ2tb4p^cJrs3 z_4W6k)_e2*{d6;A!#76!DcgN%&+9X(;tz^;uKE7v&6;~(t73Ou(7CdWdo?`5Caisx z73nAr%0qN1DqumjDfIEj9d!oW|j^uob8)4|C#MbOp(@X%Nqd2HGJ~h9pUXO@geCw#XbU|6 literal 0 HcmV?d00001 diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index bfdd92d690..58182ad7d9 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index e9d0e0a62f..f717ad2f1a 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -85,6 +85,11 @@ const providers: Provider[] = [ organizationId: true, twoFactorEnabled: true, twoFactorSecret: true, + organization: { + select: { + id: true, + }, + }, teams: { include: { team: true, diff --git a/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx new file mode 100644 index 0000000000..11a5d25a2e --- /dev/null +++ b/packages/features/ee/organizations/components/OrgUpgradeBanner.tsx @@ -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 ( + { + publishTeamMutation.mutate(); + }}> + {t("org_upgrade_banner_action")} + + } + /> + ); +} diff --git a/packages/features/ee/organizations/lib/utils.ts b/packages/features/ee/organizations/lib/utils.ts new file mode 100644 index 0000000000..4cdbdd32ec --- /dev/null +++ b/packages/features/ee/organizations/lib/utils.ts @@ -0,0 +1,13 @@ +export function extractDomainFromEmail(email: string) { + let out = ""; + try { + const match = email.match(/^(?:.*?:\/\/)?.*?(?[\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; diff --git a/packages/features/ee/organizations/pages/settings/appearance.tsx b/packages/features/ee/organizations/pages/settings/appearance.tsx new file mode 100644 index 0000000000..da5d46a302 --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/appearance.tsx @@ -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 ( + + +
+
+ + + +
+
+ + +
+ + + + +
+
+ ); +}; + +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({ + 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 ; + } + return ( + <> + + {isAdmin ? ( +
{ + mutation.mutate({ + ...values, + theme: values.theme || null, + }); + }}> +
+
+

{t("theme")}

+

{t("theme_applies_note")}

+
+
+
+ + + +
+ +
+
+
+

{t("custom_brand_colors")}

+

{t("customize_your_brand_colors")}

+
+
+ +
+ ( +
+

{t("light_brand_color")}

+ form.setValue("brandColor", value, { shouldDirty: true })} + /> +
+ )} + /> + ( +
+

{t("dark_brand_color")}

+ form.setValue("darkBrandColor", value, { shouldDirty: true })} + /> +
+ )} + /> +
+
+ +
+
+
+ +

+ {t("team_disable_cal_branding_description", { appName: APP_NAME })} +

+
+ +
+ ( + { + form.setValue("hideBranding", isChecked); + }} + /> + )} + /> +
+
+
+
+ +

{t("hide_book_a_team_member_description")}

+
+
+ ( + { + form.setValue("hideBookATeamMember", isChecked); + }} + /> + )} + /> +
+
+
+ +
+ ) : ( +
+ {t("only_owner_change")} +
+ )} + + ); +}; + +OrgAppearanceView.getLayout = getLayout; + +export default OrgAppearanceView; diff --git a/packages/features/ee/organizations/pages/settings/general.tsx b/packages/features/ee/organizations/pages/settings/general.tsx new file mode 100644 index 0000000000..31462cf9df --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/general.tsx @@ -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 ( + + +
+ + + + + + +
+
+ ); +}; + +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 ; + if (!currentOrg) { + return null; + } + const isAdminOrOwner = + currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN; + + return ( + + ); +}; + +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 ( +
{ + mutation.mutate({ + ...values, + timeFormat: values.timeFormat.value, + weekStart: values.weekStart.value, + }); + }}> + + + ( + <> + + { + if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true }); + }} + /> + + )} + /> + ( + <> + + { + if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true }); + }} + /> + + )} + /> + + + ); +}; + +OrgGeneralView.getLayout = getLayout; +export default OrgGeneralView; diff --git a/packages/features/ee/organizations/pages/settings/members.tsx b/packages/features/ee/organizations/pages/settings/members.tsx new file mode 100644 index 0000000000..b352c8156b --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/members.tsx @@ -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(""); + + 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 ( +
+ setQuery(e.target.value)} + value={query} + placeholder={`${t("search")}...`} + /> + {membersList?.length && team ? ( +
    + {membersList.map((member) => { + return ; + })} +
+ ) : null} +
+ ); +} + +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 ( + <> + setShowMemberInvitationModal(true)} + data-testid="new-organization-member-button"> + {t("add")} + + ) : ( + <> + ) + } + /> + {!isLoading && ( + <> +
+ {team && ( + <> + {isInviteOpen && ( + + )} + + )} + +
+ {showMemberInvitationModal && team && ( + 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; diff --git a/packages/features/ee/organizations/pages/settings/profile.tsx b/packages/features/ee/organizations/pages/settings/profile.tsx new file mode 100644 index 0000000000..9eefce98a9 --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/profile.tsx @@ -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("


", "").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 ( + <> + + {!isLoading && ( + <> + {isOrgAdminOrOwner ? ( +
{ + if (currentOrganisation) { + const variables = { + logo: values.logo, + name: values.name, + slug: values.slug, + bio: values.bio, + }; + + mutation.mutate(variables); + } + }}> +
+ ( + <> + +
+ { + form.setValue("logo", newLogo); + }} + imageSrc={value} + /> +
+ + )} + /> +
+ +
+ + ( +
+ { + form.setValue("name", e?.target.value); + }} + /> +
+ )} + /> +
+ +
+
+ + md.render(form.getValues("bio") || "")} + setText={(value: string) => form.setValue("bio", turndown(value))} + excludedToolbarItems={["blockType"]} + disableLists + firstRender={firstRender} + setFirstRender={setFirstRender} + /> +
+

{t("org_description")}

+ + + ) : ( +
+
+
+ +

{currentOrganisation?.name}

+
+ {currentOrganisation && !isBioEmpty && ( + <> + +
+ + )} +
+
+ { + navigator.clipboard.writeText(permalink); + showToast("Copied to clipboard", "success"); + }}> + {t("copy_link_team")} + +
+
+ )} +
+ +
{t("danger_zone")}
+ {currentOrganisation?.user.role === "OWNER" ? ( + + + + + + {t("disband_org_confirmation_message")} + + + ) : null} + {/* LEAVE ORG should go above here ^ */} + + )} + + ); +}; + +OrgProfileView.getLayout = getLayout; + +export default OrgProfileView; diff --git a/packages/features/ee/teams/components/CreateANewTeamForm.tsx b/packages/features/ee/teams/components/CreateANewTeamForm.tsx index 7346aa7bb0..3eb741a15d 100644 --- a/packages/features/ee/teams/components/CreateANewTeamForm.tsx +++ b/packages/features/ee/teams/components/CreateANewTeamForm.tsx @@ -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), { diff --git a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx index 23c6584dab..951cae6602 100644 --- a/packages/features/ee/teams/components/MemberChangeRoleModal.tsx +++ b/packages/features/ee/teams/components/MemberChangeRoleModal.tsx @@ -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) { diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index 1e228893db..a7e169d563 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -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"> diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index 2573d27e1f..6fe87a7633 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -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 && diff --git a/packages/features/ee/teams/components/TeamInviteList.tsx b/packages/features/ee/teams/components/TeamInviteList.tsx index dc81d7c3d4..5d49409fbb 100644 --- a/packages/features/ee/teams/components/TeamInviteList.tsx +++ b/packages/features/ee/teams/components/TeamInviteList.tsx @@ -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"); diff --git a/packages/features/ee/teams/components/TeamInviteListItem.tsx b/packages/features/ee/teams/components/TeamInviteListItem.tsx index 84d69ed751..7f026daa37 100644 --- a/packages/features/ee/teams/components/TeamInviteListItem.tsx +++ b/packages/features/ee/teams/components/TeamInviteListItem.tsx @@ -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(); }, }); diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index f23a65ba69..438ef4512a 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -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); diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index fa5c62ff0a..de6e138021 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -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 */} {/* - - {HOSTED_CAL_FEATURES && ( - - )} + {/* Hide if there is a parent ID */} + {!team.parentId ? ( + <> + + {HOSTED_CAL_FEATURES && ( + + )} + + ) : null} )} diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 547ab32d9a..3701985b37 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -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) => {
+ diff --git a/packages/lib/server/queries/organisations/index.ts b/packages/lib/server/queries/organisations/index.ts new file mode 100644 index 0000000000..16d7606718 --- /dev/null +++ b/packages/lib/server/queries/organisations/index.ts @@ -0,0 +1,34 @@ +import prisma from "@calcom/prisma"; + +// export type OrganisationWithMembers = Awaited>; + +// 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, + }, + })); +} diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 3a252509ed..85af16f6c6 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -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, }, diff --git a/packages/prisma/migrations/20230601141825_add_locale_timezone_timeformat_in_team/migration.sql b/packages/prisma/migrations/20230601141825_add_locale_timezone_timeformat_in_team/migration.sql new file mode 100644 index 0000000000..ec6cdb5c3c --- /dev/null +++ b/packages/prisma/migrations/20230601141825_add_locale_timezone_timeformat_in_team/migration.sql @@ -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'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 615c443ae6..7d3f40a00b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index 785ad7b416..84b023dfb2 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -73,6 +73,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe { + 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); diff --git a/packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts b/packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts new file mode 100644 index 0000000000..41e5374a5a --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts @@ -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; + }; +}; + +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; +} diff --git a/packages/trpc/server/routers/viewer/organizations/list.handler.ts b/packages/trpc/server/routers/viewer/organizations/list.handler.ts new file mode 100644 index 0000000000..7a79584e76 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/list.handler.ts @@ -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; + 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, + }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts new file mode 100644 index 0000000000..28fed7436a --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -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; + }; +}; + +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, + }, + }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts new file mode 100644 index 0000000000..6847dade49 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -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; + }; +}; + +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>; + + 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", + }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 33d95af1df..4b5f5a97a0 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -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 }; }; diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index e4d8b53ccc..5095b56af9 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 7eae61e936..603b86eb4f 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -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, + }, }, }); diff --git a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts index ee191a573f..e6925200f3 100644 --- a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts @@ -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; }; diff --git a/packages/trpc/server/routers/viewer/teams/list.handler.ts b/packages/trpc/server/routers/viewer/teams/list.handler.ts index 0db29bbb32..1a2710f421 100644 --- a/packages/trpc/server/routers/viewer/teams/list.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/list.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts index 476fc3e4ce..037c589594 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -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 }, diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts index 38ac1843c2..94036a2db2 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.schema.ts @@ -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;