diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 6accd70e51ee29..f27bee87fe2b77 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -145,12 +145,16 @@ const getTabs = ( name: "guest_notifications", href: "/settings/organizations/guest-notifications", }, + { + name: "invites", + href: "/settings/organizations/invites", + trackingMetadata: { section: "organization", page: "invites" }, + }, ...(orgBranding ? [ { name: "members", - href: `${WEBAPP_URL}/settings/organizations/${orgBranding?.slug}/members`, - isExternalLink: true, + href: "/settings/organizations/members", trackingMetadata: { section: "organization", page: "members" }, }, ] @@ -252,7 +256,6 @@ const getTabs = ( // The following keys are assigned to admin only const adminRequiredKeys = ["admin"]; -const organizationRequiredKeys = ["organization"]; const organizationAdminKeys = [ "privacy", "privacy_and_security", @@ -267,6 +270,8 @@ interface SettingsPermissions { canUpdateOrganization?: boolean; } +const availableOrganizationSettingsPages = new Set(["profile", "general", "invites", "members"]); + const useTabs = ({ isDelegationCredentialEnabled, isPbacEnabled, @@ -278,7 +283,18 @@ const useTabs = ({ }) => { const session = useSession(); const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true }); - const orgBranding = null as { id?: number; slug?: string; name?: string; logoUrl?: string | null } | null; + const { data: pendingInvites } = trpc.viewer.organizations.listPendingInvites.useQuery(); + const pendingInviteCount = pendingInvites?.length ?? 0; + const organization = user?.organization; + const orgBranding = + organization && !organization.isPlatform && organization.id != null && organization.id > 0 && "name" in organization + ? { + id: organization.id ?? undefined, + slug: organization.slug ?? undefined, + name: organization.name ?? undefined, + logoUrl: "logoUrl" in organization ? (organization.logoUrl as string | null) ?? null : null, + } + : null; const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN; const processTabsMemod = useMemo(() => { @@ -291,9 +307,10 @@ const useTabs = ({ avatar: getUserAvatarUrl(user), }; } else if (tab.href === "/settings/organizations") { - const newArray = (tab?.children ?? []).filter( - (child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name) - ); + const newArray = (tab?.children ?? []).filter((child) => { + if (!availableOrganizationSettingsPages.has(child.name)) return false; + return permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name); + }); // Add delegation-credential menu item only if feature flag is enabled if (isDelegationCredentialEnabled) { @@ -340,9 +357,16 @@ const useTabs = ({ } } + const childrenWithBadges = newArray.map((child) => { + if (child.name === "invites" && pendingInviteCount > 0) { + return { ...child, isBadged: true }; + } + return child; + }); + return { ...tab, - children: newArray, + children: childrenWithBadges, name: orgBranding?.name || "organization", avatar: getPlaceholderAvatar(orgBranding?.logoUrl, orgBranding?.name), }; @@ -367,13 +391,13 @@ const useTabs = ({ // check if name is in adminRequiredKeys return processedTabs.filter((tab) => { - if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding; + if (tab.href === "/settings/organizations") return true; if (tab.name === "other_teams" && !permissions?.canUpdateOrganization) return false; if (isAdmin) return true; return !adminRequiredKeys.includes(tab.name); }); - }, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]); + }, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions, pendingInviteCount]); return processTabsMemod; }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx new file mode 100644 index 00000000000000..85f59f559c99ed --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; +import slugify from "@calcom/lib/slugify"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { Form, TextAreaField, TextField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; + +type OrganizationGeneralFormValues = { + name: string; + slug: string; + bio: string; +}; + +export default function OrganizationGeneralPage() { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const { data: organization, isLoading } = trpc.viewer.organizations.getCurrent.useQuery(); + + const form = useForm({ + defaultValues: { + name: "", + slug: "", + bio: "", + }, + }); + + const { + formState: { isDirty, isSubmitting }, + reset, + watch, + setValue, + } = form; + + useEffect(() => { + if (!organization) return; + + reset({ + name: organization.name, + slug: organization.slug || "", + bio: organization.bio || "", + }); + }, [organization, reset]); + + const createMutation = trpc.viewer.organizations.create.useMutation({ + onSuccess: async (createdOrganization) => { + await utils.viewer.organizations.getCurrent.invalidate(); + await utils.viewer.me.get.invalidate(); + reset({ + name: createdOrganization.name, + slug: createdOrganization.slug || "", + bio: createdOrganization.bio || "", + }); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const updateMutation = trpc.viewer.organizations.update.useMutation({ + onSuccess: async (updatedOrganization) => { + await utils.viewer.organizations.getCurrent.invalidate(); + await utils.viewer.me.get.invalidate(); + reset({ + name: updatedOrganization.name, + slug: updatedOrganization.slug || "", + bio: updatedOrganization.bio || "", + }); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const watchedName = watch("name"); + const canUpdate = !organization || organization.canUpdate; + const isSaving = createMutation.isPending || updateMutation.isPending || isSubmitting; + const isSubmitDisabled = isLoading || isSaving || !canUpdate || (!!organization && !isDirty); + + return ( + +
{ + const payload = { + name: values.name, + slug: slugify(values.slug || values.name).toLowerCase(), + bio: values.bio || null, + }; + + if (organization) { + await updateMutation.mutateAsync(payload); + } else { + await createMutation.mutateAsync(payload); + } + }}> +
+ + + { + setValue("slug", slugify(event.target.value).toLowerCase(), { + shouldDirty: true, + shouldValidate: true, + }); + }, + })} + label={t("organization_url")} + placeholder={slugify(watchedName || "acme").toLowerCase()} + disabled={!canUpdate || isLoading} + required + /> + + + + {!canUpdate && ( +

{t("org_admin_only_settings")}

+ )} +
+ + + +
+
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsx new file mode 100644 index 00000000000000..c0414573da75c2 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Button } from "@calcom/ui/components/button"; +import { showToast } from "@calcom/ui/components/toast"; +import { useRouter } from "next/navigation"; + +export default function OrganizationInvitesPage() { + const { t } = useLocale(); + const router = useRouter(); + const utils = trpc.useUtils(); + + const { data: invites, isLoading } = trpc.viewer.organizations.listPendingInvites.useQuery(); + + const acceptMutation = trpc.viewer.organizations.acceptInvite.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listPendingInvites.invalidate(); + await utils.viewer.me.get.invalidate(); + showToast(t("org_invite_joined"), "success"); + router.push("/settings/organizations/general"); + }, + onError: (err) => showToast(err.message, "error"), + }); + + const declineMutation = trpc.viewer.organizations.declineInvite.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listPendingInvites.invalidate(); + showToast(t("invite_declined"), "success"); + }, + onError: (err) => showToast(err.message, "error"), + }); + + const pendingInvites = invites ?? []; + const isBusy = acceptMutation.isPending || declineMutation.isPending; + + return ( + +
+ {isLoading ? ( +

{t("loading")}

+ ) : pendingInvites.length === 0 ? ( +

{t("no_pending_invites")}

+ ) : ( +
    + {pendingInvites.map(({ team }) => ( +
  • + +
    +

    {team.name}

    + {team.slug &&

    {team.slug}

    } +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx new file mode 100644 index 00000000000000..53015ae7b2d974 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Form, TextField, SelectField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +type InviteRole = typeof MembershipRole.MEMBER | typeof MembershipRole.ADMIN; + +type InviteFormValues = { + email: string; + role: InviteRole; +}; + +const getRoleOptions = (t: (key: string) => string) => [ + { label: t("member"), value: MembershipRole.MEMBER }, + { label: t("admin"), value: MembershipRole.ADMIN }, +]; + +export default function OrganizationMembersPage() { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [inviteOpen, setInviteOpen] = useState(false); + const roleOptions = getRoleOptions(t); + + const { data, isLoading } = trpc.viewer.organizations.listMembers.useQuery({}); + + const inviteMutation = trpc.viewer.organizations.inviteMember.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listMembers.invalidate(); + showToast(t("invite_sent"), "success"); + setInviteOpen(false); + form.reset(); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const removeMutation = trpc.viewer.organizations.removeMember.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listMembers.invalidate(); + showToast(t("member_removed"), "success"); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const roleMutation = trpc.viewer.organizations.updateMemberRole.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listMembers.invalidate(); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const form = useForm({ + defaultValues: { email: "", role: MembershipRole.MEMBER }, + }); + + const members = data?.memberships ?? []; + + return ( + +
+
+ +
+ + {isLoading ? ( +

{t("loading")}

+ ) : members.length === 0 ? ( +

{t("no_members_yet")}

+ ) : ( +
    + {members.map((m) => ( +
  • + +
    +

    {m.user.name ?? m.user.email}

    +

    {m.user.email}

    +
    + o.value === m.role)} + options={roleOptions} + isDisabled={m.role === MembershipRole.OWNER || roleMutation.isPending} + onChange={(opt) => { + if (!opt) return; + roleMutation.mutate({ userId: m.user.id, role: opt.value }); + }} + /> + {m.role !== MembershipRole.OWNER && ( + + )} +
  • + ))} +
+ )} +
+ + + + +
+ inviteMutation.mutate({ email: values.email, role: values.role }) + }> +
+ + o.value === form.watch("role"))} + onChange={(opt) => { + if (opt) form.setValue("role", opt.value); + }} + /> +
+ + + + +
+
+
+
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/page.tsx new file mode 100644 index 00000000000000..1384298c0accef --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function OrganizationSettingsPage() { + redirect("/settings/organizations/profile"); +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx new file mode 100644 index 00000000000000..66057c0b150ea2 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { useSession } from "next-auth/react"; + +export default function OrganizationProfilePage() { + const { t } = useLocale(); + const { data: session } = useSession(); + const { data, isLoading } = trpc.viewer.me.get.useQuery(); + + const organization = + session?.user?.org || + data?.profile?.organization || + data?.profiles?.find((profile) => profile.organization)?.organization || + null; + + return ( +
+
+

{t("organization_profile")}

+

{t("profile_org_description")}

+
+ +
+ {isLoading ? ( +
+
+
+
+ ) : organization ? ( +
+
+

{t("organization")}

+

+ {organization?.name || t("organization")} +

+ {organization?.slug ?

/{organization.slug}

: null} +
+ +

+ {t("profile_org_description")} +

+ +
+ + +
+
+ ) : ( +
+

{t("no_organization_found")}

+

{t("not_attached_to_org")}

+
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 133974fe4cbddf..bd46391718b526 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -609,11 +609,6 @@ const nextConfig = (phase: string): NextConfig => { destination: "/apps/installed/calendar", permanent: true, }, - { - source: "/settings/organizations/members", - destination: "/members", - permanent: true, - }, { source: "/settings/admin/apps", destination: "/settings/admin/apps/calendar", diff --git a/apps/web/pages/api/trpc/organizations/[trpc].ts b/apps/web/pages/api/trpc/organizations/[trpc].ts new file mode 100644 index 00000000000000..cc3a3bca61744b --- /dev/null +++ b/apps/web/pages/api/trpc/organizations/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { organizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router"; + +export default createNextApiHandler(organizationsRouter); diff --git a/apps/web/pages/api/trpc/teams/[trpc].ts b/apps/web/pages/api/trpc/teams/[trpc].ts new file mode 100644 index 00000000000000..7e973adf124cb1 --- /dev/null +++ b/apps/web/pages/api/trpc/teams/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { teamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router"; + +export default createNextApiHandler(teamsRouter); diff --git a/apps/web/playwright/settings/organizations.e2e.ts b/apps/web/playwright/settings/organizations.e2e.ts new file mode 100644 index 00000000000000..4d6576cddd0806 --- /dev/null +++ b/apps/web/playwright/settings/organizations.e2e.ts @@ -0,0 +1,199 @@ +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { expect } from "@playwright/test"; +import { test } from "../lib/fixtures"; + +test.describe.configure({ mode: "serial" }); + +test.afterEach(({ users }) => users.deleteAll()); + +test.describe("Organization Settings", () => { + test("owner can create an organization", async ({ page, users }) => { + const owner = await users.create({ name: "Org Owner" }); + const slug = `astoria-it-${Date.now()}`; + await owner.apiLogin(); + + await page.goto("/settings/organizations/general"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("org-name-input").fill("Astoria IT"); + await page.getByTestId("org-slug-input").fill(slug); + await page.getByTestId("org-submit-btn").click(); + + await expect(page.getByText(/settings updated/i)).toBeVisible(); + + const org = await prisma.team.findFirst({ + where: { slug, isOrganization: true }, + }); + expect(org).not.toBeNull(); + + if (org) { + await prisma.team.delete({ where: { id: org.id } }); + } + }); + + test("owner sees members page with self listed", async ({ page, users, orgs }) => { + const owner = await users.create({ name: "Org Owner" }); + const org = await orgs.create({ name: "Test Org", slug: `test-org-${Date.now()}` }); + + await prisma.membership.create({ + data: { + userId: owner.id, + teamId: org.id, + role: MembershipRole.OWNER, + accepted: true, + }, + }); + await prisma.user.update({ + where: { id: owner.id }, + data: { organizationId: org.id }, + }); + + await owner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await expect(page.getByTestId("invite-member-btn")).toBeVisible(); + await expect(page.locator("ul li").filter({ hasText: owner.name ?? owner.username ?? "" })).toBeVisible(); + }); + + test("owner can invite existing member and member sees pending invite", async ({ + page, + users, + orgs, + browser, + }) => { + const owner = await users.create({ name: "Org Owner" }); + const invitee = await users.create({ name: "Invitee User" }); + const org = await orgs.create({ name: "Invite Test Org", slug: `invite-org-${Date.now()}` }); + + await prisma.membership.create({ + data: { + userId: owner.id, + teamId: org.id, + role: MembershipRole.OWNER, + accepted: true, + }, + }); + await prisma.user.update({ + where: { id: owner.id }, + data: { organizationId: org.id }, + }); + + await owner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("invite-member-btn").click(); + await page.getByTestId("invite-email-input").fill(invitee.email); + await page.getByTestId("send-invite-btn").click(); + + await expect(page.getByText("Invite sent.")).toBeVisible(); + + // Verify pending membership created + const membership = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: invitee.id, teamId: org.id } }, + }); + expect(membership).not.toBeNull(); + expect(membership?.accepted).toBe(false); + + // Invitee sees the invite + const [inviteeContext, inviteePage] = await invitee.apiLoginOnNewBrowser(browser); + await inviteePage.goto("/settings/organizations/invites"); + await inviteePage.waitForLoadState("networkidle"); + + await expect(inviteePage.getByTestId(`invite-item-${org.id}`)).toBeVisible(); + await inviteeContext.close(); + }); + + test("invitee can accept an org invite", async ({ page, users, orgs }) => { + const owner = await users.create({ name: "Org Owner" }); + const invitee = await users.create({ name: "Invitee User" }); + const org = await orgs.create({ name: "Accept Test Org", slug: `accept-org-${Date.now()}` }); + + // Set up org membership for owner + await prisma.membership.create({ + data: { userId: owner.id, teamId: org.id, role: MembershipRole.OWNER, accepted: true }, + }); + await prisma.user.update({ where: { id: owner.id }, data: { organizationId: org.id } }); + + // Create pending invite + await prisma.membership.create({ + data: { userId: invitee.id, teamId: org.id, role: MembershipRole.MEMBER, accepted: false }, + }); + + await invitee.apiLogin(); + await page.goto("/settings/organizations/invites"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId(`accept-invite-${org.id}`).click(); + + // Should redirect to org settings after accept + await page.waitForURL(/\/settings\/organizations\/general/); + + const membership = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: invitee.id, teamId: org.id } }, + }); + expect(membership?.accepted).toBe(true); + + const updatedUser = await prisma.user.findUnique({ + where: { id: invitee.id }, + select: { organizationId: true }, + }); + expect(updatedUser?.organizationId).toBe(org.id); + + const profile = await prisma.profile.findFirst({ + where: { userId: invitee.id, organizationId: org.id }, + }); + expect(profile).not.toBeNull(); + }); + + test("invitee can decline an org invite", async ({ page, users, orgs }) => { + const invitee = await users.create({ name: "Invitee User" }); + const org = await orgs.create({ name: "Decline Test Org", slug: `decline-org-${Date.now()}` }); + + await prisma.membership.create({ + data: { userId: invitee.id, teamId: org.id, role: MembershipRole.MEMBER, accepted: false }, + }); + + await invitee.apiLogin(); + await page.goto("/settings/organizations/invites"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId(`decline-invite-${org.id}`).click(); + + await expect(page.getByText("Invite declined.")).toBeVisible(); + + const membership = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: invitee.id, teamId: org.id } }, + }); + expect(membership).toBeNull(); + }); + + test("owner can remove a member", async ({ page, users, orgs }) => { + const owner = await users.create({ name: "Org Owner" }); + const member = await users.create({ name: "Regular Member" }); + const org = await orgs.create({ name: "Remove Test Org", slug: `remove-org-${Date.now()}` }); + + await prisma.membership.create({ + data: { userId: owner.id, teamId: org.id, role: MembershipRole.OWNER, accepted: true }, + }); + await prisma.membership.create({ + data: { userId: member.id, teamId: org.id, role: MembershipRole.MEMBER, accepted: true }, + }); + await prisma.user.update({ where: { id: owner.id }, data: { organizationId: org.id } }); + + await owner.apiLogin(); + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await page.getByTestId(`remove-member-${member.id}`).click(); + + await expect(page.getByText("Member removed.")).toBeVisible(); + + const membership = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: member.id, teamId: org.id } }, + }); + expect(membership).toBeNull(); + }); +}); diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index ce7460a76d96e3..f9507d591d0859 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -2795,6 +2795,19 @@ "platform_admin_email": "Your admin email address", "admin_username": "Administrator's username", "organization_name": "Organization name", + "organization_profile": "Organization profile", + "org_invites": "Organization invites", + "org_invites_description": "Pending invitations to join an organization.", + "org_invite_joined": "You have joined the organization.", + "invite_declined": "Invite declined.", + "no_pending_invites": "No pending invites.", + "invite_sent": "Invite sent.", + "invite_member": "Invite member", + "no_members_yet": "No members yet.", + "org_admin_only_settings": "Only organization owners and admins can update these settings.", + "no_organization_found": "No organization found", + "not_attached_to_org": "This account is not currently attached to an organization.", + "manage_org_members": "Manage organization members.", "platform_name": "Platform name", "organization_url": "Organization URL", "organization_verify_header": "Verify your organization email", diff --git a/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts index bb51b04dbbde62..d1ff23e78ad679 100644 --- a/packages/trpc/react/shared.ts +++ b/packages/trpc/react/shared.ts @@ -22,6 +22,7 @@ export const ENDPOINTS = [ "i18n", "me", "ooo", + "organizations", "payments", "public", "timezones", @@ -36,4 +37,5 @@ export const ENDPOINTS = [ "credits", "filterSegments", "phoneNumber", + "teams", ] as const; diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index d28acda561e8b0..6e85065b7f9a07 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -22,6 +22,7 @@ import { i18nRouter } from "./i18n/_router"; import { meRouter } from "./me/_router"; import { oAuthRouter } from "./oAuth/_router"; import { oooRouter } from "./ooo/_router"; +import { organizationsRouter } from "./organizations/_router"; import { slotsRouter } from "./slots/_router"; import { travelSchedulesRouter } from "./travelSchedules/_router"; import { userAdminRouter } from "./users/_router"; @@ -53,6 +54,7 @@ export const viewerRouter = router({ admin: adminRouter, apiKeys: apiKeysRouter, ooo: oooRouter, + organizations: organizationsRouter, holidays: holidaysRouter, travelSchedules: travelSchedulesRouter, }); diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx new file mode 100644 index 00000000000000..e256a90871b63d --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -0,0 +1,73 @@ +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; +import { + ZAcceptDeclineInviteInputSchema, + ZCreateOrganizationInputSchema, + ZInviteMemberInputSchema, + ZListMembersInputSchema, + ZRemoveMemberInputSchema, + ZUpdateMemberRoleInputSchema, + ZUpdateOrganizationInputSchema, +} from "./schema"; + +export const organizationsRouter = router({ + getCurrent: authedProcedure.query(async ({ ctx }) => { + const { getCurrentHandler } = await import("./getCurrent.handler"); + + return getCurrentHandler({ ctx }); + }), + + create: authedProcedure.input(ZCreateOrganizationInputSchema).mutation(async ({ ctx, input }) => { + const { createHandler } = await import("./create.handler"); + + return createHandler({ ctx, input }); + }), + + update: authedProcedure.input(ZUpdateOrganizationInputSchema).mutation(async ({ ctx, input }) => { + const { updateHandler } = await import("./update.handler"); + + return updateHandler({ ctx, input }); + }), + + listMembers: authedProcedure.input(ZListMembersInputSchema).query(async ({ ctx, input }) => { + const { listMembersHandler } = await import("./listMembers.handler"); + + return listMembersHandler({ ctx, input }); + }), + + inviteMember: authedProcedure.input(ZInviteMemberInputSchema).mutation(async ({ ctx, input }) => { + const { inviteMemberHandler } = await import("./inviteMember.handler"); + + return inviteMemberHandler({ ctx, input }); + }), + + removeMember: authedProcedure.input(ZRemoveMemberInputSchema).mutation(async ({ ctx, input }) => { + const { removeMemberHandler } = await import("./removeMember.handler"); + + return removeMemberHandler({ ctx, input }); + }), + + updateMemberRole: authedProcedure.input(ZUpdateMemberRoleInputSchema).mutation(async ({ ctx, input }) => { + const { updateMemberRoleHandler } = await import("./updateMemberRole.handler"); + + return updateMemberRoleHandler({ ctx, input }); + }), + + listPendingInvites: authedProcedure.query(async ({ ctx }) => { + const { listPendingInvitesHandler } = await import("./listPendingInvites.handler"); + + return listPendingInvitesHandler({ ctx }); + }), + + acceptInvite: authedProcedure.input(ZAcceptDeclineInviteInputSchema).mutation(async ({ ctx, input }) => { + const { acceptInviteHandler } = await import("./acceptInvite.handler"); + + return acceptInviteHandler({ ctx, input }); + }), + + declineInvite: authedProcedure.input(ZAcceptDeclineInviteInputSchema).mutation(async ({ ctx, input }) => { + const { declineInviteHandler } = await import("./declineInvite.handler"); + + return declineInviteHandler({ ctx, input }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts b/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts new file mode 100644 index 00000000000000..0d24ccc169a604 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts @@ -0,0 +1,65 @@ +import { v4 as uuidv4 } from "uuid"; + +import prisma from "@calcom/prisma"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TAcceptDeclineInviteInputSchema } from "./schema"; +import { getProfileUsername } from "./organizationUtils"; + +type AcceptInviteHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TAcceptDeclineInviteInputSchema; +}; + +export const acceptInviteHandler = async ({ ctx, input }: AcceptInviteHandlerOptions) => { + const [membership, user] = await Promise.all([ + prisma.membership.findUnique({ + where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId } }, + select: { accepted: true, teamId: true }, + }), + prisma.user.findUnique({ + where: { id: ctx.user.id }, + select: { id: true, username: true, email: true, organizationId: true }, + }), + ]); + + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." }); + } + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + } + + if (membership.accepted) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invite already accepted." }); + } + + if (user.organizationId) { + throw new TRPCError({ code: "CONFLICT", message: "You already belong to an organization." }); + } + + await prisma.$transaction([ + prisma.membership.update({ + where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId } }, + data: { accepted: true }, + }), + prisma.profile.create({ + data: { + uid: uuidv4(), + userId: ctx.user.id, + organizationId: input.teamId, + username: getProfileUsername(user ?? { email: "" }), + }, + }), + prisma.user.update({ + where: { id: ctx.user.id }, + data: { organizationId: input.teamId }, + }), + ]); + + return { success: true }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts new file mode 100644 index 00000000000000..da2e3f151e3a8d --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -0,0 +1,105 @@ +import { v4 as uuidv4 } from "uuid"; + +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TCreateOrganizationInputSchema } from "./schema"; +import { + assertOrganizationSlugAvailable, + getEmailDomain, + getProfileUsername, + normalizeOrganizationSlug, + organizationSelect, +} from "./organizationUtils"; + +type CreateHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TCreateOrganizationInputSchema; +}; + +export const createHandler = async ({ ctx, input }: CreateHandlerOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + email: true, + username: true, + organizationId: true, + }, + }); + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }); + } + + if (user.organizationId) { + throw new TRPCError({ code: "CONFLICT", message: "User already belongs to an organization." }); + } + + const existingMembership = await prisma.membership.findFirst({ + where: { + userId: ctx.user.id, + accepted: true, + team: { + isOrganization: true, + }, + }, + select: { id: true }, + }); + + if (existingMembership) { + throw new TRPCError({ code: "CONFLICT", message: "User already belongs to an organization." }); + } + + const slug = normalizeOrganizationSlug(input.slug); + await assertOrganizationSlugAvailable({ slug }); + + return prisma.$transaction(async (tx) => { + const organization = await tx.team.create({ + data: { + name: input.name, + slug, + bio: input.bio || null, + isOrganization: true, + organizationSettings: { + create: { + orgAutoAcceptEmail: getEmailDomain(user.email), + isOrganizationConfigured: true, + }, + }, + }, + select: organizationSelect, + }); + + await tx.membership.create({ + data: { + teamId: organization.id, + userId: user.id, + accepted: true, + role: MembershipRole.OWNER, + }, + }); + + await tx.profile.create({ + data: { + uid: uuidv4(), + userId: user.id, + organizationId: organization.id, + username: getProfileUsername(user), + }, + }); + + await tx.user.update({ + where: { id: user.id }, + data: { organizationId: organization.id }, + }); + + return organization; + }); +}; diff --git a/packages/trpc/server/routers/viewer/organizations/declineInvite.handler.ts b/packages/trpc/server/routers/viewer/organizations/declineInvite.handler.ts new file mode 100644 index 00000000000000..0520681bb8d7a6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/declineInvite.handler.ts @@ -0,0 +1,28 @@ +import prisma from "@calcom/prisma"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TAcceptDeclineInviteInputSchema } from "./schema"; + +type DeclineInviteHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TAcceptDeclineInviteInputSchema; +}; + +export const declineInviteHandler = async ({ ctx, input }: DeclineInviteHandlerOptions) => { + const { count } = await prisma.membership.deleteMany({ + where: { + userId: ctx.user.id, + teamId: input.teamId, + accepted: false, + }, + }); + + if (count === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found or already accepted." }); + } + + return { success: true }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/getCurrent.handler.ts b/packages/trpc/server/routers/viewer/organizations/getCurrent.handler.ts new file mode 100644 index 00000000000000..6fc072e8d7860a --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getCurrent.handler.ts @@ -0,0 +1,24 @@ +import { MembershipRole } from "@calcom/prisma/enums"; + +import type { TrpcSessionUser } from "../../../types"; +import { findCurrentOrganizationMembership } from "./organizationUtils"; + +type GetCurrentHandlerOptions = { + ctx: { + user: Pick, "id">; + }; +}; + +export const getCurrentHandler = async ({ ctx }: GetCurrentHandlerOptions) => { + const membership = await findCurrentOrganizationMembership({ userId: ctx.user.id }); + + if (!membership) { + return null; + } + + return { + ...membership.team, + role: membership.role, + canUpdate: membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN, + }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts new file mode 100644 index 00000000000000..c21f7d167ef42e --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts @@ -0,0 +1,79 @@ +import { sendTeamInviteEmail } from "@calcom/emails/organization-email-service"; +import { getTranslation } from "@calcom/i18n/server"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TInviteMemberInputSchema } from "./schema"; +import { assertCanManageOrganization } from "./organizationUtils"; + +type InviteMemberHandlerOptions = { + ctx: { + user: Pick, "id" | "name" | "email" | "locale">; + }; + input: TInviteMemberInputSchema; +}; + +export const inviteMemberHandler = async ({ ctx, input }: InviteMemberHandlerOptions) => { + const membership = await assertCanManageOrganization({ userId: ctx.user.id }); + + const invitee = await prisma.user.findUnique({ + where: { email: input.email.toLowerCase() }, + select: { id: true, name: true, email: true, locale: true, organizationId: true }, + }); + + if (!invitee) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No user found with that email. They must have a Cal.diy account first.", + }); + } + + if (invitee.organizationId) { + throw new TRPCError({ + code: "CONFLICT", + message: "This user already belongs to an organization.", + }); + } + + try { + await prisma.membership.create({ + data: { + userId: invitee.id, + teamId: membership.team.id, + role: input.role ?? MembershipRole.MEMBER, + accepted: false, + }, + }); + } catch (err: unknown) { + const isPrismaUniqueViolation = + typeof err === "object" && err !== null && "code" in err && (err as { code: string }).code === "P2002"; + if (isPrismaUniqueViolation) { + throw new TRPCError({ code: "CONFLICT", message: "Invite already sent or user is already a member." }); + } + throw err; + } + + const inviteeT = await getTranslation(invitee.locale ?? "en", "common"); + + await sendTeamInviteEmail({ + language: inviteeT, + from: ctx.user.name ?? ctx.user.email, + to: invitee.email, + teamName: membership.team.name, + joinLink: `${WEBAPP_URL}/settings/organizations/invites`, + isCalcomMember: true, + isAutoJoin: false, + isOrg: true, + parentTeamName: undefined, + isExistingUserMovedToOrg: false, + prevLink: null, + newLink: null, + }).catch((err: unknown) => { + console.error("Failed to send invite email", err); + }); + + return { success: true }; +}; 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 00000000000000..e269ae2028ab90 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -0,0 +1,29 @@ +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TListMembersInputSchema } from "./schema"; +import { findCurrentOrganizationMembership } from "./organizationUtils"; + +type ListMembersHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TListMembersInputSchema; +}; + +export const listMembersHandler = async ({ ctx, input }: ListMembersHandlerOptions) => { + const membership = await findCurrentOrganizationMembership({ userId: ctx.user.id }); + + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found." }); + } + + const repo = new MembershipRepository(); + return repo.searchMembers({ + teamId: membership.team.id, + search: input.search, + cursor: input.cursor, + limit: input.limit ?? 20, + }); +}; diff --git a/packages/trpc/server/routers/viewer/organizations/listPendingInvites.handler.ts b/packages/trpc/server/routers/viewer/organizations/listPendingInvites.handler.ts new file mode 100644 index 00000000000000..6de765b1f8abd3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/listPendingInvites.handler.ts @@ -0,0 +1,30 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../types"; + +type ListPendingInvitesHandlerOptions = { + ctx: { + user: Pick, "id">; + }; +}; + +export const listPendingInvitesHandler = async ({ ctx }: ListPendingInvitesHandlerOptions) => { + return prisma.membership.findMany({ + where: { + userId: ctx.user.id, + accepted: false, + team: { isOrganization: true }, + }, + select: { + team: { + select: { + id: true, + name: true, + slug: true, + logoUrl: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); +}; diff --git a/packages/trpc/server/routers/viewer/organizations/organizationUtils.ts b/packages/trpc/server/routers/viewer/organizations/organizationUtils.ts new file mode 100644 index 00000000000000..b1a1f42dd00d11 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/organizationUtils.ts @@ -0,0 +1,80 @@ +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + +export const organizationSelect = { + id: true, + name: true, + slug: true, + logoUrl: true, + bio: true, + isPlatform: true, + organizationSettings: true, +} as const; + +export const normalizeOrganizationSlug = (slug: string) => slugify(slug).toLowerCase(); + +export const getEmailDomain = (email: string) => { + const domain = email.split("@")[1]?.toLowerCase(); + return domain || "local"; +}; + +export const getProfileUsername = (user: { username?: string | null; email: string }) => { + return normalizeOrganizationSlug(user.username || user.email.split("@")[0] || "member"); +}; + +export const assertOrganizationSlugAvailable = async ({ + slug, + currentOrganizationId, +}: { + slug: string; + currentOrganizationId?: number; +}) => { + const existing = await prisma.team.findFirst({ + where: { + slug, + parentId: null, + ...(currentOrganizationId ? { id: { not: currentOrganizationId } } : {}), + }, + select: { id: true }, + }); + + if (existing) { + throw new TRPCError({ code: "CONFLICT", message: "Organization slug is already in use." }); + } +}; + +export const findCurrentOrganizationMembership = async ({ userId }: { userId: number }) => { + return prisma.membership.findFirst({ + where: { + userId, + accepted: true, + team: { + isOrganization: true, + }, + }, + select: { + role: true, + team: { + select: organizationSelect, + }, + }, + }); +}; + +export const assertCanManageOrganization = async ({ userId }: { userId: number }) => { + const membership = await findCurrentOrganizationMembership({ userId }); + + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found." }); + } + + const rolesWithWriteAccess: MembershipRole[] = [MembershipRole.OWNER, MembershipRole.ADMIN]; + + if (!rolesWithWriteAccess.includes(membership.role)) { + throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission to update this organization." }); + } + + return membership; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts b/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts new file mode 100644 index 00000000000000..2754595c8f02b7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts @@ -0,0 +1,53 @@ +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TRemoveMemberInputSchema } from "./schema"; +import { assertCanManageOrganization } from "./organizationUtils"; + +type RemoveMemberHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TRemoveMemberInputSchema; +}; + +export const removeMemberHandler = async ({ ctx, input }: RemoveMemberHandlerOptions) => { + const membership = await assertCanManageOrganization({ userId: ctx.user.id }); + + if (input.userId === ctx.user.id) { + throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot remove yourself." }); + } + + const { count } = await prisma.membership.deleteMany({ + where: { + userId: input.userId, + teamId: membership.team.id, + role: { not: MembershipRole.OWNER }, + }, + }); + + if (count === 0) { + const exists = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + select: { role: true }, + }); + if (!exists) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); + } + throw new TRPCError({ code: "FORBIDDEN", message: "Cannot remove the organization owner." }); + } + + await prisma.$transaction([ + prisma.profile.deleteMany({ + where: { userId: input.userId, organizationId: membership.team.id }, + }), + prisma.user.updateMany({ + where: { id: input.userId, organizationId: membership.team.id }, + data: { organizationId: null }, + }), + ]); + + return { success: true }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/schema.ts b/packages/trpc/server/routers/viewer/organizations/schema.ts new file mode 100644 index 00000000000000..b34370359bf313 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/schema.ts @@ -0,0 +1,52 @@ +import { MembershipRole } from "@calcom/prisma/enums"; +import { z } from "zod"; + +const slugSchema = z + .string() + .min(2) + .max(48) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and single hyphens."); + +export const ZCreateOrganizationInputSchema = z.object({ + name: z.string().trim().min(2).max(80), + slug: slugSchema, + bio: z.string().trim().max(500).optional().nullable(), +}); + +export const ZUpdateOrganizationInputSchema = z.object({ + name: z.string().trim().min(2).max(80), + slug: slugSchema, + bio: z.string().trim().max(500).optional().nullable(), +}); + +export const ZListMembersInputSchema = z.object({ + search: z.string().optional(), + cursor: z.number().optional(), + limit: z.number().min(1).max(100).optional(), +}); + +export const ZInviteMemberInputSchema = z.object({ + email: z.string().email(), + role: z.enum([MembershipRole.MEMBER, MembershipRole.ADMIN]).optional(), +}); + +export const ZRemoveMemberInputSchema = z.object({ + userId: z.number(), +}); + +export const ZUpdateMemberRoleInputSchema = z.object({ + userId: z.number(), + role: z.enum([MembershipRole.MEMBER, MembershipRole.ADMIN]), +}); + +export const ZAcceptDeclineInviteInputSchema = z.object({ + teamId: z.number(), +}); + +export type TCreateOrganizationInputSchema = z.infer; +export type TUpdateOrganizationInputSchema = z.infer; +export type TListMembersInputSchema = z.infer; +export type TInviteMemberInputSchema = z.infer; +export type TRemoveMemberInputSchema = z.infer; +export type TUpdateMemberRoleInputSchema = z.infer; +export type TAcceptDeclineInviteInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts new file mode 100644 index 00000000000000..0c5c0de67ffdc8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -0,0 +1,39 @@ +import prisma from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../types"; +import { + assertCanManageOrganization, + assertOrganizationSlugAvailable, + normalizeOrganizationSlug, + organizationSelect, +} from "./organizationUtils"; +import type { TUpdateOrganizationInputSchema } from "./schema"; + +type UpdateHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TUpdateOrganizationInputSchema; +}; + +export const updateHandler = async ({ ctx, input }: UpdateHandlerOptions) => { + const membership = await assertCanManageOrganization({ userId: ctx.user.id }); + const slug = normalizeOrganizationSlug(input.slug); + + await assertOrganizationSlugAvailable({ + slug, + currentOrganizationId: membership.team.id, + }); + + return prisma.team.update({ + where: { + id: membership.team.id, + }, + data: { + name: input.name, + slug, + bio: input.bio || null, + }, + select: organizationSelect, + }); +}; diff --git a/packages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts b/packages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts new file mode 100644 index 00000000000000..ddc074692a6e24 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts @@ -0,0 +1,40 @@ +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TUpdateMemberRoleInputSchema } from "./schema"; +import { assertCanManageOrganization } from "./organizationUtils"; + +type UpdateMemberRoleHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TUpdateMemberRoleInputSchema; +}; + +export const updateMemberRoleHandler = async ({ ctx, input }: UpdateMemberRoleHandlerOptions) => { + const membership = await assertCanManageOrganization({ userId: ctx.user.id }); + + const { count } = await prisma.membership.updateMany({ + where: { + userId: input.userId, + teamId: membership.team.id, + role: { not: MembershipRole.OWNER }, + }, + data: { role: input.role }, + }); + + if (count === 0) { + const exists = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + select: { role: true }, + }); + if (!exists) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); + } + throw new TRPCError({ code: "FORBIDDEN", message: "Cannot change the owner's role." }); + } + + return { success: true }; +};