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:
co-authored by
Younes Henni
Tushar Bhatt
parent
4b4bc28f1d
commit
a385bd4d5f
@@ -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({});
|
||||
|
||||
Reference in New Issue
Block a user