[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
+2 -1
View File
@@ -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
@@ -134,7 +134,7 @@ function getCalendarsFromUsersDelta(
function buildEmailToCalendarMap(calRecord: Record<string, Calendar>) {
const map = new Map<string, string[]>();
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);
@@ -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) =>
+130 -45
View File
@@ -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<string, string> | 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<string, Calendar>
): 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<string, string> | 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;
}
}
+1 -1
View File
@@ -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;
@@ -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,