diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx index 0411b42..29596e8 100644 --- a/__test__/components/Calendar.test.tsx +++ b/__test__/components/Calendar.test.tsx @@ -44,7 +44,7 @@ describe("CalendarSelection", () => { name: "Calendar personal", id: "user1/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -76,7 +76,7 @@ describe("CalendarSelection", () => { delegated: true, id: "user2/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -107,7 +107,7 @@ describe("CalendarSelection", () => { name: "Calendar shared", id: "user3/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -336,7 +336,7 @@ describe("calendar Availability search", () => { name: "Calendar personal", id: "user1/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: {}, }, }, diff --git a/__test__/components/CalendarColors.test.tsx b/__test__/components/CalendarColors.test.tsx index cb5cdc5..73843b2 100644 --- a/__test__/components/CalendarColors.test.tsx +++ b/__test__/components/CalendarColors.test.tsx @@ -51,7 +51,7 @@ describe("CalendarSearch", () => { name: "My Calendar", id: "user1/cal1", color: "#0000FF", - ownerEmails: ["test@test.com"], + owner: { emails: ["test@test.com"] }, events: {}, }, }, diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx index bbe3316..2e94896 100644 --- a/__test__/components/CalendarSelection.test.tsx +++ b/__test__/components/CalendarSelection.test.tsx @@ -24,20 +24,20 @@ describe("CalendarSelection", () => { name: "Calendar personal", id: "user1/cal1", color: "#FF0000", - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, }, "user2/cal1": { name: "Calendar delegated", delegated: true, id: "user2/cal1", color: "#00FF00", - ownerEmails: ["bob@example.com"], + owner: { emails: ["bob@example.com"] }, }, "user3/cal1": { name: "Calendar shared", id: "user3/cal1", color: "#0000FF", - ownerEmails: ["charlie@example.com"], + owner: { emails: ["charlie@example.com"] }, }, }; beforeAll(() => { diff --git a/__test__/components/EventErrorHandling.test.tsx b/__test__/components/EventErrorHandling.test.tsx index 95298df..b0add0f 100644 --- a/__test__/components/EventErrorHandling.test.tsx +++ b/__test__/components/EventErrorHandling.test.tsx @@ -30,7 +30,7 @@ describe("Event Error Handling", () => { name: "Calendar personal", id: "user1/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index 1851c5a..cc7e053 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -49,7 +49,7 @@ describe("CalendarApp integration", () => { name: "Calendar 1", id: "667037022b752d0026472254/cal1", color: { light: "#FFFFFF", dark: "#000000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -136,7 +136,7 @@ describe("CalendarApp integration", () => { name: "Calendar 1", id: "667037022b752d0026472254/cal1", color: { light: "#FFFFFF", dark: "#000000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -247,7 +247,7 @@ describe("CalendarApp integration", () => { name: "Calendar 1", id: "667037022b752d0026472254/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -291,7 +291,7 @@ describe("CalendarApp integration", () => { name: "Calendar 1", id: "667037022b752d0026472254/cal1", color: { light: "#FFFFFF", dark: "#000000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx index 31e031a..476be75 100644 --- a/__test__/components/MiniCalendarColor.test.tsx +++ b/__test__/components/MiniCalendarColor.test.tsx @@ -29,7 +29,7 @@ describe("MiniCalendar", () => { "667037022b752d0026472254/cal1": { name: "Calendar 1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["test@test.com"], + owner: { emails: ["test@test.com"] }, events: { event1: { calId: "667037022b752d0026472254/cal1", diff --git a/__test__/features/Calendars/CalendarAPI.test.tsx b/__test__/features/Calendars/CalendarAPI.test.tsx index 042f60e..8384793 100644 --- a/__test__/features/Calendars/CalendarAPI.test.tsx +++ b/__test__/features/Calendars/CalendarAPI.test.tsx @@ -30,7 +30,7 @@ describe("Calendar API", () => { const calendars = await getCalendars(mockUserId); expect(api.get).toHaveBeenCalledWith( - `dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&`, + `dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`, { headers: { Accept: "application/calendar+json" }, } diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index 13fc534..61f4485 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -109,8 +109,7 @@ describe("CalendarPopover (editing mode)", () => { name: "Work Calendar", description: "Team meetings", color: { light: "#33B679" }, - owner: "alice", - ownerEmails: ["alice@example.com"], + owner: { firstname: "alice", emails: ["alice@example.com"] }, visibility: "public", events: {}, }; @@ -214,8 +213,7 @@ describe("CalendarPopover - Tabs Scenarios", () => { name: "Work Calendar", description: "Team meetings", color: { light: "#33B679" }, - owner: "alice", - ownerEmails: ["alice@example.com"], + owner: { firstname: "alice", emails: ["alice@example.com"] }, visibility: "public", events: {}, }; diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx index aa1412f..db6ee91 100644 --- a/__test__/features/Calendars/CalendarSlice.test.tsx +++ b/__test__/features/Calendars/CalendarSlice.test.tsx @@ -195,9 +195,9 @@ describe("CalendarSlice", () => { expect(getUserDetailsMock).toHaveBeenCalledWith("u3"); const state = store.getState().calendars; - expect(state.list["u1/cal1"].owner).toContain("Alice"); - expect(state.list["u2/cal2"].owner).toContain("Bob"); - expect(state.list["u3/cal3"].owner).toContain("Charlie"); + expect(state.list["u1/cal1"].owner.firstname).toContain("Alice"); + expect(state.list["u2/cal2"].owner.firstname).toContain("Bob"); + expect(state.list["u3/cal3"].owner.firstname).toContain("Charlie"); }); it("getCalendarsListAsync deduplicates getUserDetails calls for same ownerId", async () => { @@ -331,8 +331,8 @@ describe("CalendarSlice", () => { expect(getUserDetailsMock).toHaveBeenCalledTimes(2); const state = store.getState().calendars; - expect(state.list["u1/cal1"].owner).toContain("Alice"); - expect(state.list["u2/cal2"].owner).toContain("Unknown User"); + expect(state.list["u1/cal1"].owner.firstname).toContain("Alice"); + expect(state.list["u2/cal2"].owner.lastname).toContain("Unknown User"); expect(result.payload.errors).toBeTruthy(); }); @@ -375,8 +375,7 @@ describe("CalendarSlice", () => { color: { "apple:color": "#f00" }, name: "Test", desc: "Desc", - owner: "Owner", - ownerEmails: ["o@example.com"], + owner: { firstname: "Owner", emails: ["o@example.com"] }, }; const state = reducer( initialState, @@ -393,8 +392,7 @@ describe("CalendarSlice", () => { link: "/calendars/u1/c1.json", name: "Shared", desc: "Shared Desc", - owner: "O", - ownerEmails: ["o@example.com"], + owner: { firstname: "O", emails: ["o@example.com"] }, }; const mockCal = { cal: { @@ -423,8 +421,7 @@ describe("CalendarSlice", () => { color: { "apple:color": "#aaa" }, events: {}, visibility: "public", - owner: "O", - ownerEmails: ["o@o.com"], + owner: { firstname: "O", emails: ["o@o.com"] }, link: "/calendars/t1.json", description: "desc", } as Calendar, diff --git a/__test__/features/Calendars/TimezoneInGrid.test.tsx b/__test__/features/Calendars/TimezoneInGrid.test.tsx index 7ee0da6..920fcc0 100644 --- a/__test__/features/Calendars/TimezoneInGrid.test.tsx +++ b/__test__/features/Calendars/TimezoneInGrid.test.tsx @@ -27,8 +27,7 @@ describe("Calendar - Timezone Integration", () => { name: "Test Calendar", description: "", color: "#33B679", - owner: "user1", - ownerEmails: ["test@test.com"], + owner: { firstname: "user1", emails: ["test@test.com"] }, visibility: "public", events: {}, }, @@ -174,8 +173,7 @@ describe("EventDisplayPreview - Timezone Display", () => { id: "user1/cal1", name: "Test Calendar", color: "#33B679", - owner: "user1", - ownerEmails: ["test@test.com"], + owner: { firstname: "user1", emails: ["test@test.com"] }, visibility: "public", events: { event1: baseEvent, diff --git a/__test__/features/Calendars/services/refreshCalendar.test.tsx b/__test__/features/Calendars/services/refreshCalendar.test.tsx index 5bcfe32..ff41edd 100644 --- a/__test__/features/Calendars/services/refreshCalendar.test.tsx +++ b/__test__/features/Calendars/services/refreshCalendar.test.tsx @@ -17,8 +17,7 @@ describe("refreshCalendarWithSyncToken", () => { id: "user1/cal1", name: "Test Calendar", link: "/calendars/user1/cal1.json", - owner: "Test User", - ownerEmails: ["test@example.com"], + owner: { firstname: "Test User", emails: ["test@example.com"] }, description: "Test calendar description", delegated: false, color: { light: "#006BD8", dark: "#FFF" }, diff --git a/__test__/features/Events/AttendanceValidation.delegated.test.tsx b/__test__/features/Events/AttendanceValidation.delegated.test.tsx new file mode 100644 index 0000000..bad587c --- /dev/null +++ b/__test__/features/Events/AttendanceValidation.delegated.test.tsx @@ -0,0 +1,132 @@ +import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { AttendanceValidation } from "@/features/Events/AttendanceValidation/AttendanceValidation"; +import { ContextualizedEvent } from "@/features/Events/EventsTypes"; +import { userAttendee } from "@/features/User/models/attendee"; +import { userData } from "@/features/User/userDataTypes"; +import { render } from "@testing-library/react"; + +jest.mock("twake-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +jest.mock("@/app/hooks", () => ({ + useAppDispatch: () => jest.fn(), + useAppSelector: jest.fn(), +})); + +const makeUser = (email: string): userData => ({ + email, + given_name: "Alice", + family_name: "User", + name: "Alice User", + sid: "user1", + sub: "user1", +}); + +const makeContext = ( + overrides: Partial = {} +): ContextualizedEvent => + ({ + event: { + uid: "event-1", + calId: "user1/cal1", + title: "Test", + start: "2024-01-15T10:00:00", + end: "2024-01-15T11:00:00", + organizer: { cal_address: "owner@example.com" }, + attendee: [ + { cal_address: "owner@example.com", partstat: "NEEDS-ACTION" }, + ], + }, + calendar: { + id: "user1/cal1", + name: "Test", + delegated: false, + owner: { emails: ["alice@example.com"] }, + events: {}, + }, + currentUserAttendee: { + cal_address: "alice@example.com", + partstat: "NEEDS-ACTION", + }, + isOwn: true, + isOrganizer: false, + isRecurring: false, + ...overrides, + }) as unknown as ContextualizedEvent; + +const noopSetFunc = jest.fn(); + +describe("AttendanceValidation - delegation", () => { + describe("non-delegated calendar", () => { + it("renders when isOwn and currentUserAttendee exists", () => { + const { container } = render( + + ); + expect(container.firstChild).not.toBeNull(); + }); + + it("returns null when not isOwn and not delegated", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + }); + + describe("delegated calendar", () => { + const delegatedContext = makeContext({ + isOwn: false, // logged-in user is not the owner + calendar: { + id: "user2/cal1", + name: "Delegated Cal", + delegated: true, + owner: { emails: ["owner@example.com"] }, + events: {}, + } as Calendar, + currentUserAttendee: { + cal_address: "owner@example.com", + partstat: "NEEDS-ACTION", + } as userAttendee, + event: { class: "PUBLIC" }, + }); + + it("renders even when isOwn is false", () => { + const { container } = render( + + ); + expect(container.firstChild).not.toBeNull(); + }); + + it("renders regardless of currentUserAttendee presence", () => { + const { container } = render( + + ); + // delegated flag alone should keep it visible + expect(container.firstChild).not.toBeNull(); + }); + }); +}); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 0fb2a27..0cacd0e 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -6,7 +6,7 @@ import { stringAvatar, stringToColor, } from "@/components/Event/utils/eventUtils"; -import * as calendarSlice from "@/features/Calendars/CalendarSlice"; +import { DelegationAccess } from "@/features/Calendars/CalendarTypes"; import * as eventThunks from "@/features/Calendars/services"; import EventPreviewModal from "@/features/Events/EventDisplayPreview"; import EventUpdateModal from "@/features/Events/EventUpdateModal"; @@ -98,7 +98,7 @@ describe("Event Preview Display", () => { end: day.toISOString(), }, }, - ownerEmails: ["test@test.com"], + owner: { emails: ["test@test.com"] }, }, "otherCal/cal": { id: "otherCal/cal", @@ -659,10 +659,6 @@ describe("Event Preview Display", () => { fireEvent.click(emailButton); - const event = - preloadedState.calendars.list["667037022b752d0026472254/cal1"].events[ - "event1" - ]; const expectedUrl = `test/mailto/?uri=mailto:john@test.com&subject=Test%20Event`; expect(mockOpen).toHaveBeenCalledWith(expectedUrl); @@ -774,7 +770,7 @@ describe("Event Preview Display", () => { name: "Calendar 1", id: "667037022b752d0026472254/cal1", color: "#FF0000", - ownerEmails: ownerEmails, + owner: { emails: ownerEmails }, events: { event1: { uid: "event1", @@ -988,7 +984,7 @@ describe("Event Preview Display", () => { attendee: attendees, }, }, - ownerEmails: ["test@test.com"], + owner: { emails: ["test@test.com"] }, }, }, pending: false, @@ -1555,6 +1551,161 @@ describe("Event Preview Display", () => { }); }); + describe("EventDisplayPreview - delegation", () => { + const delegatedBaseEvent = { + uid: "event-1", + calId: "user2/cal1", + title: "Delegated Event", + start: day.toISOString(), + end: day.toISOString(), + organizer: { cal_address: "owner@example.com" }, + attendee: [ + { cal_address: "owner@example.com", partstat: "NEEDS-ACTION" }, + ], + URL: "/calendars/user2/cal1/event-1.ics", + }; + + const makeDelegatedState = ( + eventOverrides = {}, + access: DelegationAccess = { + write: true, + freebusy: false, + read: true, + "write-properties": false, + all: false, + } + ) => ({ + ...preloadedState, + calendars: { + list: { + "user2/cal1": { + id: "user2/cal1", + name: "Delegated Calendar", + delegated: true, + access, + owner: { + id: "user2", + firstname: "Bob", + lastname: "Owner", + emails: ["owner@example.com"], + preferredEmail: "owner@example.com", + }, + color: { light: "#FF0000", dark: "#000" }, + events: { + "event-1": { ...delegatedBaseEvent, ...eventOverrides }, + }, + }, + }, + templist: {}, + pending: false, + }, + user: { + userData: { + ...preloadedState.user.userData, + email: "alice@example.com", + openpaasId: "user1", + }, + }, + }); + + describe("edit button visibility", () => { + it("shows edit button when calendar is write-delegated and owner is organizer", () => { + renderWithProviders( + , + makeDelegatedState() + ); + expect( + screen.getByTestId("EditIcon").closest("button") + ).toBeInTheDocument(); + }); + + it("does not show edit button when delegated but owner is not organizer", () => { + renderWithProviders( + , + makeDelegatedState({ + organizer: { cal_address: "someone-else@example.com" }, + }) + ); + expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument(); + }); + + it("does not show edit button when delegated with read-only access", () => { + renderWithProviders( + , + makeDelegatedState( + {}, + { + write: false, + freebusy: false, + read: true, + "write-properties": false, + all: false, + } + ) + ); + expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument(); + }); + }); + + describe("delete menu item visibility", () => { + it("shows delete option when calendar is write-delegated", () => { + renderWithProviders( + , + makeDelegatedState() + ); + fireEvent.click(screen.getByTestId("MoreVertIcon")); + expect( + screen.getByText("eventPreview.deleteEvent") + ).toBeInTheDocument(); + }); + + it("does not show delete option when delegated with read-only access", () => { + renderWithProviders( + , + makeDelegatedState( + {}, + { + write: false, + freebusy: false, + read: true, + "write-properties": false, + all: false, + } + ) + ); + fireEvent.click(screen.getByTestId("MoreVertIcon")); + expect( + screen.queryByText("eventPreview.deleteEvent") + ).not.toBeInTheDocument(); + }); + }); + }); + describe("BUGFIX", () => { it("doesnt render anything next to date of all day preview", () => { const allDayState = { diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx index 1413c75..0071ee4 100644 --- a/__test__/features/Events/EventRepetition.test.tsx +++ b/__test__/features/Events/EventRepetition.test.tsx @@ -95,7 +95,7 @@ const basePreloadedState = { ], }, }, - ownerEmails: ["test@test.com"], + owner: { emails: ["test@test.com"] }, }, }, pending: false, @@ -1151,7 +1151,7 @@ describe("Event URL handling for recurring events", () => { ], }, }, - ownerEmails: ["test@test.com"], + owner: { emails: ["test@test.com"] }, }, "667037022b752d0026472254/cal2": { id: "667037022b752d0026472254/cal2", diff --git a/__test__/features/Events/EventUpdateModal.recurring.test.tsx b/__test__/features/Events/EventUpdateModal.recurring.test.tsx index 2105dc2..35893e3 100644 --- a/__test__/features/Events/EventUpdateModal.recurring.test.tsx +++ b/__test__/features/Events/EventUpdateModal.recurring.test.tsx @@ -43,7 +43,7 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { name: "Test Calendar", color: "#FF0000", events: {}, - ownerEmails: ["test@test.com"], + owner: { emails: ["test@test.com"] }, }, }, pending: false, diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index b3c5627..74812fd 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -1,3 +1,5 @@ +import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { VObjectProperty } from "@/features/Calendars/types/CalendarData"; import { CalendarEvent, RepetitionObject } from "@/features/Events/EventsTypes"; import { calendarEventToJCal, @@ -10,7 +12,7 @@ import { describe("parseCalendarEvent", () => { const baseColor = { light: "#00FF00" }; - const calendarId = "calendar-123"; + const calendar = { id: "calendar-123" } as Calendar; it("parses a full event with MAILTO in caps correctly", () => { const rawData = [ @@ -33,12 +35,12 @@ describe("parseCalendarEvent", () => { ["TRANSP", {}, "text", "OPAQUE"], ["CLASS", {}, "text", "PUBLIC"], ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], - ] as unknown as [string, Record, string, any]; + ] as VObjectProperty[]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -95,12 +97,12 @@ describe("parseCalendarEvent", () => { ["TRANSP", {}, "text", "OPAQUE"], ["CLASS", {}, "text", "PUBLIC"], ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], - ] as unknown as [string, Record, string, any]; + ] as VObjectProperty[]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -137,16 +139,16 @@ describe("parseCalendarEvent", () => { }); it("marks allday true for DATE format", () => { - const rawData = [ + const rawData: VObjectProperty[] = [ ["UID", {}, "text", "event-2"], ["DTSTART", {}, "date", "2025-07-20"], ["DTEND", {}, "date", "2025-07-21"], - ] as any; + ]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -154,7 +156,7 @@ describe("parseCalendarEvent", () => { }); it("appends recurrence-id to UID if present", () => { - const rawData: any = [ + const rawData: VObjectProperty[] = [ ["UID", {}, "text", "event-2"], ["RECURRENCE-ID", {}, "date-time", "2025-07-18T09:00:00Z"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], @@ -163,7 +165,7 @@ describe("parseCalendarEvent", () => { const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -171,24 +173,26 @@ describe("parseCalendarEvent", () => { }); it("returns error if UID or start is missing", () => { - const rawDataMissingUid: any = [ + const rawDataMissingUid: VObjectProperty[] = [ ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ]; const result = parseCalendarEvent( rawDataMissingUid, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); expect(result.error).toMatch(/missing crucial event param/); - const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]]; + const rawDataMissingStart: VObjectProperty[] = [ + ["UID", {}, "text", "event-3"], + ]; const result2 = parseCalendarEvent( rawDataMissingStart, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); expect(result2.error).toMatch(/missing crucial event param/); @@ -197,7 +201,7 @@ describe("parseCalendarEvent", () => { it("returns computed end when there is no end but a duration", () => { jest.useFakeTimers().setSystemTime(new Date("2025-07-18T00:00:00Z")); - const rawDataMissing: any = [ + const rawDataMissing: VObjectProperty[] = [ ["UID", {}, "text", "event-1"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00"], ["duration", {}, "duration", "PT60M"], @@ -206,7 +210,7 @@ describe("parseCalendarEvent", () => { const result = parseCalendarEvent( rawDataMissing, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); expect(result.end).toBeDefined(); @@ -216,7 +220,7 @@ describe("parseCalendarEvent", () => { }); it("returns 30 minutes event if end and duration are missing", () => { - const rawDataMissing: any = [ + const rawDataMissing: VObjectProperty[] = [ ["UID", {}, "text", "event-1"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ]; @@ -224,7 +228,7 @@ describe("parseCalendarEvent", () => { const result = parseCalendarEvent( rawDataMissing, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); expect(result.end).toBeDefined(); @@ -234,23 +238,23 @@ describe("parseCalendarEvent", () => { }); it("parses alarm block correctly", () => { - const rawData = [ + const rawData: VObjectProperty[] = [ ["UID", {}, "text", "event-5"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ] as any; + ]; - const valarm: any = [ + const valarm = [ "VALARM", [ ["ACTION", {}, "text", "DISPLAY"], ["TRIGGER", {}, "duration", "-PT15M"], ], - ]; + ] as unknown as VObjectProperty[]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics", valarm ); @@ -266,12 +270,12 @@ describe("parseCalendarEvent", () => { ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], ["ATTENDEE", {}, "cal-address", "john@example.com"], ["ORGANIZER", {}, "cal-address", "jane@example.com"], - ] as unknown as [string, Record, string, any]; + ] as VObjectProperty[]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -297,12 +301,12 @@ describe("parseCalendarEvent", () => { ["UID", {}, "text", "event-tz"], ["DTSTART", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T09:00:00"], ["DTEND", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T10:00:00"], - ] as unknown as [string, Record, string, any]; + ] as VObjectProperty[]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -319,12 +323,12 @@ describe("parseCalendarEvent", () => { ["UID", {}, "text", "event-utc"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], - ] as unknown as [string, Record, string, any]; + ] as VObjectProperty[]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -337,12 +341,12 @@ describe("parseCalendarEvent", () => { ["UID", {}, "text", "event-allday"], ["DTSTART", {}, "date", "2025-07-18"], ["DTEND", {}, "date", "2025-07-19"], - ] as unknown as [string, Record, string, any]; + ] as VObjectProperty[]; const result = parseCalendarEvent( rawData, baseColor, - calendarId, + calendar, "/calendars/test.ics" ); @@ -391,8 +395,8 @@ describe("calendarEventToJCal", () => { expect(vevent[0]).toBe("vevent"); const props = vevent[1]; - const dtstart = props.find((p: any[]) => p[0] === "dtstart"); - const dtend = props.find((p: any[]) => p[0] === "dtend"); + const dtstart = props.find((p: VObjectProperty) => p[0] === "dtstart"); + const dtend = props.find((p: VObjectProperty) => p[0] === "dtend"); expect(dtstart).toBeDefined(); expect(dtstart[1]).toEqual({ tzid: "Europe/Paris" }); @@ -474,7 +478,7 @@ describe("calendarEventToJCal", () => { }); it("converts with alarm included", () => { - const mockEvent: any = { + const mockEvent = { uid: "event-10", title: "Alarm Event", start: "2025-07-20T07:00:00.000Z", @@ -483,7 +487,7 @@ describe("calendarEventToJCal", () => { allday: false, alarm: { trigger: "-PT10M", action: "DISPLAY" }, attendee: [], - }; + } as unknown as CalendarEvent; const result = calendarEventToJCal(mockEvent, "owner@example.com"); const vevent = result[2][0]; @@ -498,7 +502,7 @@ describe("calendarEventToJCal", () => { }); it("converts all-day events", () => { - const mockEvent: any = { + const mockEvent = { uid: "event-11", title: "All Day", start: "2025-07-21T00:00:00.000Z", @@ -506,7 +510,7 @@ describe("calendarEventToJCal", () => { timezone: "Europe/Paris", allday: true, attendee: [], - }; + } as unknown as CalendarEvent; const result = calendarEventToJCal(mockEvent); const veventProps = result[2][0][1]; @@ -843,7 +847,9 @@ describe("calendarEventToJCal", () => { const [vevent] = result[2]; const props = vevent[1]; - const sequenceProp = props.find((p: any[]) => p[0] === "sequence"); + const sequenceProp = props.find( + (p: VObjectProperty) => p[0] === "sequence" + ); expect(sequenceProp).toBeDefined(); expect(sequenceProp).toEqual(["sequence", {}, "integer", 3]); }); @@ -864,7 +870,9 @@ describe("calendarEventToJCal", () => { const [vevent] = result[2]; const props = vevent[1]; - const sequenceProp = props.find((p: any[]) => p[0] === "sequence"); + const sequenceProp = props.find( + (p: VObjectProperty) => p[0] === "sequence" + ); expect(sequenceProp).toBeDefined(); expect(sequenceProp).toEqual(["sequence", {}, "integer", 1]); }); @@ -887,7 +895,9 @@ describe("calendarEventToJCal", () => { const [vevent] = result[2]; const props = vevent[1]; - const sequenceProp = props.find((p: any[]) => p[0] === "sequence"); + const sequenceProp = props.find( + (p: VObjectProperty) => p[0] === "sequence" + ); expect(sequenceProp).toBeDefined(); expect(sequenceProp).toEqual(["sequence", {}, "integer", 2]); }); @@ -910,18 +920,22 @@ describe("calendarEventToJCal", () => { const [vevent] = result[2]; const props = vevent[1]; - const sequenceProp = props.find((p: any[]) => p[0] === "sequence"); + const sequenceProp = props.find( + (p: VObjectProperty) => p[0] === "sequence" + ); expect(sequenceProp).toBeDefined(); expect(sequenceProp).toEqual(["sequence", {}, "integer", 5]); // Also verify recurrence-id is present - const recurrenceIdProp = props.find((p: any[]) => p[0] === "recurrence-id"); + const recurrenceIdProp = props.find( + (p: VObjectProperty) => p[0] === "recurrence-id" + ); expect(recurrenceIdProp).toBeDefined(); }); }); describe("combineMasterDateWithFormTime", () => { - const mockFormatDateTime = (iso: string, tz: string) => { + const mockFormatDateTime = (iso: string) => { const date = new Date(iso); const year = date.getUTCFullYear(); const month = String(date.getUTCMonth() + 1).padStart(2, "0"); @@ -1127,12 +1141,6 @@ describe("normalizeTimezone", () => { describe("detectRecurringEventChanges", () => { const mockResolveTimezone = (tz: string) => tz; - const mockFormatDateTime = (iso: string, tz: string) => { - const date = new Date(iso); - const hour = String(date.getUTCHours()).padStart(2, "0"); - const minute = String(date.getUTCMinutes()).padStart(2, "0"); - return `2025-10-14T${hour}:${minute}`; - }; it("should detect when only time changes", () => { const oldEvent = { @@ -1346,3 +1354,69 @@ describe("detectRecurringEventChanges", () => { expect(result.repetitionRulesChanged).toBe(true); }); }); + +describe("parseCalendarEvent - delegated calendar URL handling", () => { + const baseColor = { light: "#00FF00" }; + const rawData: VObjectProperty[] = [ + ["uid", {}, "text", "event-uid-123"], + ["dtstart", {}, "date-time", "20240115T100000Z"], + ["dtend", {}, "date-time", "20240115T110000Z"], + ["summary", {}, "text", "Test Event"], + ]; + + const eventURL = "/calendars/user2/cal1/event-uid-123.ics"; + + describe("non-delegated calendar", () => { + const calendar = { + id: "user1/cal1", + delegated: false, + link: "/calendars/user1/cal1.json", + } as Calendar; + + it("uses the raw eventURL as-is", () => { + const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL); + expect(result.URL).toBe(eventURL); + }); + }); + + describe("delegated calendar", () => { + const calendar = { + id: "user2/cal1", + delegated: true, + link: "/calendars/user2/cal1.json", + owner: { emails: ["owner@example.com"] }, + } as unknown as Calendar; + + it("rewrites URL using calendar link base path", () => { + const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL); + expect(result.URL).toBe("/calendars/user2/cal1/event-uid-123.ics"); + }); + + it("preserves the event filename from the original URL", () => { + const differentSourceURL = "/calendars/someother/path/event-uid-123.ics"; + const result = parseCalendarEvent( + rawData, + baseColor, + calendar, + differentSourceURL + ); + // filename is extracted and rebased onto calendar link + expect(result.URL).toMatch( + /\/calendars\/user2\/cal1\/event-uid-123\.ics$/ + ); + }); + + it("still sets calId from calendar.id", () => { + const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL); + expect(result.calId).toBe("user2/cal1"); + }); + }); + + describe("empty calendar object (used in deleteEventInstance)", () => { + it("does not throw and sets URL to empty string", () => { + const result = parseCalendarEvent(rawData, baseColor, {} as Calendar, ""); + expect(result.URL).toBe(""); + expect(result.calId).toBeUndefined(); + }); + }); +}); diff --git a/__test__/features/Search/EventSearchBar.test.tsx b/__test__/features/Search/EventSearchBar.test.tsx index 444a855..42bd31e 100644 --- a/__test__/features/Search/EventSearchBar.test.tsx +++ b/__test__/features/Search/EventSearchBar.test.tsx @@ -26,7 +26,7 @@ describe("EventSearchBar", () => { name: "Calendar personal", id: "user1/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -58,7 +58,7 @@ describe("EventSearchBar", () => { delegated: true, id: "user2/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -89,7 +89,7 @@ describe("EventSearchBar", () => { name: "Calendar shared", id: "user3/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", diff --git a/__test__/features/Search/SearchResultsPage.test.tsx b/__test__/features/Search/SearchResultsPage.test.tsx index bb8e8bf..23c043b 100644 --- a/__test__/features/Search/SearchResultsPage.test.tsx +++ b/__test__/features/Search/SearchResultsPage.test.tsx @@ -24,7 +24,7 @@ describe("SearchResultsPage", () => { name: "Calendar personal", id: "user1/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -56,7 +56,7 @@ describe("SearchResultsPage", () => { delegated: true, id: "user2/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", @@ -87,7 +87,7 @@ describe("SearchResultsPage", () => { name: "Calendar shared", id: "user3/cal1", color: { light: "#FF0000", dark: "#000" }, - ownerEmails: ["alice@example.com"], + owner: { emails: ["alice@example.com"] }, events: { event1: { id: "event1", diff --git a/__test__/utils/buildDelegatedEvent.test.ts b/__test__/utils/buildDelegatedEvent.test.ts new file mode 100644 index 0000000..6bca6b4 --- /dev/null +++ b/__test__/utils/buildDelegatedEvent.test.ts @@ -0,0 +1,55 @@ +import { buildDelegatedEventURL } from "@/features/Events/eventUtils"; +import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { CalendarEvent } from "@/features/Events/EventsTypes"; + +const makeCalendar = (link: string): Calendar => + ({ + id: "user2/cal1", + delegated: true, + link, + owner: { emails: ["owner@example.com"] }, + }) as Calendar; + +const makeEvent = (url: string): CalendarEvent => + ({ URL: url, calId: "user2/cal1" }) as CalendarEvent; + +describe("buildDelegatedEventURL", () => { + it("rebases event filename onto calendar link base path", () => { + const calendar = makeCalendar("/calendars/user2/cal1.json"); + const event = makeEvent("/calendars/someother/path/event-abc.ics"); + expect(buildDelegatedEventURL(calendar, event)).toBe( + "/calendars/user2/cal1/event-abc.ics" + ); + }); + + it("strips .json suffix from calendar link", () => { + const calendar = makeCalendar("/calendars/user2/cal1.json"); + const event = makeEvent("/calendars/user2/cal1/event-abc.ics"); + const result = buildDelegatedEventURL(calendar, event); + expect(result).not.toContain(".json"); + }); + + it("preserves the exact filename from the event URL", () => { + const calendar = makeCalendar("/calendars/user2/cal1.json"); + const event = makeEvent("/calendars/user2/cal1/some-uid-with-dashes.ics"); + expect(buildDelegatedEventURL(calendar, event)).toBe( + "/calendars/user2/cal1/some-uid-with-dashes.ics" + ); + }); + + it("throws when event URL has no filename", () => { + const calendar = makeCalendar("/calendars/user2/cal1.json"); + const event = makeEvent(""); + expect(() => buildDelegatedEventURL(calendar, event)).toThrow( + /Cannot extract filename from event URL/ + ); + }); + + it("works with nested calendar link paths", () => { + const calendar = makeCalendar("/dav/calendars/users/user2/cal1.json"); + const event = makeEvent("/other/path/event-xyz.ics"); + expect(buildDelegatedEventURL(calendar, event)).toBe( + "/dav/calendars/users/user2/cal1/event-xyz.ics" + ); + }); +}); diff --git a/__test__/utils/calendarUtils.test.tsx b/__test__/utils/calendarUtils.test.tsx index 1bb7b08..5951f58 100644 --- a/__test__/utils/calendarUtils.test.tsx +++ b/__test__/utils/calendarUtils.test.tsx @@ -1,5 +1,11 @@ -import { getCalendarVisibility } from "@/components/Calendar/utils/calendarUtils"; +import { + eventToFullCalendarFormat, + getCalendarVisibility, +} from "@/components/Calendar/utils/calendarUtils"; +import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes"; import { AclEntry } from "@/features/Calendars/types/CalendarData"; +import { CalendarEvent } from "@/features/Events/EventsTypes"; +import { userOrganiser } from "@/features/User/userDataTypes"; describe("getCalendarVisibility", () => { it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => { @@ -67,3 +73,159 @@ describe("getCalendarVisibility", () => { expect(getCalendarVisibility(acl)).toBe("public"); }); }); + +jest.mock("twake-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +const baseEvent: CalendarEvent = { + uid: "event-1", + calId: "user1/cal1", + title: "Test Event", + start: "2024-01-15T10:00:00", + end: "2024-01-15T11:00:00", + allday: false, + organizer: { cal_address: "owner@example.com" } as userOrganiser, + color: { light: "#FF0000" }, + class: "PUBLIC", +} as unknown as CalendarEvent; + +const makeCalendar = (overrides: Partial = {}): Calendar => + ({ + id: "user1/cal1", + name: "Test Calendar", + delegated: false, + access: {} as DelegationAccess, + owner: { emails: ["alice@example.com"], firstname: "Alice" }, + color: { light: "#FF0000" }, + events: {}, + ...overrides, + }) as Calendar; + +const callFormat = ( + event: CalendarEvent, + calendars: Record, + userAddress = "alice@example.com", + userId = "user1" +) => + eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars); + +describe("eventToFullCalendarFormat - editable flag", () => { + describe("personal calendar (non-delegated)", () => { + const calendar = makeCalendar({ delegated: false }); + + it("is editable when user is organizer", () => { + const event = { + ...baseEvent, + organizer: { cal_address: "alice@example.com" }, + } as CalendarEvent; + const [result] = callFormat(event, { "user1/cal1": calendar }); + expect(result.editable).toBe(true); + }); + + it("is not editable when user is not organizer", () => { + const event = { + ...baseEvent, + organizer: { cal_address: "other@example.com" }, + } as CalendarEvent; + const [result] = callFormat(event, { "user1/cal1": calendar }); + expect(result.editable).toBe(false); + }); + + it("is not editable when pending", () => { + const event = { + ...baseEvent, + organizer: { cal_address: "alice@example.com" }, + } as CalendarEvent; + const [result] = eventToFullCalendarFormat( + [event], + [], + "user1", + "alice@example.com", + true, + { "user1/cal1": calendar } + ); + expect(result.editable).toBe(false); + }); + }); + + describe("delegated calendar", () => { + const writeDelegatedCalendar = makeCalendar({ + id: "user2/cal1", + delegated: true, + access: { write: true } as DelegationAccess, + owner: { emails: ["owner@example.com"], firstname: "Owner" }, + }); + const readDelegatedCalendar = makeCalendar({ + id: "user2/cal1", + delegated: true, + access: { read: true } as DelegationAccess, + owner: { emails: ["owner@example.com"], firstname: "Owner" }, + }); + const event = { ...baseEvent, calId: "user2/cal1" }; + + it("is editable when owner is organizer and delegated user has write rights", () => { + const [result] = callFormat( + { + ...event, + organizer: { cal_address: "owner@example.com" }, + } as CalendarEvent, + { "user2/cal1": writeDelegatedCalendar } + ); + expect(result.editable).toBe(true); + }); + + it("is not editable when owner is organizer but delegated user only has read access", () => { + const [result] = callFormat( + { + ...event, + organizer: { cal_address: "owner@example.com" }, + } as CalendarEvent, + { "user2/cal1": readDelegatedCalendar } + ); + expect(result.editable).toBe(false); + }); + + it("is not editable when owner is not organizer", () => { + const [result] = callFormat( + { + ...event, + organizer: { cal_address: "someone-else@example.com" }, + } as CalendarEvent, + { "user2/cal1": writeDelegatedCalendar } + ); + expect(result.editable).toBe(false); + }); + + it("is not editable when pending even if owner is organizer", () => { + const [result] = eventToFullCalendarFormat( + [ + { + ...event, + organizer: { cal_address: "owner@example.com" }, + } as CalendarEvent, + ], + [], + "user1", + "alice@example.com", + true, + { "user2/cal1": writeDelegatedCalendar } + ); + expect(result.editable).toBe(false); + }); + + it("uses owner email not logged-in user email for organizer check", () => { + // logged-in user is alice, owner is owner@example.com + // organizer matches owner → editable + const [result] = callFormat( + { + ...event, + organizer: { cal_address: "owner@example.com" }, + } as CalendarEvent, + { "user2/cal1": writeDelegatedCalendar }, + "alice@example.com" // logged-in user, should NOT be used for delegated + ); + expect(result.editable).toBe(true); + }); + }); +}); diff --git a/__test__/utils/createEventContext.test.ts b/__test__/utils/createEventContext.test.ts new file mode 100644 index 0000000..351f45a --- /dev/null +++ b/__test__/utils/createEventContext.test.ts @@ -0,0 +1,120 @@ +import { createEventContext } from "@/features/Events/createEventContext"; +import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { CalendarEvent } from "@/features/Events/EventsTypes"; +import { userData } from "@/features/User/userDataTypes"; +import { userAttendee } from "@/features/User/models/attendee"; + +const makeUser = (email: string): userData => ({ + email, + given_name: "Alice", + family_name: "User", + name: "Alice User", + sid: "user1", + sub: "user1", +}); + +const makeEvent = (overrides: Partial = {}): CalendarEvent => + ({ + uid: "event-1", + calId: "user1/cal1", + title: "Test Event", + start: "2024-01-15T10:00:00", + end: "2024-01-15T11:00:00", + organizer: { cal_address: "alice@example.com" }, + attendee: [ + { cal_address: "alice@example.com", partstat: "ACCEPTED" }, + { cal_address: "owner@example.com", partstat: "ACCEPTED" }, + { cal_address: "other@example.com", partstat: "NEEDS-ACTION" }, + ], + ...overrides, + }) as CalendarEvent; + +const makeCalendar = (overrides: Partial = {}): Calendar => + ({ + id: "user1/cal1", + name: "Test Calendar", + delegated: false, + owner: { emails: ["alice@example.com"], firstname: "Alice" }, + events: {}, + ...overrides, + }) as Calendar; + +describe("createEventContext", () => { + describe("non-delegated calendar", () => { + const calendar = makeCalendar({ delegated: false }); + const user = makeUser("alice@example.com"); + + it("finds currentUserAttendee by logged-in user email", () => { + const event = makeEvent(); + const ctx = createEventContext(event, calendar, user); + expect(ctx.currentUserAttendee?.cal_address).toBe("alice@example.com"); + }); + + it("returns null currentUserAttendee when user is not an attendee", () => { + const event = makeEvent({ attendee: [] }); + const ctx = createEventContext(event, calendar, user); + expect(ctx.currentUserAttendee).toBeUndefined(); + }); + + it("isOwn is true when user email is in owner emails", () => { + const ctx = createEventContext(makeEvent(), calendar, user); + expect(ctx.isOwn).toBe(true); + }); + + it("isOwn is false when user email is not in owner emails", () => { + const other = makeUser("other@example.com"); + const ctx = createEventContext(makeEvent(), calendar, other); + expect(ctx.isOwn).toBe(false); + }); + }); + + describe("delegated calendar", () => { + const calendar = makeCalendar({ + id: "user2/cal1", + delegated: true, + owner: { emails: ["owner@example.com"], firstname: "Owner" }, + }); + const user = makeUser("alice@example.com"); // logged-in user, not the owner + + it("finds currentUserAttendee by owner email, not logged-in user email", () => { + const event = makeEvent({ calId: "user2/cal1" }); + const ctx = createEventContext(event, calendar, user); + expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com"); + }); + + it("does not find logged-in user as attendee for delegated calendar", () => { + const event = makeEvent({ + calId: "user2/cal1", + attendee: [ + { + cal_address: "owner@example.com", + partstat: "ACCEPTED", + } as userAttendee, + ], + }); + const ctx = createEventContext(event, calendar, user); + // currentUserAttendee should be owner's, not alice's + expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com"); + }); + + it("returns undefined currentUserAttendee when owner is not an attendee", () => { + const event = makeEvent({ + calId: "user2/cal1", + attendee: [ + { + cal_address: "alice@example.com", + partstat: "ACCEPTED", + } as userAttendee, + ], + }); + const ctx = createEventContext(event, calendar, user); + expect(ctx.currentUserAttendee).toBeUndefined(); + }); + + it("passes event through unchanged", () => { + const event = makeEvent({ calId: "user2/cal1" }); + const ctx = createEventContext(event, calendar, user); + expect(ctx.event).toBe(event); + }); + }); +}); diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 7c3e4b9..5094387 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -697,7 +697,8 @@ export default function CalendarApp({ filteredTempEvents, userId, userData?.email, - isPending + isPending, + calendars )} eventOrder={(a: EventApi, b: EventApi) => a.extendedProps.priority - b.extendedProps.priority diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx index 2aaf55b..f69cb34 100644 --- a/src/components/Calendar/TempCalendarsInput.tsx +++ b/src/components/Calendar/TempCalendarsInput.tsx @@ -134,7 +134,7 @@ function getCalendarsFromUsersDelta( function buildEmailToCalendarMap(calRecord: Record) { const map = new Map(); for (const [id, cal] of Object.entries(calRecord)) { - cal.ownerEmails?.forEach((email) => { + cal.owner?.emails?.forEach((email) => { const existing = map.get(email); if (existing) { existing.push(id); diff --git a/src/components/Calendar/handlers/viewHandlers.ts b/src/components/Calendar/handlers/viewHandlers.ts index 9e61fc0..9c0d7b6 100644 --- a/src/components/Calendar/handlers/viewHandlers.ts +++ b/src/components/Calendar/handlers/viewHandlers.ts @@ -121,8 +121,8 @@ export const createViewHandlers = (props: ViewHandlersProps) => { const attendees = arg.event._def.extendedProps.attendee || []; if (!calendars[arg.event._def.extendedProps.calId]) return; const ownerEmails = new Set( - calendars[arg.event._def.extendedProps.calId].ownerEmails?.map((email) => - email.toLowerCase() + calendars[arg.event._def.extendedProps.calId].owner?.emails?.map( + (email) => email.toLowerCase() ) ); const showSpecialDisplay = attendees.filter((att: userAttendee) => diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index 9ba8f36..c9e4ece 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -1,12 +1,14 @@ import { AppDispatch } from "@/app/store"; -import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes"; import { getCalendarDetailAsync } from "@/features/Calendars/services"; import { AclEntry } from "@/features/Calendars/types/CalendarData"; import { CalendarEvent } from "@/features/Events/EventsTypes"; import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { getEffectiveEmail } from "@/utils/getEffectiveEmail"; +import { isEventOrganiser } from "@/utils/isEventOrganiser"; import { convertEventDateTimeToISO } from "@/utils/timezone"; -import { SlotLabelContentArg } from "@fullcalendar/core"; +import { EventInput, SlotLabelContentArg } from "@fullcalendar/core"; import moment from "moment-timezone"; import { useI18n } from "twake-i18n"; @@ -68,56 +70,91 @@ export function formatEventChipTitle( : e.title; } +type ConvertedEvent = CalendarEvent & { + colors: Record | undefined; + editable: boolean; + priority: number; +}; + +function applyTimezoneToEvent( + event: CalendarEvent, + convertedEvent: ConvertedEvent +): void { + const eventTimezone = event.timezone || "Etc/UTC"; + const isAllDay = event.allday ?? false; + + if (!isAllDay && event.start) { + const startISO = convertEventDateTimeToISO(event.start, eventTimezone, { + isAllDay, + }); + if (startISO) convertedEvent.start = startISO; + } + + if (!isAllDay && event.end && eventTimezone) { + const endISO = convertEventDateTimeToISO(event.end, eventTimezone, { + isAllDay, + }); + if (endISO) convertedEvent.end = endISO; + } +} + +function buildConvertedEvent( + event: CalendarEvent, + calendar: Calendar | undefined, + userId: string | undefined, + userAddress: string | undefined, + pending: boolean, + t: (key: string) => string +): ConvertedEvent { + const isWriteDelegated = + (calendar?.delegated && + calendar.access?.write && + (!event.class || event.class === "PUBLIC")) ?? + false; + + const effectiveEmail = getEffectiveEmail( + calendar, + isWriteDelegated, + userAddress + ); + const isOrganiser = isEventOrganiser(event, effectiveEmail); + const isPersonalEvent = extractEventBaseUuid(event.calId) === userId; + + const convertedEvent: ConvertedEvent = { + ...event, + title: formatEventChipTitle(event, t), + colors: event.color, + editable: (isPersonalEvent || isWriteDelegated) && isOrganiser && !pending, + priority: isPersonalEvent ? 1 : 0, + }; + + applyTimezoneToEvent(event, convertedEvent); + + return convertedEvent; +} + export const eventToFullCalendarFormat = ( filteredEvents: CalendarEvent[], filteredTempEvents: CalendarEvent[], userId: string | undefined, userAddress: string | undefined, - pending: boolean -) => { + pending: boolean, + calendars: Record +): EventInput[] => { // eslint-disable-next-line react-hooks/rules-of-hooks const { t } = useI18n(); return filteredEvents - .concat(filteredTempEvents.map((e) => ({ ...e, temp: true }))) - .map((e) => { - const eventTimezone = e.timezone || "Etc/UTC"; - const isAllDay = e.allday ?? false; - const isOrganiser = e.organizer - ? e.organizer.cal_address?.toLowerCase() === userAddress?.toLowerCase() - : true; // if there are no organizer in the event we assume it was organized by the owner - const isPersonnalEvent = extractEventBaseUuid(e.calId) === userId; - const convertedEvent: CalendarEvent & { - colors: Record | undefined; - editable: boolean; - priority: number; - } = { - ...e, - title: formatEventChipTitle(e, t), - colors: e.color, - editable: isPersonnalEvent && isOrganiser && !pending, - priority: isPersonnalEvent ? 1 : 0, - }; - - if (!isAllDay && e.start && eventTimezone) { - const startISO = convertEventDateTimeToISO(e.start, eventTimezone, { - isAllDay, - }); - if (startISO) { - convertedEvent.start = startISO; - } - } - - if (!isAllDay && e.end && eventTimezone) { - const endISO = convertEventDateTimeToISO(e.end, eventTimezone, { - isAllDay, - }); - if (endISO) { - convertedEvent.end = endISO; - } - } - - return convertedEvent; - }); + .concat(filteredTempEvents.map((event) => ({ ...event, temp: true }))) + .map((event) => + buildConvertedEvent( + event, + calendars[event.calId], + userId, + userAddress, + pending, + t + ) + ) as EventInput[]; }; export const extractEvents = ( @@ -232,7 +269,6 @@ export const updateCalsDetails = ( export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" { let hasRead = false; - // const hasFreeBusy = false; if (acl) { for (const entry of acl) { if (entry.principal !== "{DAV:}authenticated") continue; @@ -246,3 +282,52 @@ export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" { if (hasRead) return "public"; return "private"; } + +export function getCalendarDelegationAccess( + acl: AclEntry[], + userId: string +): DelegationAccess { + const userPrincipal = `principals/users/${userId}`; + const access: DelegationAccess = { + freebusy: false, + read: false, + write: false, + "write-properties": false, + all: false, + }; + + for (const entry of acl ?? []) { + if (entry.principal !== userPrincipal) continue; + privilegeToAccess(entry.privilege, access); + } + + return access; +} + +function privilegeToAccess(privilege: string, currentAccess: DelegationAccess) { + switch (privilege) { + case "{urn:ietf:params:xml:ns:caldav}read-free-busy": + currentAccess["freebusy"] = true; + break; + case "{DAV:}read": + currentAccess["read"] = true; + currentAccess["freebusy"] = true; // read implies read-free-busy + break; + case "{DAV:}write-properties": + currentAccess["write-properties"] = true; + break; + case "{DAV:}write": + currentAccess["write-properties"] = true; // write implies write-properties + currentAccess["write"] = true; + break; + case "{DAV:}all": + currentAccess["freebusy"] = true; + currentAccess["read"] = true; + currentAccess["write-properties"] = true; + currentAccess["write"] = true; + currentAccess["all"] = true; + break; + default: + break; + } +} diff --git a/src/components/Event/EventChip/EventChip.tsx b/src/components/Event/EventChip/EventChip.tsx index f599629..10e0985 100644 --- a/src/components/Event/EventChip/EventChip.tsx +++ b/src/components/Event/EventChip/EventChip.tsx @@ -57,7 +57,7 @@ export function EventChip({ // Event properties const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification); const ownerEmails = new Set( - calendar.ownerEmails?.map((e) => e.toLowerCase()) + calendar.owner?.emails?.map((e) => e.toLowerCase()) ); // const delegated = calendar.delegated; diff --git a/src/components/Event/eventHandlers/eventHandlers.ts b/src/components/Event/eventHandlers/eventHandlers.ts index 2eb3051..e4542cf 100644 --- a/src/components/Event/eventHandlers/eventHandlers.ts +++ b/src/components/Event/eventHandlers/eventHandlers.ts @@ -12,6 +12,7 @@ import { PartStat } from "@/features/User/models/attendee"; import { createAttendee } from "@/features/User/models/attendee.mapper"; import { userData } from "@/features/User/userDataTypes"; import { buildFamilyName } from "@/utils/buildFamilyName"; +import { isEventOrganiser } from "@/utils/isEventOrganiser"; function updateEventAttendees( event: CalendarEvent, @@ -23,9 +24,7 @@ function updateEventAttendees( } const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0; - const isOrganizer = - !event.organizer || - event.organizer.cal_address?.toLowerCase() === user.email?.toLowerCase(); + const isOrganizer = isEventOrganiser(event, user.email); if (eventHasNoAttendees) { const userdata = createAttendee({ cal_address: user.email, diff --git a/src/features/Calendars/CalendarApi.ts b/src/features/Calendars/CalendarApi.ts index 898e472..e8dec88 100644 --- a/src/features/Calendars/CalendarApi.ts +++ b/src/features/Calendars/CalendarApi.ts @@ -3,7 +3,7 @@ import { CalendarInput, CalendarList } from "./types/CalendarData"; export async function getCalendars( userId: string, - scope: string = "personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&", + scope: string = "personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true", signal?: AbortSignal ): Promise { const calendars = await api diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 9c71060..86ed1a7 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -251,7 +251,6 @@ const CalendarSlice = createSlice({ description: action.payload.desc, name: action.payload.name, owner: action.payload.owner, - ownerEmails: action.payload.ownerEmails, events: {}, } as Calendar; state.error = null; @@ -270,7 +269,6 @@ const CalendarSlice = createSlice({ name: action.payload.name, events: {}, owner: action.payload.owner, - ownerEmails: action.payload.ownerEmails, } as Calendar; state.error = null; }) diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 1290815..088a32d 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -1,4 +1,5 @@ import { CalendarEvent } from "../Events/EventsTypes"; +import { OpenPaasUserData } from "../User/type/OpenPaasUserData"; export interface Calendar { id: string; @@ -7,13 +8,21 @@ export interface Calendar { delegated?: boolean; prodid?: string; color?: Record; - ownerEmails?: string[]; - owner: string; + owner: OpenPaasUserData; description?: string; calscale?: string; version?: string; events: Record; visibility: "private" | "public"; + access?: DelegationAccess; lastCacheCleared?: number; syncToken?: string; } + +export interface DelegationAccess { + freebusy: boolean; + read: boolean; + write: boolean; + "write-properties": boolean; + all: boolean; +} diff --git a/src/features/Calendars/services/addSharedCalendarAsync.ts b/src/features/Calendars/services/addSharedCalendarAsync.ts index 7c9fb59..cdd4e6e 100644 --- a/src/features/Calendars/services/addSharedCalendarAsync.ts +++ b/src/features/Calendars/services/addSharedCalendarAsync.ts @@ -1,3 +1,4 @@ +import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; import { getUserDetails } from "@/features/User/userAPI"; import { toRejectedError } from "@/utils/errorUtils"; import { createAsyncThunk } from "@reduxjs/toolkit"; @@ -12,8 +13,7 @@ export const addSharedCalendarAsync = createAsyncThunk< link: string; name: string; desc: string; - owner: string; - ownerEmails: string[]; + owner: OpenPaasUserData; }, { userId: string; calId: string; cal: CalendarInput }, { rejectValue: RejectedError } @@ -37,10 +37,7 @@ export const addSharedCalendarAsync = createAsyncThunk< link: `/calendars/${userId}/${calId}.json`, desc: cal.cal["caldav:description"] ?? "", name: cal.cal["dav:name"] ?? "", - owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ - ownerData.lastname ?? "" - }`, - ownerEmails: ownerData.emails, + owner: ownerData, }; } catch (err) { return rejectWithValue(toRejectedError(err)); diff --git a/src/features/Calendars/services/createCalendarAsync.ts b/src/features/Calendars/services/createCalendarAsync.ts index e116899..0976507 100644 --- a/src/features/Calendars/services/createCalendarAsync.ts +++ b/src/features/Calendars/services/createCalendarAsync.ts @@ -1,3 +1,4 @@ +import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; import { userData } from "@/features/User/userDataTypes"; import { toRejectedError } from "@/utils/errorUtils"; import { createAsyncThunk } from "@reduxjs/toolkit"; @@ -11,8 +12,7 @@ export const createCalendarAsync = createAsyncThunk< color: Record; name: string; desc: string; - owner: string; - ownerEmails: string[]; + owner: OpenPaasUserData; }, { userData: userData; @@ -31,9 +31,6 @@ export const createCalendarAsync = createAsyncThunk< } await postCalendar(userData.openpaasId, calId, color, name, desc); - const owner = [userData.given_name, userData.family_name] - .filter(Boolean) - .join(" "); return { userId: userData.openpaasId, @@ -41,8 +38,13 @@ export const createCalendarAsync = createAsyncThunk< color, name, desc, - owner, - ownerEmails: userData.email ? [userData.email] : [], + owner: { + firstname: userData.given_name, + lastname: userData.family_name, + id: userData.openpaasId, + preferredEmail: userData.email, + emails: userData.email ? [userData.email] : [], + }, }; } catch (err) { return rejectWithValue(toRejectedError(err)); diff --git a/src/features/Calendars/services/getCalendarDetailAsync.ts b/src/features/Calendars/services/getCalendarDetailAsync.ts index 0765524..84c067f 100644 --- a/src/features/Calendars/services/getCalendarDetailAsync.ts +++ b/src/features/Calendars/services/getCalendarDetailAsync.ts @@ -9,6 +9,7 @@ import { getCalendar } from "../CalendarApi"; import { RejectedError } from "../types/RejectedError"; import { extractCalendarEvents } from "../utils/extractCalendarEvents"; import { defaultColors } from "@/components/Calendar/utils/calendarColorsUtils"; +import { type RootState } from "@/app/store"; export const getCalendarDetailAsync = createAsyncThunk< { @@ -26,8 +27,16 @@ export const getCalendarDetailAsync = createAsyncThunk< { rejectValue: RejectedError } >( "calendars/getCalendarDetails", - async ({ calId, match, calType, signal }, { rejectWithValue }) => { + async ({ calId, match, calType, signal }, { rejectWithValue, getState }) => { try { + const state = getState() as RootState; + const calendarStored = + state.calendars[calType === "temp" ? "templist" : "list"][calId]; + if (!calendarStored) { + return rejectWithValue( + toRejectedError(new Error(`Calendar ${calId} not found in store`)) + ); + } const calendar = (await getCalendar( calId, match, @@ -42,7 +51,7 @@ export const getCalendarDetailAsync = createAsyncThunk< const items = calendar._embedded?.["dav:item"]; const events: CalendarEvent[] = Array.isArray(items) ? items.flatMap((item: CalendarItem) => - extractCalendarEvents(item, { calId, color }) + extractCalendarEvents(item, { cal: calendarStored, color }) ) : []; diff --git a/src/features/Calendars/services/getCalendarsListAsync.ts b/src/features/Calendars/services/getCalendarsListAsync.ts index cb19be4..ab08bfb 100644 --- a/src/features/Calendars/services/getCalendarsListAsync.ts +++ b/src/features/Calendars/services/getCalendarsListAsync.ts @@ -28,7 +28,7 @@ export const getCalendarsListAsync = createAsyncThunk< const errors: string[] = []; const normalizedCalendars = rawCalendars.map((cal: CalendarData) => - normalizeCalendar(cal) + normalizeCalendar(cal, user.id) ); const uniqueOwnerIds = Array.from( @@ -59,7 +59,16 @@ export const getCalendarsListAsync = createAsyncThunk< } normalizedCalendars.forEach( - ({ cal, description, delegated, link, id, ownerId, visibility }) => { + ({ + cal, + description, + delegated, + link, + id, + ownerId, + visibility, + access, + }) => { const ownerData = ownerDataMap.get(ownerId) || { firstname: "", lastname: "Unknown User", @@ -74,12 +83,12 @@ export const getCalendarsListAsync = createAsyncThunk< id, name: cal["dav:name"] ?? "", link, - owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`, - ownerEmails: ownerData.emails, + owner: ownerData, description, delegated, color, visibility, + access, events: {}, }; } diff --git a/src/features/Calendars/services/putEventAsync.ts b/src/features/Calendars/services/putEventAsync.ts index 3bdd7e3..501d88b 100644 --- a/src/features/Calendars/services/putEventAsync.ts +++ b/src/features/Calendars/services/putEventAsync.ts @@ -15,7 +15,7 @@ export const putEventAsync = createAsyncThunk< try { await putEvent( newEvent, - cal.ownerEmails ? cal.ownerEmails[0] : undefined + cal.owner?.emails ? cal.owner.emails[0] : undefined ); return { diff --git a/src/features/Calendars/services/refreshCalendar.ts b/src/features/Calendars/services/refreshCalendar.ts index f406d09..65766d1 100644 --- a/src/features/Calendars/services/refreshCalendar.ts +++ b/src/features/Calendars/services/refreshCalendar.ts @@ -1,4 +1,5 @@ import { CalendarEvent } from "@/features/Events/EventsTypes"; +import { buildDelegatedEventURL } from "@/features/Events/eventUtils"; import { toRejectedError } from "@/utils/errorUtils"; import { createAsyncThunk } from "@reduxjs/toolkit"; import pMap from "p-map"; @@ -62,7 +63,11 @@ export const refreshCalendarWithSyncToken = createAsyncThunk< return { calId: calendar.id, - deletedEvents: toDelete, + deletedEvents: toDelete.map((eventURL) => + calendar.delegated + ? buildDelegatedEventURL(calendar, { URL: eventURL }) + : eventURL + ), createdOrUpdatedEvents: createdOrUpdatedEvents .flat() .filter(Boolean) as CalendarEvent[], diff --git a/src/features/Calendars/services/updateEventInstanceAsync.ts b/src/features/Calendars/services/updateEventInstanceAsync.ts index 40176b5..9df8c49 100644 --- a/src/features/Calendars/services/updateEventInstanceAsync.ts +++ b/src/features/Calendars/services/updateEventInstanceAsync.ts @@ -13,7 +13,7 @@ export const updateEventInstanceAsync = createAsyncThunk< "calendars/updateEventInstance", async ({ cal, event }, { rejectWithValue }) => { try { - await putEventWithOverrides(event, cal.ownerEmails?.[0]); + await putEventWithOverrides(event, cal.owner?.emails?.[0]); return { calId: cal.id, event }; } catch (err) { return rejectWithValue(toRejectedError(err)); diff --git a/src/features/Calendars/services/updateSeriesAsync.ts b/src/features/Calendars/services/updateSeriesAsync.ts index 54dd3a8..f817b55 100644 --- a/src/features/Calendars/services/updateSeriesAsync.ts +++ b/src/features/Calendars/services/updateSeriesAsync.ts @@ -13,7 +13,7 @@ export const updateSeriesAsync = createAsyncThunk< "calendars/updateSeries", async ({ cal, event, removeOverrides = true }, { rejectWithValue }) => { try { - await updateSeries(event, cal.ownerEmails?.[0] ?? "", removeOverrides); + await updateSeries(event, cal.owner?.emails?.[0], removeOverrides); } catch (err) { return rejectWithValue(toRejectedError(err)); } diff --git a/src/features/Calendars/utils/expandEventFunction.ts b/src/features/Calendars/utils/expandEventFunction.ts index 84da10a..5ab8364 100644 --- a/src/features/Calendars/utils/expandEventFunction.ts +++ b/src/features/Calendars/utils/expandEventFunction.ts @@ -15,7 +15,7 @@ export function expandEventFunction( end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), }); const events: CalendarEvent[] = extractCalendarEvents(item, { - calId: calendar.id, + cal: calendar, color: calendar.color, }); return events; diff --git a/src/features/Calendars/utils/extractCalendarEvents.ts b/src/features/Calendars/utils/extractCalendarEvents.ts index a301756..5fedf56 100644 --- a/src/features/Calendars/utils/extractCalendarEvents.ts +++ b/src/features/Calendars/utils/extractCalendarEvents.ts @@ -2,12 +2,13 @@ import { defaultColors } from "@/components/Calendar/utils/calendarColorsUtils"; import { CalendarEvent } from "@/features/Events/EventsTypes"; import { parseCalendarEvent } from "@/features/Events/eventUtils"; import { CalDavItem } from "../api/types"; +import { Calendar } from "../CalendarTypes"; import { VCalComponent } from "../types/CalendarData"; export function extractCalendarEvents( item: CalDavItem, options: { - calId: string; + cal: Calendar; color?: Record; } ): CalendarEvent[] { @@ -43,7 +44,7 @@ export function extractCalendarEvents( return parseCalendarEvent( eventProps, options?.color ?? defaultColors[0], - options.calId, + options.cal, eventURL, valarm ); diff --git a/src/features/Calendars/utils/normalizeCalendar.ts b/src/features/Calendars/utils/normalizeCalendar.ts index 3cd6ef0..96dd305 100644 --- a/src/features/Calendars/utils/normalizeCalendar.ts +++ b/src/features/Calendars/utils/normalizeCalendar.ts @@ -1,7 +1,10 @@ -import { getCalendarVisibility } from "@/components/Calendar/utils/calendarUtils"; +import { + getCalendarDelegationAccess, + getCalendarVisibility, +} from "@/components/Calendar/utils/calendarUtils"; import { CalendarData } from "../types/CalendarData"; -export function normalizeCalendar(rawCalendar: CalendarData) { +export function normalizeCalendar(rawCalendar: CalendarData, userId: string) { const description = rawCalendar["caldav:description"]; let delegated = false; let source = rawCalendar["calendarserver:source"] @@ -18,6 +21,8 @@ export function normalizeCalendar(rawCalendar: CalendarData) { const id = source.replace("/calendars/", "").replace(".json", ""); const ownerId = id.split("/")[0]; const visibility = getCalendarVisibility(rawCalendar["acl"] ?? []); + const access = getCalendarDelegationAccess(rawCalendar["acl"] ?? [], userId); + return { cal: rawCalendar, description, @@ -27,5 +32,6 @@ export function normalizeCalendar(rawCalendar: CalendarData) { id, ownerId, visibility, + access, }; } diff --git a/src/features/Events/AttendanceValidation/AttendanceValidation.tsx b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx index 37691f3..f2f8789 100644 --- a/src/features/Events/AttendanceValidation/AttendanceValidation.tsx +++ b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx @@ -1,10 +1,10 @@ +import { PartStat } from "@/features/User/models/attendee"; import { userData } from "@/features/User/userDataTypes"; import { Box, Typography } from "@linagora/twake-mui"; import { Dispatch, SetStateAction, useState } from "react"; import { useI18n } from "twake-i18n"; import { ContextualizedEvent } from "../EventsTypes"; import { RSVPButton } from "./RSVPButton"; -import { PartStat } from "@/features/User/models/attendee"; interface AttendanceValidationProps { contextualizedEvent: ContextualizedEvent; @@ -31,7 +31,14 @@ export function AttendanceValidation({ !(contextualizedEvent.event?.attendee?.length > 0) && !contextualizedEvent.event?.organizer; - if (!((currentUserAttendee || hasNoAttendeesOrOrganizer) && isOwn)) { + const createByTheUser = currentUserAttendee || hasNoAttendeesOrOrganizer; + const editRightInSelfCalendar = createByTheUser && isOwn; + const isDelegatedPublicEvent = + contextualizedEvent.calendar.delegated && + (!contextualizedEvent.event.class || + contextualizedEvent.event.class === "PUBLIC"); + + if (!(editRightInSelfCalendar || isDelegatedPublicEvent)) { return null; } diff --git a/src/features/Events/AttendanceValidation/RSVPButton.tsx b/src/features/Events/AttendanceValidation/RSVPButton.tsx index 10a5b31..fbecda5 100644 --- a/src/features/Events/AttendanceValidation/RSVPButton.tsx +++ b/src/features/Events/AttendanceValidation/RSVPButton.tsx @@ -40,8 +40,10 @@ export function RSVPButton({ }: RSVPButtonProps) { const { t } = useI18n(); const dispatch = useAppDispatch(); - const { currentUserAttendee } = contextualizedEvent; + const { currentUserAttendee, calendar } = contextualizedEvent; const showLoading = isLoading && loadingValue === rsvpValue; + const isReadDelegated = + calendar.delegated && calendar.access?.read && !calendar.access?.write; const previousPartstatRef = useRef( currentUserAttendee?.partstat ); @@ -67,7 +69,6 @@ export function RSVPButton({ if (previousPartstatRef.current === rsvpValue) { return; } - // For recurring events, don't set loading yet - wait for modal choice if (!contextualizedEvent.isRecurring) { onLoadingChange(true, rsvpValue); @@ -120,7 +121,7 @@ export function RSVPButton({ : {}, }} onClick={handleClick} - disabled={isLoading} + disabled={isLoading || isReadDelegated} > {showLoading && } diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index fd658e8..3af983e 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -3,6 +3,7 @@ import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone"; import { TIMEZONES } from "@/utils/timezone-data"; import ICAL from "ical.js"; import { CalDavItem } from "../Calendars/api/types"; +import { Calendar } from "../Calendars/CalendarTypes"; import { VCalComponent, VObjectProperty, @@ -106,7 +107,7 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) { const eventjson = parseCalendarEvent( targetVevent[1], event.color ?? {}, - event.calId, + { id: event?.calId } as Calendar, event.URL ); @@ -219,7 +220,12 @@ export const deleteEventInstance = async (event: CalendarEvent) => { } const exdateValue = event.recurrenceId || event.start; - const seriesEvent = parseCalendarEvent(vevents[masterIndex][1], {}, "", ""); + const seriesEvent = parseCalendarEvent( + vevents[masterIndex][1], + {}, + { id: event.calId } as Calendar, + "" + ); const masterProps = vevents[masterIndex][1]; // Check if this date is already in EXDATE (avoid duplicates) diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index 552283a..164c47c 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -1,7 +1,6 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { CalendarName } from "@/components/Calendar/CalendarName"; -import { getTimezoneOffset } from "@/utils/timezone"; import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils"; import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog"; import { EditModeDialog } from "@/components/Event/EditModeDialog"; @@ -9,7 +8,9 @@ import EventDuplication from "@/components/Event/EventDuplicate"; import { handleDelete } from "@/components/Event/eventHandlers/eventHandlers"; import { InfoRow } from "@/components/Event/InfoRow"; import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils"; -import { browserDefaultTimeZone } from "@/utils/timezone"; +import { getEffectiveEmail } from "@/utils/getEffectiveEmail"; +import { isEventOrganiser } from "@/utils/isEventOrganiser"; +import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone"; import { DateSelectArg } from "@fullcalendar/core"; import { AvatarGroup, @@ -23,7 +24,6 @@ import { Typography, useTheme, } from "@linagora/twake-mui"; -import { alpha } from "@mui/material/styles"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import CircleIcon from "@mui/icons-material/Circle"; import CloseIcon from "@mui/icons-material/Close"; @@ -38,10 +38,12 @@ import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined"; import RepeatIcon from "@mui/icons-material/Repeat"; import SubjectIcon from "@mui/icons-material/Subject"; import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined"; +import { alpha } from "@mui/material/styles"; import { useEffect, useRef, useState } from "react"; import { useI18n } from "twake-i18n"; import { deleteEventAsync } from "../Calendars/services"; import { userAttendee } from "../User/models/attendee"; +import { ToUserData } from "../User/type/OpenPaasUserData"; import { AttendanceValidation } from "./AttendanceValidation/AttendanceValidation"; import { createEventContext } from "./createEventContext"; import { dlEvent } from "./EventApi"; @@ -72,7 +74,7 @@ export default function EventPreviewModal({ const calendar = tempEvent ? calendars.templist[calId] : calendars.list[calId]; - const event = calendar.events[eventId]; + const event = calendar?.events[eventId]; const user = useAppSelector((state) => state.user.userData); const theme = useTheme(); const infoIconColor = alpha(theme.palette.grey[900], 0.9); @@ -80,10 +82,19 @@ export default function EventPreviewModal({ if (!user) return null; const isRecurring = event?.uid?.includes("/"); - const isOwn = calendar.ownerEmails?.includes(user.email); + const isOwn = calendar.owner?.emails?.includes(user.email) ?? false; + const isDelegated = calendar.delegated; + const isWriteDelegated = (isDelegated && calendar.access?.write) ?? false; + const effectiveEmail = getEffectiveEmail( + calendar, + isWriteDelegated, + user.email + ); const isOrganizer = event.organizer - ? user.email === event.organizer.cal_address + ? isEventOrganiser(event, effectiveEmail) : isOwn; + const isNotPrivate = + event.class !== "PRIVATE" && event.class !== "CONFIDENTIAL"; const [showAllAttendees, setShowAllAttendees] = useState(false); const [openUpdateModal, setOpenUpdateModal] = useState(false); const [openDuplicateModal, setOpenDuplicateModal] = useState(false); @@ -325,7 +336,7 @@ export default function EventPreviewModal({ )} - {isOrganizer && isOwn && ( + {isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)) && ( { @@ -344,7 +355,7 @@ export default function EventPreviewModal({ )} - {((event.class !== "PRIVATE" && !isOwn) || isOwn) && ( + {((isNotPrivate && !isOwn) || isOwn) && ( setToggleActionMenu(e.currentTarget)} @@ -380,7 +391,7 @@ export default function EventPreviewModal({ setOpenDuplicateModal(true); }} /> - {isOwn && ( + {(isOwn || isWriteDelegated) && ( { if (isRecurring) { @@ -434,7 +445,11 @@ export default function EventPreviewModal({ actions={ @@ -486,7 +501,7 @@ export default function EventPreviewModal({ ` – ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`} - {((event.class !== "PRIVATE" && !isOwn) || isOwn) && ( + {((isNotPrivate && !isOwn) || isOwn) && ( <> {/* Video */} {event.x_openpass_videoconference && ( @@ -664,7 +679,7 @@ export default function EventPreviewModal({ )} )} - {event.class === "PRIVATE" && !isOwn && ( + {!isNotPrivate && !isOwn && ( person.cal_address === user.email + (person) => person.cal_address === attendeeEmail ); - return { event, calendar, diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 6dfecf8..c302e83 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -4,6 +4,7 @@ import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone"; import { TIMEZONES } from "@/utils/timezone-data"; import ICAL from "ical.js"; import moment from "moment-timezone"; +import { Calendar } from "../Calendars/CalendarTypes"; import { RepetitionRule, VObjectProperty, @@ -34,7 +35,7 @@ function inferTimezoneFromValue( export function parseCalendarEvent( data: VObjectProperty[], color: Record, - calendarid: string, + calendar: Calendar, eventURL: string, valarm?: VObjectProperty[] ): CalendarEvent { @@ -180,14 +181,19 @@ export function parseCalendarEvent( } } } - event.calId = calendarid; - event.URL = eventURL; + event.calId = calendar.id; + event.URL = calendar.delegated + ? buildDelegatedEventURL(calendar, { + ...event, + URL: eventURL, + } as CalendarEvent) + : eventURL; if (!event.uid || !event.start) { console.error( - `missing crucial event param in calendar ${calendarid} `, + `missing crucial event param in calendar ${calendar.id} `, data ); - event.error = `missing crucial event param in calendar ${calendarid} `; + event.error = `missing crucial event param in calendar ${calendar.id} `; } const eventTimezone = event.timezone; @@ -556,3 +562,15 @@ export function detectRecurringEventChanges( repetitionRulesChanged, }; } + +export function buildDelegatedEventURL( + calendar: Calendar, + event: CalendarEvent +): string { + const calendarBasePath = calendar.link.replace(/\.json$/, ""); + const eventFilename = event.URL.split("/").pop(); + if (!eventFilename) { + throw new Error(`Cannot extract filename from event URL: ${event.URL}`); + } + return `${calendarBasePath}/${eventFilename}`; +} diff --git a/src/features/User/type/OpenPaasUserData.ts b/src/features/User/type/OpenPaasUserData.ts index b0ac704..67dd800 100644 --- a/src/features/User/type/OpenPaasUserData.ts +++ b/src/features/User/type/OpenPaasUserData.ts @@ -1,4 +1,4 @@ -import { ModuleConfiguration } from "../userDataTypes"; +import { ModuleConfiguration, userData } from "../userDataTypes"; export interface OpenPaasUserData { firstname?: string; @@ -8,4 +8,25 @@ export interface OpenPaasUserData { configurations?: { modules?: ModuleConfiguration[]; }; + emails: string[]; +} + +export function ToUserData( + openpaas: OpenPaasUserData | undefined +): userData | undefined { + if (!openpaas) return undefined; + const email = openpaas.preferredEmail ?? openpaas.emails?.[0] ?? ""; + + const given_name = openpaas.firstname ?? ""; + const family_name = openpaas.lastname ?? ""; + + return { + email, + given_name, + family_name, + name: [given_name, family_name].filter(Boolean).join(" "), + sid: openpaas.id ?? "", + sub: openpaas.id ?? "", + openpaasId: openpaas.id, + }; } diff --git a/src/utils/getEffectiveEmail.ts b/src/utils/getEffectiveEmail.ts new file mode 100644 index 0000000..bd3767b --- /dev/null +++ b/src/utils/getEffectiveEmail.ts @@ -0,0 +1,9 @@ +import { Calendar } from "@/features/Calendars/CalendarTypes"; + +export function getEffectiveEmail( + calendar: Calendar | undefined, + isWriteDelegated: boolean, + userAddress: string | undefined +): string | undefined { + return isWriteDelegated ? calendar?.owner?.emails?.[0] : userAddress; +} diff --git a/src/utils/isEventOrganiser.ts b/src/utils/isEventOrganiser.ts new file mode 100644 index 0000000..48979da --- /dev/null +++ b/src/utils/isEventOrganiser.ts @@ -0,0 +1,11 @@ +import { CalendarEvent } from "@/features/Events/EventsTypes"; + +export function isEventOrganiser( + event: CalendarEvent, + effectiveEmail: string | undefined +): boolean { + if (!event.organizer) return true; // no organizer = assume owner + const organizerEmail = event.organizer.cal_address?.toLowerCase(); + if (!organizerEmail || !effectiveEmail) return false; + return organizerEmail === effectiveEmail.toLowerCase(); +}