Skip to content

Commit 27304af

Browse files
authored
Merge branch 'main' into credit-issue
2 parents 094977f + 29e1dcb commit 27304af

13 files changed

Lines changed: 410 additions & 68 deletions

File tree

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import { createRouterCaller } from "app/_trpc/context";
12
import { _generateMetadata, getTranslate } from "app/_utils";
3+
import { unstable_cache } from "next/cache";
24

3-
import LegacyPage from "@calcom/features/ee/teams/pages/team-members-view";
5+
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
46
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
7+
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
8+
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
9+
10+
import { TeamMembersView } from "~/teams/team-members-view";
511

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

15-
const Page = async () => {
21+
const getCachedTeamRoles = unstable_cache(
22+
async (teamId: number, organizationId?: number) => {
23+
if (!organizationId) return []; // Fallback to traditional roles
24+
try {
25+
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
26+
return await roleManager.getTeamRoles(teamId);
27+
} catch (error) {
28+
// PBAC not enabled or error occurred, return empty array
29+
return [];
30+
}
31+
},
32+
undefined,
33+
{ revalidate: 3600, tags: ["pbac.team.roles.list"] } // Cache for 1 hour
34+
);
35+
36+
const getCachedTeamAttributes = unstable_cache(
37+
async (organizationId?: number) => {
38+
if (!organizationId) return [];
39+
try {
40+
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
41+
} catch (error) {
42+
return [];
43+
}
44+
},
45+
undefined,
46+
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
47+
);
48+
49+
const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
1650
const t = await getTranslate();
51+
const { id } = await params;
52+
const teamId = parseInt(id);
53+
54+
const teamCaller = await createRouterCaller(viewerTeamsRouter);
55+
const team = await teamCaller.get({ teamId });
56+
57+
if (!team) {
58+
throw new Error("Team not found");
59+
}
60+
61+
// Get organization ID (either the team's parent or the team itself if it's an org)
62+
const organizationId = team.parentId || teamId;
63+
64+
// Load PBAC roles and attributes if available
65+
const [roles, attributes] = await Promise.all([
66+
getCachedTeamRoles(teamId, organizationId),
67+
getCachedTeamAttributes(organizationId),
68+
]);
69+
70+
const facetedTeamValues = {
71+
roles,
72+
teams: [team],
73+
attributes: attributes.map((attribute) => ({
74+
id: attribute.id,
75+
name: attribute.name,
76+
options: Array.from(new Set(attribute.options.map((option) => option.value))).map((value) => ({
77+
value,
78+
})),
79+
})),
80+
};
1781

1882
return (
1983
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
20-
<LegacyPage />
84+
<TeamMembersView team={team} facetedTeamValues={facetedTeamValues} attributes={attributes} />
2185
</SettingsHeader>
2286
);
2387
};

apps/web/components/booking/BookingListItem.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ function BookingListItem(booking: BookingItemProps) {
245245
isDisabledCancelling,
246246
isDisabledRescheduling,
247247
isCalVideoLocation:
248-
!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "",
248+
!booking.location ||
249+
booking.location === "integrations:daily" ||
250+
(typeof booking.location === "string" && booking.location.trim() === ""),
249251
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
250252
cardCharged,
251253
attendeeList,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
6+
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
7+
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
8+
import MemberList from "@calcom/features/ee/teams/components/MemberList";
9+
import { useLocale } from "@calcom/lib/hooks/useLocale";
10+
import type { RouterOutputs } from "@calcom/trpc/react";
11+
12+
interface TeamMembersViewProps {
13+
team: NonNullable<RouterOutputs["viewer"]["teams"]["get"]>;
14+
facetedTeamValues?: {
15+
roles: { id: string; name: string }[];
16+
teams: RouterOutputs["viewer"]["teams"]["get"][];
17+
attributes: {
18+
id: string;
19+
name: string;
20+
options: {
21+
value: string;
22+
}[];
23+
}[];
24+
};
25+
attributes?: any[];
26+
}
27+
28+
export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProps) => {
29+
const { t } = useLocale();
30+
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
31+
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
32+
33+
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
34+
const canLoggedInUserSeeMembers = !team.isPrivate || isTeamAdminOrOwner;
35+
36+
return (
37+
<LicenseRequired>
38+
<div>
39+
{canLoggedInUserSeeMembers && (
40+
<div className="mb-6">
41+
<MemberList
42+
team={team}
43+
isOrgAdminOrOwner={false}
44+
setShowMemberInvitationModal={setShowMemberInvitationModal}
45+
facetedTeamValues={facetedTeamValues}
46+
/>
47+
</div>
48+
)}
49+
{!canLoggedInUserSeeMembers && (
50+
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
51+
<h2 className="text-default">{t("only_admin_can_see_members_of_team")}</h2>
52+
</div>
53+
)}
54+
{showMemberInvitationModal && team && team.id && (
55+
<MemberInvitationModalWithoutMembers
56+
hideInvitationModal={() => setShowMemberInvitationModal(false)}
57+
showMemberInvitationModal={showMemberInvitationModal}
58+
teamId={team.id}
59+
token={team.inviteToken?.token}
60+
onSettingsOpen={() => setShowInviteLinkSettingsModal(true)}
61+
/>
62+
)}
63+
</div>
64+
</LicenseRequired>
65+
);
66+
};

