[#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: {},
invite: [],
owner: { emails: ["user1@example.com"] },
access: { write: true },
delegated: true,
};
describe("CalendarAccessRights", () => {
@@ -102,7 +104,8 @@ describe("CalendarAccessRights", () => {
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
/>,
{ ...userState }
);
expect(
@@ -113,7 +116,7 @@ describe("CalendarAccessRights", () => {
it("shows a list of users already passed in via value prop", () => {
const users: UserWithAccess[] = [
{
openpaasId: "abc",
openpaasId: "user1",
displayName: "Alice",
email: "alice@example.com",
accessRight: 2,
@@ -136,10 +139,10 @@ describe("CalendarAccessRights", () => {
it("calls onChange with user removed when remove button is clicked", () => {
const users: UserWithAccess[] = [
{
openpaasId: "abc",
openpaasId: "user1",
displayName: "Alice",
email: "alice@example.com",
accessRight: 2,
accessRight: 5,
},
];
@@ -149,7 +152,8 @@ describe("CalendarAccessRights", () => {
value={users}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
/>,
{ ...userState }
);
fireEvent.click(screen.getByLabelText(/remove/i));
@@ -182,7 +186,8 @@ describe("CalendarAccessRights", () => {
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
/>,
{ ...userState }
);
await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123"));
@@ -219,7 +224,8 @@ describe("CalendarAccessRights", () => {
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
/>,
{ ...userState }
);
await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([]));
@@ -249,7 +255,8 @@ describe("CalendarAccessRights", () => {
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
/>,
{ ...userState }
);
expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument();
@@ -286,7 +293,7 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
).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 = {
...baseCalendar,
id: "otherUser/cal1",
@@ -306,9 +313,10 @@ describe("AccessTab conditional rendering of CalendarAccessRights", () => {
}
);
expect(screen.queryByText("peopleSearch.label")).not.toBeInTheDocument();
expect(
screen.queryByText("calendarPopover.access.grantAccessRights")
).not.toBeInTheDocument();
screen.getByText("calendarPopover.access.accessRights")
).toBeInTheDocument();
});
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 {
exportCalendar,
getSecretLink,
@@ -36,11 +35,6 @@ export function AccessTab({
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", "")}`;
@@ -98,14 +92,12 @@ export function AccessTab({
return (
<>
{(isPersonalCalendar || isDelegatedWithAdministration) && (
<CalendarAccessRights
calendar={calendar}
value={usersWithAccess}
onChange={onUsersWithAccessChange}
onInvitesLoaded={onInvitesLoaded}
/>
)}
<CalendarAccessRights
calendar={calendar}
value={usersWithAccess}
onChange={onUsersWithAccessChange}
onInvitesLoaded={onInvitesLoaded}
/>
{!!window.DAV_BASE_URL && (
<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 { getUserDetails } from "@/features/User/userAPI";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { normalizeEmail } from "@/utils/normalizeEmail";
import {
AutocompleteRenderInputParams,
Avatar,
@@ -38,6 +41,17 @@ export function CalendarAccessRights({
onInvitesLoaded,
}: CalendarAccessRightsProps) {
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 [searchWidth, setSearchWidth] = useState<number | undefined>(undefined);
@@ -115,8 +129,6 @@ export function CalendarAccessRights({
};
}, [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(
@@ -153,188 +165,217 @@ export function CalendarAccessRights({
return (
<FieldWithLabel
label={t("calendarPopover.access.grantAccessRights")}
label={
isPersonalCalendar || isDelegatedWithAdministration
? t("calendarPopover.access.grantAccessRights")
: t("calendarPopover.access.accessRights")
}
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],
{(isPersonalCalendar || isDelegatedWithAdministration) && (
<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%",
},
},
],
},
}}
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" },
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;
}
}}
>
<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}
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" },
}}
>
{opt.label}
</MenuItem>
))}
</Select>
{accessRightOptions.map((opt) => (
<MenuItem
key={opt.value}
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
size="small"
aria-label={t("actions.remove")}
@@ -343,12 +384,12 @@ export function CalendarAccessRights({
>
<HighlightOffIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
))}
</Box>
)
)}
</Box>
))
)}
</Box>
</FieldWithLabel>
);
}
+11 -1
View File
@@ -39,6 +39,16 @@ function CalendarPopover({
const isOwn = calendar?.id
? extractEventBaseUuid(calendar.id) === userData.openpaasId
: 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
const [name, setName] = useState("");
@@ -146,7 +156,7 @@ function CalendarPopover({
.filter((u) => !!normaliseEmail(u) && !currentMap.has(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(
updateDelegationCalendarAsync({
+2
View File
@@ -84,8 +84,10 @@
},
"access": {
"grantAccessRights": "Grant Access rights",
"accessRights": "Access rights",
"viewAllEvents": "View all events",
"editor": "Editor",
"owner": "Owner",
"administrator": "Administrator"
}
},
+1
View File
@@ -86,6 +86,7 @@
},
"access": {
"grantAccessRights": "Accorder des droits d'accès",
"accessRights": "Droits d'accès",
"viewAllEvents": "Voir tous les événements",
"editor": "Éditeur",
"administrator": "Administrateur"
+1
View File
@@ -86,6 +86,7 @@
},
"access": {
"grantAccessRights": "Предоставить права доступа",
"accessRights": "Права доступа",
"viewAllEvents": "Просмотр всех событий",
"editor": "Редактор",
"administrator": "Администратор"
+1
View File
@@ -84,6 +84,7 @@
},
"access": {
"grantAccessRights": "Cấp quyền truy cập",
"accessRights": "Quyền truy cập",
"viewAllEvents": "Xem tất cả sự kiện",
"editor": "Biên tập viên",
"administrator": "Quản trị viên"
+3
View File
@@ -0,0 +1,3 @@
export function normalizeEmail(email?: string) {
return email?.trim().toLowerCase() ?? "";
}