From f7cf1cc285e603f410eaa1b0ec737c1fc00d5380 Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 13:38:34 +0000 Subject: [PATCH 01/11] feat(organizations): add CRUD router and settings pages Adds viewer.organizations tRPC router (getCurrent, create, update) and settings UI at /settings/organizations/{profile,general}. - Create org: Team(isOrganization) + OrganizationSettings + Membership OWNER + Profile + User.organizationId in single transaction - Update: restricted to OWNER/ADMIN, validates slug uniqueness - Sidebar shows org nav only when user belongs to an org - /settings/organizations redirects to /profile Co-Authored-By: Claude Sonnet 4.6 --- .../SettingsLayoutAppDirClient.tsx | 22 ++- .../organizations/general/page.tsx | 152 ++++++++++++++++++ .../(settings-layout)/organizations/page.tsx | 5 + .../organizations/profile/page.tsx | 67 ++++++++ .../trpc/server/routers/viewer/_router.tsx | 2 + .../routers/viewer/organizations/_router.tsx | 23 +++ .../viewer/organizations/create.handler.ts | 105 ++++++++++++ .../organizations/getCurrent.handler.ts | 24 +++ .../viewer/organizations/organizationUtils.ts | 80 +++++++++ .../routers/viewer/organizations/schema.ts | 22 +++ .../viewer/organizations/update.handler.ts | 39 +++++ 11 files changed, 536 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx create mode 100644 packages/trpc/server/routers/viewer/organizations/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/organizations/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getCurrent.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/organizationUtils.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/schema.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/update.handler.ts 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..2d1b21cdeb4cdd 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 @@ -267,6 +267,8 @@ interface SettingsPermissions { canUpdateOrganization?: boolean; } +const availableOrganizationSettingsPages = new Set(["profile", "general"]); + const useTabs = ({ isDelegationCredentialEnabled, isPbacEnabled, @@ -278,7 +280,16 @@ 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 organization = user?.organization; + const orgBranding = + organization && !organization.isPlatform && organization.id > 0 && "name" in organization + ? { + id: organization.id, + slug: organization.slug ?? undefined, + name: organization.name ?? undefined, + logoUrl: "logoUrl" in organization ? organization.logoUrl ?? null : null, + } + : null; const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN; const processTabsMemod = useMemo(() => { @@ -291,9 +302,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) { @@ -367,7 +379,7 @@ const useTabs = ({ // check if name is in adminRequiredKeys return processedTabs.filter((tab) => { - if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding; + if (organizationRequiredKeys.includes(tab.name)) return true; if (tab.name === "other_teams" && !permissions?.canUpdateOrganization) return false; if (isAdmin) return true; 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..a7a845339e5aa1 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx @@ -0,0 +1,152 @@ +"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 && ( +

Only organization owners and admins can update these settings.

+ )} +
+ + + +
+
+ ); +} 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..67cb39e5564eeb --- /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 ( +
+
+

Organization profile

+

{t("profile_org_description")}

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

{t("organization")}

+

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

+ {organization?.slug ?

/{organization.slug}

: null} +
+ +

+ {t("profile_org_description")} +

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

No organization found

+

This account is not currently attached to an organization.

+
+ +
+
+ )} +
+
+ ); +} 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..86eb6cec5acbf6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -0,0 +1,23 @@ +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; +import { ZCreateOrganizationInputSchema, 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 }); + }), +}); 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/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/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/schema.ts b/packages/trpc/server/routers/viewer/organizations/schema.ts new file mode 100644 index 00000000000000..f013600c50377e --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/schema.ts @@ -0,0 +1,22 @@ +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 type TCreateOrganizationInputSchema = z.infer; +export type TUpdateOrganizationInputSchema = 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, + }); +}; From a36af846b99588724c7aa7f742631488689b547e Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 13:38:57 +0000 Subject: [PATCH 02/11] feat(organizations): add member management (list, invite, remove, role update) Adds viewer.organizations.listMembers/inviteMember/removeMember/updateMemberRole tRPC procedures and /settings/organizations/members UI page. - listMembers: paginated search via existing MembershipRepository.searchMembers - inviteMember: creates pending Membership + sends team invite email (existing users only) - removeMember: guards against removing OWNER or self - updateMemberRole: Zod-validated to MEMBER|ADMIN only, blocks changing OWNER - Sidebar members link now routes to internal /settings/organizations/members Co-Authored-By: Claude Sonnet 4.6 --- .../SettingsLayoutAppDirClient.tsx | 3 +- .../organizations/members/page.tsx | 158 ++++++++++++++++++ .../routers/viewer/organizations/_router.tsx | 33 +++- .../organizations/inviteMember.handler.ts | 75 +++++++++ .../organizations/listMembers.handler.ts | 29 ++++ .../organizations/removeMember.handler.ts | 41 +++++ .../routers/viewer/organizations/schema.ts | 25 +++ .../organizations/updateMemberRole.handler.ts | 38 +++++ 8 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx create mode 100644 packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts 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 2d1b21cdeb4cdd..a3f7a92bcf2b7e 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 @@ -149,8 +149,7 @@ const getTabs = ( ? [ { name: "members", - href: `${WEBAPP_URL}/settings/organizations/${orgBranding?.slug}/members`, - isExternalLink: true, + href: "/settings/organizations/members", trackingMetadata: { section: "organization", page: "members" }, }, ] 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..08de87c9b4560d --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx @@ -0,0 +1,158 @@ +"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, DialogTitle } 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 InviteFormValues = { + email: string; + role: MembershipRole.MEMBER | MembershipRole.ADMIN; +}; + +const roleOptions = [ + { label: "Member", value: MembershipRole.MEMBER }, + { label: "Admin", value: MembershipRole.ADMIN }, +]; + +export default function OrganizationMembersPage() { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [inviteOpen, setInviteOpen] = useState(false); + + const { data, isLoading } = trpc.viewer.organizations.listMembers.useQuery({}); + + const inviteMutation = trpc.viewer.organizations.inviteMember.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listMembers.invalidate(); + showToast("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("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 ? ( +

Loading...

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

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 && ( + + )} +
  • + ))} +
