From a385bd4d5f4285ad074ce1a00433c40cb753b87a Mon Sep 17 00:00:00 2001 From: Vijay Date: Fri, 7 Mar 2025 21:49:30 +0530 Subject: [PATCH] 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 * updated error msgs with t * nit * nit --------- Co-authored-by: Younes Henni Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com> --- apps/web/playwright/out-of-office.e2e.ts | 80 ++++++++++++++++++- apps/web/public/static/locales/en/common.json | 2 + .../CreateNewOutOfOfficeEntryButton.tsx | 17 +++- .../CreateOrEditOutOfOfficeModal.tsx | 12 ++- .../outOfOffice/OutOfOfficeEntriesList.tsx | 10 ++- .../outOfOffice/OutOfOfficeToggleGroup.tsx | 16 +--- .../outOfOfficeEntriesList.handler.ts | 31 +++++-- .../viewer/teams/legacyListMembers.handler.ts | 7 ++ .../viewer/teams/legacyListMembers.schema.ts | 1 + 9 files changed, 147 insertions(+), 29 deletions(-) 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 && ( <>
diff --git a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx index 1a0dcf66ce..accc2fe627 100644 --- a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx +++ b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx @@ -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} /> @@ -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={() => { diff --git a/packages/features/settings/outOfOffice/OutOfOfficeToggleGroup.tsx b/packages/features/settings/outOfOffice/OutOfOfficeToggleGroup.tsx index 78d8162646..b6bc0d8091 100644 --- a/packages/features/settings/outOfOffice/OutOfOfficeToggleGroup.tsx +++ b/packages/features/settings/outOfOffice/OutOfOfficeToggleGroup.tsx @@ -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 ( { router.push(`${pathname}?${newQuery}`); }} options={toggleGroupOptions} - disabled={!hasTeamOOOAccess} /> ); }; diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.handler.ts b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.handler.ts index 9ce4077082..00e1b3a57c 100644 --- a/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts index ae18dfd4b8..c84dc61edb 100644 --- a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts @@ -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); diff --git a/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts b/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts index a6a31373f6..24dd6f64e5 100644 --- a/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/legacyListMembers.schema.ts @@ -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({});