feat: team ooo read-only for non-admin (#19700)

* feat team ooo read-only for non-admin

* Update apps/web/playwright/out-of-office.e2e.ts

Co-authored-by: Younes Henni <youneshenniwrites@gmail.com>

* updated error msgs with t

* nit

* nit

---------

Co-authored-by: Younes Henni <youneshenniwrites@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
This commit is contained in:
Vijay
2025-03-07 16:19:30 +00:00
committed by GitHub
co-authored by Younes Henni Tushar Bhatt
parent 4b4bc28f1d
commit a385bd4d5f
9 changed files with 147 additions and 29 deletions
+79 -1
View File
@@ -486,7 +486,7 @@ test.describe("Out of office", () => {
});
test.describe("Team OOO", () => {
test("Create, edit and delete", async ({ page, users }) => {
test("Admin can create, edit and delete team member's OOO", async ({ page, users }) => {
const t = await localize("en");
const teamMatesObj = [{ name: "member-1" }, { name: "member-2" }, { name: "member-3" }];
const teamAdmin = await users.create(
@@ -567,6 +567,84 @@ test.describe("Out of office", () => {
expect(page.locator(`text=${t("success_deleted_entry_out_of_office")}`)).toBeTruthy();
});
});
test("Non-Admin has read-only access to team mate's OOO", async ({ page, users }) => {
const member1Name = `member-1-${Date.now()}`;
const member2Name = `member-2-${Date.now()}`;
const member3Name = `member-3-${Date.now()}`;
const teamMatesObj = [{ name: member1Name }, { name: member2Name }];
const member3User = await users.create(
{ name: member3Name },
{
hasTeam: true,
teamRole: MembershipRole.MEMBER,
teammates: teamMatesObj,
}
);
const member1User = users.get().find((user) => user.name === member1Name);
const member2User = users.get().find((user) => user.name === member2Name);
//create ooos for member1 and member2
await prisma.outOfOfficeEntry.create({
data: {
start: dayjs().startOf("day").toDate(),
end: dayjs().startOf("day").add(1, "w").toDate(),
uuid: uuidv4(),
user: { connect: { id: member1User?.id } },
toUser: { connect: { id: member2User?.id } },
createdAt: new Date(),
reason: {
connect: {
id: 1,
},
},
},
});
await prisma.outOfOfficeEntry.create({
data: {
start: dayjs().startOf("day").add(3, "w").toDate(),
end: dayjs().startOf("day").add(4, "w").toDate(),
uuid: uuidv4(),
user: { connect: { id: member2User?.id } },
toUser: { connect: { id: member3User?.id } },
createdAt: new Date(),
reason: {
connect: {
id: 2,
},
},
},
});
await test.step("member3 logins and navigates to team OOO", async () => {
await member3User?.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office?type=team");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
//Non-Admin canNot create OOO for team members
await expect(page.locator('[data-testid="add_entry_ooo"]')).toBeDisabled();
//Non-Admin can view OOO entries of team members
await expect(
page.locator(`data-testid=table-redirect-${member2User?.username ?? "n-a"}`).nth(0)
).toBeVisible();
await expect(
page.locator(`data-testid=table-redirect-${member3User?.username ?? "n-a"}`).nth(0)
).toBeVisible();
//Non-Admin canNot edit OOO for team members
await expect(page.locator(`[data-testid="ooo-edit-${member2User?.username}"]`)).toBeDisabled();
await expect(page.locator(`[data-testid="ooo-edit-${member3User?.username}"]`)).toBeDisabled();
//Non-Admin canNot delete OOO for team members
await expect(page.locator(`[data-testid="ooo-delete-${member2User?.username}"]`)).toBeDisabled();
await expect(page.locator(`[data-testid="ooo-delete-${member3User?.username}"]`)).toBeDisabled();
});
});
});
});
@@ -3004,5 +3004,7 @@
"uid": "UID",
"link": "Link",
"tip_username_plus": "Tip: You can a '+' between usernames: cal.com/anna+brian to make a dynamic group meeting",
"user_has_no_team_yet": "You don't have a team yet",
"no_team_members": "You don't have team members yet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,13 +1,17 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { ButtonProps } from "@calcom/ui";
import { Button } from "@calcom/ui";
import { CreateOrEditOutOfOfficeEntryModal } from "./CreateOrEditOutOfOfficeModal";
import { OutOfOfficeTab } from "./OutOfOfficeToggleGroup";
const CreateNewOutOfOfficeEntry = ({
size,
@@ -17,8 +21,13 @@ const CreateNewOutOfOfficeEntry = ({
"data-testid"?: string;
}) => {
const { t } = useLocale();
const me = useMeQuery();
const { data: orgData } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner =
orgData && (orgData.user.role === MembershipRole.OWNER || orgData.user.role === MembershipRole.ADMIN);
const hasTeamOOOAdminAccess = isOrgAdminOrOwner || me?.data?.isTeamAdminOrOwner;
const params = useSearchParams();
const params = useCompatSearchParams();
const openModalOnStart = !!params?.get("om");
useEffect(() => {
if (openModalOnStart) {
@@ -27,6 +36,7 @@ const CreateNewOutOfOfficeEntry = ({
}, [openModalOnStart]);
const [openModal, setOpenModal] = useState(false);
const selectedTab = params?.get("type") ?? OutOfOfficeTab.MINE;
return (
<>
@@ -36,7 +46,8 @@ const CreateNewOutOfOfficeEntry = ({
className="flex items-center justify-between px-4"
StartIcon="plus"
onClick={() => setOpenModal(true)}
data-testid={rest["data-testid"]}>
data-testid={rest["data-testid"]}
disabled={selectedTab === OutOfOfficeTab.TEAM && !hasTeamOOOAdminAccess}>
{t("add")}
</Button>
{openModal && (
@@ -62,9 +62,9 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
const [searchMember, setSearchMember] = useState("");
const debouncedSearchMember = useDebounce(searchMember, 500);
const oooForMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
{ limit: 10, searchText: debouncedSearchMember },
{ limit: 10, searchText: debouncedSearchMember, adminOrOwnedTeamsOnly: true },
{
enabled: true,
enabled: oooType === OutOfOfficeTab.TEAM,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
@@ -97,7 +97,11 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
const [searchRedirectMember, setSearchRedirectMember] = useState("");
const debouncedSearchRedirect = useDebounce(searchRedirectMember, 500);
const redirectMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
{ limit: 10, searchText: debouncedSearchRedirect },
{
limit: 10,
searchText: debouncedSearchRedirect,
adminOrOwnedTeamsOnly: oooType === OutOfOfficeTab.TEAM,
},
{
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
@@ -218,7 +222,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
/>
{/* In case of Team, Select Member for whom OOO is created */}
{oooType === "team" && (
{oooType === OutOfOfficeTab.TEAM && (
<>
<div className="mb-4">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
@@ -37,6 +37,7 @@ interface OutOfOfficeEntry {
} | null;
notes: string | null;
user: { id: number; avatarUrl: string; username: string; email: string; name: string } | null;
canEditAndDelete: boolean;
}
export const OutOfOfficeEntriesList = () => {
@@ -220,7 +221,7 @@ export const OutOfOfficeEntriesList = () => {
};
editOutOfOfficeEntry(outOfOfficeEntryData);
}}
disabled={isPending || isFetching}
disabled={isPending || isFetching || !item.canEditAndDelete}
/>
</Tooltip>
<Tooltip content={t("delete")}>
@@ -229,7 +230,12 @@ export const OutOfOfficeEntriesList = () => {
type="button"
color="destructive"
variant="icon"
disabled={deleteOutOfOfficeEntryMutation.isPending || isPending || isFetching}
disabled={
deleteOutOfOfficeEntryMutation.isPending ||
isPending ||
isFetching ||
!item.canEditAndDelete
}
StartIcon="trash-2"
data-testid={`ooo-delete-${item.toUser?.username || "n-a"}`}
onClick={() => {
@@ -5,8 +5,6 @@ import { useCallback } from "react";
import { useCompatSearchParams } from "@calcom/embed-core/src/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { ToggleGroup } from "@calcom/ui";
export enum OutOfOfficeTab {
@@ -32,15 +30,10 @@ export const OutOfOfficeToggleGroup = () => {
const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
const { data: user } = trpc.viewer.me.useQuery();
const { data } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner =
data && (data.user.role === MembershipRole.OWNER || data.user.role === MembershipRole.ADMIN);
const toggleGroupOptions = [{ value: OutOfOfficeTab.MINE, label: t("my_ooo") }];
const hasTeamOOOAccess = isOrgAdminOrOwner || user?.isTeamAdminOrOwner;
if (hasTeamOOOAccess) {
toggleGroupOptions.push({ value: OutOfOfficeTab.TEAM, label: t("team_ooo") });
}
const toggleGroupOptions = [
{ value: OutOfOfficeTab.MINE, label: t("my_ooo") },
{ value: OutOfOfficeTab.TEAM, label: t("team_ooo") },
];
return (
<ToggleGroup
@@ -52,7 +45,6 @@ export const OutOfOfficeToggleGroup = () => {
router.push(`${pathname}?${newQuery}`);
}}
options={toggleGroupOptions}
disabled={!hasTeamOOOAccess}
/>
);
};
@@ -1,3 +1,4 @@
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -14,26 +15,29 @@ type GetOptions = {
};
export const outOfOfficeEntriesList = async ({ ctx, input }: GetOptions) => {
const t = await getTranslation(ctx.user.locale, "common");
const { cursor, limit, fetchTeamMembersEntries, searchTerm } = input;
let fetchOOOEntriesForIds = [ctx.user.id];
let reportingUserIds = [0];
if (fetchTeamMembersEntries) {
// Get teams where context user is admin or owner
// Get teams of context user
const teams = await prisma.membership.findMany({
where: {
userId: ctx.user.id,
role: {
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
accepted: true,
},
select: {
teamId: true,
role: true,
},
});
if (teams.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "user_not_admin_nor_owner" });
throw new TRPCError({ code: "NOT_FOUND", message: t("user_has_no_team_yet") });
}
const ownerOrAdminTeamIds = teams
.filter((team) => team.role === MembershipRole.OWNER || team.role === MembershipRole.ADMIN)
.map((team) => team.teamId);
// Fetch team member userIds
const teamMembers = await prisma.team.findMany({
@@ -43,6 +47,7 @@ export const outOfOfficeEntriesList = async ({ ctx, input }: GetOptions) => {
},
},
select: {
id: true,
members: {
select: {
userId: true,
@@ -55,9 +60,15 @@ export const outOfOfficeEntriesList = async ({ ctx, input }: GetOptions) => {
.flatMap((team) => team.members.filter((member) => member.accepted).map((member) => member.userId))
.filter((id) => id !== ctx.user.id);
if (userIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "no_team_members" });
throw new TRPCError({ code: "NOT_FOUND", message: t("no_team_members") });
}
fetchOOOEntriesForIds = userIds;
const adminTeams = teamMembers.filter(({ id }) => ownerOrAdminTeamIds.includes(id));
reportingUserIds = adminTeams.flatMap(({ members }) =>
members.filter(({ accepted, userId }) => accepted && userId !== ctx.user.id).map(({ userId }) => userId)
);
}
const whereClause = {
@@ -145,7 +156,13 @@ export const outOfOfficeEntriesList = async ({ ctx, input }: GetOptions) => {
}
return {
rows: outOfOfficeEntries || [],
rows:
outOfOfficeEntries.map((ooo) => {
return {
...ooo,
canEditAndDelete: fetchTeamMembersEntries ? reportingUserIds.includes(ooo.user.id) : true,
};
}) || [],
nextCursor,
meta: {
totalRowCount: getTotalEntries || 0,
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TLegacyListMembersInputSchema } from "./legacyListMembers.schema";
@@ -37,6 +38,9 @@ export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => {
where: {
userId: ctx.user.id,
accepted: true,
...(input.adminOrOwnedTeamsOnly
? { role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] } }
: {}),
},
select: { teamId: true },
});
@@ -47,6 +51,9 @@ export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => {
teamId: { in: input.teamIds },
userId: ctx.user.id,
accepted: true,
...(input.adminOrOwnedTeamsOnly
? { role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] } }
: {}),
},
});
teamsToQuery = memberships.map((m) => m.teamId);
@@ -5,6 +5,7 @@ export const ZListMembersInputSchema = z.object({
searchText: z.string().optional(),
limit: z.number().min(1).max(100).nullish(),
cursor: z.number().nullish(),
adminOrOwnedTeamsOnly: z.boolean().optional(),
});
export const ZLegacyListMembersInputSchema = ZListMembersInputSchema.extend({});