diff --git a/__test__/features/Calendars/CalendarAccessRights.test.tsx b/__test__/features/Calendars/CalendarAccessRights.test.tsx
index 6e86a45..40d43da 100644
--- a/__test__/features/Calendars/CalendarAccessRights.test.tsx
+++ b/__test__/features/Calendars/CalendarAccessRights.test.tsx
@@ -87,7 +87,7 @@ const baseCalendar: Calendar = {
events: {},
invite: [],
owner: { emails: ["user1@example.com"] },
- access: { write: true },
+ access: { write: true } as any,
delegated: true,
};
@@ -261,6 +261,82 @@ describe("CalendarAccessRights", () => {
expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument();
});
+
+ it("displays the calendar owner information", () => {
+ renderWithProviders(
+ ,
+ { ...userState }
+ );
+
+ expect(screen.getAllByText("user1@example.com").length).toBeGreaterThan(0);
+ expect(
+ screen.getByText("calendarPopover.access.owner")
+ ).toBeInTheDocument();
+ });
+
+ it("fetches and displays resource administrators for resource calendars", async () => {
+ const resourceCalendar: any = {
+ ...baseCalendar,
+ owner: {
+ _id: "resource1",
+ resource: true,
+ emails: ["resource1@example.com"],
+ administrators: [
+ { id: "admin1" },
+ { id: "admin2" },
+ { id: "resource1" }, // Owner shouldn't be loaded as an admin
+ ],
+ },
+ };
+
+ (getUserDetails as jest.Mock).mockImplementation((id: string) => {
+ if (id === "admin1") {
+ return Promise.resolve({
+ preferredEmail: "admin1@example.com",
+ firstname: "Admin",
+ lastname: "One",
+ });
+ }
+ if (id === "admin2") {
+ return Promise.resolve({
+ preferredEmail: "admin2@example.com",
+ firstname: "Admin",
+ lastname: "Two",
+ });
+ }
+ return Promise.resolve(null);
+ });
+
+ renderWithProviders(
+ ,
+ { ...userState }
+ );
+
+ await waitFor(() => {
+ expect(getUserDetails).toHaveBeenCalledWith("admin1");
+ expect(getUserDetails).toHaveBeenCalledWith("admin2");
+ });
+
+ expect(getUserDetails).not.toHaveBeenCalledWith("resource1");
+
+ expect(await screen.findByText("Admin One")).toBeInTheDocument();
+ expect(screen.getByText("admin1@example.com")).toBeInTheDocument();
+ expect(screen.getByText("Admin Two")).toBeInTheDocument();
+ expect(screen.getByText("admin2@example.com")).toBeInTheDocument();
+ expect(
+ screen.getAllByText("calendarPopover.access.administrator")
+ ).toHaveLength(2);
+ });
});
const userState = {
diff --git a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx
index 5ec1800..4104870 100644
--- a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx
+++ b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx
@@ -1,16 +1,15 @@
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { addSharedCalendar } from "@/features/Calendars/CalendarApi";
-import { getResourceDetails, getUserDetails } from "@/features/User/userAPI";
+import { fetchOwnerOfResource } from "@/features/Calendars/services/helpers";
import { toRejectedError } from "@/utils/errorUtils";
import { configureStore } from "@reduxjs/toolkit";
jest.mock("@/features/Calendars/CalendarApi");
-jest.mock("@/features/User/userAPI");
+jest.mock("@/features/Calendars/services/helpers");
jest.mock("@/utils/errorUtils");
const mockedAddSharedCalendar = addSharedCalendar as jest.Mock;
-const mockedGetResourceDetails = getResourceDetails as jest.Mock;
-const mockedGetUserDetails = getUserDetails as jest.Mock;
+const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock;
const mockedToRejectedError = toRejectedError as jest.Mock;
describe("addCalendarResourceAsync thunk", () => {
@@ -55,8 +54,7 @@ describe("addCalendarResourceAsync thunk", () => {
};
it("should add shared calendar, fetch resource details, map userData", async () => {
- mockedGetResourceDetails.mockResolvedValueOnce(mockResolvedResourceData);
- mockedGetUserDetails.mockResolvedValueOnce({
+ mockedFetchOwnerOfResource.mockResolvedValueOnce({
firstname: "Creator",
lastname: "User",
emails: ["creator@example.com"],
@@ -72,8 +70,7 @@ describe("addCalendarResourceAsync thunk", () => {
mockPayload.calId,
mockPayload.cal
);
- expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456");
- expect(mockedGetUserDetails).toHaveBeenCalledWith("user-789");
+ expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456");
expect(result.type).toBe("calendars/addCalendarResource/fulfilled");
expect(result.payload).toEqual({
@@ -94,9 +91,12 @@ describe("addCalendarResourceAsync thunk", () => {
it("should fallback to name if resource details fetch fails", async () => {
mockedAddSharedCalendar.mockResolvedValueOnce({});
const errorDetails = new Error("Fetch failed");
- mockedGetResourceDetails.mockRejectedValueOnce(errorDetails);
- const mockRejectedErrorResult = { message: "Fetch failed" };
- mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult);
+ mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails);
+
+ // Silence expected console error in tests
+ const consoleSpy = jest
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters[0]
@@ -107,9 +107,9 @@ describe("addCalendarResourceAsync thunk", () => {
mockPayload.calId,
mockPayload.cal
);
- expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456");
+ expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456");
- expect(mockedToRejectedError).toHaveBeenCalledWith(errorDetails);
+ consoleSpy.mockRestore();
expect(result.type).toBe("calendars/addCalendarResource/fulfilled");
expect(result.payload).toEqual({
@@ -142,7 +142,7 @@ describe("addCalendarResourceAsync thunk", () => {
mockPayload.calId,
mockPayload.cal
);
- expect(mockedGetResourceDetails).not.toHaveBeenCalled();
+ expect(mockedFetchOwnerOfResource).not.toHaveBeenCalled();
expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd);
diff --git a/src/components/Calendar/CalendarAccessRights.tsx b/src/components/Calendar/CalendarAccessRights.tsx
index d37fbe2..d68b963 100644
--- a/src/components/Calendar/CalendarAccessRights.tsx
+++ b/src/components/Calendar/CalendarAccessRights.tsx
@@ -17,11 +17,12 @@ import {
} 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 { useCallback, 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";
+import { ResourceAdmin } from "./ResourceAdmins";
export interface UserWithAccess extends User {
accessRight: AccessRight;
@@ -34,6 +35,11 @@ interface CalendarAccessRightsProps {
onInvitesLoaded: (users: UserWithAccess[]) => void;
}
+interface UserInCalendar {
+ id: string;
+ access: AccessRight;
+}
+
export function CalendarAccessRights({
calendar,
value: usersWithAccess,
@@ -57,6 +63,8 @@ export function CalendarAccessRights({
const [searchWidth, setSearchWidth] = useState(undefined);
const [accessRight, setAccessRight] = useState(2);
const [inviteLoading, setInvitesLoading] = useState(false);
+ const [resourceAdmins, setResourceAdmins] = useState([]);
+ const [adminLoading, setAdminLoading] = useState(false);
const currentUsersRef = useRef(usersWithAccess);
useEffect(() => {
@@ -74,6 +82,39 @@ export function CalendarAccessRights({
return () => observer.disconnect();
}, []);
+ const handleLoadUsers = useCallback(
+ async (usersInCal: UserInCalendar[], cancelled: boolean) => {
+ const results = await Promise.allSettled(
+ usersInCal.map(async (user) => {
+ const details = await getUserDetails(user.id);
+ const email = details?.preferredEmail ?? details?.emails?.[0] ?? "";
+ return {
+ openpaasId: user.id,
+ displayName:
+ [details?.firstname, details?.lastname]
+ .filter(Boolean)
+ .join(" ")
+ .trim() || email,
+ email,
+ accessRight: user.access as AccessRight,
+ } satisfies UserWithAccess;
+ })
+ );
+
+ if (cancelled) {
+ return [] as UserWithAccess[];
+ }
+
+ const loaded: UserWithAccess[] = results
+ .filter((r) => r.status === "fulfilled")
+ .map((r) => (r as PromiseFulfilledResult).value)
+ .filter(Boolean) as UserWithAccess[];
+
+ return loaded;
+ },
+ []
+ );
+
useEffect(() => {
if (!calendar.invite?.length) return;
@@ -82,33 +123,19 @@ export function CalendarAccessRights({
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);
+ const usersInCal = (calendar.invite
+ ?.map((invite) => {
+ const principalId = invite.principal.split("/").pop();
+ if (!principalId) return null;
- if (cancelled) return;
+ return {
+ id: principalId,
+ access: invite.access,
+ };
+ })
+ ?.filter((invite) => !!invite) || []) satisfies UserInCalendar[];
+
+ const loaded = await handleLoadUsers(usersInCal, cancelled);
const loadedIds = new Set(loaded.map((u) => normalizeEmail(u.email)));
const manuallyAdded = currentUsersRef.current.filter(
@@ -127,7 +154,42 @@ export function CalendarAccessRights({
return () => {
cancelled = true;
};
- }, [calendar.invite, onChange, onInvitesLoaded]);
+ }, [calendar.invite, handleLoadUsers, onChange, onInvitesLoaded]);
+
+ useEffect(() => {
+ const isResource = calendar.owner.resource;
+ const resourceAdmins = calendar.owner.administrators || [];
+ if (!isResource || !resourceAdmins?.length) return;
+
+ let cancelled = false;
+
+ async function loadAdmins() {
+ try {
+ setAdminLoading(true);
+ const resourceAdminsWithoutOwner = resourceAdmins
+ .filter((admin) => admin.id !== calendar.owner._id)
+ .map((admin) => ({
+ id: admin.id,
+ access: 5, // ADMIN
+ })) satisfies UserInCalendar[];
+ const admins = await handleLoadUsers(
+ resourceAdminsWithoutOwner,
+ cancelled
+ );
+
+ setResourceAdmins(admins);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ if (!cancelled) setAdminLoading(false);
+ }
+ }
+
+ loadAdmins();
+ return () => {
+ cancelled = true;
+ };
+ }, [calendar.owner, handleLoadUsers, setResourceAdmins]);
const handleUserSelect = (_event: unknown, users: User[]) => {
const updated: UserWithAccess[] = users.map((user) => {
@@ -309,6 +371,15 @@ export function CalendarAccessRights({
+ {adminLoading ? (
+
+
+
+ ) : (
+ resourceAdmins.map((admin) => (
+
+ ))
+ )}
{inviteLoading ? (
diff --git a/src/components/Calendar/ResourceAdmins.tsx b/src/components/Calendar/ResourceAdmins.tsx
new file mode 100644
index 0000000..3fb35cf
--- /dev/null
+++ b/src/components/Calendar/ResourceAdmins.tsx
@@ -0,0 +1,45 @@
+import { Avatar, Box, Typography } from "@linagora/twake-mui";
+import { useI18n } from "twake-i18n";
+import { stringAvatar } from "../Event/utils/eventUtils";
+import { UserWithAccess } from "./CalendarAccessRights";
+
+interface ResourceAdminProps {
+ admin: UserWithAccess;
+}
+
+export function ResourceAdmin({ admin }: ResourceAdminProps) {
+ const { t } = useI18n();
+
+ return (
+
+
+
+
+ {admin.displayName}
+
+ {admin.email}
+
+
+
+
+
+
+ {t("calendarPopover.access.administrator")}
+
+
+
+ );
+}
diff --git a/src/features/Calendars/api/addCalendarResourceAsync.ts b/src/features/Calendars/api/addCalendarResourceAsync.ts
index 910fa34..0144bbd 100644
--- a/src/features/Calendars/api/addCalendarResourceAsync.ts
+++ b/src/features/Calendars/api/addCalendarResourceAsync.ts
@@ -1,11 +1,11 @@
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
-import { getResourceDetails, getUserDetails } from "@/features/User/userAPI";
import { toRejectedError } from "@/utils/errorUtils";
import { createAsyncThunk } from "@reduxjs/toolkit";
import { addSharedCalendar } from "../CalendarApi";
import { CalendarInput } from "../types/CalendarData";
import { RejectedError } from "../types/RejectedError";
import { User } from "@/components/Attendees/PeopleSearch";
+import { fetchOwnerOfResource } from "../services/helpers";
export const addCalendarResourceAsync = createAsyncThunk<
{
@@ -43,13 +43,12 @@ export const addCalendarResourceAsync = createAsyncThunk<
if (resourceId) {
try {
- const resource = await getResourceDetails(resourceId);
owner = {
- ...(await getUserDetails(resource.creator)),
+ ...(await fetchOwnerOfResource(resourceId)),
resource: true,
};
} catch (e) {
- toRejectedError(e);
+ console.error("Error while fetching owner of resource: ", e);
}
}
diff --git a/src/features/Calendars/services/helpers.ts b/src/features/Calendars/services/helpers.ts
index e7b21fd..5c32c2c 100644
--- a/src/features/Calendars/services/helpers.ts
+++ b/src/features/Calendars/services/helpers.ts
@@ -1,15 +1,18 @@
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
import { getResourceDetails, getUserDetails } from "@/features/User/userAPI";
-const fetchOwnerOfResource = async (
- ownerId: string
+export const fetchOwnerOfResource = async (
+ resourceId: string
): Promise => {
try {
- const data = await getResourceDetails(ownerId);
+ const data = await getResourceDetails(resourceId);
const ownerData = await getUserDetails(data.creator);
- return ownerData;
+ return {
+ ...ownerData,
+ administrators: data.administrators,
+ };
} catch (error) {
- console.error(`Failed to fetch resource details for ${ownerId}:`, error);
+ console.error(`Failed to fetch resource details for ${resourceId}:`, error);
throw error;
}
};
diff --git a/src/features/User/type/OpenPaasUserData.ts b/src/features/User/type/OpenPaasUserData.ts
index bfd9908..b422fcd 100644
--- a/src/features/User/type/OpenPaasUserData.ts
+++ b/src/features/User/type/OpenPaasUserData.ts
@@ -4,12 +4,18 @@ export interface OpenPaasUserData {
firstname?: string;
lastname?: string;
id?: string;
+ _id?: string;
preferredEmail?: string;
configurations?: {
modules?: ModuleConfiguration[];
};
emails: string[];
resource?: boolean;
+ administrators?: {
+ _id: string;
+ id: string;
+ objectType: string;
+ }[];
}
export function ToUserData(