diff --git a/apps/web/playwright/out-of-office.e2e.ts b/apps/web/playwright/out-of-office.e2e.ts index b65e5677d5..b0a8a5fed6 100644 --- a/apps/web/playwright/out-of-office.e2e.ts +++ b/apps/web/playwright/out-of-office.e2e.ts @@ -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(); + }); + }); }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d1d9ab8f94..07b90ea1bf 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton.tsx b/packages/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton.tsx index ac55e844ed..30e9ea4d8d 100644 --- a/packages/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton.tsx +++ b/packages/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton.tsx @@ -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")} {openModal && ( diff --git a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx index 7438ae0074..c69c1c6474 100644 --- a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx +++ b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx @@ -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 && ( <>