packages/features/ee/teams/components/EditMemberSheet.tsx

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { MembershipRole } from "@calcom/prisma/enums";
1313
import { trpc } from "@calcom/trpc/react";
1414
import { Avatar } from "@calcom/ui/components/avatar";
1515
import { Form } from "@calcom/ui/components/form";
16-
import { ToggleGroup } from "@calcom/ui/components/form";
16+
import { ToggleGroup, Select } from "@calcom/ui/components/form";
1717
import { Icon } from "@calcom/ui/components/icon";
1818
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetBody } from "@calcom/ui/components/sheet";
1919
import { Skeleton, Loader } from "@calcom/ui/components/skeleton";
@@ -24,7 +24,7 @@ import { updateRoleInCache } from "./MemberChangeRoleModal";
2424
import type { Action, State, User } from "./MemberList";
2525

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

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

63+
// Load custom roles for the team
64+
const { data: customRoles, isPending: isLoadingRoles } = trpc.viewer.pbac.getTeamRoles.useQuery(
65+
{ teamId },
66+
{
67+
enabled: !!teamId,
68+
retry: false, // Don't retry if PBAC is not enabled
69+
}
70+
);
71+
6372
const options = useMemo(() => {
73+
// If we have custom roles, only show custom roles
74+
if (customRoles && customRoles.length > 0) {
75+
return customRoles.map((customRole) => ({
76+
label: customRole.name,
77+
value: customRole.id,
78+
}));
79+
}
80+
81+
// Otherwise, show traditional roles
6482
return [
6583
{
6684
label: t("member"),
@@ -75,12 +93,16 @@ export function EditMemberSheet({
7593
value: MembershipRole.OWNER,
7694
},
7795
].filter(({ value }) => value !== MembershipRole.OWNER || currentMember === MembershipRole.OWNER);
78-
}, [t, currentMember]);
96+
}, [t, currentMember, customRoles]);
97+
98+
// Determine if we should use Select (when custom roles exist) or ToggleGroup (traditional only)
99+
const hasCustomRoles = customRoles && customRoles.length > 0;
100+
const shouldUseSelect = hasCustomRoles; // Use Select for custom roles, ToggleGroup for traditional roles
79101

80102
const form = useForm({
81103
resolver: zodResolver(formSchema),
82104
defaultValues: {
83-
role: selectedUser.role,
105+
role: selectedUser.customRoleId || selectedUser.role, // Use custom role ID if available, otherwise traditional role
84106
},
85107
});
86108

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

103125
if (previousValue) {
104-
updateRoleInCache({ utils, teamId, memberId, role, searchTerm: undefined });
126+
updateRoleInCache({
127+
utils,
128+
teamId,
129+
memberId,
130+
role: role as MembershipRole | string,
131+
searchTerm: undefined,
132+
customRoles,
133+
});
105134
}
106135

