[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:
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CalendarList> {
|
||||
const calendars = await api
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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<string, string>;
|
||||
ownerEmails?: string[];
|
||||
owner: string;
|
||||
owner: OpenPaasUserData;
|
||||
description?: string;
|
||||
calscale?: string;
|
||||
version?: string;
|
||||
events: Record<string, CalendarEvent>;
|
||||
visibility: "private" | "public";
|
||||
access?: DelegationAccess;
|
||||
lastCacheCleared?: number;
|
||||
syncToken?: string;
|
||||
}
|
||||
|
||||
export interface DelegationAccess {
|
||||
freebusy: boolean;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
"write-properties": boolean;
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string, string>;
|
||||
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));
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
): CalendarEvent[] {
|
||||
@@ -43,7 +44,7 @@ export function extractCalendarEvents(
|
||||
return parseCalendarEvent(
|
||||
eventProps,
|
||||
options?.color ?? defaultColors[0],
|
||||
options.calId,
|
||||
options.cal,
|
||||
eventURL,
|
||||
valarm
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user