[#69] added secret link for calendars (#284)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
Co-authored-by: Benoit TELLIER <btellier@linagora.com>
This commit is contained in:
Camille Moussu
2025-11-07 08:30:47 +01:00
committed by GitHub
parent 0647be551f
commit daed6d97ed
6 changed files with 187 additions and 22 deletions
@@ -4,6 +4,7 @@ import {
addSharedCalendar,
getCalendar,
getCalendars,
getSecretLink,
postCalendar,
proppatchCalendar,
removeCalendar,
@@ -114,6 +115,40 @@ describe("Calendar API", () => {
});
});
it("get secret link ", async () => {
const calLink = "/calendars/calId.json";
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue("link"),
});
const noreset = await getSecretLink(calLink, false);
expect(api.get).toHaveBeenCalledWith(
`calendar/api${calLink}/secret-link?shouldResetLink=false`,
{
headers: {
Accept: "application/json, text/plain, */*",
},
}
);
});
it("get secret link ", async () => {
const calLink = "/calendars/calId.json";
(api.get as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue("link"),
});
const reset = await getSecretLink(calLink, true);
expect(api.get).toHaveBeenCalledWith(
`calendar/api${calLink}/secret-link?shouldResetLink=true`,
{
headers: {
Accept: "application/json, text/plain, */*",
},
}
);
});
it("When adding a sharedCal with #default as a name a new name is sent to the back", async () => {
const mockApiPost = jest.spyOn(api, "post");
@@ -3,6 +3,11 @@ import CalendarPopover from "../../../src/components/Calendar/CalendarModal";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
import { Calendars } from "../../../src/features/Calendars/CalendarTypes";
import { getSecretLink } from "../../../src/features/Calendars/CalendarApi";
jest.mock("../../../src/features/Calendars/CalendarApi", () => ({
getSecretLink: jest.fn(),
}));
describe("CalendarPopover", () => {
const mockOnClose = jest.fn();
@@ -308,6 +313,9 @@ describe("CalendarPopover - Tabs Scenarios", () => {
Object.assign(navigator, {
clipboard: { writeText: jest.fn() },
});
(getSecretLink as jest.Mock).mockResolvedValue({
secretLink: "https://example.org/secret/initial",
});
renderWithProviders(
<CalendarPopover
@@ -326,7 +334,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1");
// Click copy button (find button containing ContentCopyIcon)
const copyIcon = screen.getByTestId("ContentCopyIcon");
const copyIcon = screen.getAllByTestId("ContentCopyIcon")[0];
const copyButton = copyIcon.closest("button");
if (copyButton) {
fireEvent.click(copyButton);
@@ -433,4 +441,46 @@ describe("CalendarPopover - Tabs Scenarios", () => {
expect(importButton).toBeDisabled();
});
});
it("fetches and resets the secret link", async () => {
(window as any).CALENDAR_BASE_URL = "https://cal.example.org";
(getSecretLink as jest.Mock)
.mockResolvedValueOnce({
secretLink: "https://example.org/secret/initial",
})
.mockResolvedValueOnce({
secretLink: "https://example.org/secret/new",
});
renderWithProviders(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ user: baseUser }
);
fireEvent.click(screen.getByRole("tab", { name: /Access/i }));
await waitFor(() =>
expect(
screen.getByDisplayValue("https://example.org/secret/initial")
).toBeInTheDocument()
);
fireEvent.click(screen.getByRole("button", { name: /reset/i }));
await waitFor(() =>
expect(
screen.getByDisplayValue("https://example.org/secret/new")
).toBeInTheDocument()
);
expect(getSecretLink).toHaveBeenCalledWith(
existingCalendar.link.replace(".json", ""),
true
);
});
});
+82 -19
View File
@@ -1,39 +1,102 @@
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { Box, IconButton, TextField } from "@mui/material";
import { useState } from "react";
import {
Box,
IconButton,
TextField,
Button,
Typography,
InputAdornment,
} from "@mui/material";
import { useState, useEffect } from "react";
import { getSecretLink } from "../../features/Calendars/CalendarApi";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
export function AccessTab({ calendar }: { calendar: Calendars }) {
const { t } = useI18n();
const calDAVLink = `${(window as any).CALENDAR_BASE_URL}${calendar.link.replace(".json", "")}`;
const [secretLink, setSecretLink] = useState("");
const [open, setOpen] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(calDAVLink);
useEffect(() => {
async function fetchSecret() {
const existing = await getSecretLink(
calendar.link.replace(".json", ""),
false
);
setSecretLink(existing.secretLink);
}
fetchSecret();
}, [calendar.link]);
const handleCopy = (content: string) => {
navigator.clipboard.writeText(content);
setOpen(true);
};
const handleResetSecretLink = async () => {
const newSecret = await getSecretLink(
calendar.link.replace(".json", ""),
true
);
setSecretLink(newSecret.secretLink);
};
return (
<>
<Box mt={2}>
<TextField
disabled
fullWidth
label={t("calendar.caldav_access")}
value={calDAVLink}
slotProps={{
input: {
<FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}>
<Box mt={2}>
<TextField
disabled
fullWidth
label={t("calendar.caldav_access")}
value={calDAVLink}
InputProps={{
endAdornment: (
<IconButton onClick={handleCopy} edge="end">
<ContentCopyIcon />
</IconButton>
<InputAdornment position="end">
<IconButton onClick={() => handleCopy(calDAVLink)} edge="end">
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
},
}}
/>
</Box>
}}
/>
</Box>
</FieldWithLabel>
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}>
<Box mt={3} display="flex" alignItems="center" gap={1}>
<TextField
disabled
fullWidth
label={t("calendar.secretUrl")}
value={secretLink}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => handleCopy(secretLink)} edge="end">
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
/>
<Button variant="outlined" onClick={handleResetSecretLink}>
{t("actions.reset")}
</Button>
</Box>
</FieldWithLabel>
<Typography
variant="body2"
sx={{ color: "text.secondary", mt: 1, lineHeight: 1.5 }}
>
{t("calendar.secretUrlDesc")}
</Typography>
<SnackbarAlert
setOpen={setOpen}
open={open}
+11
View File
@@ -128,3 +128,14 @@ export async function updateAclCalendar(calLink: string, request: string) {
});
return response;
}
export async function getSecretLink(calLink: string, reset: boolean) {
const response = await api
.get(`calendar/api${calLink}/secret-link?shouldResetLink=${reset}`, {
headers: {
Accept: "application/json, text/plain, */*",
},
})
.json();
return response as { secretLink: string };
}
+4 -1
View File
@@ -5,6 +5,8 @@
"delegated": "Delegated Calendars",
"other": "Other Calendars",
"caldav_access": "CalDAV access",
"secretUrl": "Secret URL",
"secretUrlDesc": "The secret address can be used to view all calendar events even without access permissions. You can reset your current secret address and get a new one.",
"import_file_description": "Import events from an ICS file to one of your calendars.",
"ics_feed_url": "ICS feed URL",
"import_to": "Import to",
@@ -28,7 +30,8 @@
"save": "Save",
"create": "Create",
"import": "Import",
"add": "Add"
"add": "Add",
"reset": "Reset"
},
"common": {
"cancel": "Cancel",
+4 -1
View File
@@ -5,6 +5,8 @@
"delegated": "Calendriers délégués",
"other": "Autres calendriers",
"caldav_access": "Accès CalDAV",
"secretUrl": "URL secrète",
"secretUrlDesc": "L'adresse secrète peut être utilisée pour visualiser tous vos calendiers même sans permissions. Vous pouvez la réinitialiser pour en obtenir une nouvelle.",
"import_file_description": "Importer des événements depuis un fichier ICS vers l'un de vos calendriers.",
"ics_feed_url": "URL du flux ICS",
"import_to": "Importer vers",
@@ -28,7 +30,8 @@
"save": "Enregistrer",
"create": "Créer",
"import": "Importer",
"add": "Ajouter"
"add": "Ajouter",
"reset": "Réinitialiser"
},
"common": {
"cancel": "Annuler",