* [#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:
@@ -0,0 +1,426 @@
|
|||||||
|
import { AccessTab } from "@/components/Calendar/AccessTab";
|
||||||
|
import {
|
||||||
|
CalendarAccessRights,
|
||||||
|
UserWithAccess,
|
||||||
|
} from "@/components/Calendar/CalendarAccessRights";
|
||||||
|
import CalendarPopover from "@/components/Calendar/CalendarModal";
|
||||||
|
import { updateDelegationCalendar } from "@/features/Calendars/api/updateDelegationCalendar";
|
||||||
|
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
|
import * as eventThunks from "@/features/Calendars/services";
|
||||||
|
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
|
||||||
|
import { getUserDetails } from "@/features/User/userAPI";
|
||||||
|
import { accessRightToDavProp } from "@/utils/accessRightToDavProp";
|
||||||
|
import { api } from "@/utils/apiUtils";
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||||
|
|
||||||
|
jest.mock("@/utils/apiUtils", () => ({
|
||||||
|
api: { post: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/features/User/userAPI", () => ({
|
||||||
|
getUserDetails: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/features/Calendars/CalendarApi", () => ({
|
||||||
|
getSecretLink: jest.fn().mockReturnValue(""),
|
||||||
|
exportCalendar: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
|
||||||
|
jest.fn().mockImplementation(() => {
|
||||||
|
const result = Object.assign(Promise.resolve(resolvedValue), {
|
||||||
|
unwrap: () => Promise.resolve(resolvedValue),
|
||||||
|
});
|
||||||
|
return jest.fn().mockReturnValue(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("accessRightToDavProp", () => {
|
||||||
|
it("maps 5 (ADMIN) → dav:administration", () => {
|
||||||
|
expect(accessRightToDavProp(5)).toBe("dav:administration");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 3 (EDITOR) → dav:read-write", () => {
|
||||||
|
expect(accessRightToDavProp(3)).toBe("dav:read-write");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 2 (VIEW) → dav:read", () => {
|
||||||
|
expect(accessRightToDavProp(2)).toBe("dav:read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to dav:read for unknown values (covered by default case)", () => {
|
||||||
|
expect(accessRightToDavProp(999 as AccessRight)).toBe("dav:read");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateDelegationCalendar", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("posts to the correct DAV endpoint with the share body", async () => {
|
||||||
|
(api.post as jest.Mock).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const share = {
|
||||||
|
set: [{ "dav:href": "mailto:alice@example.com", "dav:read": true }],
|
||||||
|
remove: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateDelegationCalendar("/calendars/user/cal1.json", share);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.post).toHaveBeenCalledWith(
|
||||||
|
"dav/calendars/user/cal1.json",
|
||||||
|
expect.objectContaining({
|
||||||
|
body: JSON.stringify({ share }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseCalendar: Calendar = {
|
||||||
|
id: "user1/cal1",
|
||||||
|
name: "My Calendar",
|
||||||
|
description: "",
|
||||||
|
color: { color: "#0062FF", dark: "#FFF" },
|
||||||
|
link: "/calendars/user1/cal1.json",
|
||||||
|
visibility: "public",
|
||||||
|
events: {},
|
||||||
|
invite: [],
|
||||||
|
owner: { emails: ["user1@example.com"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CalendarAccessRights", () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
const mockOnInvitesLoaded = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("renders the grant access rights section", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarAccessRights
|
||||||
|
calendar={baseCalendar}
|
||||||
|
value={[]}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
onInvitesLoaded={mockOnInvitesLoaded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("calendarPopover.access.grantAccessRights")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a list of users already passed in via value prop", () => {
|
||||||
|
const users: UserWithAccess[] = [
|
||||||
|
{
|
||||||
|
openpaasId: "abc",
|
||||||
|
displayName: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
accessRight: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarAccessRights
|
||||||
|
calendar={baseCalendar}
|
||||||
|
value={users}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
onInvitesLoaded={mockOnInvitesLoaded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange with user removed when remove button is clicked", () => {
|
||||||
|
const users: UserWithAccess[] = [
|
||||||
|
{
|
||||||
|
openpaasId: "abc",
|
||||||
|
displayName: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
accessRight: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarAccessRights
|
||||||
|
calendar={baseCalendar}
|
||||||
|
value={users}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
onInvitesLoaded={mockOnInvitesLoaded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/remove/i));
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads invited users from calendar.invite on mount", async () => {
|
||||||
|
const calendarWithInvite: Calendar = {
|
||||||
|
...baseCalendar,
|
||||||
|
invite: [
|
||||||
|
{
|
||||||
|
href: "mailto:bob@example.com",
|
||||||
|
principal: "/principals/users/bob123",
|
||||||
|
access: 3,
|
||||||
|
inviteStatus: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
(getUserDetails as jest.Mock).mockResolvedValue({
|
||||||
|
preferredEmail: "bob@example.com",
|
||||||
|
firstname: "Bob",
|
||||||
|
lastname: "Smith",
|
||||||
|
emails: ["bob@example.com"],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarAccessRights
|
||||||
|
calendar={calendarWithInvite}
|
||||||
|
value={[]}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
onInvitesLoaded={mockOnInvitesLoaded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockOnInvitesLoaded).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
email: "bob@example.com",
|
||||||
|
accessRight: 3,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips invite entries where getUserDetails throws", async () => {
|
||||||
|
const calendarWithInvite: Calendar = {
|
||||||
|
...baseCalendar,
|
||||||
|
invite: [
|
||||||
|
{
|
||||||
|
href: "mailto:ghost@example.com",
|
||||||
|
principal: "/principals/users/ghost",
|
||||||
|
access: 2,
|
||||||
|
inviteStatus: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
(getUserDetails as jest.Mock).mockRejectedValue(new Error("Not found"));
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarAccessRights
|
||||||
|
calendar={calendarWithInvite}
|
||||||
|
value={[]}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
onInvitesLoaded={mockOnInvitesLoaded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a loading spinner while invite users are being fetched", async () => {
|
||||||
|
const calendarWithInvite: Calendar = {
|
||||||
|
...baseCalendar,
|
||||||
|
invite: [
|
||||||
|
{
|
||||||
|
href: "mailto:carol@example.com",
|
||||||
|
principal: "/principals/users/carol",
|
||||||
|
access: 2,
|
||||||
|
inviteStatus: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Never resolves during the assertion window
|
||||||
|
(getUserDetails as jest.Mock).mockImplementation(
|
||||||
|
() => new Promise(() => {})
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarAccessRights
|
||||||
|
calendar={calendarWithInvite}
|
||||||
|
value={[]}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
onInvitesLoaded={mockOnInvitesLoaded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const userState = {
|
||||||
|
user: {
|
||||||
|
userData: { openpaasId: "user1", email: "user1@example.com" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AccessTab – conditional rendering of CalendarAccessRights", () => {
|
||||||
|
const noop = jest.fn();
|
||||||
|
|
||||||
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("shows CalendarAccessRights when the current user owns the calendar", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<AccessTab
|
||||||
|
calendar={baseCalendar}
|
||||||
|
usersWithAccess={[]}
|
||||||
|
onUsersWithAccessChange={noop}
|
||||||
|
onInvitesLoaded={noop}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
...userState,
|
||||||
|
calendars: { list: { "user1/cal1": baseCalendar } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("calendarPopover.access.grantAccessRights")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides CalendarAccessRights for a non-owner without admin delegation", () => {
|
||||||
|
const foreignCalendar: Calendar = {
|
||||||
|
...baseCalendar,
|
||||||
|
id: "otherUser/cal1",
|
||||||
|
invite: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<AccessTab
|
||||||
|
calendar={foreignCalendar}
|
||||||
|
usersWithAccess={[]}
|
||||||
|
onUsersWithAccessChange={noop}
|
||||||
|
onInvitesLoaded={noop}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
...userState,
|
||||||
|
calendars: { list: { "otherUser/cal1": foreignCalendar } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText("calendarPopover.access.grantAccessRights")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => {
|
||||||
|
const delegatedCalendar: Calendar = {
|
||||||
|
...baseCalendar,
|
||||||
|
id: "otherUser/cal1",
|
||||||
|
invite: [
|
||||||
|
{
|
||||||
|
href: "mailto:user1@example.com",
|
||||||
|
principal: "/principals/users/user1",
|
||||||
|
access: 5,
|
||||||
|
inviteStatus: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<AccessTab
|
||||||
|
calendar={delegatedCalendar}
|
||||||
|
usersWithAccess={[]}
|
||||||
|
onUsersWithAccessChange={noop}
|
||||||
|
onInvitesLoaded={noop}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
...userState,
|
||||||
|
calendars: { list: { "otherUser/cal1": delegatedCalendar } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("calendarPopover.access.grantAccessRights")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingCalendar: Calendar = {
|
||||||
|
id: "user1/cal1",
|
||||||
|
name: "Existing Cal",
|
||||||
|
description: "Desc",
|
||||||
|
color: { color: "#0062FF", dark: "#FFF" },
|
||||||
|
link: "/calendars/user/cal1",
|
||||||
|
visibility: "public",
|
||||||
|
events: {},
|
||||||
|
invite: [],
|
||||||
|
owner: { emails: ["user1@example.com"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CalendarModal – updateDelegationCalendarAsync integration", () => {
|
||||||
|
const mockOnClose = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest
|
||||||
|
.spyOn(eventThunks, "patchCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
|
jest
|
||||||
|
.spyOn(eventThunks, "patchACLCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
|
jest
|
||||||
|
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call updateDelegationCalendarAsync when no users are added or removed", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarPopover
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
calendar={existingCalendar}
|
||||||
|
/>,
|
||||||
|
{ ...userState, calendars: { list: { "user1/cal1": existingCalendar } } }
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(
|
||||||
|
delegationThunks.updateDelegationCalendarAsync
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CalendarModal – cancel button", () => {
|
||||||
|
const mockOnClose = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest
|
||||||
|
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
|
jest
|
||||||
|
.spyOn(eventThunks, "patchCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose without saving when Cancel is clicked", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<CalendarPopover
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
calendar={existingCalendar}
|
||||||
|
/>,
|
||||||
|
{ ...userState }
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
|
||||||
|
expect(
|
||||||
|
delegationThunks.updateDelegationCalendarAsync
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import CalendarPopover from "@/components/Calendar/CalendarModal";
|
|||||||
import { getSecretLink } from "@/features/Calendars/CalendarApi";
|
import { getSecretLink } from "@/features/Calendars/CalendarApi";
|
||||||
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
import * as eventThunks from "@/features/Calendars/services";
|
import * as eventThunks from "@/features/Calendars/services";
|
||||||
|
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
|
||||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||||
|
|
||||||
@@ -9,11 +10,22 @@ jest.mock("@/features/Calendars/CalendarApi", () => ({
|
|||||||
getSecretLink: jest.fn(),
|
getSecretLink: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
|
||||||
|
jest.fn().mockImplementation(() => {
|
||||||
|
const dispatchResult = Object.assign(Promise.resolve(resolvedValue), {
|
||||||
|
unwrap: () => Promise.resolve(resolvedValue),
|
||||||
|
});
|
||||||
|
return jest.fn().mockReturnValue(dispatchResult);
|
||||||
|
});
|
||||||
|
|
||||||
describe("CalendarPopover", () => {
|
describe("CalendarPopover", () => {
|
||||||
const mockOnClose = jest.fn();
|
const mockOnClose = jest.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest
|
||||||
|
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderPopover = (open = true) => {
|
const renderPopover = (open = true) => {
|
||||||
@@ -54,12 +66,11 @@ describe("CalendarPopover", () => {
|
|||||||
expect(descInput).toHaveValue("Test description");
|
expect(descInput).toHaveValue("Test description");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches createCalendar and calls onClose when Save clicked", () => {
|
it("dispatches createCalendar and calls onClose when Save clicked", async () => {
|
||||||
const spy = jest
|
jest
|
||||||
.spyOn(eventThunks, "createCalendarAsync")
|
.spyOn(eventThunks, "createCalendarAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
return () => Promise.resolve(payload) as any;
|
|
||||||
});
|
|
||||||
renderPopover();
|
renderPopover();
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Name/i), {
|
fireEvent.change(screen.getByLabelText(/Name/i), {
|
||||||
@@ -77,9 +88,12 @@ describe("CalendarPopover", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Create/i }));
|
fireEvent.click(screen.getByRole("button", { name: /Create/i }));
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalled();
|
await waitFor(() =>
|
||||||
|
expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
|
||||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick")
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onClose when Cancel clicked", () => {
|
it("calls onClose when Cancel clicked", () => {
|
||||||
@@ -116,6 +130,9 @@ describe("CalendarPopover (editing mode)", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest
|
||||||
|
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefills fields when calendar prop is given", () => {
|
it("prefills fields when calendar prop is given", () => {
|
||||||
@@ -151,11 +168,9 @@ describe("CalendarPopover (editing mode)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows modifying and saving existing calendar", async () => {
|
it("allows modifying and saving existing calendar", async () => {
|
||||||
const spy = jest
|
jest
|
||||||
.spyOn(eventThunks, "patchCalendarAsync")
|
.spyOn(eventThunks, "patchCalendarAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
return () => Promise.resolve(payload) as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<CalendarPopover
|
<CalendarPopover
|
||||||
@@ -175,7 +190,7 @@ describe("CalendarPopover (editing mode)", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(spy).toHaveBeenCalledWith(
|
expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
calId: "user1/cal1",
|
calId: "user1/cal1",
|
||||||
calLink: "/calendars/user/cal1",
|
calLink: "/calendars/user/cal1",
|
||||||
@@ -187,7 +202,7 @@ describe("CalendarPopover (editing mode)", () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
expect(mockOnClose).toHaveBeenCalled();
|
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,6 +235,9 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest
|
||||||
|
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets state after closing and reopening", () => {
|
it("resets state after closing and reopening", () => {
|
||||||
@@ -265,11 +283,13 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("patches ACL when visibility changes", async () => {
|
it("patches ACL when visibility changes", async () => {
|
||||||
const patchSpy = jest
|
jest
|
||||||
|
.spyOn(eventThunks, "patchCalendarAsync")
|
||||||
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
|
|
||||||
|
jest
|
||||||
.spyOn(eventThunks, "patchACLCalendarAsync")
|
.spyOn(eventThunks, "patchACLCalendarAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
return () => Promise.resolve(payload) as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<CalendarPopover
|
<CalendarPopover
|
||||||
@@ -297,7 +317,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(patchSpy).toHaveBeenCalledWith(
|
expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
calId: "user1/cal1",
|
calId: "user1/cal1",
|
||||||
request: "",
|
request: "",
|
||||||
@@ -352,16 +372,12 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
|||||||
const file = new File(["test"], "events.ics", { type: "text/calendar" });
|
const file = new File(["test"], "events.ics", { type: "text/calendar" });
|
||||||
|
|
||||||
it("creates a new calendar and imports events when Import with 'new' target", async () => {
|
it("creates a new calendar and imports events when Import with 'new' target", async () => {
|
||||||
const createSpy = jest
|
jest
|
||||||
.spyOn(eventThunks, "createCalendarAsync")
|
.spyOn(eventThunks, "createCalendarAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
return () => Promise.resolve(payload) as any;
|
jest
|
||||||
});
|
|
||||||
const importSpy = jest
|
|
||||||
.spyOn(eventThunks, "importEventFromFileAsync")
|
.spyOn(eventThunks, "importEventFromFileAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
return () => Promise.resolve(payload) as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<CalendarPopover open={true} onClose={mockOnClose} />,
|
<CalendarPopover open={true} onClose={mockOnClose} />,
|
||||||
@@ -383,16 +399,18 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
|||||||
// Click Import
|
// Click Import
|
||||||
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
|
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
|
||||||
|
|
||||||
await waitFor(() => expect(createSpy).toHaveBeenCalled());
|
await waitFor(() =>
|
||||||
await waitFor(() => expect(importSpy).toHaveBeenCalled());
|
expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalled()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports into an existing calendar when target is set", async () => {
|
it("imports into an existing calendar when target is set", async () => {
|
||||||
const importSpy = jest
|
jest
|
||||||
.spyOn(eventThunks, "importEventFromFileAsync")
|
.spyOn(eventThunks, "importEventFromFileAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation(mockThunkWithUnwrap());
|
||||||
return () => Promise.resolve(payload) as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
const calendars = {
|
const calendars = {
|
||||||
"user1/cal1": existingCalendar,
|
"user1/cal1": existingCalendar,
|
||||||
@@ -414,7 +432,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
|
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(importSpy).toHaveBeenCalledWith(
|
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
calLink: "/calendars/user1/cal1.json",
|
calLink: "/calendars/user1/cal1.json",
|
||||||
file,
|
file,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAppSelector } from "@/app/hooks";
|
||||||
import {
|
import {
|
||||||
exportCalendar,
|
exportCalendar,
|
||||||
getSecretLink,
|
getSecretLink,
|
||||||
@@ -19,9 +20,28 @@ import { useI18n } from "twake-i18n";
|
|||||||
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
|
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
|
||||||
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
|
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
|
||||||
import { SnackbarAlert } from "../Loading/SnackBarAlert";
|
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 { 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", "")}`;
|
||||||
|
|
||||||
const [secretLink, setSecretLink] = useState("");
|
const [secretLink, setSecretLink] = useState("");
|
||||||
@@ -60,11 +80,8 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
|
|||||||
const exportedData = await exportCalendar(
|
const exportedData = await exportCalendar(
|
||||||
calendar.link.replace(".json", "")
|
calendar.link.replace(".json", "")
|
||||||
);
|
);
|
||||||
const blob = new Blob([exportedData], {
|
const blob = new Blob([exportedData], { type: "text/calendar" });
|
||||||
type: "text/calendar",
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `${calendar.id.split("/")[1]}.ics`;
|
link.download = `${calendar.id.split("/")[1]}.ics`;
|
||||||
@@ -74,7 +91,6 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setExportError((e as Error).message);
|
setExportError((e as Error).message);
|
||||||
setExportLoading(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
}
|
}
|
||||||
@@ -82,6 +98,15 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{(isPersonalCalendar || isDelegatedWithAdministration) && (
|
||||||
|
<CalendarAccessRights
|
||||||
|
calendar={calendar}
|
||||||
|
value={usersWithAccess}
|
||||||
|
onChange={onUsersWithAccessChange}
|
||||||
|
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}>
|
||||||
<Box mt={2}>
|
<Box mt={2}>
|
||||||
@@ -108,6 +133,7 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}>
|
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}>
|
||||||
<Box mt={3} display="flex" alignItems="center" gap={1}>
|
<Box mt={3} display="flex" alignItems="center" gap={1}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -147,11 +173,10 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
|
|||||||
<FieldWithLabel label={t("calendar.exportCalendar")} isExpanded={false}>
|
<FieldWithLabel label={t("calendar.exportCalendar")} isExpanded={false}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ color: "text.secondary", m: 1, lineHeight: 1.5 }}
|
sx={{ color: "text.secondary", my: 1, lineHeight: 1.5 }}
|
||||||
>
|
>
|
||||||
{t("calendar.exportDesc")}
|
{t("calendar.exportDesc")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,15 +6,19 @@ import {
|
|||||||
patchACLCalendarAsync,
|
patchACLCalendarAsync,
|
||||||
patchCalendarAsync,
|
patchCalendarAsync,
|
||||||
} from "@/features/Calendars/services";
|
} 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 { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
|
||||||
import { Button, Tab, Tabs } from "@linagora/twake-mui";
|
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 { useI18n } from "twake-i18n";
|
||||||
import { ResponsiveDialog } from "../Dialog";
|
import { ResponsiveDialog } from "../Dialog";
|
||||||
|
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
|
||||||
import { AccessTab } from "./AccessTab";
|
import { AccessTab } from "./AccessTab";
|
||||||
|
import { UserWithAccess } from "./CalendarAccessRights";
|
||||||
import { ImportTab } from "./ImportTab";
|
import { ImportTab } from "./ImportTab";
|
||||||
import { SettingsTab } from "./SettingsTab";
|
import { SettingsTab } from "./SettingsTab";
|
||||||
import { defaultColors } from "@/utils/defaultColors";
|
|
||||||
|
|
||||||
function CalendarPopover({
|
function CalendarPopover({
|
||||||
open,
|
open,
|
||||||
@@ -42,6 +46,13 @@ function CalendarPopover({
|
|||||||
const [color, setColor] = useState<Record<string, string>>(defaultColors[0]);
|
const [color, setColor] = useState<Record<string, string>>(defaultColors[0]);
|
||||||
const [visibility, setVisibility] = useState<"private" | "public">("public");
|
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
|
// import tab state
|
||||||
const [tab, setTab] = useState<"settings" | "access" | "import">("settings");
|
const [tab, setTab] = useState<"settings" | "access" | "import">("settings");
|
||||||
const [importedContent, setImportedContent] = useState<File | null>(null);
|
const [importedContent, setImportedContent] = useState<File | null>(null);
|
||||||
@@ -70,27 +81,82 @@ function CalendarPopover({
|
|||||||
setVisibility("public");
|
setVisibility("public");
|
||||||
setImportTarget("new");
|
setImportTarget("new");
|
||||||
}
|
}
|
||||||
|
setUsersWithAccess([]);
|
||||||
|
initialUsersRef.current = [];
|
||||||
}, [calendar, open]);
|
}, [calendar, open]);
|
||||||
|
|
||||||
const updateCalendar = (calId: string, calLink: string) => {
|
const handleUsersWithAccessChange = useCallback((users: UserWithAccess[]) => {
|
||||||
dispatch(
|
setUsersWithAccess(users);
|
||||||
patchCalendarAsync({
|
}, []);
|
||||||
calId,
|
|
||||||
calLink,
|
const handleInvitesLoaded = useCallback((users: UserWithAccess[]) => {
|
||||||
patch: { name: name.trim(), desc: description.trim(), color },
|
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) {
|
if (visibility !== calendar?.visibility) {
|
||||||
dispatch(
|
await dispatch(
|
||||||
patchACLCalendarAsync({
|
patchACLCalendarAsync({
|
||||||
calId,
|
calId,
|
||||||
calLink,
|
calLink,
|
||||||
request: visibility === "public" ? "{DAV:}read" : "",
|
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 (
|
const createCalendar = async (
|
||||||
calId: string,
|
calId: string,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -116,15 +182,19 @@ function CalendarPopover({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
|
|
||||||
if (calendar) {
|
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 {
|
} else {
|
||||||
createCalendar(crypto.randomUUID(), name, description, color, visibility);
|
createCalendar(crypto.randomUUID(), name, description, color, visibility);
|
||||||
}
|
}
|
||||||
handleClose({}, "backdropClick");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
@@ -162,9 +232,14 @@ function CalendarPopover({
|
|||||||
|
|
||||||
const handleClose = (
|
const handleClose = (
|
||||||
e: object | null,
|
e: object | null,
|
||||||
reason: "backdropClick" | "escapeKeyDown"
|
reason: "backdropClick" | "escapeKeyDown" | "cancel"
|
||||||
): void => {
|
): void => {
|
||||||
onClose(e, reason);
|
if (reason !== "cancel") {
|
||||||
|
handleSave();
|
||||||
|
onClose(e, reason);
|
||||||
|
} else {
|
||||||
|
onClose(e, "backdropClick");
|
||||||
|
}
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setColor(defaultColors[0]);
|
setColor(defaultColors[0]);
|
||||||
@@ -172,7 +247,9 @@ function CalendarPopover({
|
|||||||
setVisibility("public");
|
setVisibility("public");
|
||||||
setImportTarget("new");
|
setImportTarget("new");
|
||||||
setImportedContent(null);
|
setImportedContent(null);
|
||||||
|
setUsersWithAccess([]);
|
||||||
|
initialUsersRef.current = [];
|
||||||
|
setSaveError("");
|
||||||
setNewCalName("");
|
setNewCalName("");
|
||||||
setNewCalDescription("");
|
setNewCalDescription("");
|
||||||
setNewCalColor(defaultColors[0]);
|
setNewCalColor(defaultColors[0]);
|
||||||
@@ -203,16 +280,17 @@ function CalendarPopover({
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button variant="outlined" onClick={() => handleClose({}, "cancel")}>
|
||||||
variant="outlined"
|
|
||||||
onClick={() => handleClose({}, "backdropClick")}
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={tab === "import" ? !importedContent : !name.trim()}
|
disabled={tab === "import" ? !importedContent : !name.trim()}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={tab === "import" ? handleImport : handleSave}
|
onClick={
|
||||||
|
tab === "import"
|
||||||
|
? handleImport
|
||||||
|
: () => handleClose({}, "backdropClick")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{tab === "import"
|
{tab === "import"
|
||||||
? t("actions.import")
|
? t("actions.import")
|
||||||
@@ -254,7 +332,15 @@ function CalendarPopover({
|
|||||||
calendar={calendar}
|
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>
|
</ResponsiveDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface Calendar {
|
|||||||
access?: DelegationAccess;
|
access?: DelegationAccess;
|
||||||
lastCacheCleared?: number;
|
lastCacheCleared?: number;
|
||||||
syncToken?: string;
|
syncToken?: string;
|
||||||
|
invite?: CalendarInvite[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DelegationAccess {
|
export interface DelegationAccess {
|
||||||
@@ -26,3 +27,12 @@ export interface DelegationAccess {
|
|||||||
"write-properties": boolean;
|
"write-properties": boolean;
|
||||||
all: boolean;
|
all: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CalendarInvite = {
|
||||||
|
href: string;
|
||||||
|
principal: string;
|
||||||
|
access: AccessRight;
|
||||||
|
inviteStatus: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessRight = 2 | 3 | 5; // VIEW = 2, EDITOR = 3, ADMIN = 5
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { api } from "@/utils/apiUtils";
|
||||||
|
|
||||||
|
export async function updateDelegationCalendar(
|
||||||
|
calLink: string,
|
||||||
|
share: {
|
||||||
|
set: { [x: string]: string | boolean; "dav:href": string }[];
|
||||||
|
remove: { [x: string]: string | boolean; "dav:href": string }[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const response = await api.post(`dav${calLink}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json, text/plain, */*",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ share }),
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { RootState } from "@/app/store";
|
import { RootState } from "@/app/store";
|
||||||
import { defaultColors } from "@/utils/defaultColors";
|
|
||||||
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
|
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
|
||||||
import { getOpenPaasUser, getUserDetails } from "@/features/User/userAPI";
|
import { getOpenPaasUser, getUserDetails } from "@/features/User/userAPI";
|
||||||
|
import { defaultColors } from "@/utils/defaultColors";
|
||||||
import { formatReduxError } from "@/utils/errorUtils";
|
import { formatReduxError } from "@/utils/errorUtils";
|
||||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
import { getCalendars } from "../CalendarApi";
|
import { getCalendars } from "../CalendarApi";
|
||||||
import { Calendar } from "../CalendarTypes";
|
import { Calendar, CalendarInvite } from "../CalendarTypes";
|
||||||
import { CalendarData } from "../types/CalendarData";
|
import { CalendarData } from "../types/CalendarData";
|
||||||
import { RejectedError } from "../types/RejectedError";
|
import { RejectedError } from "../types/RejectedError";
|
||||||
import { normalizeCalendar } from "../utils/normalizeCalendar";
|
import { normalizeCalendar } from "../utils/normalizeCalendar";
|
||||||
@@ -82,6 +82,18 @@ export const getCalendarsListAsync = createAsyncThunk<
|
|||||||
dark: "#FFF",
|
dark: "#FFF",
|
||||||
}
|
}
|
||||||
: defaultColors[0];
|
: defaultColors[0];
|
||||||
|
|
||||||
|
const invite: CalendarInvite[] = (
|
||||||
|
(cal.invite ?? []) as Array<{
|
||||||
|
href: string;
|
||||||
|
principal: string;
|
||||||
|
access: number;
|
||||||
|
inviteStatus: number;
|
||||||
|
}>
|
||||||
|
).filter((inv): inv is CalendarInvite =>
|
||||||
|
[2, 3, 5].includes(inv.access)
|
||||||
|
);
|
||||||
|
|
||||||
fetchedCalendars[id] = {
|
fetchedCalendars[id] = {
|
||||||
id,
|
id,
|
||||||
name: cal["dav:name"] ?? "",
|
name: cal["dav:name"] ?? "",
|
||||||
@@ -93,6 +105,7 @@ export const getCalendarsListAsync = createAsyncThunk<
|
|||||||
visibility,
|
visibility,
|
||||||
access,
|
access,
|
||||||
events: {},
|
events: {},
|
||||||
|
invite,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { toRejectedError } from "@/utils/errorUtils";
|
||||||
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
|
import { updateDelegationCalendar } from "../api/updateDelegationCalendar";
|
||||||
|
import { RejectedError } from "../types/RejectedError";
|
||||||
|
|
||||||
|
export const updateDelegationCalendarAsync = createAsyncThunk<
|
||||||
|
{
|
||||||
|
calId: string;
|
||||||
|
calLink: string;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
calId: string;
|
||||||
|
calLink: string;
|
||||||
|
share: {
|
||||||
|
set: { [x: string]: string | boolean; "dav:href": string }[];
|
||||||
|
remove: { [x: string]: string | boolean; "dav:href": string }[];
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ rejectValue: RejectedError }
|
||||||
|
>(
|
||||||
|
"calendars/patchDelegationCalendar",
|
||||||
|
async ({ calId, calLink, share }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await updateDelegationCalendar(calLink, share);
|
||||||
|
return {
|
||||||
|
calId,
|
||||||
|
calLink,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return rejectWithValue(toRejectedError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -81,6 +81,12 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"calendarName": "Calendar name"
|
"calendarName": "Calendar name"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"grantAccessRights": "Grant Access rights",
|
||||||
|
"viewAllEvents": "View all events",
|
||||||
|
"editor": "Editor",
|
||||||
|
"administrator": "Administrator"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AccessRight } from "@/features/Calendars/CalendarTypes";
|
||||||
|
|
||||||
|
// Maps our AccessRight numeric value to the DAV share property key
|
||||||
|
export function accessRightToDavProp(
|
||||||
|
right: AccessRight
|
||||||
|
): "dav:administration" | "dav:read-write" | "dav:read" {
|
||||||
|
switch (right) {
|
||||||
|
case 5:
|
||||||
|
return "dav:administration";
|
||||||
|
case 3:
|
||||||
|
return "dav:read-write";
|
||||||
|
case 2:
|
||||||
|
default:
|
||||||
|
return "dav:read";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user