-
-
- {tab && tab.icon && (
-
- )}
-
- {t(tab.name)}
-
+ return (
+ <>
+ {!["teams", "other_teams"].includes(tab.name) && (
+
+
+
+ {tab && tab.icon && (
+
+ )}
+ {!tab.icon && tab?.avatar && (
+

+ )}
+
+ {t(tab.name)}
+
+
-
- {teams &&
- teamMenuState &&
- teams.map((team, index: number) => {
- if (!teamMenuState[index]) {
- return null;
- }
- if (teamMenuState.some((teamState) => teamState.teamId === team.id))
- return (
-
- setTeamMenuState([
- ...teamMenuState,
- (teamMenuState[index] = {
- ...teamMenuState[index],
- teamMenuOpen: !teamMenuState[index].teamMenuOpen,
- }),
- ])
- }>
-
-
+
+ {tab.children?.map((child, index) => (
+
+ ))}
+
+
+ )}
+
+ {tab.name === "teams" && (
+
+
+
+
+ {tab && tab.icon && (
+
+ )}
+
+ {t(tab.name)}
+
+
+
+ {teams &&
+ teamMenuState &&
+ teams.map((team, index: number) => {
+ if (!teamMenuState[index]) {
+ return null;
+ }
+ if (teamMenuState.some((teamState) => teamState.teamId === team.id))
+ return (
+
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
@@ -322,96 +351,213 @@ const SettingsSidebarContainer = ({
}),
])
}>
-
- {teamMenuState[index].teamMenuOpen ? (
-
- ) : (
-
+
+
+ setTeamMenuState([
+ ...teamMenuState,
+ (teamMenuState[index] = {
+ ...teamMenuState[index],
+ teamMenuOpen: !teamMenuState[index].teamMenuOpen,
+ }),
+ ])
+ }>
+
+ {teamMenuState[index].teamMenuOpen ? (
+
+ ) : (
+
+ )}
+
+

+
{team.name}
+ {!team.accepted && (
+
+ Inv.
+
+ )}
+
+
+
+ {team.accepted && (
+
)}
-
-
- {team.name}
- {!team.accepted && (
-
- Inv.
-
- )}
-
-
-
- {team.accepted && (
-
- )}
-
- {(team.role === MembershipRole.OWNER ||
- team.role === MembershipRole.ADMIN ||
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore this exists wtf?
- (team.isOrgAdmin && team.isOrgAdmin)) && (
- <>
- {/* TODO */}
- {/* */}
- {/* Hide if there is a parent ID */}
- {!team.parentId ? (
+ {(team.role === MembershipRole.OWNER ||
+ team.role === MembershipRole.ADMIN ||
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore this exists wtf?
+ (team.isOrgAdmin && team.isOrgAdmin)) && (
<>
+ {/* TODO */}
+ {/* */}
- {HOSTED_CAL_FEATURES && (
-
- )}
+ {/* Hide if there is a parent ID */}
+ {!team.parentId ? (
+ <>
+
+ {HOSTED_CAL_FEATURES && (
+
+ )}
+ >
+ ) : null}
>
- ) : null}
- >
- )}
-
-
- );
- })}
- {(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
-
- )}
-
-
+ )}
+
+
+ );
+ })}
+ {(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
+
+ )}
+
+
+ )}
+
+ {tab.name === "other_teams" && (
+
+
+
+
+ {tab && tab.icon && (
+
+ )}
+
+ {t("org_admin_other_teams")}
+
+
+
+ {otherTeams &&
+ otherTeamMenuState &&
+ otherTeams.map((otherTeam, index: number) => {
+ if (!otherTeamMenuState[index]) {
+ return null;
+ }
+ if (otherTeamMenuState.some((teamState) => teamState.teamId === otherTeam.id))
+ return (
+
+ setOtherTeamMenuState([
+ ...otherTeamMenuState,
+ (otherTeamMenuState[index] = {
+ ...otherTeamMenuState[index],
+ teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
+ }),
+ ])
+ }>
+
+
+ setOtherTeamMenuState([
+ ...otherTeamMenuState,
+ (otherTeamMenuState[index] = {
+ ...otherTeamMenuState[index],
+ teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
+ }),
+ ])
+ }>
+
+ {otherTeamMenuState[index].teamMenuOpen ? (
+
+ ) : (
+
+ )}
+
+

+
{otherTeam.name}
+ {!otherTeam.accepted && (
+
+ Inv.
+
+ )}
+
+
+
+ {otherTeam.accepted && (
+
+ )}
+
+
+ <>
+ {/* TODO: enable appearance edit */}
+ {/* */}
+ >
+
+
+ );
+ })}
+
+
+ )}
+ >
);
})}
>
diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts
index 71f1ff2e18..f484d8189c 100644
--- a/packages/trpc/server/middlewares/sessionMiddleware.ts
+++ b/packages/trpc/server/middlewares/sessionMiddleware.ts
@@ -174,4 +174,13 @@ export const isAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
return next({ ctx: { ...ctx, user: user } });
});
+// Org admins can be admins or owners
+export const isOrgAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
+ const { user } = ctx;
+ if (!user?.organization?.isOrgAdmin) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+ return next({ ctx: { ...ctx, user: user } });
+});
+
export default sessionMiddleware;
diff --git a/packages/trpc/server/procedures/authedProcedure.ts b/packages/trpc/server/procedures/authedProcedure.ts
index 8be31a0d05..78845080e6 100644
--- a/packages/trpc/server/procedures/authedProcedure.ts
+++ b/packages/trpc/server/procedures/authedProcedure.ts
@@ -1,5 +1,5 @@
import perfMiddleware from "../middlewares/perfMiddleware";
-import { isAdminMiddleware, isAuthed } from "../middlewares/sessionMiddleware";
+import { isAdminMiddleware, isAuthed, isOrgAdminMiddleware } from "../middlewares/sessionMiddleware";
import { procedure } from "../trpc";
import publicProcedure from "./publicProcedure";
@@ -27,5 +27,6 @@ const authedProcedure = procedure.use(perfMiddleware).use(isAuthed);
/*export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) =>
authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit }));*/
export const authedAdminProcedure = publicProcedure.use(isAdminMiddleware);
+export const authedOrgAdminProcedure = publicProcedure.use(isOrgAdminMiddleware);
export default authedProcedure;
diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx
index b98fc38512..e71ddff380 100644
--- a/packages/trpc/server/routers/viewer/organizations/_router.tsx
+++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx
@@ -1,12 +1,17 @@
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
-import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure";
+import authedProcedure, {
+ authedAdminProcedure,
+ authedOrgAdminProcedure,
+} from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAdminVerifyInput } from "./adminVerify.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZGetMembersInput } from "./getMembers.schema";
+import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
import { ZListMembersSchema } from "./listMembers.schema";
+import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
@@ -24,6 +29,9 @@ type OrganizationsRouterHandlerCache = {
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
getMembers?: typeof import("./getMembers.handler").getMembersHandler;
+ listOtherTeams?: typeof import("./listOtherTeams.handler").listOtherTeamHandler;
+ getOtherTeam?: typeof import("./getOtherTeam.handler").getOtherTeamHandler;
+ listOtherTeamMembers?: typeof import("./listOtherTeamMembers.handler").listOtherTeamMembers;
};
const UNSTABLE_HANDLER_CACHE: OrganizationsRouterHandlerCache = {};
@@ -227,4 +235,56 @@ export const viewerOrganizationsRouter = router({
ctx,
});
}),
+ listOtherTeams: authedOrgAdminProcedure.query(async ({ ctx }) => {
+ if (!UNSTABLE_HANDLER_CACHE.listOtherTeams) {
+ UNSTABLE_HANDLER_CACHE.listOtherTeams = await import("./listOtherTeams.handler").then(
+ (mod) => mod.listOtherTeamHandler
+ );
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.listOtherTeams) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.listOtherTeams({
+ ctx,
+ });
+ }),
+ getOtherTeam: authedOrgAdminProcedure.input(ZGetOtherTeamInputSchema).query(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.getOtherTeam) {
+ UNSTABLE_HANDLER_CACHE.getOtherTeam = await import("./getOtherTeam.handler").then(
+ (mod) => mod.getOtherTeamHandler
+ );
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.getOtherTeam) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.getOtherTeam({
+ ctx,
+ input,
+ });
+ }),
+ listOtherTeamMembers: authedOrgAdminProcedure
+ .input(ZListOtherTeamMembersSchema)
+ .query(async ({ ctx, input }) => {
+ if (!UNSTABLE_HANDLER_CACHE.listOtherTeamMembers) {
+ UNSTABLE_HANDLER_CACHE.listOtherTeamMembers = await import("./listOtherTeamMembers.handler").then(
+ (mod) => mod.listOtherTeamMembers
+ );
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.listOtherTeamMembers) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.listOtherTeamMembers({
+ ctx,
+ input,
+ });
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/organizations/getOtherTeam.handler.ts b/packages/trpc/server/routers/viewer/organizations/getOtherTeam.handler.ts
new file mode 100644
index 0000000000..46cb6f05a2
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/getOtherTeam.handler.ts
@@ -0,0 +1,54 @@
+import { z } from "zod";
+
+import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
+import prisma from "@calcom/prisma";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../trpc";
+
+export const ZGetOtherTeamInputSchema = z.object({
+ teamId: z.number(),
+});
+
+export type TGetOtherTeamInputSchema = z.infer
;
+
+type GetOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TGetOtherTeamInputSchema;
+};
+
+export const getOtherTeamHandler = async ({ input }: GetOptions) => {
+ // No need to validate if user is admin of org as we already do that on authedOrgAdminProcedure
+ const team = await prisma.team.findFirst({
+ where: {
+ id: input.teamId,
+ },
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ logo: true,
+ bio: true,
+ metadata: true,
+ isPrivate: true,
+ parent: {
+ select: {
+ id: true,
+ slug: true,
+ },
+ },
+ },
+ });
+
+ if (!team) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
+ }
+
+ return {
+ ...team,
+ safeBio: markdownToSafeHTML(team.bio),
+ };
+};
diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts
new file mode 100644
index 0000000000..93099cf24f
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts
@@ -0,0 +1,81 @@
+import type { Prisma } from "@prisma/client";
+import z from "zod";
+
+import { prisma } from "@calcom/prisma";
+
+import type { TrpcSessionUser } from "../../../trpc";
+
+export const ZListOtherTeamMembersSchema = z.object({
+ teamId: z.number(),
+ query: z.string().optional(),
+ limit: z.number().optional(),
+ offset: z.number().optional(),
+});
+
+export type TListOtherTeamMembersSchema = z.infer;
+
+type ListOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TListOtherTeamMembersSchema;
+};
+
+export const listOtherTeamMembers = async ({ ctx, input }: ListOptions) => {
+ const whereConditional: Prisma.MembershipWhereInput = {
+ teamId: input.teamId,
+ };
+ const { limit = 20 } = input;
+ let { offset = 0 } = input;
+
+ if (input.query) {
+ offset = 0;
+ whereConditional.user = {
+ OR: [
+ {
+ username: {
+ contains: input.query,
+ mode: "insensitive",
+ },
+ },
+ {
+ name: {
+ contains: input.query,
+ mode: "insensitive",
+ },
+ },
+ {
+ email: {
+ contains: input.query,
+ mode: "insensitive",
+ },
+ },
+ ],
+ };
+ }
+
+ const members = await prisma.membership.findMany({
+ where: whereConditional,
+ select: {
+ id: true,
+ role: true,
+ accepted: true,
+ disableImpersonation: true,
+ user: {
+ select: {
+ id: true,
+ username: true,
+ name: true,
+ email: true,
+ avatar: true,
+ },
+ },
+ },
+ distinct: ["userId"],
+ orderBy: { role: "desc" },
+ take: limit,
+ skip: offset,
+ });
+
+ return members;
+};
diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts
new file mode 100644
index 0000000000..7fcb71dd96
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts
@@ -0,0 +1,43 @@
+import { prisma } from "@calcom/prisma";
+
+import type { TrpcSessionUser } from "../../../trpc";
+
+type ListOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+};
+
+export const listOtherTeamHandler = async ({ ctx }: ListOptions) => {
+ const teamsInOrgIamNotPartOf = await prisma.membership.findMany({
+ where: {
+ userId: {
+ not: ctx.user.id,
+ },
+ team: {
+ parent: {
+ is: {
+ id: ctx.user?.organization?.id,
+ },
+ },
+ members: {
+ none: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ },
+ include: {
+ team: true,
+ },
+ orderBy: { role: "desc" },
+ distinct: ["teamId"],
+ });
+
+ return teamsInOrgIamNotPartOf.map(({ team, ...membership }) => ({
+ role: membership.role,
+ accepted: membership.accepted,
+ isOrgAdmin: true,
+ ...team,
+ }));
+};
diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts
index 22a98e4dc5..e578eefc2c 100644
--- a/packages/trpc/server/routers/viewer/teams/update.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts
@@ -19,7 +19,13 @@ type UpdateOptions = {
};
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
- if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
+ const isOrgAdmin = ctx.user?.organization?.isOrgAdmin;
+
+ if (!isOrgAdmin) {
+ if (!(await isTeamAdmin(ctx.user?.id, input.id))) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+ }
if (input.slug) {
const userConflict = await prisma.team.findMany({