diff --git a/cypress/e2e/builder/item/download/export-zip.cy.ts b/cypress/e2e/builder/item/download/export-zip.cy.ts index dfb71e4a1..a5a9b619e 100644 --- a/cypress/e2e/builder/item/download/export-zip.cy.ts +++ b/cypress/e2e/builder/item/download/export-zip.cy.ts @@ -9,7 +9,9 @@ import { import { buildDownloadButtonId, buildExportAsZipButtonId, + buildItemCard, buildItemsGridMoreButtonSelector, + buildPublicExportZipButtonId, } from '../../../../../src/config/selectors'; import { HOME_PATH, buildItemPath } from '../../utils'; @@ -61,3 +63,61 @@ describe('Export Folder as ZIP', () => { ); }); }); + +describe('Download public folder as ZIP', () => { + const publicVisibility = (item: { path: string }) => ({ + id: 'public-visibility-id', + type: 'public' as const, + itemPath: item.path, + createdAt: new Date().toISOString(), + }); + const buildAdminExportHref = (itemId: string) => + `/public/folders/${itemId}/export`; + + it('logged out visitor can download a public folder and its subfolder', () => { + const parent = PackedFolderItemFactory(); + const parentWithVisibility = { + ...parent, + public: publicVisibility(parent), + }; + const child = PackedFolderItemFactory({ parentItem: parent }); + const childWithVisibility = { + ...child, + public: publicVisibility(parent), + }; + cy.setUpApi({ + items: [parentWithVisibility, childWithVisibility], + currentMember: null, + }); + + // subfolder card menu + cy.visit(buildItemPath(parent.id)); + cy.get(buildItemsGridMoreButtonSelector(child.id)).click(); + cy.get(`[role="menu"] #${buildPublicExportZipButtonId(child.id)}`) + .should('have.attr', 'href') + .and('eq', buildAdminExportHref(child.id)); + cy.get('body').type('{esc}'); + + // folder header menu + cy.get(`[aria-label="More"]`).first().click(); + cy.get(`[role="menu"] #${buildPublicExportZipButtonId(parent.id)}`) + .should('have.attr', 'href') + .and('eq', buildAdminExportHref(parent.id)); + // logged out visitors do not use the emailed export + cy.get(`[role="menu"] #${buildExportAsZipButtonId(parent.id)}`).should( + 'not.exist', + ); + }); + + it('is not shown on a non-public folder', () => { + const parent = PackedFolderItemFactory(); + const child = PackedFolderItemFactory({ parentItem: parent }); + cy.setUpApi({ items: [parent, child], currentMember: null }); + cy.visit(buildItemPath(parent.id)); + // the menus are not rendered at all, so the zip button cannot be reached + cy.get(`#${buildItemCard(child.id)}`).should('exist'); + cy.get(buildItemsGridMoreButtonSelector(child.id)).should('not.exist'); + cy.get(`#${buildPublicExportZipButtonId(child.id)}`).should('not.exist'); + cy.get(`#${buildPublicExportZipButtonId(parent.id)}`).should('not.exist'); + }); +}); diff --git a/cypress/e2e/builder/item/pin/pinItem.cy.ts b/cypress/e2e/builder/item/pin/pinItem.cy.ts index 931822eb1..8fbb432f5 100644 --- a/cypress/e2e/builder/item/pin/pinItem.cy.ts +++ b/cypress/e2e/builder/item/pin/pinItem.cy.ts @@ -4,6 +4,7 @@ import { ITEM_HEADER_ID, PIN_ITEM_BUTTON_CLASS, buildItemsGridMoreButtonSelector, + buildPublicExportZipButtonId, } from '../../../../../src/config/selectors'; import { HOME_PATH, buildItemPath } from '../../utils'; @@ -27,7 +28,10 @@ describe('Anonymous', () => { }); it("Can see item but can't pin", () => { cy.get(`#${ITEM_HEADER_ID}`).should('be.visible'); - cy.get(`#${ITEM_HEADER_ID} [aria-label="More"]`).should('not.exist'); + // logged out visitors can only download the zip of a public folder + cy.get(`#${ITEM_HEADER_ID} [aria-label="More"]`).click(); + cy.get(`#${buildPublicExportZipButtonId(itemId)}`).should('be.visible'); + cy.get(`.${PIN_ITEM_BUTTON_CLASS}`).should('not.exist'); }); }); diff --git a/src/config/selectors.ts b/src/config/selectors.ts index c9725e1e5..3ef7bcde1 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -501,6 +501,8 @@ export const CONFIRM_MEMBERSHIP_DELETE_BUTTON_ID = 'confirmDeleteMembershipButton'; export const buildDownloadButtonId = (itemId: string): string => `download-button-id-${itemId}`; +export const buildPublicExportZipButtonId = (itemId: string): string => + `public-export-zip-button-id-${itemId}`; export const buildExportAsZipButtonId = (itemId: string): string => `export-zip-button-id-${itemId}`; export const CUSTOM_APP_URL_ID = 'customAppURLId'; diff --git a/src/locales/de/builder.json b/src/locales/de/builder.json index 3360dfd94..b00d31aa2 100644 --- a/src/locales/de/builder.json +++ b/src/locales/de/builder.json @@ -134,6 +134,7 @@ "ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Verknüpfung erstellen", "ITEM_MENU_FLAG_MENU_ITEM": "Melden", "ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Als ZIP exportieren", + "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "ZIP herunterladen", "ITEM_METADATA_CREATED_AT_TITLE": "Hergestellt in", "ITEM_METADATA_CREATOR_TITLE": "Urheber", "ITEM_METADATA_LINK_TITLE": "Verknüpfung", diff --git a/src/locales/en/builder.json b/src/locales/en/builder.json index a3a0d35a1..b0d33840f 100644 --- a/src/locales/en/builder.json +++ b/src/locales/en/builder.json @@ -134,6 +134,7 @@ "ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Create Shortcut", "ITEM_MENU_FLAG_MENU_ITEM": "Flag", "ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Export as ZIP", + "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Download ZIP", "ITEM_METADATA_CREATED_AT_TITLE": "Creation", "ITEM_METADATA_CREATOR_TITLE": "Creator", "ITEM_METADATA_LINK_TITLE": "Link", diff --git a/src/locales/es/builder.json b/src/locales/es/builder.json index adf216e5f..60a2f7e59 100644 --- a/src/locales/es/builder.json +++ b/src/locales/es/builder.json @@ -129,6 +129,7 @@ "ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Crear acceso directo", "ITEM_MENU_FLAG_MENU_ITEM": "Bandera", "ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Exportar como ZIP", + "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Descargar ZIP", "ITEM_METADATA_CREATED_AT_TITLE": "Creado en", "ITEM_METADATA_CREATOR_TITLE": "Creador", "ITEM_METADATA_LINK_TITLE": "Enlace", diff --git a/src/locales/fr/builder.json b/src/locales/fr/builder.json index d4b38459f..88c7c9f37 100644 --- a/src/locales/fr/builder.json +++ b/src/locales/fr/builder.json @@ -134,6 +134,7 @@ "ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Créer un Raccourci", "ITEM_MENU_FLAG_MENU_ITEM": "Signaler", "ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Exporter en ZIP", + "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Télécharger le ZIP", "ITEM_METADATA_CREATED_AT_TITLE": "Créé à", "ITEM_METADATA_CREATOR_TITLE": "Créateur", "ITEM_METADATA_LINK_TITLE": "Lien", diff --git a/src/locales/ja/builder.json b/src/locales/ja/builder.json index bf8f8ed9d..77fa8e025 100644 --- a/src/locales/ja/builder.json +++ b/src/locales/ja/builder.json @@ -134,6 +134,7 @@ "ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "ショートカットを作成する", "ITEM_MENU_FLAG_MENU_ITEM": "フラグ", "ITEM_MENU_EXPORT_RAW_MENU_ITEM": "ZIPファイルとしてエクスポート", + "ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "ZIPをダウンロード", "ITEM_METADATA_CREATED_AT_TITLE": "創造", "ITEM_METADATA_CREATOR_TITLE": "クリエイター", "ITEM_METADATA_LINK_TITLE": "リンク", diff --git a/src/modules/builder/components/common/PublicExportZipButton.tsx b/src/modules/builder/components/common/PublicExportZipButton.tsx new file mode 100644 index 000000000..abb3c44d9 --- /dev/null +++ b/src/modules/builder/components/common/PublicExportZipButton.tsx @@ -0,0 +1,55 @@ +import { type JSX } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ListItemIcon, MenuItem } from '@mui/material'; + +import { PackageIcon } from 'lucide-react'; + +import { NS } from '@/config/constants'; +import { buildPublicExportZipButtonId } from '@/config/selectors'; +import type { GenericItem } from '@/openapi/client'; + +type Props = { + itemId: GenericItem['id']; + + /** + * ui context the button is located + */ + dataUmamiContext?: string; + + /** + * called when the download starts, e.g. to close the parent menu + */ + onClick?: () => void; +}; + +/** + * Download a public folder as zip, for visitors who are not logged in + * The export is served by the admin app, which also serves this client, + * so the path is absolute without a host + */ +const PublicExportZipButton = ({ + itemId, + dataUmamiContext, + onClick, +}: Props): JSX.Element => { + const { t } = useTranslation(NS.Builder); + + return ( + + + + + {t('ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM')} + + ); +}; + +export default PublicExportZipButton; diff --git a/src/modules/builder/components/item/header/Actions.tsx b/src/modules/builder/components/item/header/Actions.tsx index 04d025d28..6a544e1f0 100644 --- a/src/modules/builder/components/item/header/Actions.tsx +++ b/src/modules/builder/components/item/header/Actions.tsx @@ -15,6 +15,7 @@ import type { PackedItem } from '@/openapi/client'; import { ActionButton } from '@/ui/types'; import ExportRawZipButton from '~builder/components/common/ExportRawZipButton'; +import PublicExportZipButton from '~builder/components/common/PublicExportZipButton'; import useModalStatus from '~builder/components/hooks/useModalStatus'; import DownloadButton from '~builder/components/main/DownloadButton'; @@ -43,7 +44,7 @@ const internalId = 'menu'; const Actions = ({ item }: Props): JSX.Element[] | null => { const { copyOpen } = useSearch({ from: '/builder/items/$itemId' }); const { t } = useTranslation(NS.Common, { keyPrefix: 'ARIA' }); - const { data: member } = hooks.useCurrentMember(); + const { data: member, isPending: isMemberPending } = hooks.useCurrentMember(); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const handleClick = (event: MouseEvent): void => { @@ -68,10 +69,47 @@ const Actions = ({ item }: Props): JSX.Element[] | null => { const canAdmin = item.permission && PermissionLevelCompare.gte(item.permission, 'admin'); - if (!member?.id) { + const moreButton = ( + + + + ); + + // wait for the current member to avoid showing the logged out menu + if (isMemberPending) { return null; } + if (!member?.id) { + // logged out visitors can only download the zip of a public folder + if (item.type !== 'folder' || !item.public) { + return null; + } + return [ + moreButton, + + + , + ]; + } + const downloadButton = item.type === 'folder' ? ( { onClose={closeCreateShortcutModal} open={isCreateShortcutOpen} />, - - - , + moreButton, )} + {!account && isPublicFolder && ( + + )} {account?.id ? : false} diff --git a/src/modules/builder/langs.ts b/src/modules/builder/langs.ts index 95f4d0f96..c7a16e30a 100644 --- a/src/modules/builder/langs.ts +++ b/src/modules/builder/langs.ts @@ -241,6 +241,8 @@ export const BUILDER = { ITEM_MENU_DUPLICATE_MENU_ITEM: 'ITEM_MENU_DUPLICATE_MENU_ITEM', ITEM_MENU_FLAG_MENU_ITEM: 'ITEM_MENU_FLAG_MENU_ITEM', ITEM_MENU_EXPORT_RAW_MENU_ITEM: 'ITEM_MENU_EXPORT_RAW_MENU_ITEM', + ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM: + 'ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM', ITEM_METADATA_CREATED_AT_TITLE: 'ITEM_METADATA_CREATED_AT_TITLE', ITEM_METADATA_LINK_TITLE: 'ITEM_METADATA_LINK_TITLE', ITEM_METADATA_SIZE_TITLE: 'ITEM_METADATA_SIZE_TITLE',