From 72a9d0ee05ad4516517d3fc5aee786cf5d14f5c6 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:34:45 +0100 Subject: [PATCH] [#527] calendar delegation CRUD (#581) * [#527] added panel to grant right for calendar delegation * [#527] added rights management to access modal * [#527] conditionnal rendering of access granting based on rights * [#527] saves on close except cancel * [#527] added tests --- .../Calendars/CalendarAccessRights.test.tsx | 426 ++++++++++++++++++ .../features/Calendars/CalendarModal.test.tsx | 86 ++-- src/components/Calendar/AccessTab.tsx | 41 +- .../Calendar/CalendarAccessRights.tsx | 354 +++++++++++++++ src/components/Calendar/CalendarModal.tsx | 136 +++++- src/features/Calendars/CalendarTypes.ts | 10 + .../Calendars/api/updateDelegationCalendar.ts | 17 + .../services/getCalendarsListAsync.ts | 17 +- .../services/updateDelegationCalendarAsync.ts | 33 ++ src/locales/en.json | 6 + src/utils/accessRightToDavProp.tsx | 16 + 11 files changed, 1073 insertions(+), 69 deletions(-) create mode 100644 __test__/features/Calendars/CalendarAccessRights.test.tsx create mode 100644 src/components/Calendar/CalendarAccessRights.tsx create mode 100644 src/features/Calendars/api/updateDelegationCalendar.ts create mode 100644 src/features/Calendars/services/updateDelegationCalendarAsync.ts create mode 100644 src/utils/accessRightToDavProp.tsx diff --git a/__test__/features/Calendars/CalendarAccessRights.test.tsx b/__test__/features/Calendars/CalendarAccessRights.test.tsx new file mode 100644 index 0000000..dcee911 --- /dev/null +++ b/__test__/features/Calendars/CalendarAccessRights.test.tsx @@ -0,0 +1,426 @@ +import { AccessTab } from "@/components/Calendar/AccessTab"; +import { + CalendarAccessRights, + UserWithAccess, +} from "@/components/Calendar/CalendarAccessRights"; +import CalendarPopover from "@/components/Calendar/CalendarModal"; +import { updateDelegationCalendar } from "@/features/Calendars/api/updateDelegationCalendar"; +import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes"; +import * as eventThunks from "@/features/Calendars/services"; +import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync"; +import { getUserDetails } from "@/features/User/userAPI"; +import { accessRightToDavProp } from "@/utils/accessRightToDavProp"; +import { api } from "@/utils/apiUtils"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "../../utils/Renderwithproviders"; + +jest.mock("@/utils/apiUtils", () => ({ + api: { post: jest.fn() }, +})); + +jest.mock("@/features/User/userAPI", () => ({ + getUserDetails: jest.fn(), +})); + +jest.mock("@/features/Calendars/CalendarApi", () => ({ + getSecretLink: jest.fn().mockReturnValue(""), + exportCalendar: jest.fn(), +})); + +const mockThunkWithUnwrap = (resolvedValue: unknown = {}) => + jest.fn().mockImplementation(() => { + const result = Object.assign(Promise.resolve(resolvedValue), { + unwrap: () => Promise.resolve(resolvedValue), + }); + return jest.fn().mockReturnValue(result); + }); + +describe("accessRightToDavProp", () => { + it("maps 5 (ADMIN) → dav:administration", () => { + expect(accessRightToDavProp(5)).toBe("dav:administration"); + }); + + it("maps 3 (EDITOR) → dav:read-write", () => { + expect(accessRightToDavProp(3)).toBe("dav:read-write"); + }); + + it("maps 2 (VIEW) → dav:read", () => { + expect(accessRightToDavProp(2)).toBe("dav:read"); + }); + + it("defaults to dav:read for unknown values (covered by default case)", () => { + expect(accessRightToDavProp(999 as AccessRight)).toBe("dav:read"); + }); +}); + +describe("updateDelegationCalendar", () => { + beforeEach(() => jest.clearAllMocks()); + + it("posts to the correct DAV endpoint with the share body", async () => { + (api.post as jest.Mock).mockResolvedValue({ ok: true }); + + const share = { + set: [{ "dav:href": "mailto:alice@example.com", "dav:read": true }], + remove: [], + }; + + await updateDelegationCalendar("/calendars/user/cal1.json", share); + + await waitFor(() => + expect(api.post).toHaveBeenCalledWith( + "dav/calendars/user/cal1.json", + expect.objectContaining({ + body: JSON.stringify({ share }), + }) + ) + ); + }); +}); + +const baseCalendar: Calendar = { + id: "user1/cal1", + name: "My Calendar", + description: "", + color: { color: "#0062FF", dark: "#FFF" }, + link: "/calendars/user1/cal1.json", + visibility: "public", + events: {}, + invite: [], + owner: { emails: ["user1@example.com"] }, +}; + +describe("CalendarAccessRights", () => { + const mockOnChange = jest.fn(); + const mockOnInvitesLoaded = jest.fn(); + + beforeEach(() => jest.clearAllMocks()); + + it("renders the grant access rights section", () => { + renderWithProviders( + + ); + + expect( + screen.getByText("calendarPopover.access.grantAccessRights") + ).toBeInTheDocument(); + }); + + it("shows a list of users already passed in via value prop", () => { + const users: UserWithAccess[] = [ + { + openpaasId: "abc", + displayName: "Alice", + email: "alice@example.com", + accessRight: 2, + }, + ]; + + renderWithProviders( + + ); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + }); + + it("calls onChange with user removed when remove button is clicked", () => { + const users: UserWithAccess[] = [ + { + openpaasId: "abc", + displayName: "Alice", + email: "alice@example.com", + accessRight: 2, + }, + ]; + + renderWithProviders( + + ); + + fireEvent.click(screen.getByLabelText(/remove/i)); + expect(mockOnChange).toHaveBeenCalledWith([]); + }); + + it("loads invited users from calendar.invite on mount", async () => { + const calendarWithInvite: Calendar = { + ...baseCalendar, + invite: [ + { + href: "mailto:bob@example.com", + principal: "/principals/users/bob123", + access: 3, + inviteStatus: 1, + }, + ], + }; + + (getUserDetails as jest.Mock).mockResolvedValue({ + preferredEmail: "bob@example.com", + firstname: "Bob", + lastname: "Smith", + emails: ["bob@example.com"], + }); + + renderWithProviders( + + ); + + await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123")); + await waitFor(() => + expect(mockOnInvitesLoaded).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + email: "bob@example.com", + accessRight: 3, + }), + ]) + ) + ); + }); + + it("skips invite entries where getUserDetails throws", async () => { + const calendarWithInvite: Calendar = { + ...baseCalendar, + invite: [ + { + href: "mailto:ghost@example.com", + principal: "/principals/users/ghost", + access: 2, + inviteStatus: 1, + }, + ], + }; + + (getUserDetails as jest.Mock).mockRejectedValue(new Error("Not found")); + + renderWithProviders( + + ); + + await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([])); + }); + + it("shows a loading spinner while invite users are being fetched", async () => { + const calendarWithInvite: Calendar = { + ...baseCalendar, + invite: [ + { + href: "mailto:carol@example.com", + principal: "/principals/users/carol", + access: 2, + inviteStatus: 1, + }, + ], + }; + + // Never resolves during the assertion window + (getUserDetails as jest.Mock).mockImplementation( + () => new Promise(() => {}) + ); + + renderWithProviders( + + ); + + expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument(); + }); +}); + +const userState = { + user: { + userData: { openpaasId: "user1", email: "user1@example.com" }, + }, +}; + +describe("AccessTab – conditional rendering of CalendarAccessRights", () => { + const noop = jest.fn(); + + afterEach(() => jest.clearAllMocks()); + + it("shows CalendarAccessRights when the current user owns the calendar", () => { + renderWithProviders( + , + { + ...userState, + calendars: { list: { "user1/cal1": baseCalendar } }, + } + ); + + expect( + screen.getByText("calendarPopover.access.grantAccessRights") + ).toBeInTheDocument(); + }); + + it("hides CalendarAccessRights for a non-owner without admin delegation", () => { + const foreignCalendar: Calendar = { + ...baseCalendar, + id: "otherUser/cal1", + invite: [], + }; + + renderWithProviders( + , + { + ...userState, + calendars: { list: { "otherUser/cal1": foreignCalendar } }, + } + ); + + expect( + screen.queryByText("calendarPopover.access.grantAccessRights") + ).not.toBeInTheDocument(); + }); + + it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => { + const delegatedCalendar: Calendar = { + ...baseCalendar, + id: "otherUser/cal1", + invite: [ + { + href: "mailto:user1@example.com", + principal: "/principals/users/user1", + access: 5, + inviteStatus: 1, + }, + ], + }; + + renderWithProviders( + , + { + ...userState, + calendars: { list: { "otherUser/cal1": delegatedCalendar } }, + } + ); + + expect( + screen.getByText("calendarPopover.access.grantAccessRights") + ).toBeInTheDocument(); + }); +}); + +const existingCalendar: Calendar = { + id: "user1/cal1", + name: "Existing Cal", + description: "Desc", + color: { color: "#0062FF", dark: "#FFF" }, + link: "/calendars/user/cal1", + visibility: "public", + events: {}, + invite: [], + owner: { emails: ["user1@example.com"] }, +}; + +describe("CalendarModal – updateDelegationCalendarAsync integration", () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(eventThunks, "patchCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); + jest + .spyOn(eventThunks, "patchACLCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); + jest + .spyOn(delegationThunks, "updateDelegationCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); + }); + + it("does NOT call updateDelegationCalendarAsync when no users are added or removed", async () => { + renderWithProviders( + , + { ...userState, calendars: { list: { "user1/cal1": existingCalendar } } } + ); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); + + expect( + delegationThunks.updateDelegationCalendarAsync + ).not.toHaveBeenCalled(); + }); +}); + +describe("CalendarModal – cancel button", () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(delegationThunks, "updateDelegationCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); + jest + .spyOn(eventThunks, "patchCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); + }); + + it("calls onClose without saving when Cancel is clicked", async () => { + renderWithProviders( + , + { ...userState } + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); + expect( + delegationThunks.updateDelegationCalendarAsync + ).not.toHaveBeenCalled(); + expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index 0e9d6d7..e371b2b 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -2,6 +2,7 @@ import CalendarPopover from "@/components/Calendar/CalendarModal"; import { getSecretLink } from "@/features/Calendars/CalendarApi"; import { Calendar } from "@/features/Calendars/CalendarTypes"; import * as eventThunks from "@/features/Calendars/services"; +import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import { renderWithProviders } from "../../utils/Renderwithproviders"; @@ -9,11 +10,22 @@ jest.mock("@/features/Calendars/CalendarApi", () => ({ getSecretLink: jest.fn(), })); +const mockThunkWithUnwrap = (resolvedValue: unknown = {}) => + jest.fn().mockImplementation(() => { + const dispatchResult = Object.assign(Promise.resolve(resolvedValue), { + unwrap: () => Promise.resolve(resolvedValue), + }); + return jest.fn().mockReturnValue(dispatchResult); + }); + describe("CalendarPopover", () => { const mockOnClose = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + jest + .spyOn(delegationThunks, "updateDelegationCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); }); const renderPopover = (open = true) => { @@ -54,12 +66,11 @@ describe("CalendarPopover", () => { expect(descInput).toHaveValue("Test description"); }); - it("dispatches createCalendar and calls onClose when Save clicked", () => { - const spy = jest + it("dispatches createCalendar and calls onClose when Save clicked", async () => { + jest .spyOn(eventThunks, "createCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .mockImplementation(mockThunkWithUnwrap()); + renderPopover(); fireEvent.change(screen.getByLabelText(/Name/i), { @@ -77,9 +88,12 @@ describe("CalendarPopover", () => { fireEvent.click(screen.getByRole("button", { name: /Create/i })); - expect(spy).toHaveBeenCalled(); - - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + await waitFor(() => + expect(eventThunks.createCalendarAsync).toHaveBeenCalled() + ); + await waitFor(() => + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick") + ); }); it("calls onClose when Cancel clicked", () => { @@ -116,6 +130,9 @@ describe("CalendarPopover (editing mode)", () => { beforeEach(() => { jest.clearAllMocks(); + jest + .spyOn(delegationThunks, "updateDelegationCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); }); it("prefills fields when calendar prop is given", () => { @@ -151,11 +168,9 @@ describe("CalendarPopover (editing mode)", () => { }); it("allows modifying and saving existing calendar", async () => { - const spy = jest + jest .spyOn(eventThunks, "patchCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .mockImplementation(mockThunkWithUnwrap()); renderWithProviders( { fireEvent.click(screen.getByRole("button", { name: "actions.save" })); await waitFor(() => - expect(spy).toHaveBeenCalledWith( + expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith( expect.objectContaining({ calId: "user1/cal1", calLink: "/calendars/user/cal1", @@ -187,7 +202,7 @@ describe("CalendarPopover (editing mode)", () => { }) ) ); - expect(mockOnClose).toHaveBeenCalled(); + await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); }); }); @@ -220,6 +235,9 @@ describe("CalendarPopover - Tabs Scenarios", () => { beforeEach(() => { jest.clearAllMocks(); + jest + .spyOn(delegationThunks, "updateDelegationCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); }); it("resets state after closing and reopening", () => { @@ -265,11 +283,13 @@ describe("CalendarPopover - Tabs Scenarios", () => { }); it("patches ACL when visibility changes", async () => { - const patchSpy = jest + jest + .spyOn(eventThunks, "patchCalendarAsync") + .mockImplementation(mockThunkWithUnwrap()); + + jest .spyOn(eventThunks, "patchACLCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .mockImplementation(mockThunkWithUnwrap()); renderWithProviders( { fireEvent.click(screen.getByRole("button", { name: "actions.save" })); await waitFor(() => - expect(patchSpy).toHaveBeenCalledWith( + expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith( expect.objectContaining({ calId: "user1/cal1", request: "", @@ -352,16 +372,12 @@ describe("CalendarPopover - Tabs Scenarios", () => { const file = new File(["test"], "events.ics", { type: "text/calendar" }); it("creates a new calendar and imports events when Import with 'new' target", async () => { - const createSpy = jest + jest .spyOn(eventThunks, "createCalendarAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); - const importSpy = jest + .mockImplementation(mockThunkWithUnwrap()); + jest .spyOn(eventThunks, "importEventFromFileAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .mockImplementation(mockThunkWithUnwrap()); renderWithProviders( , @@ -383,16 +399,18 @@ describe("CalendarPopover - Tabs Scenarios", () => { // Click Import fireEvent.click(screen.getByRole("button", { name: "actions.import" })); - await waitFor(() => expect(createSpy).toHaveBeenCalled()); - await waitFor(() => expect(importSpy).toHaveBeenCalled()); + await waitFor(() => + expect(eventThunks.createCalendarAsync).toHaveBeenCalled() + ); + await waitFor(() => + expect(eventThunks.importEventFromFileAsync).toHaveBeenCalled() + ); }); it("imports into an existing calendar when target is set", async () => { - const importSpy = jest + jest .spyOn(eventThunks, "importEventFromFileAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .mockImplementation(mockThunkWithUnwrap()); const calendars = { "user1/cal1": existingCalendar, @@ -414,7 +432,7 @@ describe("CalendarPopover - Tabs Scenarios", () => { fireEvent.click(screen.getByRole("button", { name: "actions.import" })); await waitFor(() => - expect(importSpy).toHaveBeenCalledWith( + expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith( expect.objectContaining({ calLink: "/calendars/user1/cal1.json", file, diff --git a/src/components/Calendar/AccessTab.tsx b/src/components/Calendar/AccessTab.tsx index 2f4d82f..7f35cb1 100644 --- a/src/components/Calendar/AccessTab.tsx +++ b/src/components/Calendar/AccessTab.tsx @@ -1,3 +1,4 @@ +import { useAppSelector } from "@/app/hooks"; import { exportCalendar, getSecretLink, @@ -19,9 +20,28 @@ import { useI18n } from "twake-i18n"; import { ErrorSnackbar } from "../Error/ErrorSnackbar"; import { FieldWithLabel } from "../Event/components/FieldWithLabel"; import { SnackbarAlert } from "../Loading/SnackBarAlert"; +import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights"; -export function AccessTab({ calendar }: { calendar: Calendar }) { +interface AccessTabProps { + calendar: Calendar; + usersWithAccess: UserWithAccess[]; + onUsersWithAccessChange: (users: UserWithAccess[]) => void; + onInvitesLoaded: (users: UserWithAccess[]) => void; +} + +export function AccessTab({ + calendar, + usersWithAccess, + onUsersWithAccessChange, + onInvitesLoaded, +}: AccessTabProps) { const { t } = useI18n(); + const userData = useAppSelector((state) => state.user.userData); + const isPersonalCalendar = userData.openpaasId === calendar.id.split("/")[0]; + const isDelegatedWithAdministration = calendar.invite?.find( + (invite) => invite.href.includes(userData.email) && invite.access === 5 + ); + const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`; const [secretLink, setSecretLink] = useState(""); @@ -60,11 +80,8 @@ export function AccessTab({ calendar }: { calendar: Calendar }) { const exportedData = await exportCalendar( calendar.link.replace(".json", "") ); - const blob = new Blob([exportedData], { - type: "text/calendar", - }); + const blob = new Blob([exportedData], { type: "text/calendar" }); const url = URL.createObjectURL(blob); - const link = document.createElement("a"); link.href = url; link.download = `${calendar.id.split("/")[1]}.ics`; @@ -74,7 +91,6 @@ export function AccessTab({ calendar }: { calendar: Calendar }) { URL.revokeObjectURL(url); } catch (e) { setExportError((e as Error).message); - setExportLoading(false); } finally { setExportLoading(false); } @@ -82,6 +98,15 @@ export function AccessTab({ calendar }: { calendar: Calendar }) { return ( <> + {(isPersonalCalendar || isDelegatedWithAdministration) && ( + + )} + {!!window.DAV_BASE_URL && ( @@ -108,6 +133,7 @@ export function AccessTab({ calendar }: { calendar: Calendar }) { )} + {t("calendar.exportDesc")} -