+ )} +
+ + + + + Invite member + +
+ 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/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 86eb6cec5acbf6..2214979ece4e65 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -1,6 +1,13 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; -import { ZCreateOrganizationInputSchema, ZUpdateOrganizationInputSchema } from "./schema"; +import { + ZCreateOrganizationInputSchema, + ZInviteMemberInputSchema, + ZListMembersInputSchema, + ZRemoveMemberInputSchema, + ZUpdateMemberRoleInputSchema, + ZUpdateOrganizationInputSchema, +} from "./schema"; export const organizationsRouter = router({ getCurrent: authedProcedure.query(async ({ ctx }) => { @@ -20,4 +27,28 @@ export const organizationsRouter = router({ 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 }); + }), }); 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..490fb0c2d0003c --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts @@ -0,0 +1,75 @@ +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 }, + }); + + if (!invitee) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No user found with that email. They must have a Cal.diy account first.", + }); + } + + const existing = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: invitee.id, teamId: membership.team.id } }, + select: { accepted: true }, + }); + + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: existing.accepted ? "User is already a member." : "Invite already sent.", + }); + } + + await prisma.membership.create({ + data: { + userId: invitee.id, + teamId: membership.team.id, + role: input.role ?? MembershipRole.MEMBER, + accepted: false, + }, + }); + + 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/members`, + 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/removeMember.handler.ts b/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts new file mode 100644 index 00000000000000..a60018b7be1403 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts @@ -0,0 +1,41 @@ +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 target = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + select: { role: true }, + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); + } + + if (target.role === MembershipRole.OWNER) { + throw new TRPCError({ code: "FORBIDDEN", message: "Cannot remove the organization owner." }); + } + + await prisma.membership.delete({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + }); + + return { success: true }; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/schema.ts b/packages/trpc/server/routers/viewer/organizations/schema.ts index f013600c50377e..71d72bc46534db 100644 --- a/packages/trpc/server/routers/viewer/organizations/schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/schema.ts @@ -1,3 +1,4 @@ +import { MembershipRole } from "@calcom/prisma/enums"; import { z } from "zod"; const slugSchema = z @@ -18,5 +19,29 @@ export const ZUpdateOrganizationInputSchema = z.object({ 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 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; 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..0796e5b305f288 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts @@ -0,0 +1,38 @@ +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 target = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + select: { role: true }, + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); + } + + if (target.role === MembershipRole.OWNER) { + throw new TRPCError({ code: "FORBIDDEN", message: "Cannot change the owner's role." }); + } + + await prisma.membership.update({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + data: { role: input.role }, + }); + + return { success: true }; +}; From 703ba44d1818ba3ddc208ddb5d840450de7d2231 Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 13:39:12 +0000 Subject: [PATCH 03/11] feat(organizations): add invite accept/decline flow Adds listPendingInvites, acceptInvite, declineInvite tRPC procedures and /settings/organizations/invites UI page. - acceptInvite: sets membership.accepted=true + updates user.organizationId in a single transaction; redirects to org settings after accept - declineInvite: deletes pending membership - Sidebar shows "invites" link under organization section for all users; badge appears when pendingInviteCount > 0 - availableOrganizationSettingsPages expanded to include invites + members Co-Authored-By: Claude Sonnet 4.6 --- .../SettingsLayoutAppDirClient.tsx | 20 ++++- .../organizations/invites/page.tsx | 86 +++++++++++++++++++ .../routers/viewer/organizations/_router.tsx | 19 ++++ .../organizations/acceptInvite.handler.ts | 40 +++++++++ .../organizations/declineInvite.handler.ts | 33 +++++++ .../listPendingInvites.handler.ts | 30 +++++++ .../routers/viewer/organizations/schema.ts | 5 ++ 7 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsx create mode 100644 packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/declineInvite.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/listPendingInvites.handler.ts 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 a3f7a92bcf2b7e..beb73a7e27f3a2 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,6 +145,11 @@ const getTabs = ( name: "guest_notifications", href: "/settings/organizations/guest-notifications", }, + { + name: "invites", + href: "/settings/organizations/invites", + trackingMetadata: { section: "organization", page: "invites" }, + }, ...(orgBranding ? [ { @@ -266,7 +271,7 @@ interface SettingsPermissions { canUpdateOrganization?: boolean; } -const availableOrganizationSettingsPages = new Set(["profile", "general"]); +const availableOrganizationSettingsPages = new Set(["profile", "general", "invites", "members"]); const useTabs = ({ isDelegationCredentialEnabled, @@ -279,6 +284,8 @@ const useTabs = ({ }) => { const session = useSession(); const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true }); + const { data: pendingInvites } = trpc.viewer.organizations.listPendingInvites.useQuery(); + const pendingInviteCount = pendingInvites?.length ?? 0; const organization = user?.organization; const orgBranding = organization && !organization.isPlatform && organization.id > 0 && "name" in organization @@ -351,9 +358,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), }; @@ -384,7 +398,7 @@ const useTabs = ({ 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/invites/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsx new file mode 100644 index 00000000000000..cfba85eaec9969 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsx @@ -0,0 +1,86 @@ +"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("You have joined the organization.", "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("Invite declined.", "success"); + }, + onError: (err) => showToast(err.message, "error"), + }); + + const pendingInvites = invites ?? []; + const isBusy = acceptMutation.isPending || declineMutation.isPending; + + return ( + +
+ {isLoading ? ( +

Loading...

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

No pending invites.

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

    {team.name}

    + {team.slug &&

    {team.slug}

    } +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 2214979ece4e65..e256a90871b63d 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -1,6 +1,7 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; import { + ZAcceptDeclineInviteInputSchema, ZCreateOrganizationInputSchema, ZInviteMemberInputSchema, ZListMembersInputSchema, @@ -51,4 +52,22 @@ export const organizationsRouter = router({ 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..b5491f8d56ef79 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts @@ -0,0 +1,40 @@ +import prisma from "@calcom/prisma"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TAcceptDeclineInviteInputSchema } from "./schema"; + +type AcceptInviteHandlerOptions = { + ctx: { + user: Pick, "id">; + }; + input: TAcceptDeclineInviteInputSchema; +}; + +export const acceptInviteHandler = async ({ ctx, input }: AcceptInviteHandlerOptions) => { + const membership = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId } }, + select: { accepted: true, teamId: true }, + }); + + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." }); + } + + if (membership.accepted) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invite already accepted." }); + } + + await prisma.$transaction([ + prisma.membership.update({ + where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId } }, + data: { accepted: true }, + }), + prisma.user.update({ + where: { id: ctx.user.id }, + data: { organizationId: input.teamId }, + }), + ]); + + return { success: true }; +}; 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..ac6b42f6f26303 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/declineInvite.handler.ts @@ -0,0 +1,33 @@ +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 membership = await prisma.membership.findUnique({ + where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId } }, + select: { accepted: true }, + }); + + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found." }); + } + + if (membership.accepted) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot decline an already accepted invite." }); + } + + await prisma.membership.delete({ + where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId } }, + }); + + return { success: true }; +}; 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/schema.ts b/packages/trpc/server/routers/viewer/organizations/schema.ts index 71d72bc46534db..b34370359bf313 100644 --- a/packages/trpc/server/routers/viewer/organizations/schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/schema.ts @@ -39,9 +39,14 @@ export const ZUpdateMemberRoleInputSchema = z.object({ 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; From 9f545cea19146a5068761499f653c53942cac4b3 Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 13:39:22 +0000 Subject: [PATCH 04/11] feat(organizations): add API route, fix redirect conflict, and E2E tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /api/trpc/organizations/[trpc].ts endpoint (was missing from ENDPOINTS) - Add "organizations" to tRPC ENDPOINTS so client routes correctly - Remove conflicting next.config.ts redirect /settings/organizations/members → /members - Fix DialogTitle import (doesn't exist in this fork's UI lib, use DialogHeader title prop) - Add E2E tests for all 6 organization flows (create, list members, invite, accept invite, decline invite, remove member) - Fix E2E test: use apiLoginOnNewBrowser for multi-user invitee scenarios - Fix E2E test: use unique slugs to avoid DB state conflicts across runs Co-Authored-By: Claude Sonnet 4.6 --- .../organizations/general/page.tsx | 4 +- .../organizations/invites/page.tsx | 4 +- .../organizations/members/page.tsx | 12 +- apps/web/next.config.ts | 5 - .../pages/api/trpc/organizations/[trpc].ts | 4 + .../playwright/settings/organizations.e2e.ts | 190 ++++++++++++++++++ packages/trpc/react/shared.ts | 1 + 7 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 apps/web/pages/api/trpc/organizations/[trpc].ts create mode 100644 apps/web/playwright/settings/organizations.e2e.ts 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 index a7a845339e5aa1..fb468a546e9fb5 100644 --- 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 @@ -107,6 +107,7 @@ export default function OrganizationGeneralPage() {
{ @@ -142,7 +144,7 @@ export default function OrganizationGeneralPage() { )}
- 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 index cfba85eaec9969..2966a58112fdfe 100644 --- 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 @@ -49,7 +49,7 @@ export default function OrganizationInvitesPage() { ) : (
    {pendingInvites.map(({ team }) => ( -
  • +
  • +
    {isLoading ? ( @@ -103,6 +103,7 @@ export default function OrganizationMembersPage() { /> {m.role !== MembershipRole.OWNER && ( - 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/playwright/settings/organizations.e2e.ts b/apps/web/playwright/settings/organizations.e2e.ts new file mode 100644 index 00000000000000..8a6f0b0ea79b2c --- /dev/null +++ b/apps/web/playwright/settings/organizations.e2e.ts @@ -0,0 +1,190 @@ +import { expect } from "@playwright/test"; + +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +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.getByText(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); + }); + + 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/trpc/react/shared.ts b/packages/trpc/react/shared.ts index bb51b04dbbde62..aa06fcb9472b47 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", From ec9d601ad2f6ff1fdb9787a1019e1b918b74d123 Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 13:39:34 +0000 Subject: [PATCH 05/11] style(organizations): fix import order in E2E test Co-Authored-By: Claude Sonnet 4.6 --- apps/web/playwright/settings/organizations.e2e.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/playwright/settings/organizations.e2e.ts b/apps/web/playwright/settings/organizations.e2e.ts index 8a6f0b0ea79b2c..10842ee4ae49fc 100644 --- a/apps/web/playwright/settings/organizations.e2e.ts +++ b/apps/web/playwright/settings/organizations.e2e.ts @@ -1,8 +1,6 @@ -import { expect } from "@playwright/test"; - 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" }); From 5c4c176525bed4ca7e6a9269a7418c4a8ddad5ca Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 13:39:45 +0000 Subject: [PATCH 06/11] fix(organizations): fix data consistency bugs found in code review - acceptInvite: create Profile + guard against user already in another org - removeMember: delete Profile + clear user.organizationId in transaction - inviteMember: reject invite if invitee already belongs to an org - profile page: fix members link pointing to /teams (now /settings/organizations/members) - E2E acceptInvite: assert user.organizationId and Profile after accept - E2E members list: scope name selector to ul li to avoid strict mode violation Co-Authored-By: Claude Sonnet 4.6 --- .../organizations/profile/page.tsx | 2 +- .../playwright/settings/organizations.e2e.ts | 13 ++++++++- .../organizations/acceptInvite.handler.ts | 29 ++++++++++++++++--- .../organizations/inviteMember.handler.ts | 9 +++++- .../organizations/removeMember.handler.ts | 15 ++++++++-- 5 files changed, 58 insertions(+), 10 deletions(-) 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 index 67cb39e5564eeb..d8132396ffc5fb 100644 --- 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 @@ -44,7 +44,7 @@ export default function OrganizationProfilePage() {

    - + diff --git a/apps/web/playwright/settings/organizations.e2e.ts b/apps/web/playwright/settings/organizations.e2e.ts index 10842ee4ae49fc..4d6576cddd0806 100644 --- a/apps/web/playwright/settings/organizations.e2e.ts +++ b/apps/web/playwright/settings/organizations.e2e.ts @@ -54,7 +54,7 @@ test.describe("Organization Settings", () => { await page.waitForLoadState("networkidle"); await expect(page.getByTestId("invite-member-btn")).toBeVisible(); - await expect(page.getByText(owner.name ?? owner.username ?? "")).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 ({ @@ -135,6 +135,17 @@ test.describe("Organization Settings", () => { 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 }) => { diff --git a/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts b/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts index b5491f8d56ef79..e2ee8ca87ff4ae 100644 --- a/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts @@ -1,8 +1,11 @@ +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: { @@ -12,10 +15,16 @@ type AcceptInviteHandlerOptions = { }; export const acceptInviteHandler = async ({ ctx, input }: AcceptInviteHandlerOptions) => { - const membership = await prisma.membership.findUnique({ - where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId } }, - select: { accepted: true, teamId: true }, - }); + 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." }); @@ -25,11 +34,23 @@ export const acceptInviteHandler = async ({ ctx, input }: AcceptInviteHandlerOpt 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 }, diff --git a/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts index 490fb0c2d0003c..6f35454a1ca5aa 100644 --- a/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts @@ -21,7 +21,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberHandlerOpt const invitee = await prisma.user.findUnique({ where: { email: input.email.toLowerCase() }, - select: { id: true, name: true, email: true, locale: true }, + select: { id: true, name: true, email: true, locale: true, organizationId: true }, }); if (!invitee) { @@ -31,6 +31,13 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberHandlerOpt }); } + if (invitee.organizationId) { + throw new TRPCError({ + code: "CONFLICT", + message: "This user already belongs to an organization.", + }); + } + const existing = await prisma.membership.findUnique({ where: { userId_teamId: { userId: invitee.id, teamId: membership.team.id } }, select: { accepted: true }, diff --git a/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts b/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts index a60018b7be1403..1b4c24b50722a3 100644 --- a/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts @@ -33,9 +33,18 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberHandlerOpt throw new TRPCError({ code: "FORBIDDEN", message: "Cannot remove the organization owner." }); } - await prisma.membership.delete({ - where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, - }); + await prisma.$transaction([ + prisma.membership.delete({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + }), + 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 }; }; From aedf968b933a35262c2b040ed3329e3d78af2397 Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 13:41:18 +0000 Subject: [PATCH 07/11] fix(teams): re-add tRPC endpoint and ENDPOINTS entry after organizations merge The organizations feature commits overwrote shared.ts, losing the teams endpoint registration. Re-adds /api/trpc/teams/[trpc].ts and "teams" to ENDPOINTS so viewer.teams.* calls resolve correctly. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/pages/api/trpc/teams/[trpc].ts | 4 ++++ packages/trpc/react/shared.ts | 1 + 2 files changed, 5 insertions(+) create mode 100644 apps/web/pages/api/trpc/teams/[trpc].ts 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/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts index aa06fcb9472b47..d1ff23e78ad679 100644 --- a/packages/trpc/react/shared.ts +++ b/packages/trpc/react/shared.ts @@ -37,4 +37,5 @@ export const ENDPOINTS = [ "credits", "filterSegments", "phoneNumber", + "teams", ] as const; From b05375a2e8da7889d1d93aef62861ecc0665b456 Mon Sep 17 00:00:00 2001 From: regisstedile <158179411+regisstedile@users.noreply.github.com> Date: Mon, 18 May 2026 14:28:29 +0000 Subject: [PATCH 08/11] fix(organizations): address code review feedback - Fix race conditions: declineInvite uses atomic deleteMany with accepted=false condition; inviteMember catches P2002 unique violation instead of pre-check; removeMember and updateMemberRole push owner guard inside the DB operation - Add null user guard in acceptInvite before transaction writes - Add i18n keys for all hardcoded UI strings in organization pages - Fix SettingsLayoutAppDirClient to match org tab by href instead of name (tab.name is replaced with org display name, making name-based check brittle) Co-Authored-By: Claude Sonnet 4.6 --- .../SettingsLayoutAppDirClient.tsx | 3 +- .../organizations/general/page.tsx | 2 +- .../organizations/invites/page.tsx | 12 +++---- .../organizations/members/page.tsx | 29 ++++++++-------- .../organizations/profile/page.tsx | 6 ++-- packages/i18n/locales/en/common.json | 13 ++++++++ .../organizations/acceptInvite.handler.ts | 6 +++- .../organizations/declineInvite.handler.ts | 21 +++++------- .../organizations/inviteMember.handler.ts | 33 +++++++++---------- .../organizations/removeMember.handler.ts | 25 +++++++------- .../organizations/updateMemberRole.handler.ts | 28 ++++++++-------- 11 files changed, 96 insertions(+), 82 deletions(-) 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 beb73a7e27f3a2..0008887b943f98 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 @@ -256,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", @@ -392,7 +391,7 @@ const useTabs = ({ // check if name is in adminRequiredKeys return processedTabs.filter((tab) => { - if (organizationRequiredKeys.includes(tab.name)) return true; + if (tab.href === "/settings/organizations") return true; if (tab.name === "other_teams" && !permissions?.canUpdateOrganization) return false; if (isAdmin) return true; 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 index fb468a546e9fb5..85f59f559c99ed 100644 --- 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 @@ -140,7 +140,7 @@ export default function OrganizationGeneralPage() { /> {!canUpdate && ( -

    Only organization owners and admins can update these settings.

    +

    {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 index 2966a58112fdfe..0f63c75a53f8e2 100644 --- 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 @@ -19,7 +19,7 @@ export default function OrganizationInvitesPage() { onSuccess: async () => { await utils.viewer.organizations.listPendingInvites.invalidate(); await utils.viewer.me.get.invalidate(); - showToast("You have joined the organization.", "success"); + showToast(t("org_invite_joined"), "success"); router.push("/settings/organizations/general"); }, onError: (err) => showToast(err.message, "error"), @@ -28,7 +28,7 @@ export default function OrganizationInvitesPage() { const declineMutation = trpc.viewer.organizations.declineInvite.useMutation({ onSuccess: async () => { await utils.viewer.organizations.listPendingInvites.invalidate(); - showToast("Invite declined.", "success"); + showToast(t("invite_declined"), "success"); }, onError: (err) => showToast(err.message, "error"), }); @@ -38,14 +38,14 @@ export default function OrganizationInvitesPage() { return (
    {isLoading ? ( -

    Loading...

    +

    {t("loading")}

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

    No pending invites.

    +

    {t("no_pending_invites")}

    ) : (
      {pendingInvites.map(({ team }) => ( 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 index a775206d3c3d61..62b27857f72029 100644 --- 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 @@ -17,22 +17,23 @@ type InviteFormValues = { role: MembershipRole.MEMBER | MembershipRole.ADMIN; }; -const roleOptions = [ - { label: "Member", value: MembershipRole.MEMBER }, - { label: "Admin", value: MembershipRole.ADMIN }, +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("Invite sent.", "success"); + showToast(t("invite_sent"), "success"); setInviteOpen(false); form.reset(); }, @@ -44,7 +45,7 @@ export default function OrganizationMembersPage() { const removeMutation = trpc.viewer.organizations.removeMember.useMutation({ onSuccess: async () => { await utils.viewer.organizations.listMembers.invalidate(); - showToast("Member removed.", "success"); + showToast(t("member_removed"), "success"); }, onError: (err) => { showToast(err.message, "error"); @@ -68,16 +69,16 @@ export default function OrganizationMembersPage() { const members = data?.memberships ?? []; return ( - +
      - +
      {isLoading ? ( -

      Loading...

      +

      {t("loading")}

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

      No members yet.

      +

      {t("no_members_yet")}

      ) : (
        {members.map((m) => ( @@ -108,7 +109,7 @@ export default function OrganizationMembersPage() { size="sm" disabled={removeMutation.isPending} onClick={() => removeMutation.mutate({ userId: m.user.id })}> - Remove + {t("remove")} )} @@ -119,7 +120,7 @@ export default function OrganizationMembersPage() { - + @@ -129,12 +130,12 @@ export default function OrganizationMembersPage() { o.value === form.watch("role"))} onChange={(opt) => { @@ -147,7 +148,7 @@ export default function OrganizationMembersPage() { {t("cancel")} 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 index d8132396ffc5fb..66057c0b150ea2 100644 --- 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 @@ -19,7 +19,7 @@ export default function OrganizationProfilePage() { return (
        -

        Organization profile

        +

        {t("organization_profile")}

        {t("profile_org_description")}

        @@ -52,8 +52,8 @@ export default function OrganizationProfilePage() {
        ) : (
        -

        No organization found

        -

        This account is not currently attached to an organization.

        +

        {t("no_organization_found")}

        +

        {t("not_attached_to_org")}

        -