[DELEGATION] Edit attendance and events, delete event for someone (#553)

* [#523] added way to manage attendance for delegated calendars

* [DELEGATION] #523 & #524 changed eventURL calculation for delegated events to have the rigth one

* [#524] added drag and drop authorisation to delegated event

* [#524] added tests

* [#524] support for the permission access

* [#524] no edit when delegated event is not public
This commit is contained in:
Camille Moussu
2026-02-19 09:50:29 +01:00
committed by GitHub
parent f520a9b6fe
commit cfb2b59584
52 changed files with 1116 additions and 219 deletions
@@ -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;
}
@@ -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<PartStat | undefined>(
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}
>
<Box display="flex" alignItems="center" gap={1}>
{showLoading && <CircularProgress size={20} color="inherit" />}
+8 -2
View File
@@ -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)
+27 -12
View File
@@ -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({
<FileDownloadOutlinedIcon />
</IconButton>
)}
{isOrganizer && isOwn && (
{isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)) && (
<IconButton
size="small"
onClick={() => {
@@ -344,7 +355,7 @@ export default function EventPreviewModal({
<EditIcon />
</IconButton>
)}
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
{((isNotPrivate && !isOwn) || isOwn) && (
<IconButton
size="small"
onClick={(e) => setToggleActionMenu(e.currentTarget)}
@@ -380,7 +391,7 @@ export default function EventPreviewModal({
setOpenDuplicateModal(true);
}}
/>
{isOwn && (
{(isOwn || isWriteDelegated) && (
<MenuItem
onClick={async () => {
if (isRecurring) {
@@ -434,7 +445,11 @@ export default function EventPreviewModal({
actions={
<AttendanceValidation
contextualizedEvent={contextualizedEvent}
user={user}
user={
isWriteDelegated && calendar.owner
? ToUserData(calendar.owner)
: user
}
setAfterChoiceFunc={setAfterChoiceFunc}
setOpenEditModePopup={setOpenEditModePopup}
/>
@@ -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)) : ""}`}
</Typography>
</Box>
{((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 && (
<Box
sx={{
backgroundColor: "#F3F4F6",
+1 -1
View File
@@ -715,7 +715,7 @@ function EventUpdateModal({
};
// STEP 3: Persist new event to server
await putEvent(finalNewEvent, targetCalendar.ownerEmails?.[0]);
await putEvent(finalNewEvent, targetCalendar.owner?.emails?.[0]);
// STEP 4: Update Redux store - Add new event first to prevent empty grid
dispatch(updateEventLocal({ calId, event: finalNewEvent }));
+1 -1
View File
@@ -10,7 +10,7 @@ export interface CalendarEvent {
transp?: string;
start: string; // ISO date string
end?: string;
class?: string;
class?: "PRIVATE" | "PUBLIC" | "CONFIDENTIAL";
x_openpass_videoconference?: string;
title?: string;
description?: string;
+5 -3
View File
@@ -7,15 +7,17 @@ export function createEventContext(
calendar: Calendar,
user: userData
): ContextualizedEvent {
const isOwn = calendar.ownerEmails?.includes(user.email) ?? false;
const isOwn = calendar.owner?.emails?.includes(user.email) ?? false;
const isRecurring = event?.uid?.includes("/") ?? false;
const isOrganizer = event.organizer
? user?.email === event.organizer.cal_address
: isOwn;
const attendeeEmail = calendar.delegated
? calendar.owner?.emails?.[0]
: user.email;
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.email
(person) => person.cal_address === attendeeEmail
);
return {
event,
calendar,
+23 -5
View File
@@ -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<string, string>,
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}`;
}