[#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, addSharedCalendar,
getCalendar, getCalendar,
getCalendars, getCalendars,
getSecretLink,
postCalendar, postCalendar,
proppatchCalendar, proppatchCalendar,
removeCalendar, 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 () => { 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"); const mockApiPost = jest.spyOn(api, "post");
@@ -3,6 +3,11 @@ import CalendarPopover from "../../../src/components/Calendar/CalendarModal";
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
import { Calendars } from "../../../src/features/Calendars/CalendarTypes"; 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", () => { describe("CalendarPopover", () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
@@ -308,6 +313,9 @@ describe("CalendarPopover - Tabs Scenarios", () => {
Object.assign(navigator, { Object.assign(navigator, {
clipboard: { writeText: jest.fn() }, clipboard: { writeText: jest.fn() },
}); });
(getSecretLink as jest.Mock).mockResolvedValue({
secretLink: "https://example.org/secret/initial",
});
renderWithProviders( renderWithProviders(
<CalendarPopover <CalendarPopover
@@ -326,7 +334,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1"); expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1");
// Click copy button (find button containing ContentCopyIcon) // Click copy button (find button containing ContentCopyIcon)
const copyIcon = screen.getByTestId("ContentCopyIcon"); const copyIcon = screen.getAllByTestId("ContentCopyIcon")[0];
const copyButton = copyIcon.closest("button"); const copyButton = copyIcon.closest("button");
if (copyButton) { if (copyButton) {
fireEvent.click(copyButton); fireEvent.click(copyButton);
@@ -433,4 +441,46 @@ describe("CalendarPopover - Tabs Scenarios", () => {
expect(importButton).toBeDisabled(); 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 ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { Box, IconButton, TextField } from "@mui/material"; import {
import { useState } from "react"; 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 { Calendars } from "../../features/Calendars/CalendarTypes";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { SnackbarAlert } from "../Loading/SnackBarAlert"; import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
export function AccessTab({ calendar }: { calendar: Calendars }) { export function AccessTab({ calendar }: { calendar: Calendars }) {
const { t } = useI18n(); const { t } = useI18n();
const calDAVLink = `${(window as any).CALENDAR_BASE_URL}${calendar.link.replace(".json", "")}`; const calDAVLink = `${(window as any).CALENDAR_BASE_URL}${calendar.link.replace(".json", "")}`;
const [secretLink, setSecretLink] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const handleCopy = () => { useEffect(() => {
navigator.clipboard.writeText(calDAVLink); 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); setOpen(true);
}; };
const handleResetSecretLink = async () => {
const newSecret = await getSecretLink(
calendar.link.replace(".json", ""),
true
);
setSecretLink(newSecret.secretLink);
};
return ( return (
<> <>
<Box mt={2}> <FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}>
<TextField <Box mt={2}>
disabled <TextField
fullWidth disabled
label={t("calendar.caldav_access")} fullWidth
value={calDAVLink} label={t("calendar.caldav_access")}
slotProps={{ value={calDAVLink}
input: { InputProps={{
endAdornment: ( endAdornment: (
<IconButton onClick={handleCopy} edge="end"> <InputAdornment position="end">
<ContentCopyIcon /> <IconButton onClick={() => handleCopy(calDAVLink)} edge="end">
</IconButton> <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 <SnackbarAlert
setOpen={setOpen} setOpen={setOpen}
open={open} open={open}
+11
View File
@@ -128,3 +128,14 @@ export async function updateAclCalendar(calLink: string, request: string) {
}); });
return response; 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", "delegated": "Delegated Calendars",
"other": "Other Calendars", "other": "Other Calendars",
"caldav_access": "CalDAV access", "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.", "import_file_description": "Import events from an ICS file to one of your calendars.",
"ics_feed_url": "ICS feed URL", "ics_feed_url": "ICS feed URL",
"import_to": "Import to", "import_to": "Import to",
@@ -28,7 +30,8 @@
"save": "Save", "save": "Save",
"create": "Create", "create": "Create",
"import": "Import", "import": "Import",
"add": "Add" "add": "Add",
"reset": "Reset"
}, },
"common": { "common": {
"cancel": "Cancel", "cancel": "Cancel",
+4 -1
View File
@@ -5,6 +5,8 @@
"delegated": "Calendriers délégués", "delegated": "Calendriers délégués",
"other": "Autres calendriers", "other": "Autres calendriers",
"caldav_access": "Accès CalDAV", "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.", "import_file_description": "Importer des événements depuis un fichier ICS vers l'un de vos calendriers.",
"ics_feed_url": "URL du flux ICS", "ics_feed_url": "URL du flux ICS",
"import_to": "Importer vers", "import_to": "Importer vers",
@@ -28,7 +30,8 @@
"save": "Enregistrer", "save": "Enregistrer",
"create": "Créer", "create": "Créer",
"import": "Importer", "import": "Importer",
"add": "Ajouter" "add": "Ajouter",
"reset": "Réinitialiser"
}, },
"common": { "common": {
"cancel": "Annuler", "cancel": "Annuler",