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:
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 |
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+4
@@ -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';
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user