Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";

import LegacyPage from "@calcom/features/ee/teams/pages/team-members-view";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";

import { TeamMembersView } from "~/teams/team-members-view";

export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
Expand All @@ -12,12 +18,70 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/teams/${(await params).id}/members`
);

const Page = async () => {
const getCachedTeamRoles = unstable_cache(
async (teamId: number, organizationId?: number) => {
if (!organizationId) return []; // Fallback to traditional roles
try {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
return await roleManager.getTeamRoles(teamId);
} catch (error) {
// PBAC not enabled or error occurred, return empty array
return [];
}
},
undefined,
{ revalidate: 3600, tags: ["pbac.team.roles.list"] } // Cache for 1 hour
);

const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
try {
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
);

const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const t = await getTranslate();
const { id } = await params;
const teamId = parseInt(id);

const teamCaller = await createRouterCaller(viewerTeamsRouter);
const team = await teamCaller.get({ teamId });

if (!team) {
throw new Error("Team not found");
}

// Get organization ID (either the team's parent or the team itself if it's an org)
const organizationId = team.parentId || teamId;

// Load PBAC roles and attributes if available
const [roles, attributes] = await Promise.all([
getCachedTeamRoles(teamId, organizationId),
getCachedTeamAttributes(organizationId),
]);

const facetedTeamValues = {
roles,
teams: [team],
attributes: attributes.map((attribute) => ({
id: attribute.id,
name: attribute.name,
options: Array.from(new Set(attribute.options.map((option) => option.value))).map((value) => ({
value,
})),
})),
};
Comment on lines +70 to +80
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Populate the filters via server side


return (
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
<LegacyPage />
<TeamMembersView team={team} facetedTeamValues={facetedTeamValues} attributes={attributes} />
</SettingsHeader>
);
};
Expand Down
66 changes: 66 additions & 0 deletions apps/web/modules/teams/team-members-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { useState } from "react";

import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
import MemberList from "@calcom/features/ee/teams/components/MemberList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";

interface TeamMembersViewProps {
team: NonNullable<RouterOutputs["viewer"]["teams"]["get"]>;
facetedTeamValues?: {
roles: { id: string; name: string }[];
teams: RouterOutputs["viewer"]["teams"]["get"][];
attributes: {
id: string;
name: string;
options: {
value: string;
}[];
}[];
};
attributes?: any[];
}

export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProps) => {
const { t } = useLocale();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);

const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
const canLoggedInUserSeeMembers = !team.isPrivate || isTeamAdminOrOwner;

return (
<LicenseRequired>
<div>
{canLoggedInUserSeeMembers && (
<div className="mb-6">
<MemberList
team={team}
isOrgAdminOrOwner={false}
setShowMemberInvitationModal={setShowMemberInvitationModal}
facetedTeamValues={facetedTeamValues}
/>
</div>
)}
{!canLoggedInUserSeeMembers && (
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
<h2 className="text-default">{t("only_admin_can_see_members_of_team")}</h2>
</div>
)}
{showMemberInvitationModal && team && team.id && (
<MemberInvitationModalWithoutMembers
hideInvitationModal={() => setShowMemberInvitationModal(false)}
showMemberInvitationModal={showMemberInvitationModal}
teamId={team.id}
token={team.inviteToken?.token}
onSettingsOpen={() => setShowInviteLinkSettingsModal(true)}
/>
)}
</div>
</LicenseRequired>
);
};
84 changes: 66 additions & 18 deletions packages/features/ee/teams/components/EditMemberSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Form } from "@calcom/ui/components/form";
import { ToggleGroup } from "@calcom/ui/components/form";
import { ToggleGroup, Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetBody } from "@calcom/ui/components/sheet";
import { Skeleton, Loader } from "@calcom/ui/components/skeleton";
Expand All @@ -24,7 +24,7 @@ import { updateRoleInCache } from "./MemberChangeRoleModal";
import type { Action, State, User } from "./MemberList";

const formSchema = z.object({
role: z.enum([MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER]),
role: z.union([z.nativeEnum(MembershipRole), z.string()]), // Support both traditional roles and custom role IDs
});

type FormSchema = z.infer<typeof formSchema>;
Expand All @@ -47,7 +47,7 @@ export function EditMemberSheet({
(state) => [state.editMode, state.setEditMode, state.setMutationLoading],
shallow
);
const [role, setRole] = useState(selectedUser.role);
const [role, setRole] = useState<string>(selectedUser.customRoleId || selectedUser.role);
const name =
selectedUser.name ||
(() => {
Expand All @@ -60,7 +60,25 @@ export function EditMemberSheet({
const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, "");
const bookingLink = !!selectedUser.username ? `${bookerUrlWithoutProtocol}/${selectedUser.username}` : "";

// Load custom roles for the team
const { data: customRoles, isPending: isLoadingRoles } = trpc.viewer.pbac.getTeamRoles.useQuery(
{ teamId },
{
enabled: !!teamId,
retry: false, // Don't retry if PBAC is not enabled
}
);

const options = useMemo(() => {
// If we have custom roles, only show custom roles
if (customRoles && customRoles.length > 0) {
return customRoles.map((customRole) => ({
label: customRole.name,
value: customRole.id,
}));
}

// Otherwise, show traditional roles
return [
{
label: t("member"),
Expand All @@ -75,12 +93,16 @@ export function EditMemberSheet({
value: MembershipRole.OWNER,
},
].filter(({ value }) => value !== MembershipRole.OWNER || currentMember === MembershipRole.OWNER);
}, [t, currentMember]);
}, [t, currentMember, customRoles]);

// Determine if we should use Select (when custom roles exist) or ToggleGroup (traditional only)
const hasCustomRoles = customRoles && customRoles.length > 0;
const shouldUseSelect = hasCustomRoles; // Use Select for custom roles, ToggleGroup for traditional roles

const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
role: selectedUser.role,
role: selectedUser.customRoleId || selectedUser.role, // Use custom role ID if available, otherwise traditional role
},
});

Expand All @@ -101,13 +123,20 @@ export function EditMemberSheet({
});

if (previousValue) {
updateRoleInCache({ utils, teamId, memberId, role, searchTerm: undefined });
updateRoleInCache({
utils,
teamId,
memberId,
role: role as MembershipRole | string,
searchTerm: undefined,
customRoles,
});
}

return { previousValue };
},
onSuccess: async (_data, { role }) => {
setRole(role);
setRole(role as string);
setMutationLoading(false);
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.listMembers.invalidate();
Expand Down Expand Up @@ -153,7 +182,7 @@ export function EditMemberSheet({
dispatch({ type: "CLOSE_MODAL" });
}}>
<SheetContent className="bg-muted">
{!isPending ? (
{!isPending && !isLoadingRoles ? (
<Form form={form} handleSubmit={changeRole} className="flex h-full flex-col">
<SheetHeader showCloseButton={false} className="w-full">
<div className="border-sublte bg-default w-full rounded-xl border p-4">
Expand Down Expand Up @@ -185,23 +214,42 @@ export function EditMemberSheet({
<DisplayInfo label="Cal" value={bookingLink} icon="external-link" />
<DisplayInfo label={t("email")} value={selectedUser.email} icon="at-sign" />
{!editMode ? (
<DisplayInfo label={t("role")} value={[role]} icon="fingerprint" />
<DisplayInfo
label={t("role")}
value={[selectedUser.customRole?.name || selectedUser.role]}
icon="fingerprint"
/>
) : (
<div className="flex items-center gap-6">
<div className="flex w-[110px] items-center gap-2">
<Icon className="h-4 w-4" name="fingerprint" />
<label className="text-sm font-medium">{t("role")}</label>
</div>
<div className="flex flex-1">
<ToggleGroup
isFullWidth
defaultValue={role}
value={form.watch("role")}
options={options}
onValueChange={(value: FormSchema["role"]) => {
form.setValue("role", value);
}}
/>
{shouldUseSelect ? (
<Select
value={options.find((option) => option.value === form.watch("role"))}
onChange={(selectedOption: any) => {
if (selectedOption) {
form.setValue("role", selectedOption.value);
}
}}
options={options}
isDisabled={isLoadingRoles}
placeholder={isLoadingRoles ? t("loading") : t("select_role")}
className="flex-1"
/>
) : (
<ToggleGroup
isFullWidth
defaultValue={role}
value={form.watch("role")}
options={options}
onValueChange={(value: FormSchema["role"]) => {
form.setValue("role", value);
}}
/>
)}
</div>
</div>
)}
Expand Down
25 changes: 20 additions & 5 deletions packages/features/ee/teams/components/MemberChangeRoleModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ export const updateRoleInCache = ({
searchTerm,
role,
memberId,
customRoles,
}: {
utils: ReturnType<typeof trpc.useUtils>;
teamId: number;
searchTerm: string | undefined;
role: MembershipRole;
role: MembershipRole | string;
memberId: number;
customRoles?: { id: string; name: string }[];
}) => {
utils.viewer.teams.listMembers.setInfiniteData(
{
Expand All @@ -45,10 +47,23 @@ export const updateRoleInCache = ({
...data,
pages: data.pages.map((page) => ({
...page,
members: page.members.map((member) => ({
...member,
role: member.id === memberId ? role : member.role,
})),
members: page.members.map((member) => {
if (member.id === memberId) {
const isTraditionalRole = Object.values(MembershipRole).includes(role as MembershipRole);

// Find the new custom role object if assigning a custom role
const newCustomRole =
!isTraditionalRole && customRoles ? customRoles.find((cr) => cr.id === role) || null : null;

return {
...member,
role: isTraditionalRole ? (role as MembershipRole) : member.role,
customRoleId: isTraditionalRole ? null : (role as string),
customRole: newCustomRole,
};
}
return member;
}),
})),
};
}
Expand Down
Loading
Loading