[#527] calendar delegation CRUD (#581)

* [#527] added panel to grant right for calendar delegation

* [#527] added rights management to access modal

* [#527] conditionnal rendering of access granting based on rights

* [#527] saves on close except cancel

* [#527] added tests
This commit is contained in:
Camille Moussu
2026-03-02 09:34:45 +01:00
committed by GitHub
parent 1af571d98e
commit 72a9d0ee05
11 changed files with 1073 additions and 69 deletions
+33 -8
View File
@@ -1,3 +1,4 @@
import { useAppSelector } from "@/app/hooks";
import {
exportCalendar,
getSecretLink,
@@ -19,9 +20,28 @@ import { useI18n } from "twake-i18n";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights";
export function AccessTab({ calendar }: { calendar: Calendar }) {
interface AccessTabProps {
calendar: Calendar;
usersWithAccess: UserWithAccess[];
onUsersWithAccessChange: (users: UserWithAccess[]) => void;
onInvitesLoaded: (users: UserWithAccess[]) => void;
}
export function AccessTab({
calendar,
usersWithAccess,
onUsersWithAccessChange,
onInvitesLoaded,
}: AccessTabProps) {
const { t } = useI18n();
const userData = useAppSelector((state) => state.user.userData);
const isPersonalCalendar = userData.openpaasId === calendar.id.split("/")[0];
const isDelegatedWithAdministration = calendar.invite?.find(
(invite) => invite.href.includes(userData.email) && invite.access === 5
);
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`;
const [secretLink, setSecretLink] = useState("");
@@ -60,11 +80,8 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
const exportedData = await exportCalendar(
calendar.link.replace(".json", "")
);
const blob = new Blob([exportedData], {
type: "text/calendar",
});
const blob = new Blob([exportedData], { type: "text/calendar" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${calendar.id.split("/")[1]}.ics`;
@@ -74,7 +91,6 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
URL.revokeObjectURL(url);
} catch (e) {
setExportError((e as Error).message);
setExportLoading(false);
} finally {
setExportLoading(false);
}
@@ -82,6 +98,15 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
return (
<>
{(isPersonalCalendar || isDelegatedWithAdministration) && (
<CalendarAccessRights
calendar={calendar}
value={usersWithAccess}
onChange={onUsersWithAccessChange}
onInvitesLoaded={onInvitesLoaded}
/>
)}
{!!window.DAV_BASE_URL && (
<FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}>
<Box mt={2}>
@@ -108,6 +133,7 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
</Box>
</FieldWithLabel>
)}
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}>
<Box mt={3} display="flex" alignItems="center" gap={1}>
<TextField
@@ -147,11 +173,10 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
<FieldWithLabel label={t("calendar.exportCalendar")} isExpanded={false}>
<Typography
variant="body2"
sx={{ color: "text.secondary", m: 1, lineHeight: 1.5 }}
sx={{ color: "text.secondary", my: 1, lineHeight: 1.5 }}
>
{t("calendar.exportDesc")}
</Typography>
<Button
variant="contained"
color="secondary"
@@ -0,0 +1,354 @@
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
import { getUserDetails } from "@/features/User/userAPI";
import {
AutocompleteRenderInputParams,
Avatar,
Box,
CircularProgress,
IconButton,
InputAdornment,
MenuItem,
Select,
TextField,
Typography,
} from "@linagora/twake-mui";
import HighlightOffIcon from "@mui/icons-material/HighlightOff";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
import { useEffect, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { stringAvatar } from "../Event/utils/eventUtils";
export interface UserWithAccess extends User {
accessRight: AccessRight;
}
interface CalendarAccessRightsProps {
calendar: Calendar;
value: UserWithAccess[];
onChange: (users: UserWithAccess[]) => void;
onInvitesLoaded: (users: UserWithAccess[]) => void;
}
export function CalendarAccessRights({
calendar,
value: usersWithAccess,
onChange,
onInvitesLoaded,
}: CalendarAccessRightsProps) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const [searchWidth, setSearchWidth] = useState<number | undefined>(undefined);
const [accessRight, setAccessRight] = useState<AccessRight>(2);
const [inviteLoading, setInvitesLoading] = useState(false);
const currentUsersRef = useRef<UserWithAccess[]>(usersWithAccess);
useEffect(() => {
currentUsersRef.current = usersWithAccess;
}, [usersWithAccess]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setSearchWidth(entry.contentRect.width);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!calendar.invite?.length) return;
let cancelled = false;
async function loadInvitedUsers() {
setInvitesLoading(true);
try {
const loaded: UserWithAccess[] = (
await Promise.all(
calendar.invite.map(async (invite) => {
const principalId = invite.principal.split("/").pop();
if (!principalId) return null;
try {
const details = await getUserDetails(principalId);
const email =
details?.preferredEmail ?? details?.emails?.[0] ?? "";
return {
openpaasId: principalId,
displayName:
[details?.firstname, details?.lastname]
.filter(Boolean)
.join(" ")
.trim() || email,
email,
accessRight: invite.access as AccessRight,
} satisfies UserWithAccess;
} catch {
return null;
}
})
)
).filter((u) => u !== null && !!u.email);
if (cancelled) return;
const loadedIds = new Set(loaded.map((u) => normalizeEmail(u.email)));
const manuallyAdded = currentUsersRef.current.filter(
(u) => !loadedIds.has(normalizeEmail(u.email))
);
const merged = [...loaded, ...manuallyAdded];
onInvitesLoaded(loaded);
onChange(merged);
} finally {
if (!cancelled) setInvitesLoading(false);
}
}
loadInvitedUsers();
return () => {
cancelled = true;
};
}, [calendar.invite, onChange, onInvitesLoaded]);
const normalizeEmail = (email?: string) => email?.trim().toLowerCase() ?? "";
const handleUserSelect = (_event: unknown, users: User[]) => {
const updated: UserWithAccess[] = users.map((user) => {
const existing = usersWithAccess.find(
(u) => normalizeEmail(u.email) === normalizeEmail(user.email)
);
return existing ?? { ...user, accessRight };
});
onChange(updated);
};
const handleRemoveUser = (email: string) => {
onChange(
usersWithAccess.filter(
(u) => normalizeEmail(u.email) !== normalizeEmail(email)
)
);
};
const handleChangeUserRight = (email: string, right: AccessRight) => {
onChange(
usersWithAccess.map((u) =>
normalizeEmail(u.email) === normalizeEmail(email)
? { ...u, accessRight: right }
: u
)
);
};
const accessRightOptions: { value: AccessRight; label: string }[] = [
{ value: 2, label: t("calendarPopover.access.viewAllEvents") },
{ value: 3, label: t("calendarPopover.access.editor") },
{ value: 5, label: t("calendarPopover.access.administrator") },
];
return (
<FieldWithLabel
label={t("calendarPopover.access.grantAccessRights")}
isExpanded={false}
>
<Box ref={containerRef}>
<PeopleSearch
selectedUsers={usersWithAccess}
onChange={handleUserSelect}
objectTypes={["user"]}
onToggleEventPreview={() => {}}
customSlotProps={{
popper: {
anchorEl: containerRef.current,
placement: "bottom-start",
sx: {
minWidth: searchWidth,
"& .MuiPaper-root": {
width: "100%",
},
},
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
},
},
],
},
}}
customRenderInput={(
params: AutocompleteRenderInputParams,
query: string,
setQuery: (value: string) => void
) => (
<TextField
{...params}
fullWidth
autoFocus
placeholder={t("peopleSearch.label")}
value={query}
inputRef={(el) => {
const ref = params.InputProps.ref;
if (typeof ref === "function") {
ref(el);
} else if (ref && "current" in ref) {
(
ref as React.MutableRefObject<HTMLInputElement | null>
).current = el;
}
}}
onChange={(e) => setQuery(e.target.value)}
variant="outlined"
inputProps={{
...params.inputProps,
sx: {
fontSize: "14px",
"&::placeholder": { fontSize: "14px" },
},
}}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<PeopleOutlineOutlinedIcon
sx={{ color: "text.secondary" }}
/>
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<Select
value={accessRight}
onChange={(e) =>
setAccessRight(e.target.value as AccessRight)
}
variant="standard"
disableUnderline
sx={{
fontSize: "0.875rem",
color: "text.secondary",
"& .MuiSelect-select": {
paddingRight: "24px !important",
paddingY: 0,
},
"& .MuiSelect-icon": { fontSize: "1rem" },
"&:before, &:after": { display: "none" },
}}
>
{accessRightOptions.map((opt) => (
<MenuItem
key={opt.value}
value={opt.value}
sx={{ color: "text.secondary" }}
>
{opt.label}
</MenuItem>
))}
</Select>
</InputAdornment>
),
}}
/>
)}
/>
</Box>
{inviteLoading ? (
<Box mt={2} display="flex" justifyContent="center">
<CircularProgress size={24} />
</Box>
) : (
usersWithAccess.length > 0 && (
<Box mt={2} display="flex" flexDirection="column" gap={1}>
{usersWithAccess.map((user) => (
<Box
key={user.email}
display="flex"
alignItems="center"
justifyContent="space-between"
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(user.displayName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
>
{user.displayName?.[0]?.toUpperCase()}
</Avatar>
<Box
minWidth={0}
display="flex"
flexDirection="column"
gap={0}
>
<Typography noWrap>{user.displayName}</Typography>
<Typography variant="caption" color="text.secondary">
{user.email}
</Typography>
</Box>
</Box>
<Box
display="flex"
alignItems="center"
gap={0.5}
flexShrink={0}
>
<Select
value={user.accessRight}
onChange={(e) =>
handleChangeUserRight(
user.email,
e.target.value as AccessRight
)
}
variant="standard"
disableUnderline
sx={{
fontSize: "0.875rem",
color: "text.secondary",
"& .MuiSelect-select": {
paddingRight: "24px !important",
paddingY: 0,
},
"& .MuiSelect-icon": { fontSize: "1rem" },
}}
>
{accessRightOptions.map((opt) => (
<MenuItem
key={opt.value}
value={opt.value}
sx={{ color: "text.secondary" }}
>
{opt.label}
</MenuItem>
))}
</Select>
<IconButton
size="small"
aria-label={t("actions.remove")}
onClick={() => handleRemoveUser(user.email)}
sx={{ color: "text.secondary" }}
>
<HighlightOffIcon fontSize="small" />
</IconButton>
</Box>
</Box>
))}
</Box>
)
)}
</FieldWithLabel>
);
}
+111 -25
View File
@@ -6,15 +6,19 @@ import {
patchACLCalendarAsync,
patchCalendarAsync,
} from "@/features/Calendars/services";
import { updateDelegationCalendarAsync } from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { accessRightToDavProp } from "@/utils/accessRightToDavProp";
import { defaultColors } from "@/utils/defaultColors";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { Button, Tab, Tabs } from "@linagora/twake-mui";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { ResponsiveDialog } from "../Dialog";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { AccessTab } from "./AccessTab";
import { UserWithAccess } from "./CalendarAccessRights";
import { ImportTab } from "./ImportTab";
import { SettingsTab } from "./SettingsTab";
import { defaultColors } from "@/utils/defaultColors";
function CalendarPopover({
open,
@@ -42,6 +46,13 @@ function CalendarPopover({
const [color, setColor] = useState<Record<string, string>>(defaultColors[0]);
const [visibility, setVisibility] = useState<"private" | "public">("public");
// access tab state
const [usersWithAccess, setUsersWithAccess] = useState<UserWithAccess[]>([]);
// Snapshot of the invitee list as loaded from calendar.invite on open.
// Used to diff on save: what changed vs what was removed.
const initialUsersRef = useRef<UserWithAccess[]>([]);
// import tab state
const [tab, setTab] = useState<"settings" | "access" | "import">("settings");
const [importedContent, setImportedContent] = useState<File | null>(null);
@@ -70,27 +81,82 @@ function CalendarPopover({
setVisibility("public");
setImportTarget("new");
}
setUsersWithAccess([]);
initialUsersRef.current = [];
}, [calendar, open]);
const updateCalendar = (calId: string, calLink: string) => {
dispatch(
patchCalendarAsync({
calId,
calLink,
patch: { name: name.trim(), desc: description.trim(), color },
})
);
const handleUsersWithAccessChange = useCallback((users: UserWithAccess[]) => {
setUsersWithAccess(users);
}, []);
const handleInvitesLoaded = useCallback((users: UserWithAccess[]) => {
if (initialUsersRef.current.length === 0) {
initialUsersRef.current = users;
}
}, []);
const [saveError, setSaveError] = useState("");
const updateCalendar = async (calId: string, calLink: string) => {
const nameChanged = name.trim() !== calendar?.name;
const descChanged = description.trim() !== (calendar?.description ?? "");
const colorChanged =
JSON.stringify(color) !==
JSON.stringify(calendar?.color ?? defaultColors[0]);
if (nameChanged || descChanged || colorChanged) {
await dispatch(
patchCalendarAsync({
calId,
calLink,
patch: { name: name.trim(), desc: description.trim(), color },
})
).unwrap();
}
if (visibility !== calendar?.visibility) {
dispatch(
await dispatch(
patchACLCalendarAsync({
calId,
calLink,
request: visibility === "public" ? "{DAV:}read" : "",
})
);
).unwrap();
}
};
async function updateCalendarInvites(calLink: string) {
const normaliseEmail = (u: UserWithAccess) =>
u.email?.trim().toLowerCase() ?? "";
const currentMap = new Map(
usersWithAccess
.filter((u) => !!normaliseEmail(u))
.map((u) => [normaliseEmail(u), u])
);
const set = usersWithAccess
.filter((u) => !!normaliseEmail(u))
.map((u) => ({
"dav:href": `mailto:${normaliseEmail(u)}`,
[accessRightToDavProp(u.accessRight)]: true,
}));
const remove = initialUsersRef.current
.filter((u) => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u)))
.map((u) => ({ "dav:href": `mailto:${normaliseEmail(u)}` }));
if (set.length === 0 && remove.length === 0) return;
await dispatch(
updateDelegationCalendarAsync({
calId: calendar?.id,
calLink,
share: { set, remove },
})
).unwrap();
}
const createCalendar = async (
calId: string,
name: string,
@@ -116,15 +182,19 @@ function CalendarPopover({
);
};
const handleSave = () => {
const handleSave = async () => {
if (!name.trim()) return;
if (calendar) {
updateCalendar(calendar.id, calendar.link);
try {
await updateCalendar(calendar.id, calendar.link);
await updateCalendarInvites(calendar.link);
} catch {
setSaveError(t("error.title"));
return;
}
} else {
createCalendar(crypto.randomUUID(), name, description, color, visibility);
}
handleClose({}, "backdropClick");
};
const handleImport = async () => {
@@ -162,9 +232,14 @@ function CalendarPopover({
const handleClose = (
e: object | null,
reason: "backdropClick" | "escapeKeyDown"
reason: "backdropClick" | "escapeKeyDown" | "cancel"
): void => {
onClose(e, reason);
if (reason !== "cancel") {
handleSave();
onClose(e, reason);
} else {
onClose(e, "backdropClick");
}
setName("");
setDescription("");
setColor(defaultColors[0]);
@@ -172,7 +247,9 @@ function CalendarPopover({
setVisibility("public");
setImportTarget("new");
setImportedContent(null);
setUsersWithAccess([]);
initialUsersRef.current = [];
setSaveError("");
setNewCalName("");
setNewCalDescription("");
setNewCalColor(defaultColors[0]);
@@ -203,16 +280,17 @@ function CalendarPopover({
}
actions={
<>
<Button
variant="outlined"
onClick={() => handleClose({}, "backdropClick")}
>
<Button variant="outlined" onClick={() => handleClose({}, "cancel")}>
{t("common.cancel")}
</Button>
<Button
disabled={tab === "import" ? !importedContent : !name.trim()}
variant="contained"
onClick={tab === "import" ? handleImport : handleSave}
onClick={
tab === "import"
? handleImport
: () => handleClose({}, "backdropClick")
}
>
{tab === "import"
? t("actions.import")
@@ -254,7 +332,15 @@ function CalendarPopover({
calendar={calendar}
/>
)}
{tab === "access" && calendar && <AccessTab calendar={calendar} />}
{tab === "access" && calendar && (
<AccessTab
calendar={calendar}
usersWithAccess={usersWithAccess}
onUsersWithAccessChange={handleUsersWithAccessChange}
onInvitesLoaded={handleInvitesLoaded}
/>
)}
<ErrorSnackbar error={saveError} type="calendar" />
</ResponsiveDialog>
);
}