From ffe9838967ad144ca83bf3771810b34b979beea7 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Fri, 9 Oct 2026 17:15:38 +0200 Subject: [PATCH 1/4] feat: add public folder zip download for logged out visitors (#290) --- .github/workflows/deploy.yml | 1 + Dockerfile | 1 + .../builder/item/download/export-zip.cy.ts | 56 +++++++++++++++++++ src/config/env.ts | 6 ++ src/config/selectors.ts | 2 + src/locales/de/builder.json | 1 + src/locales/en/builder.json | 1 + src/locales/es/builder.json | 1 + src/locales/fr/builder.json | 1 + src/locales/ja/builder.json | 1 + .../common/PublicExportZipButton.tsx | 48 ++++++++++++++++ .../components/item/header/Actions.tsx | 27 ++++++++- .../components/main/ItemMenuContent.tsx | 10 +++- src/modules/builder/langs.ts | 1 + src/vite-env.d.ts | 1 + 15 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 src/modules/builder/components/common/PublicExportZipButton.tsx diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ebb8bfd1b..f1234b875 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,7 @@ jobs: VITE_VERSION: ${{ inputs.version || github.sha }} VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }} VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }} + VITE_GRAASP_ADMIN_HOST: ${{ vars.VITE_GRAASP_ADMIN_HOST }} VITE_SENTRY_ENV: ${{ vars.VITE_SENTRY_ENV }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} diff --git a/Dockerfile b/Dockerfile index aa25ef5ee..e7b305a2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ ARG VITE_SENTRY_ENV ARG VITE_SENTRY_DSN ARG VITE_RECAPTCHA ARG VITE_GRAASP_H5P_INTEGRATION_URL +ARG VITE_GRAASP_ADMIN_HOST ARG VITE_GRAASP_REDIRECTION_HOST # Install pnpm in the builder stage diff --git a/cypress/e2e/builder/item/download/export-zip.cy.ts b/cypress/e2e/builder/item/download/export-zip.cy.ts index dfb71e4a1..bcb2a42be 100644 --- a/cypress/e2e/builder/item/download/export-zip.cy.ts +++ b/cypress/e2e/builder/item/download/export-zip.cy.ts @@ -9,6 +9,7 @@ import { import { buildDownloadButtonId, buildExportAsZipButtonId, + buildPublicExportZipButtonId, buildItemsGridMoreButtonSelector, } from '../../../../../src/config/selectors'; import { HOME_PATH, buildItemPath } from '../../utils'; @@ -61,3 +62,58 @@ 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) => + `${Cypress.env('VITE_GRAASP_ADMIN_HOST') ?? 'http://localhost:4000'}/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)); + cy.get(`#${buildPublicExportZipButtonId(child.id)}`).should('not.exist'); + cy.get(`#${buildPublicExportZipButtonId(parent.id)}`).should('not.exist'); + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts index 281ad3d59..72d37ede3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -11,6 +11,12 @@ export const APP_VERSION = import.meta.env.VITE_VERSION; export const GRAASP_LIBRARY_HOST = import.meta.env.VITE_GRAASP_LIBRARY_HOST ?? 'http://localhost:3005'; +/** + * Host of the admin app, which serves the export of public folders + */ +export const GRAASP_ADMIN_HOST = + import.meta.env.VITE_GRAASP_ADMIN_HOST ?? 'http://localhost:4000'; + export const SENTRY_ENV = import.meta.env.VITE_SENTRY_ENV; export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN; export const GRAASP_REDIRECTION_HOST = import.meta.env 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..c27a884e9 --- /dev/null +++ b/src/modules/builder/components/common/PublicExportZipButton.tsx @@ -0,0 +1,48 @@ +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 { GRAASP_ADMIN_HOST } from '@/config/env'; +import { buildPublicExportZipButtonId } from '@/config/selectors'; +import type { GenericItem } from '@/openapi/client'; + +type Props = { + itemId: GenericItem['id']; + + /** + * ui context the button is located + */ + dataUmamiContext?: string; +}; + +/** + * Download a public folder as zip, for visitors who are not logged in + * The export is served by the admin app + */ +const PublicExportZipButton = ({ + itemId, + dataUmamiContext, +}: 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..46c114172 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'; @@ -69,7 +70,31 @@ const Actions = ({ item }: Props): JSX.Element[] | null => { item.permission && PermissionLevelCompare.gte(item.permission, 'admin'); if (!member?.id) { - return null; + // logged out visitors can only download the zip of a public folder + if (item.type !== 'folder' || !item.public) { + return null; + } + return [ + + + , + + + , + ]; } const downloadButton = diff --git a/src/modules/builder/components/main/ItemMenuContent.tsx b/src/modules/builder/components/main/ItemMenuContent.tsx index 2a29973f4..8202db6ae 100644 --- a/src/modules/builder/components/main/ItemMenuContent.tsx +++ b/src/modules/builder/components/main/ItemMenuContent.tsx @@ -21,6 +21,7 @@ import ExportRawZipButton from '../common/ExportRawZipButton'; import FlagButton from '../common/FlagButton'; import HideButton from '../common/HideButton'; import PinButton from '../common/PinButton'; +import PublicExportZipButton from '../common/PublicExportZipButton'; import RecycleButton from '../common/RecycleButton'; import useModalStatus from '../hooks/useModalStatus'; import CopyButton from '../item/copy/CopyButton'; @@ -55,8 +56,10 @@ function GuestAndPublicMenu({ setAnchorEl(null); }; - // logged out user see nothing for public folder - if (!account && item.type === 'folder') { + const isPublicFolder = item.type === 'folder' && Boolean(item.public); + + // logged out user see nothing for a folder, except the zip of a public one + if (!account && item.type === 'folder' && !isPublicFolder) { return null; } @@ -75,6 +78,9 @@ function GuestAndPublicMenu({ {item.type !== 'folder' && ( )} + {!account && isPublicFolder && ( + + )} {account?.id ? : false} diff --git a/src/modules/builder/langs.ts b/src/modules/builder/langs.ts index 95f4d0f96..e4d84e3a8 100644 --- a/src/modules/builder/langs.ts +++ b/src/modules/builder/langs.ts @@ -241,6 +241,7 @@ 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', diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 73be18af1..fe3761762 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { readonly VITE_VERSION: string; readonly VITE_GRAASP_API_HOST?: string; readonly VITE_GRAASP_LIBRARY_HOST?: string; + readonly VITE_GRAASP_ADMIN_HOST?: string; readonly VITE_SENTRY_ENV: string; readonly VITE_SENTRY_DSN: string; readonly VITE_RECAPTCHA: string; From 1d377709a5a9df86b4697233ab3faa071c019bb9 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Fri, 9 Oct 2026 17:21:46 +0200 Subject: [PATCH 2/4] refactor: use a host-less path for the public zip download (#290) --- .github/workflows/deploy.yml | 1 - Dockerfile | 1 - cypress/e2e/builder/item/download/export-zip.cy.ts | 4 ++-- src/config/env.ts | 6 ------ .../builder/components/common/PublicExportZipButton.tsx | 6 +++--- src/modules/builder/langs.ts | 3 ++- src/vite-env.d.ts | 1 - 7 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f1234b875..ebb8bfd1b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,7 +48,6 @@ jobs: VITE_VERSION: ${{ inputs.version || github.sha }} VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }} VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }} - VITE_GRAASP_ADMIN_HOST: ${{ vars.VITE_GRAASP_ADMIN_HOST }} VITE_SENTRY_ENV: ${{ vars.VITE_SENTRY_ENV }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} diff --git a/Dockerfile b/Dockerfile index e7b305a2e..aa25ef5ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,6 @@ ARG VITE_SENTRY_ENV ARG VITE_SENTRY_DSN ARG VITE_RECAPTCHA ARG VITE_GRAASP_H5P_INTEGRATION_URL -ARG VITE_GRAASP_ADMIN_HOST ARG VITE_GRAASP_REDIRECTION_HOST # Install pnpm in the builder stage diff --git a/cypress/e2e/builder/item/download/export-zip.cy.ts b/cypress/e2e/builder/item/download/export-zip.cy.ts index bcb2a42be..22269ad71 100644 --- a/cypress/e2e/builder/item/download/export-zip.cy.ts +++ b/cypress/e2e/builder/item/download/export-zip.cy.ts @@ -9,8 +9,8 @@ import { import { buildDownloadButtonId, buildExportAsZipButtonId, - buildPublicExportZipButtonId, buildItemsGridMoreButtonSelector, + buildPublicExportZipButtonId, } from '../../../../../src/config/selectors'; import { HOME_PATH, buildItemPath } from '../../utils'; @@ -71,7 +71,7 @@ describe('Download public folder as ZIP', () => { createdAt: new Date().toISOString(), }); const buildAdminExportHref = (itemId: string) => - `${Cypress.env('VITE_GRAASP_ADMIN_HOST') ?? 'http://localhost:4000'}/public/folders/${itemId}/export`; + `/public/folders/${itemId}/export`; it('logged out visitor can download a public folder and its subfolder', () => { const parent = PackedFolderItemFactory(); diff --git a/src/config/env.ts b/src/config/env.ts index 72d37ede3..281ad3d59 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -11,12 +11,6 @@ export const APP_VERSION = import.meta.env.VITE_VERSION; export const GRAASP_LIBRARY_HOST = import.meta.env.VITE_GRAASP_LIBRARY_HOST ?? 'http://localhost:3005'; -/** - * Host of the admin app, which serves the export of public folders - */ -export const GRAASP_ADMIN_HOST = - import.meta.env.VITE_GRAASP_ADMIN_HOST ?? 'http://localhost:4000'; - export const SENTRY_ENV = import.meta.env.VITE_SENTRY_ENV; export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN; export const GRAASP_REDIRECTION_HOST = import.meta.env diff --git a/src/modules/builder/components/common/PublicExportZipButton.tsx b/src/modules/builder/components/common/PublicExportZipButton.tsx index c27a884e9..de6813fa7 100644 --- a/src/modules/builder/components/common/PublicExportZipButton.tsx +++ b/src/modules/builder/components/common/PublicExportZipButton.tsx @@ -6,7 +6,6 @@ import { ListItemIcon, MenuItem } from '@mui/material'; import { PackageIcon } from 'lucide-react'; import { NS } from '@/config/constants'; -import { GRAASP_ADMIN_HOST } from '@/config/env'; import { buildPublicExportZipButtonId } from '@/config/selectors'; import type { GenericItem } from '@/openapi/client'; @@ -21,7 +20,8 @@ type Props = { /** * Download a public folder as zip, for visitors who are not logged in - * The export is served by the admin app + * The export is served by the admin app, which also serves this client, + * so the path is absolute without a host */ const PublicExportZipButton = ({ itemId, @@ -33,7 +33,7 @@ const PublicExportZipButton = ({ diff --git a/src/modules/builder/langs.ts b/src/modules/builder/langs.ts index e4d84e3a8..c7a16e30a 100644 --- a/src/modules/builder/langs.ts +++ b/src/modules/builder/langs.ts @@ -241,7 +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_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', diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index fe3761762..73be18af1 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,7 +5,6 @@ interface ImportMetaEnv { readonly VITE_VERSION: string; readonly VITE_GRAASP_API_HOST?: string; readonly VITE_GRAASP_LIBRARY_HOST?: string; - readonly VITE_GRAASP_ADMIN_HOST?: string; readonly VITE_SENTRY_ENV: string; readonly VITE_SENTRY_DSN: string; readonly VITE_RECAPTCHA: string; From 976b2a9245bfb467ea40048f016683c504329250 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Fri, 9 Oct 2026 17:52:45 +0200 Subject: [PATCH 3/4] fix: via code review --- .../builder/item/download/export-zip.cy.ts | 4 ++ .../common/PublicExportZipButton.tsx | 7 +++ .../components/item/header/Actions.tsx | 48 ++++++++++--------- .../components/main/ItemMenuContent.tsx | 6 ++- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/cypress/e2e/builder/item/download/export-zip.cy.ts b/cypress/e2e/builder/item/download/export-zip.cy.ts index 22269ad71..a5a9b619e 100644 --- a/cypress/e2e/builder/item/download/export-zip.cy.ts +++ b/cypress/e2e/builder/item/download/export-zip.cy.ts @@ -9,6 +9,7 @@ import { import { buildDownloadButtonId, buildExportAsZipButtonId, + buildItemCard, buildItemsGridMoreButtonSelector, buildPublicExportZipButtonId, } from '../../../../../src/config/selectors'; @@ -113,6 +114,9 @@ describe('Download public folder as ZIP', () => { 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/src/modules/builder/components/common/PublicExportZipButton.tsx b/src/modules/builder/components/common/PublicExportZipButton.tsx index de6813fa7..abb3c44d9 100644 --- a/src/modules/builder/components/common/PublicExportZipButton.tsx +++ b/src/modules/builder/components/common/PublicExportZipButton.tsx @@ -16,6 +16,11 @@ type Props = { * ui context the button is located */ dataUmamiContext?: string; + + /** + * called when the download starts, e.g. to close the parent menu + */ + onClick?: () => void; }; /** @@ -26,6 +31,7 @@ type Props = { const PublicExportZipButton = ({ itemId, dataUmamiContext, + onClick, }: Props): JSX.Element => { const { t } = useTranslation(NS.Builder); @@ -34,6 +40,7 @@ const PublicExportZipButton = ({ id={buildPublicExportZipButtonId(itemId)} component="a" href={`/public/folders/${itemId}/export`} + onClick={onClick} data-umami-event="public-export-zip" data-umami-event-context={dataUmamiContext} > diff --git a/src/modules/builder/components/item/header/Actions.tsx b/src/modules/builder/components/item/header/Actions.tsx index 46c114172..6a544e1f0 100644 --- a/src/modules/builder/components/item/header/Actions.tsx +++ b/src/modules/builder/components/item/header/Actions.tsx @@ -44,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 => { @@ -69,22 +69,31 @@ const Actions = ({ item }: Props): JSX.Element[] | null => { const canAdmin = item.permission && PermissionLevelCompare.gte(item.permission, 'admin'); + 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, { open={open} onClose={closeMenu} > - + , ]; } @@ -124,16 +137,7 @@ const Actions = ({ item }: Props): JSX.Element[] | null => { onClose={closeCreateShortcutModal} open={isCreateShortcutOpen} />, - - - , + moreButton, )} {!account && isPublicFolder && ( - + )} {account?.id ? : false} From 96d282c7c7a4ea6ec88347c81b71a48753b95f93 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Fri, 9 Oct 2026 18:35:56 +0200 Subject: [PATCH 4/4] fix(test): update test based on the changes --- cypress/e2e/builder/item/pin/pinItem.cy.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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'); }); });