107136
return { previousValue };
108137
},
109138
onSuccess: async (_data, { role }) => {
110-
setRole(role);
139+
setRole(role as string);
111140
setMutationLoading(false);
112141
await utils.viewer.teams.get.invalidate();
113142
await utils.viewer.teams.listMembers.invalidate();
@@ -153,7 +182,7 @@ export function EditMemberSheet({
153182
dispatch({ type: "CLOSE_MODAL" });
154183
}}>
155184
<SheetContent className="bg-muted">
156-
{!isPending ? (
185+
{!isPending && !isLoadingRoles ? (
157186
<Form form={form} handleSubmit={changeRole} className="flex h-full flex-col">
158187
<SheetHeader showCloseButton={false} className="w-full">
159188
<div className="border-sublte bg-default w-full rounded-xl border p-4">
@@ -185,23 +214,42 @@ export function EditMemberSheet({
185214
<DisplayInfo label="Cal" value={bookingLink} icon="external-link" />
186215
<DisplayInfo label={t("email")} value={selectedUser.email} icon="at-sign" />
187216
{!editMode ? (
188-
<DisplayInfo label={t("role")} value={[role]} icon="fingerprint" />
217+
<DisplayInfo
218+
label={t("role")}
219+
value={[selectedUser.customRole?.name || selectedUser.role]}
220+
icon="fingerprint"
221+
/>
189222
) : (
190223
<div className="flex items-center gap-6">
191224
<div className="flex w-[110px] items-center gap-2">
192225
<Icon className="h-4 w-4" name="fingerprint" />
193226
<label className="text-sm font-medium">{t("role")}</label>
194227
</div>
195228
<div className="flex flex-1">
196-
<ToggleGroup
197-
isFullWidth
198-
defaultValue={role}
199-
value={form.watch("role")}
200-
options={options}
201-
onValueChange={(value: FormSchema["role"]) => {
202-
form.setValue("role", value);
203-
}}
204-
/>
229+
{shouldUseSelect ? (
230+
<Select
231+
value={options.find((option) => option.value === form.watch("role"))}
232+
onChange={(selectedOption: any) => {
233+
if (selectedOption) {
234+
form.setValue("role", selectedOption.value);
235+
}
236+
}}
237+
options={options}
238+
isDisabled={isLoadingRoles}
239+
placeholder={isLoadingRoles ? t("loading") : t("select_role")}
240+
className="flex-1"
241+
/>
242+
) : (
243+
<ToggleGroup
244+
isFullWidth
245+
defaultValue={role}
246+
value={form.watch("role")}
247+
options={options}
248+
onValueChange={(value: FormSchema["role"]) => {
249+
form.setValue("role", value);
250+
}}
251+
/>
252+
)}
205253
</div>
206254
</div>
207255
)}

packages/features/ee/teams/components/MemberChangeRoleModal.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ export const updateRoleInCache = ({
2020
searchTerm,
2121
role,
2222
memberId,
23+
customRoles,
2324
}: {
2425
utils: ReturnType<typeof trpc.useUtils>;
2526
teamId: number;
2627
searchTerm: string | undefined;
27-
role: MembershipRole;
28+
role: MembershipRole | string;
2829
memberId: number;
30+
customRoles?: { id: string; name: string }[];
2931
}) => {
3032
utils.viewer.teams.listMembers.setInfiniteData(
3133
{
@@ -45,10 +47,23 @@ export const updateRoleInCache = ({
4547
...data,
4648
pages: data.pages.map((page) => ({
4749
...page,
48-
members: page.members.map((member) => ({
49-
...member,
50-
role: member.id === memberId ? role : member.role,
51-
})),
50+
members: page.members.map((member) => {
51+
if (member.id === memberId) {
52+
const isTraditionalRole = Object.values(MembershipRole).includes(role as MembershipRole);
53+
54+
// Find the new custom role object if assigning a custom role
55+
const newCustomRole =
56+
!isTraditionalRole && customRoles ? customRoles.find((cr) => cr.id === role) || null : null;
57+
58+
return {
59+
...member,
60+
role: isTraditionalRole ? (role as MembershipRole) : member.role,
61+
customRoleId: isTraditionalRole ? null : (role as string),
62+
customRole: newCustomRole,
63+
};
64+
}
65+
return member;
66+
}),
5267
})),
5368
};
5469
}

0 commit comments

Comments
 (0)