[#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", () => {
-8
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}>
@@ -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,9 +165,14 @@ 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}
> >
{(isPersonalCalendar || isDelegatedWithAdministration) && (
<Box ref={containerRef}> <Box ref={containerRef}>
<PeopleSearch <PeopleSearch
selectedUsers={usersWithAccess} selectedUsers={usersWithAccess}
@@ -258,15 +275,47 @@ export function CalendarAccessRights({
)} )}
/> />
</Box> </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 ? ( {inviteLoading ? (
<Box mt={2} display="flex" justifyContent="center"> <Box mt={2} display="flex" justifyContent="center">
<CircularProgress size={24} /> <CircularProgress size={24} />
</Box> </Box>
) : ( ) : (
usersWithAccess.length > 0 && ( usersWithAccess.length > 0 &&
<Box mt={2} display="flex" flexDirection="column" gap={1}> usersWithAccess.map((user) => (
{usersWithAccess.map((user) => (
<Box <Box
key={user.email} key={user.email}
display="flex" display="flex"
@@ -283,15 +332,8 @@ export function CalendarAccessRights({
<Avatar <Avatar
{...stringAvatar(user.displayName)} {...stringAvatar(user.displayName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }} sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
> />
{user.displayName?.[0]?.toUpperCase()} <Box minWidth={0} display="flex" flexDirection="column" gap={0}>
</Avatar>
<Box
minWidth={0}
display="flex"
flexDirection="column"
gap={0}
>
<Typography noWrap>{user.displayName}</Typography> <Typography noWrap>{user.displayName}</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{user.email} {user.email}
@@ -299,12 +341,7 @@ export function CalendarAccessRights({
</Box> </Box>
</Box> </Box>
<Box <Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
display="flex"
alignItems="center"
gap={0.5}
flexShrink={0}
>
<Select <Select
value={user.accessRight} value={user.accessRight}
onChange={(e) => onChange={(e) =>
@@ -315,6 +352,9 @@ export function CalendarAccessRights({
} }
variant="standard" variant="standard"
disableUnderline disableUnderline
disabled={
!(isPersonalCalendar || isDelegatedWithAdministration)
}
sx={{ sx={{
fontSize: "0.875rem", fontSize: "0.875rem",
color: "text.secondary", color: "text.secondary",
@@ -335,6 +375,7 @@ export function CalendarAccessRights({
</MenuItem> </MenuItem>
))} ))}
</Select> </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() ?? "";
}