[#558] added owner to access details (#603)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2026-03-03 21:12:34 +01:00
committed by GitHub
parent f0e63e07a6
commit edf158f13f
9 changed files with 267 additions and 208 deletions
@@ -87,6 +87,8 @@ const baseCalendar: Calendar = {
events: {}, events: {},
invite: [], invite: [],
owner: { emails: ["user1@example.com"] }, owner: { emails: ["user1@example.com"] },
access: { write: true },
delegated: true,
}; };
describe("CalendarAccessRights", () => { describe("CalendarAccessRights", () => {
@@ -102,7 +104,8 @@ describe("CalendarAccessRights", () => {
value={[]} value={[]}
onChange={mockOnChange} onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/> />,
{ ...userState }
); );
expect( expect(
@@ -113,7 +116,7 @@ describe("CalendarAccessRights", () => {
it("shows a list of users already passed in via value prop", () => { it("shows a list of users already passed in via value prop", () => {
const users: UserWithAccess[] = [ const users: UserWithAccess[] = [
{ {
openpaasId: "abc", openpaasId: "user1",
displayName: "Alice", displayName: "Alice",
email: "alice@example.com", email: "alice@example.com",
accessRight: 2, accessRight: 2,
@@ -136,10 +139,10 @@ describe("CalendarAccessRights", () => {
it("calls onChange with user removed when remove button is clicked", () => { it("calls onChange with user removed when remove button is clicked", () => {
const users: UserWithAccess[] = [ const users: UserWithAccess[] = [
{ {
openpaasId: "abc", openpaasId: "user1",
displayName: "Alice", displayName: "Alice",
email: "alice@example.com", email: "alice@example.com",
accessRight: 2, accessRight: 5,
}, },
]; ];
@@ -149,7 +152,8 @@ describe("CalendarAccessRights", () => {
value={users} value={users}
onChange={mockOnChange} onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/> />,
{ ...userState }
); );
fireEvent.click(screen.getByLabelText(/remove/i)); fireEvent.click(screen.getByLabelText(/remove/i));
@@ -182,7 +186,8 @@ describe("CalendarAccessRights", () => {
value={[]} value={[]}
onChange={mockOnChange} onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/> />,
{ ...userState }
); );
await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123")); await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123"));
@@ -219,7 +224,8 @@ describe("CalendarAccessRights", () => {
value={[]} value={[]}
onChange={mockOnChange} onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/> />,
{ ...userState }
); );
await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([])); await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([]));
@@ -249,7 +255,8 @@ describe("CalendarAccessRights", () => {
value={[]} value={[]}
onChange={mockOnChange} onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded} onInvitesLoaded={mockOnInvitesLoaded}
/> />,
{ ...userState }
); );
expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument(); expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument();
@@ -286,7 +293,7 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("hides CalendarAccessRights for a non-owner without admin delegation", () => { it("hides CalendarAccessRights textfield for a non-owner without admin delegation", () => {
const foreignCalendar: Calendar = { const foreignCalendar: Calendar = {
...baseCalendar, ...baseCalendar,
id: "otherUser/cal1", id: "otherUser/cal1",
@@ -306,9 +313,10 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
} }
); );
expect(screen.queryByText("peopleSearch.label")).not.toBeInTheDocument();
expect( expect(
screen.queryByText("calendarPopover.access.grantAccessRights") screen.getByText("calendarPopover.access.accessRights")
).not.toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => { it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => {
+6 -14
View File
@@ -1,4 +1,3 @@
import { useAppSelector } from "@/app/hooks";
import { import {
exportCalendar, exportCalendar,
getSecretLink, getSecretLink,
@@ -36,11 +35,6 @@ export function AccessTab({
onInvitesLoaded, onInvitesLoaded,
}: AccessTabProps) { }: AccessTabProps) {
const { t } = useI18n(); 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 calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`;
@@ -98,14 +92,12 @@ export function AccessTab({
return ( return (
<> <>
{(isPersonalCalendar || isDelegatedWithAdministration) && ( <CalendarAccessRights
<CalendarAccessRights calendar={calendar}
calendar={calendar} value={usersWithAccess}
value={usersWithAccess} onChange={onUsersWithAccessChange}
onChange={onUsersWithAccessChange} onInvitesLoaded={onInvitesLoaded}
onInvitesLoaded={onInvitesLoaded} />
/>
)}
{!!window.DAV_BASE_URL && ( {!!window.DAV_BASE_URL && (
<FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}> <FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}>
+223 -182
View File
@@ -1,5 +1,8 @@
import { useAppSelector } from "@/app/hooks";
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes"; import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
import { getUserDetails } from "@/features/User/userAPI"; import { getUserDetails } from "@/features/User/userAPI";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { normalizeEmail } from "@/utils/normalizeEmail";
import { import {
AutocompleteRenderInputParams, AutocompleteRenderInputParams,
Avatar, Avatar,
@@ -38,6 +41,17 @@ export function CalendarAccessRights({
onInvitesLoaded, onInvitesLoaded,
}: CalendarAccessRightsProps) { }: CalendarAccessRightsProps) {
const { t } = useI18n(); const { t } = useI18n();
const userData = useAppSelector((state) => state.user.userData);
const isPersonalCalendar = userData?.openpaasId === calendar.id.split("/")[0];
const currentUserEmail = normalizeEmail(userData?.email);
const isDelegatedWithAdministration = !!calendar.invite?.some((invite) => {
const invitedEmail = normalizeEmail(invite.href.replace(/^mailto:/i, ""));
return invitedEmail === currentUserEmail && invite.access === 5;
});
const ownerEmail =
calendar.owner?.preferredEmail ?? calendar.owner?.emails?.[0] ?? "";
const ownerName = makeDisplayName(calendar) ?? ownerEmail;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [searchWidth, setSearchWidth] = useState<number | undefined>(undefined); const [searchWidth, setSearchWidth] = useState<number | undefined>(undefined);
@@ -115,8 +129,6 @@ export function CalendarAccessRights({
}; };
}, [calendar.invite, onChange, onInvitesLoaded]); }, [calendar.invite, onChange, onInvitesLoaded]);
const normalizeEmail = (email?: string) => email?.trim().toLowerCase() ?? "";
const handleUserSelect = (_event: unknown, users: User[]) => { const handleUserSelect = (_event: unknown, users: User[]) => {
const updated: UserWithAccess[] = users.map((user) => { const updated: UserWithAccess[] = users.map((user) => {
const existing = usersWithAccess.find( const existing = usersWithAccess.find(
@@ -153,188 +165,217 @@ export function CalendarAccessRights({
return ( return (
<FieldWithLabel <FieldWithLabel
label={t("calendarPopover.access.grantAccessRights")} label={
isPersonalCalendar || isDelegatedWithAdministration
? t("calendarPopover.access.grantAccessRights")
: t("calendarPopover.access.accessRights")
}
isExpanded={false} isExpanded={false}
> >
<Box ref={containerRef}> {(isPersonalCalendar || isDelegatedWithAdministration) && (
<PeopleSearch <Box ref={containerRef}>
selectedUsers={usersWithAccess} <PeopleSearch
onChange={handleUserSelect} selectedUsers={usersWithAccess}
objectTypes={["user"]} onChange={handleUserSelect}
onToggleEventPreview={() => {}} objectTypes={["user"]}
customSlotProps={{ onToggleEventPreview={() => {}}
popper: { customSlotProps={{
anchorEl: containerRef.current, popper: {
placement: "bottom-start", anchorEl: containerRef.current,
sx: { placement: "bottom-start",
minWidth: searchWidth, sx: {
"& .MuiPaper-root": { minWidth: searchWidth,
width: "100%", "& .MuiPaper-root": {
}, width: "100%",
},
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
}, },
}, },
], modifiers: [
}, {
}} name: "offset",
customRenderInput={( options: {
params: AutocompleteRenderInputParams, offset: [0, 8],
query: string, },
setQuery: (value: string) => void },
) => ( ],
<TextField },
{...params} }}
fullWidth customRenderInput={(
autoFocus params: AutocompleteRenderInputParams,
placeholder={t("peopleSearch.label")} query: string,
value={query} setQuery: (value: string) => void
inputRef={(el) => { ) => (
const ref = params.InputProps.ref; <TextField
if (typeof ref === "function") { {...params}
ref(el); fullWidth
} else if (ref && "current" in ref) { autoFocus
( placeholder={t("peopleSearch.label")}
ref as React.MutableRefObject<HTMLInputElement | null> value={query}
).current = el; inputRef={(el) => {
} const ref = params.InputProps.ref;
}} if (typeof ref === "function") {
onChange={(e) => setQuery(e.target.value)} ref(el);
variant="outlined" } else if (ref && "current" in ref) {
inputProps={{ (
...params.inputProps, ref as React.MutableRefObject<HTMLInputElement | null>
sx: { ).current = el;
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" },
}} }}
> onChange={(e) => setQuery(e.target.value)}
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}> variant="outlined"
<Avatar inputProps={{
{...stringAvatar(user.displayName)} ...params.inputProps,
sx={{ width: 28, height: 28, fontSize: "0.875rem" }} sx: {
> fontSize: "14px",
{user.displayName?.[0]?.toUpperCase()} "&::placeholder": { fontSize: "14px" },
</Avatar> },
<Box }}
minWidth={0} InputProps={{
display="flex" ...params.InputProps,
flexDirection="column" startAdornment: (
gap={0} <InputAdornment position="start">
> <PeopleOutlineOutlinedIcon
<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" }} 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" },
}}
> >
{opt.label} {accessRightOptions.map((opt) => (
</MenuItem> <MenuItem
))} key={opt.value}
</Select> value={opt.value}
sx={{ color: "text.secondary" }}
>
{opt.label}
</MenuItem>
))}
</Select>
</InputAdornment>
),
}}
/>
)}
/>
</Box>
)}
<Box mt={2} display="flex" flexDirection="column" gap={1}>
<Box
key={ownerEmail}
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(ownerName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{ownerName}</Typography>
<Typography variant="caption" color="text.secondary">
{ownerEmail}
</Typography>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Typography variant="caption">
{t("calendarPopover.access.owner")}
</Typography>
</Box>
</Box>
{inviteLoading ? (
<Box mt={2} display="flex" justifyContent="center">
<CircularProgress size={24} />
</Box>
) : (
usersWithAccess.length > 0 &&
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" }}
/>
<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
disabled={
!(isPersonalCalendar || isDelegatedWithAdministration)
}
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>
{(isPersonalCalendar || isDelegatedWithAdministration) && (
<IconButton <IconButton
size="small" size="small"
aria-label={t("actions.remove")} aria-label={t("actions.remove")}
@@ -343,12 +384,12 @@ export function CalendarAccessRights({
> >
<HighlightOffIcon fontSize="small" /> <HighlightOffIcon fontSize="small" />
</IconButton> </IconButton>
</Box> )}
</Box> </Box>
))} </Box>
</Box> ))
) )}
)} </Box>
</FieldWithLabel> </FieldWithLabel>
); );
} }
+11 -1
View File
@@ -39,6 +39,16 @@ function CalendarPopover({
const isOwn = calendar?.id const isOwn = calendar?.id
? extractEventBaseUuid(calendar.id) === userData.openpaasId ? extractEventBaseUuid(calendar.id) === userData.openpaasId
: true; : true;
const canManageInvites =
isOwn ||
!!calendar?.invite?.some((invite) => {
const inviteEmail = invite.href
.replace(/^mailto:/i, "")
.trim()
.toLowerCase();
const currentEmail = userData.email?.trim().toLowerCase();
return inviteEmail === currentEmail && invite.access === 5;
});
// existing calendar params // existing calendar params
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -146,7 +156,7 @@ function CalendarPopover({
.filter((u) => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u))) .filter((u) => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u)))
.map((u) => ({ "dav:href": `mailto:${normaliseEmail(u)}` })); .map((u) => ({ "dav:href": `mailto:${normaliseEmail(u)}` }));
if (set.length === 0 && remove.length === 0) return; if ((set.length === 0 && remove.length === 0) || !canManageInvites) return;
await dispatch( await dispatch(
updateDelegationCalendarAsync({ updateDelegationCalendarAsync({
+2
View File
@@ -84,8 +84,10 @@
}, },
"access": { "access": {
"grantAccessRights": "Grant Access rights", "grantAccessRights": "Grant Access rights",
"accessRights": "Access rights",
"viewAllEvents": "View all events", "viewAllEvents": "View all events",
"editor": "Editor", "editor": "Editor",
"owner": "Owner",
"administrator": "Administrator" "administrator": "Administrator"
} }
}, },
+1
View File
@@ -86,6 +86,7 @@
}, },
"access": { "access": {
"grantAccessRights": "Accorder des droits d'accès", "grantAccessRights": "Accorder des droits d'accès",
"accessRights": "Droits d'accès",
"viewAllEvents": "Voir tous les événements", "viewAllEvents": "Voir tous les événements",
"editor": "Éditeur", "editor": "Éditeur",
"administrator": "Administrateur" "administrator": "Administrateur"
+1
View File
@@ -86,6 +86,7 @@
}, },
"access": { "access": {
"grantAccessRights": "Предоставить права доступа", "grantAccessRights": "Предоставить права доступа",
"accessRights": "Права доступа",
"viewAllEvents": "Просмотр всех событий", "viewAllEvents": "Просмотр всех событий",
"editor": "Редактор", "editor": "Редактор",
"administrator": "Администратор" "administrator": "Администратор"
+1
View File
@@ -84,6 +84,7 @@
}, },
"access": { "access": {
"grantAccessRights": "Cấp quyền truy cập", "grantAccessRights": "Cấp quyền truy cập",
"accessRights": "Quyền truy cập",
"viewAllEvents": "Xem tất cả sự kiện", "viewAllEvents": "Xem tất cả sự kiện",
"editor": "Biên tập viên", "editor": "Biên tập viên",
"administrator": "Quản trị viên" "administrator": "Quản trị viên"
+3
View File
@@ -0,0 +1,3 @@
export function normalizeEmail(email?: string) {
return email?.trim().toLowerCase() ?? "";
}