diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml deleted file mode 100644 index 9c8cb5cc3d1a4..0000000000000 --- a/.github/workflows/cypress.yml +++ /dev/null @@ -1,255 +0,0 @@ -# This workflow is provided via the organization template repository -# -# https://github.com/nextcloud/.github -# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization -# -# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors -# SPDX-License-Identifier: MIT - -name: Cypress - -on: pull_request - -concurrency: - group: cypress-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -env: - # Adjust APP_NAME if your repository name is different - APP_NAME: ${{ github.event.repository.name }} - - # This represents the server branch to checkout. - # Usually it's the base branch of the PR, but for pushes it's the branch itself. - # e.g. 'main', 'stable27' or 'feature/my-feature' - # n.b. server will use head_ref, as we want to test the PR branch. - BRANCH: ${{ github.base_ref || github.ref_name }} - - -permissions: - contents: read - -jobs: - init: - runs-on: ubuntu-latest - outputs: - nodeVersion: ${{ steps.versions.outputs.nodeVersion }} - npmVersion: ${{ steps.versions.outputs.npmVersion }} - - env: - # We'll install cypress in the cypress job - CYPRESS_INSTALL_BINARY: 0 - PUPPETEER_SKIP_DOWNLOAD: true - - steps: - - name: Disabled on forks - if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} - run: | - echo 'Can not run cypress on forks' - exit 1 - - - name: Checkout server - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - persist-credentials: false - # We need to checkout submodules for 3rdparty - submodules: true - - - name: Check composer.json - id: check_composer - uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0 - with: - files: 'composer.json' - - - name: Install composer dependencies - if: steps.check_composer.outputs.files_exists == 'true' - run: composer install --no-dev - - - name: Read package.json node and npm engines version - uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 - id: versions - with: - fallbackNode: '^24' - fallbackNpm: '^11.3' - - - name: Set up node ${{ steps.versions.outputs.nodeVersion }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: ${{ steps.versions.outputs.nodeVersion }} - - - name: Set up npm ${{ steps.versions.outputs.npmVersion }} - run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' - - - name: Install node dependencies & build app - run: | - npm ci - TESTING=true npm run build --if-present - - - name: Save context - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - key: cypress-context-${{ github.run_id }} - path: ./ - - cypress: - runs-on: ubuntu-latest - needs: init - - strategy: - fail-fast: false - matrix: - # Run multiple copies of the current job in parallel - # Please increase the number or runners as your tests suite grows (0 based index for e2e tests) - containers: ['setup', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] - # Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions - # Always align this number with the total of e2e runners (max. index + 1) - total-containers: [10] - - services: - mysql: - # Only start mysql if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'ghcr.io/nextcloud/continuous-integration-mysql-8.4:latest' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '3306/tcp' - env: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_USER: oc_autotest - MYSQL_PASSWORD: nextcloud - MYSQL_DATABASE: oc_autotest - options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10 - - mariadb: - # Only start mariadb if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'mariadb:11.4' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '3306/tcp' - env: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_USER: oc_autotest - MYSQL_PASSWORD: nextcloud - MYSQL_DATABASE: oc_autotest - options: --health-cmd="mariadb-admin ping" --health-interval 5s --health-timeout 2s --health-retries 5 - - postgres: - # Only start postgres if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'ghcr.io/nextcloud/continuous-integration-postgres-17:latest' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '5432/tcp' - env: - POSTGRES_USER: root - POSTGRES_PASSWORD: rootpassword - POSTGRES_DB: nextcloud - options: --mount type=tmpfs,destination=/var/lib/postgresql/data --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 - - oracle: - # Only start oracle if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'ghcr.io/gvenzl/oracle-free:23' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '1521' - env: - ORACLE_PASSWORD: oracle - options: --health-cmd healthcheck.sh --health-interval 20s --health-timeout 10s --health-retries 10 - - name: runner ${{ matrix.containers }} - - steps: - - name: Restore context - id: cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - fail-on-cache-miss: true - key: cypress-context-${{ github.run_id }} - path: ./ - - - name: Set up node ${{ needs.init.outputs.nodeVersion }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: ${{ needs.init.outputs.nodeVersion }} - - - name: Set up npm ${{ needs.init.outputs.npmVersion }} - run: npm i -g 'npm@${{ needs.init.outputs.npmVersion }}' - - - name: Install cypress - run: ./node_modules/cypress/bin/cypress install - - - name: Run ${{ matrix.containers == 'component' && 'component' || 'E2E' }} cypress tests - uses: cypress-io/github-action@bc22e01685c56e89e7813fd8e26f33dc47f87e15 # v7.1.5 - with: - # We already installed the dependencies in the init job - install: false - component: ${{ matrix.containers == 'component' }} - group: ${{ matrix.use-cypress-cloud && matrix.containers == 'component' && 'Run component' || matrix.use-cypress-cloud && 'Run E2E' || '' }} - # cypress env - ci-build-id: ${{ matrix.use-cypress-cloud && format('{0}-{1}', github.sha, github.run_number) || '' }} - tag: ${{ matrix.use-cypress-cloud && github.event_name || '' }} - env: - # Needs to be prefixed with CYPRESS_ - CYPRESS_BRANCH: ${{ env.BRANCH }} - # https://github.com/cypress-io/github-action/issues/124 - COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }} - # Needed for some specific code workarounds - TESTING: true - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - SPLIT: ${{ matrix.total-containers }} - SPLIT_INDEX: ${{ matrix.containers == 'component' && 0 || matrix.containers }} - SPLIT_RANDOM_SEED: ${{ github.run_id }} - SETUP_TESTING: ${{ matrix.containers == 'setup' && 'true' || '' }} - - - name: Upload snapshots and videos - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: always() - with: - name: snapshots_${{ matrix.containers }} - path: | - cypress/snapshots - cypress/videos - - - name: Show logs - if: failure() && matrix.containers != 'component' - run: | - for id in $(docker ps -aq); do - docker container inspect "$id" --format '=== Logs for container {{.Name}} ===' - docker logs "$id" >> nextcloud.log - done - echo '=== Nextcloud server logs ===' - docker exec nextcloud-e2e-test-server_${{ env.APP_NAME }} cat data/nextcloud.log - - - name: Create data dir archive - if: failure() && matrix.containers != 'component' - run: docker exec nextcloud-e2e-test-server_${{ env.APP_NAME }} tar -cvjf - data > data.tar - - - name: Upload data archive - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: failure() && matrix.containers != 'component' - with: - name: nc_data_${{ matrix.containers }} - path: data.tar - - summary: - runs-on: ubuntu-latest-low - needs: [init, cypress] - - if: always() - - name: cypress-summary - - permissions: - # `actions:write` permission is required to delete caches - # See also: https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-a-github-actions-cache-for-a-repository-using-a-cache-id - actions: write - contents: read - - steps: - - name: Summary status - run: if ${{ needs.init.result != 'success' || ( needs.cypress.result != 'success' && needs.cypress.result != 'skipped' ) }}; then exit 1; fi - - - name: Delete cache on success - run: | - ## Setting this to not fail the workflow while deleting cache keys. - set +e - echo "Deleting cache..." - gh cache delete 'cypress-context-${{ github.run_id }}' - echo "Done" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000000..9c5772ad910fa --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,325 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Playwright Tests + +on: + pull_request: + branches: + - master + - stable33 + types: + - opened + - synchronize + - reopened + - ready_for_review + - labeled + +permissions: + contents: read + +jobs: + gate: + runs-on: ubuntu-latest-low + steps: + - name: Evaluate e2e tests execution conditions + id: gate-e2e + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v8.0.0 + with: + script: | + const pr = context.payload.pull_request + + const hasForceLabel = pr.labels.some((label) => label.name === 'force-e2e-tests') + const hasToReviewLabel = pr.labels.some((label) => label.name === '3. to review') + const hasToReleaseLabel = pr.labels.some((label) => label.name === '4. to release') + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }) + const playwrightTouched = files.some((file) => file.filename.startsWith('tests/playwright')) + + if (hasForceLabel || hasToReviewLabel || hasToReleaseLabel || playwrightTouched) { + return + } else { + core.setFailed('Skipping Playwright: draft state, missing labels or no playwright path changes.') + } + + playwright-setup: + timeout-minutes: 15 + name: Playwright setup + runs-on: ubuntu-latest + needs: gate + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true # for 3rdparty + - name: Read package.json + uses: nextcloud-libraries/parse-package-engines-action@122ae05d4257008180a514e1ddeb0c1b9d094bdd # v0.1.0 + id: versions + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.node-version }} + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.package-manager-version }}' + - name: Install dependencies and build + run: | + npm ci + npm run build --if-present + - name: Save context + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: playwright-context-${{ github.run_id }} + path: ./ + + playwright-tests: + needs: [gate, playwright-setup] + timeout-minutes: 60 + name: Playwright tests ${{ matrix.shardIndex }} / ${{ matrix.shardTotal }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] + shardTotal: [8] + outputs: + node-version: ${{ steps.versions.outputs.node-version }} + package-manager-version: ${{ steps.versions.outputs.package-manager-version }} + + steps: + - name: Restore context + id: cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: playwright-context-${{ github.run_id }} + path: ./ + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.cache.outputs.cache-hit != 'true' + with: + persist-credentials: false + submodules: true # for 3rdparty + + - name: Read package.json + uses: nextcloud-libraries/parse-package-engines-action@122ae05d4257008180a514e1ddeb0c1b9d094bdd # v0.1.0 + id: versions + + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.node-version }} + + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.package-manager-version }}' + + - name: Install dependencies and build + if: steps.cache.outputs.cache-hit != 'true' + run: | + npm ci + npm run build --if-present + + - name: Install Playwright browsers + run: npm run playwright:install-ci --if-present + + - name: Run Playwright tests + run: npm run playwright -- --shard='${{ matrix.shardIndex }}/${{ matrix.shardTotal }}' + + - name: Show logs + if: failure() + run: | + for id in $(docker ps -aq); do + docker container inspect "$id" --format '=== Logs for container {{.Name}} ===' + docker logs "$id" >> nextcloud.log + done + echo '=== Nextcloud server logs ===' + docker exec nextcloud-e2e-test-server_server cat data/nextcloud.log + + - name: Upload blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: blob-report-${{ matrix.shardIndex }} + path: blob-report + retention-days: 1 + + playwright-installer-tests: + needs: [gate, playwright-setup] + timeout-minutes: 30 + name: Playwright tests for installer + runs-on: ubuntu-latest + + # The installation-wizard tests exercise every supported database backend, so + # they need reachable database service containers. The Nextcloud container is + # joined to the GitHub Actions network at startup so it can resolve these by + # hostname (see tests/playwright/start-nextcloud-server.js). + services: + mysql: + image: mysql:9.7 # zizmor: ignore[unpinned-images] + ports: + - '3306/tcp' + env: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_USER: nextcloud + MYSQL_PASSWORD: nextcloud + MYSQL_DATABASE: nextcloud + options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10 + + mariadb: + image: mariadb:12.3 # zizmor: ignore[unpinned-images] + ports: + - '3306/tcp' + env: + MARIADB_ROOT_PASSWORD: rootpassword + MARIADB_USER: nextcloud + MARIADB_PASSWORD: nextcloud + MARIADB_DATABASE: nextcloud + options: --health-cmd="mariadb-admin ping" --health-interval 5s --health-timeout 2s --health-retries 5 + + postgres: + image: postgres:18 # zizmor: ignore[unpinned-images] + ports: + - '5432/tcp' + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: rootpassword + POSTGRES_DB: nextcloud + options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 + + oracle: + image: ghcr.io/gvenzl/oracle-free:23 # zizmor: ignore[unpinned-images] + ports: + - '1521' + env: + ORACLE_PASSWORD: oracle + options: --health-cmd healthcheck.sh --health-interval 20s --health-timeout 10s --health-retries 10 + + steps: + - name: Restore context + id: cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: playwright-context-${{ github.run_id }} + path: ./ + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.cache.outputs.cache-hit != 'true' + with: + persist-credentials: false + submodules: true # for 3rdparty + + - name: Read package.json + uses: nextcloud-libraries/parse-package-engines-action@122ae05d4257008180a514e1ddeb0c1b9d094bdd # v0.1.0 + id: versions + + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.node-version }} + + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.package-manager-version }}' + + - name: Install dependencies and build + if: steps.cache.outputs.cache-hit != 'true' + run: | + npm ci + npm run build --if-present + + - name: Install Playwright browsers + run: npm run playwright:install-ci --if-present + + - name: Run Playwright setup tests + run: npm run playwright:setup + env: + PLAYWRIGHT_SETUP: 'true' + + - name: Show logs + if: failure() + run: | + for id in $(docker ps -aq); do + docker container inspect "$id" --format '=== Logs for container {{.Name}} ===' + docker logs "$id" >> nextcloud.log + done + echo '=== Nextcloud server logs ===' + docker exec nextcloud-e2e-test-server_server cat data/nextcloud.log || true + + - name: Upload blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: blob-report-setup + path: blob-report + retention-days: 1 + + merge-reports: + # Merge reports after playwright-tests, even if some shards have failed + if: ${{ !cancelled() }} + needs: [gate, playwright-tests, playwright-installer-tests] + + runs-on: ubuntu-latest-low + steps: + - name: Restore context + id: cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: playwright-context-${{ github.run_id }} + path: ./ + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.cache.outputs.cache-hit != 'true' + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: steps.cache.outputs.cache-hit != 'true' + with: + node-version: ${{ needs.playwright-tests.outputs.node-version }} + + - name: Set up npm + if: steps.cache.outputs.cache-hit != 'true' + run: npm i -g 'npm@${{ needs.playwright-tests.outputs.package-manager-version }}' + + - name: Install dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: npm ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge into HTML Report + run: npx playwright merge-reports --config tests/playwright/merge.config.ts --reporter html,github ./all-blob-reports + + - name: Upload HTML report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: html-report--attempt-${{ github.run_attempt }} + path: playwright-report + retention-days: 7 + + - name: Show the logs + run: | + echo 'To view the report:' + echo ' 1. Extract the folder from the zip file' + echo ' 2. run "npx playwright show-report name-of-my-extracted-playwright-report"' + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: [gate, playwright-tests, playwright-installer-tests] + + if: always() + + name: playwright-test-summary + + steps: + - name: Summary status + run: if ${{ needs.playwright-tests.result != 'success' || needs.playwright-installer-tests.result != 'success' }}; then exit 1; fi diff --git a/.nextcloudignore b/.nextcloudignore index b49dc8e0bd1f8..4641d10afdf34 100644 --- a/.nextcloudignore +++ b/.nextcloudignore @@ -34,13 +34,12 @@ codecov.yml cs-fixer csfixer custom.d.ts -cypress -cypress.config.ts eslint.config.js flake.lock flake.nix openapi-extractor phpunit +playwright.config.ts psalm psalm*.xml rector diff --git a/REUSE.toml b/REUSE.toml index e1e801fbeba38..1f68db1051905 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -178,7 +178,7 @@ SPDX-FileCopyrightText = "2020 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] -path = ["cypress/tsconfig.json", "cypress/fixtures/appstore/apps.json", "dist/*.css"] +path = ["dist/*.css"] precedence = "aggregate" SPDX-FileCopyrightText = "2022 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" @@ -382,7 +382,13 @@ SPDX-FileCopyrightText = "2016 Andrew Nayenko " SPDX-License-Identifier = "CC-BY-SA-3.0 OR GPL-3.0-or-later" [[annotations]] -path = "cypress/fixtures/image.jpg" +path = "tests/playwright/tsconfig.json" +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + +[[annotations]] +path = "tests/data/images/image.jpg" precedence = "aggregate" SPDX-FileCopyrightText = "2019 Tom Gainor " SPDX-License-Identifier = "LicenseRef-Unsplash" diff --git a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue index f9173d84c1850..07a422d47dc6e 100644 --- a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue +++ b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue @@ -95,17 +95,17 @@ export default { }, preSelectedOption() { - const permissions = this.share.permissions - const basePermissions = this.bundledPermissions - if (permissions === basePermissions.READ_ONLY) { - return this.canViewText - } else if (permissions === basePermissions.ALL || permissions === basePermissions.ALL_FILE) { - return this.canEditText - } else if (permissions === basePermissions.FILE_DROP) { - return this.fileDropText + switch (this.permissionsBundle) { + case 'READ_ONLY': + return this.canViewText + case 'ALL': + case 'ALL_FILE': + return this.canEditText + case 'FILE_DROP': + return this.fileDropText + default: + return this.customPermissionsText } - - return this.customPermissionsText }, options() { diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.js index 3638d94f5f607..6462874637fd7 100644 --- a/apps/files_sharing/src/lib/SharePermissionsToolBox.js +++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.js @@ -122,3 +122,33 @@ export function togglePermissions(initialPermissionSet, permissionsToToggle) { export function canTogglePermissions(permissionSet, permissionsToToggle) { return permissionsSetIsValid(togglePermissions(permissionSet, permissionsToToggle)) } + +/** + * The permission bundles the share editor offers, in the order they are matched. + * + * @type {string[]} + */ +const EDITOR_BUNDLES = ['READ_ONLY', 'ALL', 'ALL_FILE', 'FILE_DROP'] + +/** + * Find the permission bundle a share's permissions correspond to. + * + * Link and email shares carry the SHARE permission whenever federation on + * public shares is enabled: the server adds it on top of whatever bundle was + * picked, so it must be ignored when matching those shares against a bundle. + * + * @param {number} permissions - the share permissions. + * @param {object} [options] - matching options. + * @param {boolean} [options.isPublicShare] - whether the share is a link or email share. + * @param {boolean} [options.excludeReshareFromEdit] - whether SHARE is excluded from the editing bundles. + * + * @return {string|null} the name of the matching bundle, or `null` for custom permissions. + */ +export function matchBundledPermissions(permissions, { isPublicShare = false, excludeReshareFromEdit = false } = {}) { + const bundles = getBundledPermissions(isPublicShare || excludeReshareFromEdit) + const comparablePermissions = isPublicShare + ? subtractPermissions(permissions, ATOMIC_PERMISSIONS.SHARE) + : permissions + + return EDITOR_BUNDLES.find((bundle) => bundles[bundle] === comparablePermissions) ?? null +} diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js index 14ac7bfbbbb74..f8f04d29edd41 100644 --- a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js +++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js @@ -9,6 +9,7 @@ import { canTogglePermissions, getBundledPermissions, hasPermissions, + matchBundledPermissions, permissionsSetIsValid, subtractPermissions, togglePermissions, @@ -144,4 +145,51 @@ describe('SharePermissionsToolBox', () => { // BUNDLED_PERMISSIONS.ALL_FILE already includes SHARE expect(BUNDLED_PERMISSIONS.ALL_FILE).toBe(permissionsWithShare.ALL_FILE) }) + + describe('Matching bundled permissions', () => { + const { READ, UPDATE, CREATE, DELETE, SHARE } = ATOMIC_PERMISSIONS + + test('matches the bundles of an internal share', () => { + expect(matchBundledPermissions(READ)).toBe('READ_ONLY') + expect(matchBundledPermissions(CREATE)).toBe('FILE_DROP') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE | SHARE)).toBe('ALL_FILE') + }) + + test('reports permissions outside of a bundle as custom', () => { + expect(matchBundledPermissions(READ | UPDATE)).toBe(null) + expect(matchBundledPermissions(READ | CREATE)).toBe(null) + expect(matchBundledPermissions(ATOMIC_PERMISSIONS.NONE)).toBe(null) + }) + + test('matches the editing bundle without SHARE when resharing is excluded from editing', () => { + const options = { excludeReshareFromEdit: true } + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE, options)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE, options)).toBe('ALL_FILE') + // With resharing excluded, a share that grants it is no longer the editing bundle + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE, options)).toBe(null) + }) + + test('ignores the SHARE permission the server adds to public shares', () => { + const options = { isPublicShare: true } + // Link and email shares carry SHARE for federation, whatever bundle was picked + expect(matchBundledPermissions(READ | SHARE, options)).toBe('READ_ONLY') + expect(matchBundledPermissions(CREATE | SHARE, options)).toBe('FILE_DROP') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE, options)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE | SHARE, options)).toBe('ALL_FILE') + }) + + test('matches public shares the same way with resharing excluded from editing', () => { + const options = { isPublicShare: true, excludeReshareFromEdit: true } + expect(matchBundledPermissions(READ | SHARE, options)).toBe('READ_ONLY') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE | SHARE, options)).toBe('ALL') + expect(matchBundledPermissions(READ | UPDATE | CREATE | DELETE, options)).toBe('ALL') + }) + + test('still reports custom permissions on a public share', () => { + const options = { isPublicShare: true } + expect(matchBundledPermissions(READ | CREATE | SHARE, options)).toBe(null) + expect(matchBundledPermissions(READ | UPDATE | DELETE | SHARE, options)).toBe(null) + }) + }) }) diff --git a/apps/files_sharing/src/mixins/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js index ad5651ef9b76b..aedd5c5d2f85b 100644 --- a/apps/files_sharing/src/mixins/SharesMixin.js +++ b/apps/files_sharing/src/mixins/SharesMixin.js @@ -10,7 +10,7 @@ import { ShareType } from '@nextcloud/sharing' import debounce from 'debounce' import PQueue from 'p-queue' import { fetchNode } from '../../../files/src/services/WebdavClient.ts' -import { getBundledPermissions } from '../lib/SharePermissionsToolBox.js' +import { matchBundledPermissions } from '../lib/SharePermissionsToolBox.js' import Share from '../models/Share.ts' import Config from '../services/ConfigService.ts' import logger from '../services/logger.ts' @@ -134,15 +134,14 @@ export default { } return this.config.isDefaultInternalExpireDateEnforced }, + permissionsBundle() { + return matchBundledPermissions(this.share.permissions, { + isPublicShare: this.isPublicShare, + excludeReshareFromEdit: this.config.excludeReshareFromEdit, + }) + }, hasCustomPermissions() { - const basePermissions = getBundledPermissions(this.config.excludeReshareFromEdit) - const bundledPermissions = [ - basePermissions.ALL, - basePermissions.ALL_FILE, - basePermissions.READ_ONLY, - basePermissions.FILE_DROP, - ] - return !bundledPermissions.includes(this.share.permissions) + return this.permissionsBundle === null }, maxExpirationDateEnforced() { if (this.isExpiryDateEnforced) { diff --git a/build/files-checker.php b/build/files-checker.php index 178083fe6baa9..22aa63f2d98af 100644 --- a/build/files-checker.php +++ b/build/files-checker.php @@ -56,8 +56,6 @@ 'core', 'cron.php', 'custom.d.ts', - 'cypress.config.ts', - 'cypress', 'dist', 'eslint.config.js', 'flake.lock', @@ -72,6 +70,7 @@ 'openapi.json', 'package-lock.json', 'package.json', + 'playwright.config.ts', 'psalm-ncu.xml', 'psalm-ocp.xml', 'psalm.xml', diff --git a/build/frontend-legacy/eslint.config.mjs b/build/frontend-legacy/eslint.config.mjs index 18a95d62380b6..dfd77d270cc9a 100644 --- a/build/frontend-legacy/eslint.config.mjs +++ b/build/frontend-legacy/eslint.config.mjs @@ -4,7 +4,6 @@ */ import { recommendedVue2 } from '@nextcloud/eslint-config' -import CypressEslint from 'eslint-plugin-cypress' import { defineConfig } from 'eslint/config' import * as globals from 'globals' @@ -48,18 +47,7 @@ export default defineConfig([ 'jsdoc/require-jsdoc': 'off', }, }, - // Cypress setup - CypressEslint.configs.recommended, - { - name: 'server/cypress', - files: ['cypress/**', '**/*.cy.*'], - rules: { - 'no-console': 'off', - 'jsdoc/require-jsdoc': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-expressions': 'off', - }, - }, + // customer server ignore files { name: 'server/ignored-files', diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 197f76ee09099..0000000000000 --- a/cypress.config.ts +++ /dev/null @@ -1,213 +0,0 @@ -/* eslint-disable no-console */ -/*! - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { configureNextcloud, docker, getContainer, getContainerName, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server' -import { defineConfig } from 'cypress' -import cypressSplit from 'cypress-split' -import vitePreprocessor from 'cypress-vite' -import { existsSync, rmdirSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { nodePolyfills } from 'vite-plugin-node-polyfills' - -if (!globalThis.__dirname) { - // Cypress has their own weird parser - globalThis.__dirname = dirname(fileURLToPath(new URL(import.meta.url))) -} - -export default defineConfig({ - projectId: '37xpdh', - - // 16/9 screen ratio - viewportWidth: 1280, - viewportHeight: 720, - - // Tries again when in run mode (cypress run) e.g. on CI - retries: { - runMode: 3, - // do not retry in `cypress open` - openMode: 0, - }, - - // Needed to trigger `after:run` events with cypress open - experimentalInteractiveRunEvents: true, - - // disabled if running in CI but enabled in debug mode - video: !process.env.CI || !!process.env.RUNNER_DEBUG, - - // faster video processing - videoCompression: false, - - // Prevent elements to be scrolled under a top bar during actions (click, clear, type, etc). Default is 'top'. - // https://github.com/cypress-io/cypress/issues/871 - scrollBehavior: 'center', - - // Visual regression testing - env: { - failSilently: false, - type: 'actual', - }, - - screenshotsFolder: 'cypress/snapshots/actual', - trashAssetsBeforeRuns: true, - - e2e: { - // Disable session isolation - testIsolation: false, - - // The default 4s regularly expires on plain rendering latency on slow - // CI runners. Prefer explicit waits where a request or state exists to - // wait on; this only buys headroom for rendering, which has neither. - defaultCommandTimeout: 10000, - - requestTimeout: 30000, - - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - async setupNodeEvents(on, config) { - on('file:preprocessor', vitePreprocessor({ - plugins: [nodePolyfills()], - })) - - // This allows to store global data (e.g. the name of a snapshot) - // because Cypress.env() and other options are local to the current spec file. - const data: Record = {} - on('task', { - setVariable({ key, value }) { - data[key] = value - return null - }, - getVariable({ key }) { - return data[key] ?? null - }, - // allow to clear the downloads folder - deleteFolder(path: string) { - try { - if (existsSync(path)) { - rmdirSync(path, { maxRetries: 10, recursive: true }) - } - return null - } catch (error) { - throw Error(`Error while deleting ${path}. Original error: ${error}`) - } - }, - }) - - // Disable spell checking to prevent rendering differences - on('before:browser:launch', (browser, launchOptions) => { - if (browser.family === 'chromium' && browser.name !== 'electron') { - launchOptions.preferences.default['browser.enable_spellchecking'] = false - return launchOptions - } - - if (browser.family === 'firefox') { - launchOptions.preferences['layout.spellcheckDefault'] = 0 - return launchOptions - } - - if (browser.name === 'electron') { - launchOptions.preferences.spellcheck = false - return launchOptions - } - }) - - // Remove container after run - on('after:run', () => { - if (!process.env.CI) { - stopNextcloud() - } - }) - - // Check if we are running the setup checks - if (process.env.SETUP_TESTING === 'true') { - console.log('Adding setup tests to specPattern 🧮') - config.specPattern = [join(__dirname, 'cypress/e2e/core/setup.ts')] - console.log('└─ Done') - } else { - // If we are not running the setup tests, we need to remove the setup tests from the specPattern - cypressSplit(on, config) - } - - const mounts = { - '3rdparty': resolve(__dirname, './3rdparty'), - apps: resolve(__dirname, './apps'), - core: resolve(__dirname, './core'), - cypress: resolve(__dirname, './cypress'), - dist: resolve(__dirname, './dist'), - lib: resolve(__dirname, './lib'), - ocs: resolve(__dirname, './ocs'), - 'ocs-provider': resolve(__dirname, './ocs-provider'), - resources: resolve(__dirname, './resources'), - tests: resolve(__dirname, './tests'), - 'console.php': resolve(__dirname, './console.php'), - 'cron.php': resolve(__dirname, './cron.php'), - 'index.php': resolve(__dirname, './index.php'), - occ: resolve(__dirname, './occ'), - 'public.php': resolve(__dirname, './public.php'), - 'remote.php': resolve(__dirname, './remote.php'), - 'status.php': resolve(__dirname, './status.php'), - 'version.php': resolve(__dirname, './version.php'), - } as Record - - for (const [key, path] of Object.entries(mounts)) { - if (!existsSync(path)) { - delete mounts[key] - } - } - - // Before the browser launches - // starting Nextcloud testing container - const port = 8042 - const ip = await startNextcloud(process.env.BRANCH, false, { - mounts, - exposePort: port, - forceRecreate: true, - }) - // Setting container's IP as base Url - config.baseUrl = `http://localhost:${port}/index.php` - // if needed for the setup tests, connect to the actions network - await connectToActionsNetwork() - // now wait until Nextcloud is ready and configure it - await waitOnNextcloud(ip) - await configureNextcloud() - // additionally we do not want to DoS the app store - runOcc(['config:system:set', 'appstoreenabled', '--value', 'false', '--type', 'boolean']) - // Disable the unsupported-browser redirect so Cypress (Chrome 118 / Electron 27) - // does not hit the "Your browser is not supported" page on every visit. - runOcc(['config:system:set', 'no_unsupported_browser_warning', '--value', 'true', '--type', 'boolean']) - - // for later use in tests save the container name - // @ts-expect-error we are adding a custom property - config.dockerContainerName = getContainerName() - - // IMPORTANT: return the config otherwise cypress-split will not work - return config - }, - }, -}) - -/** - * Connect the running test container to the GitHub Actions network - */ -async function connectToActionsNetwork() { - if (process.env.SETUP_TESTING !== 'true') { - console.log('├─ Not running setup tests, skipping actions network connection 🌐') - return - } - - console.log('├─ Looking for github actions network... 🔍') - const networks = await docker.listNetworks() - const network = networks.find((network) => network.Name.startsWith('github_network')) - if (!network) { - console.log('│ └─ No actions network found ⚠️') - return - } - - console.log('│ |─ Found actions network: ' + network.Name) - await docker.getNetwork(network.Id) - .connect({ Container: getContainer().id }) - console.log('│ └─ Connected to actions network 🌐') -} diff --git a/cypress/e2e/core-utils.ts b/cypress/e2e/core-utils.ts deleted file mode 100644 index c01ee05a61eaf..0000000000000 --- a/cypress/e2e/core-utils.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Get the unified search modal (if open) - */ -export function getUnifiedSearchModal() { - return cy.get('#unified-search') -} - -/** - * Handle the confirm password dialog (if needed) - * - * @param adminPassword The admin password for the dialog - */ -export function handlePasswordConfirmation(adminPassword = 'admin') { - const handleModal = (context: Cypress.Chainable) => { - return context.contains('.modal-container', 'Authentication required') - .if() - .within(() => { - cy.get('input[type="password"]') - .type(adminPassword) - cy.findByRole('button', { name: 'Confirm' }) - .click() - }) - } - - return cy.get('body') - .if() - .then(() => handleModal(cy.get('body'))) - .else() - // Handle if inside a cy.within - .root().closest('body') - .then(($body) => handleModal(cy.wrap($body))) -} - -/** - * Open the unified search modal - */ -export function openUnifiedSearch() { - cy.get('button[aria-label="Unified search"]').click({ force: true }) - // wait for it to be open - getUnifiedSearchModal().should('be.visible') -} - -/** - * Close the unified search modal - */ -export function closeUnifiedSearch() { - getUnifiedSearchModal().find('button[aria-label="Close"]').click({ force: true }) - getUnifiedSearchModal().should('not.be.visible') -} - -/** - * Get the input field of the unified search - */ -export function getUnifiedSearchInput() { - return getUnifiedSearchModal().find('[data-cy-unified-search-input]') -} - -export enum UnifiedSearchFilter { - FilterCurrentView = 'current-view', - Places = 'places', - People = 'people', - Date = 'date', -} - -/** - * Get a filter action from the unified search - * - * @param filter The filter to get - */ -export function getUnifiedSearchFilter(filter: UnifiedSearchFilter) { - return getUnifiedSearchModal().find(`[data-cy-unified-search-filters] [data-cy-unified-search-filter="${CSS.escape(filter)}"]`) -} - -/** - * Assertion that an element is fully within the current viewport. - * - * @param $el The element - * @param expected If the element is expected to be fully in viewport or not fully - * @example - * ```js - * cy.get('#my-element') - * .should(beFullyInViewport) - * ``` - */ -export function beFullyInViewport($el: JQuery, expected = true) { - const { top, left, bottom, right } = $el.get(0)!.getBoundingClientRect() - const innerHeight = Cypress.$('body').innerHeight()! - const innerWidth = Cypress.$('body').innerWidth()! - const fullyVisible = top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth - - console.debug(`fullyVisible: ${fullyVisible}, top: ${top >= 0}, left: ${left >= 0}, bottom: ${bottom <= innerHeight}, right: ${right <= innerWidth}`) - - if (expected) { - expect(fullyVisible, 'Fully within viewport').to.be.true - } else { - expect(fullyVisible, 'Not fully within viewport').to.be.false - } -} - -/** - * Opposite of `beFullyInViewport` - resolves when element is not or only partially in viewport. - * - * @param $el The element - * @example - * ```js - * cy.get('#my-element') - * .should(notBeFullyInViewport) - * ``` - */ -export function notBeFullyInViewport($el: JQuery) { - return beFullyInViewport($el, false) -} diff --git a/cypress/e2e/core/404-error.cy.ts b/cypress/e2e/core/404-error.cy.ts deleted file mode 100644 index b24562933e8bb..0000000000000 --- a/cypress/e2e/core/404-error.cy.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -describe('404 error page', { testIsolation: true }, () => { - it('renders 404 page', () => { - cy.visit('/doesnotexist', { failOnStatusCode: false }) - - cy.findByRole('heading', { name: /Page not found/ }) - .should('be.visible') - cy.findByRole('link', { name: /Back to Nextcloud/ }) - .should('be.visible') - .click() - - cy.url() - .should('match', /(\/index.php)\/login$/) - }) -}) diff --git a/cypress/e2e/core/header_access-levels.cy.ts b/cypress/e2e/core/header_access-levels.cy.ts deleted file mode 100644 index e04109bfb3b26..0000000000000 --- a/cypress/e2e/core/header_access-levels.cy.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Header: Ensure regular users do not have admin settings in the Settings menu', { testIsolation: true }, () => { - beforeEach(() => { - clearState() - }) - - it('Regular users can see basic items in the Settings menu', () => { - // Given I am logged in - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/') - }) - // I open the settings menu - getNextcloudUserMenuToggle().click() - - getNextcloudUserMenu().find('ul').within(($el) => { - // I see the settings menu is open - cy.wrap($el).should('be.visible') - - // I see that the Settings menu has only 6 items - cy.get('li').should('have.length', 6) - // I see that the "View profile" item in the Settings menu is shown - cy.contains('li', 'View profile').should('be.visible') - // I see that the "Set status" item in the Settings menu is shown - cy.contains('li', 'Set status').should('be.visible') - // I see that the "Appearance and accessibility" item in the Settings menu is shown - cy.contains('li', 'Appearance and accessibility').should('be.visible') - // I see that the "Settings" item in the Settings menu is shown - cy.contains('li', 'Settings').should('be.visible') - // I see that the "Help" item in the Settings menu is shown - cy.contains('li', 'Help').should('be.visible') - // I see that the "Log out" item in the Settings menu is shown - cy.contains('li', 'Log out').should('be.visible') - }) - }) - - it('Regular users cannot see admin-level items in the Settings menu', () => { - // Given I am logged in - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/') - }) - // I open the settings menu - getNextcloudUserMenuToggle().click() - - getNextcloudUserMenu().find('ul').within(($el) => { - // I see the settings menu is open - cy.wrap($el).should('be.visible') - - // I see that the "Users" item in the Settings menu is NOT shown - cy.contains('li', 'Users').should('not.exist') - // I see that the "Administration settings" item in the Settings menu is NOT shown - cy.contains('li', 'Administration settings').should('not.exist') - cy.get('#admin_settings').should('not.exist') - }) - }) - - it('Admin users can see admin-level items in the Settings menu', () => { - // Given I am logged in - cy.login(admin) - cy.visit('/') - - // I open the settings menu - getNextcloudUserMenuToggle().click() - - getNextcloudUserMenu().find('ul').within(($el) => { - // I see the settings menu is open - cy.wrap($el).should('be.visible') - - // I see that the Settings menu has only 9 items - cy.get('li').should('have.length', 9) - // I see that the "Set status" item in the Settings menu is shown - cy.contains('li', 'View profile').should('be.visible') - // I see that the "Set status" item in the Settings menu is shown - cy.contains('li', 'Set status').should('be.visible') - // I see that the "Appearance and accessibility" item in the Settings menu is shown - cy.contains('li', 'Appearance and accessibility').should('be.visible') - // I see that the "Personal Settings" item in the Settings menu is shown - cy.contains('li', 'Personal settings').should('be.visible') - // I see that the "Administration settings" item in the Settings menu is shown - cy.contains('li', 'Administration settings').should('be.visible') - // I see that the "Apps" item in the Settings menu is shown - cy.contains('li', 'Apps').should('be.visible') - // I see that the "Users" item in the Settings menu is shown - cy.contains('li', 'Accounts').should('be.visible') - // I see that the "Help" item in the Settings menu is shown - cy.contains('li', 'Help').should('be.visible') - // I see that the "Log out" item in the Settings menu is shown - cy.contains('li', 'Log out').should('be.visible') - }) - }) -}) diff --git a/cypress/e2e/core/header_contacts-menu.cy.ts b/cypress/e2e/core/header_contacts-menu.cy.ts deleted file mode 100644 index 657a50bc85c5d..0000000000000 --- a/cypress/e2e/core/header_contacts-menu.cy.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState, getNextcloudHeader } from '../../support/commonUtils.ts' -import { randomString } from '../../support/utils/randomString.ts' - -const admin = new User('admin', 'admin') - -const getContactsMenu = () => getNextcloudHeader().find('#header-menu-contactsmenu') -const getContactsMenuToggle = () => getNextcloudHeader().find('#contactsmenu .header-menu__trigger') -const getContactsSearch = () => getContactsMenu().find('#contactsmenu__menu__search') - -describe('Header: Contacts menu', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - // clear user and group state - clearState() - // ensure the contacts menu is not restricted - cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group') - // create a new user for testing the contacts - cy.createRandomUser().then(($user) => { - user = $user - }) - - // Given I am logged in as the admin - cy.login(admin) - cy.visit('/') - }) - - it('Other users are seen in the contacts menu', () => { - // When I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - - it('Just added users are seen in the contacts menu', () => { - // I create a new user - const newUserName = randomString(7) - // we can not use createRandomUser as it will invalidate the session - cy.runOccCommand(`user:add --password-from-env '${newUserName}'`, { env: { OC_PASS: '1234567' } }) - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact of the new user in the Contacts menu is shown - getContactsMenu().contains('li.contact', newUserName).should('be.visible') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - - it('Search for other users in the contacts menu', () => { - cy.createRandomUser().then((otherUser) => { - // Given I am logged in as the admin - cy.login(admin) - cy.visit('/') - - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact of the new user in the Contacts menu is shown - getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible') - - // I see that the Contacts menu search input is shown - getContactsSearch().should('exist') - // I search for the otherUser - getContactsSearch().type(otherUser.userId) - // I see that the contact otherUser in the Contacts menu is shown - getContactsMenu().contains('li.contact', otherUser.userId).should('be.visible') - // I see that the contact user in the Contacts menu is not shown - getContactsMenu().contains('li.contact', user.userId).should('not.exist') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - }) - - it('Search for unknown users in the contacts menu', () => { - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - - // I see that the Contacts menu search input is shown - getContactsSearch().should('exist') - // I search for an unknown user - getContactsSearch().type('surely-unknown-user') - // I see that the no results message in the Contacts menu is shown - getContactsMenu().find('ul li').should('have.length', 0) - // I see that the contact user in the Contacts menu is not shown - getContactsMenu().contains('li.contact', user.userId).should('not.exist') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) - - it('Users from other groups are not seen in the contacts menu when autocompletion is restricted within the same group', () => { - // I enable restricting username autocompletion to groups - cy.runOccCommand('config:app:set --value yes core shareapi_restrict_user_enumeration_to_group') - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is not shown - getContactsMenu().contains('li.contact', user.userId).should('not.exist') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - - // I close the Contacts menu - getContactsMenuToggle().click() - // I disable restricting username autocompletion to groups - cy.runOccCommand('config:app:set --value no core shareapi_restrict_user_enumeration_to_group') - // I open the Contacts menu - getContactsMenuToggle().click() - // I see that the Contacts menu is shown - getContactsMenu().should('exist') - // I see that the contact user in the Contacts menu is shown - getContactsMenu().contains('li.contact', user.userId).should('be.visible') - // I see that the contact "admin" in the Contacts menu is not shown - getContactsMenu().contains('li.contact', admin.userId).should('not.exist') - }) -}) diff --git a/cypress/e2e/core/setup.ts b/cypress/e2e/core/setup.ts deleted file mode 100644 index 126c8058b83bc..0000000000000 --- a/cypress/e2e/core/setup.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randomString } from '../../support/utils/randomString.ts' -import { handlePasswordConfirmation } from '../core-utils.ts' - -type RecommendedAppsMode = 'skip' | 'install-success' | 'install-failure' - -/** - * DO NOT RENAME THIS FILE to .cy.ts ⚠️ - * This is not following the pattern of the other files in this folder - * because it is manually added to the tests by the cypress config. - */ -describe('Can install Nextcloud', { testIsolation: true, retries: 0 }, () => { - beforeEach(() => { - // Move the config file and data folder - cy.runCommand('rm /var/www/html/config/config.php', { failOnNonZeroExit: false }) - cy.runCommand('rm /var/www/html/data/owncloud.db', { failOnNonZeroExit: false }) - }) - - it('Sqlite', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true }) - - sharedSetup() - }) - - it('Sqlite - Install recommended apps (success)', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true }) - - sharedSetup('install-success') - }) - - it('Sqlite - Install recommended apps (failure)', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true }) - - sharedSetup('install-failure') - }) - - it('MySQL', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mysql:3306') - - sharedSetup() - }) - - it('MariaDB', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mariadb:3306') - - sharedSetup() - }) - - it('PostgreSQL', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-pgsql"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}root') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}rootpassword') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}nextcloud') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}postgres:5432') - - sharedSetup() - }) - - it('Oracle', () => { - Cypress.config('pageLoadTimeout', 200000) - cy.runCommand('cp /var/www/html/tests/databases-all-config.php /var/www/html/config/config.php') - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-oci"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}system') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}oracle') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}FREE') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}oracle:1521') - - sharedSetup() - }) -}) - -/** - * Shared admin setup function for the Nextcloud setup - * - * @param mode How to handle the recommended apps screen at the end of the - * install assistant: skip it, exercise the install button with a - * stubbed success response, or stub a failure response. - */ -function sharedSetup(mode: RecommendedAppsMode = 'skip') { - const randAdmin = 'admin-' + randomString(10) - - // mock appstore - cy.intercept('**/settings/apps/list', { fixture: 'appstore/apps.json' }) - - // Fill in the form - cy.get('[data-cy-setup-form-field="adminlogin"]').type(randAdmin) - cy.get('[data-cy-setup-form-field="adminpass"]').type(randAdmin) - - // Nothing more to do on sqlite, let's continue - cy.get('[data-cy-setup-form-submit]').click() - - // Wait for the setup to finish - cy.location('pathname', { timeout: 10000 }) - .should('include', '/core/apps/recommended') - - // See the apps setup - cy.get('[data-cy-setup-recommended-apps]') - .should('be.visible') - .within(() => { - cy.findByRole('heading', { name: 'Recommended apps' }) - .should('be.visible') - cy.findByRole('button', { name: 'Skip' }) - .should('be.visible') - cy.findByRole('button', { name: 'Install recommended apps' }) - .should('be.visible') - }) - - if (mode === 'skip') { - // Skip the setup apps - cy.get('[data-cy-setup-recommended-apps-skip]').click() - - // Go to files - cy.visit('/apps/files/') - cy.get('[data-cy-files-content]').should('be.visible') - return - } - - // Stub the bulk enable endpoint so we exercise the frontend flow without - // hitting the real app store. - cy.intercept('POST', '**/settings/apps/enable', mode === 'install-success' - ? { statusCode: 200, body: { data: { update_required: false } } } - : { statusCode: 500, body: { data: { message: 'Forced failure' } } }).as('enableApps') - - cy.get('[data-cy-setup-recommended-apps-install]').click() - - // The strict password-confirmation dialog must appear and must result in a - // Basic auth header on the enable request. - cy.findByRole('dialog', { name: 'Authentication required' }) - .should('be.visible') - handlePasswordConfirmation(randAdmin) - cy.wait('@enableApps') - .its('request.headers.authorization') - .should('match', /^Basic /) - - if (mode === 'install-success') { - // Frontend redirects via window.location to the default page. - cy.location('pathname', { timeout: 10000 }) - .should('not.include', '/core/apps/recommended') - } else { - // Stay on the recommended-apps page and surface the per-app error state. - cy.location('pathname').should('include', '/core/apps/recommended') - cy.get('[data-cy-setup-recommended-apps]') - .should('contain.text', 'App download or installation failed') - } -} diff --git a/cypress/e2e/dashboard/widget-performance.cy.ts b/cypress/e2e/dashboard/widget-performance.cy.ts deleted file mode 100644 index 99e46d7b0ae38..0000000000000 --- a/cypress/e2e/dashboard/widget-performance.cy.ts +++ /dev/null @@ -1,41 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Regression test of https://github.com/nextcloud/server/issues/48403 - * Ensure that only visible widget data is loaded - */ -describe('dashboard: performance', () => { - before(() => { - cy.createRandomUser().then((user) => { - // Enable one widget - cy.runOccCommand(`user:setting -- '${user.userId}' dashboard layout files-favorites`) - cy.login(user) - }) - }) - - it('Only load needed widgets', () => { - cy.intercept('**/dashboard/api/v2/widget-items?widgets*').as('loadedWidgets') - - const now = new Date(2025, 0, 14, 15) - cy.clock(now) - - // The dashboard is loaded - cy.visit('/apps/dashboard') - cy.get('#app-dashboard') - .should('be.visible') - .contains('Good afternoon') - .should('be.visible') - - // Wait that one data is loaded (ensure the API works), this should be the favorite files. - cy.wait('@loadedWidgets') - // Wait and check no requests are made (ensure that the user statuses data is NOT loaded) - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(4000, { timeout: 8000 }) - cy.get('@loadedWidgets.all').then((interceptions) => { - expect(interceptions).to.have.length(1) - }) - }) -}) diff --git a/cypress/e2e/dav/availability.cy.ts b/cypress/e2e/dav/availability.cy.ts deleted file mode 100644 index 5abb3cdb456c8..0000000000000 --- a/cypress/e2e/dav/availability.cy.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { clearState } from '../../support/commonUtils.ts' - -describe('Calendar: Availability', { testIsolation: true }, () => { - before(() => { - clearState() - }) - - it('User can see the availability section in settings', () => { - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/settings/user') - }) - - // can see the section - cy.findAllByRole('link', { name: /Availability/ }) - .should('be.visible') - .click() - - cy.url().should('match', /settings\/user\/availability$/) - cy.findByRole('heading', { name: /Availability/, level: 2 }) - .should('be.visible') - }) - - it('Users can set their availability status', () => { - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/settings/user/availability') - }) - - // can see the settings - cy.findByRole('list', { name: 'Weekdays' }) - .should('be.visible') - .within(() => { - cy.contains('li', 'Friday') - .should('be.visible') - .should('contain.text', 'No working hours set') - .as('fridayItem') - .findByRole('button', { name: 'Add slot' }) - .click() - }) - - cy.get('@fridayItem') - .findByLabelText(/start time/i) - .type('09:00') - - cy.get('@fridayItem') - .findByLabelText(/end time/i) - .type('18:00') - - cy.intercept('PROPPATCH', '**/remote.php/dav/calendars/*/inbox').as('saveAvailability') - cy.get('#availability') - .findByRole('button', { name: 'Save' }) - .click() - cy.wait('@saveAvailability') - - cy.reload() - - cy.findByRole('list', { name: 'Weekdays' }) - .should('be.visible') - .within(() => { - cy.contains('li', 'Friday') - .should('be.visible') - .should('not.contain.text', 'No working hours set') - }) - }) - - it('Users can set their absence', () => { - cy.createUser({ language: 'en', password: 'password', userId: 'replacement-user' }) - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/settings/user/availability') - }) - - cy.findByRole('heading', { name: /absence/i }).scrollIntoView() - - cy.findByLabelText(/First day/) - .should('be.visible') - .type('2024-12-24') - - cy.findByLabelText(/Last day/) - .should('be.visible') - .type('2024-12-28') - - cy.findByRole('textbox', { name: /Short absence/ }) - .should('be.visible') - .type('Vacation') - cy.findByRole('textbox', { name: /Long absence/ }) - .should('be.visible') - .type('Happy holidays!') - - cy.intercept('GET', '**/ocs/v2.php/apps/files_sharing/api/v1/sharees?*search=replacement*').as('userSearch') - cy.findByLabelText(/Out of office replacement/) - .should('be.visible') - .as('userSearchBox') - .click() - cy.get('@userSearchBox') - .type('replacement') - cy.wait('@userSearch') - - cy.findByRole('option', { name: 'replacement-user' }) - .click() - - cy.intercept('POST', '**/ocs/v2.php/apps/dav/api/v1/outOfOffice/*').as('saveAbsence') - cy.get('#absence') - .findByRole('button', { name: 'Save' }) - .click() - cy.wait('@saveAbsence') - - cy.reload() - - // see its saved - cy.findByLabelText(/First day/) - .should('have.value', '2024-12-24') - cy.findByLabelText(/Last day/) - .should('have.value', '2024-12-28') - cy.findByRole('textbox', { name: /Short absence/ }) - .should('have.value', 'Vacation') - cy.findByRole('textbox', { name: /Long absence/ }) - .should('have.value', 'Happy holidays!') - cy.findByLabelText(/Out of office replacement/) - .closest('.v-select') - .should('contain.text', 'replacement-user') - }) -}) diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts deleted file mode 100644 index 1ee0e593cdd81..0000000000000 --- a/cypress/e2e/files/FilesUtils.ts +++ /dev/null @@ -1,582 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -const ACTION_COPY_MOVE = 'move-copy' - -export const getRowForFileId = (fileid: string | number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`) -export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`) - -// Atomic query so the lookup is retried as a whole when rows re-render -// (chained .find() can fail with "subject no longer attached" mid-render). -export const getActionsForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"] [data-cy-files-list-row-actions]`) -export const getActionsForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"] [data-cy-files-list-row-actions]`) - -export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(fileid).findByRole('button', { name: 'Actions' }) -export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' }) - -/** - * - * @param fileid - * @param actionId - */ -export function getActionEntryForFileId(fileid: number, actionId: string) { - return getActionButtonForFileId(fileid) - .should('have.attr', 'aria-controls') - .then((menuId) => cy.get(`#${menuId}`) - .should('exist') - .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)) -} - -/** - * - * @param file - * @param actionId - */ -export function getActionEntryForFile(file: string, actionId: string) { - return getActionButtonForFile(file) - .should('have.attr', 'aria-controls') - .then((menuId) => cy.get(`#${menuId}`) - .should('exist') - .find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`)) -} - -/** - * - * @param fileid - * @param actionId - */ -export function getInlineActionEntryForFileId(fileid: number, actionId: string) { - return cy.get(`[data-cy-files-list-row-fileid="${fileid}"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) -} - -/** - * - * @param file - * @param actionId - */ -export function getInlineActionEntryForFile(file: string, actionId: string) { - return cy.get(`[data-cy-files-list-row-name="${CSS.escape(file)}"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) -} - -/** - * Poll a row's actions menu until `tryFinish` succeeds against its popover. - * - * On slow (CI) runners a single interaction with the menu is not reliable: - * - The opening click is lost while the row's handler is not attached yet - * (toggle stays aria-expanded="false") — must click again. - * - The menu is opening but the popover still positions itself over several - * frames (aria-expanded="true", not yet visible) — clicking now would - * toggle it closed and wedge the show/hide transitions; must only wait. - * - A concurrent list re-render (e.g. a preview finishing) can replace the - * popover at any moment — `tryFinish` gets a freshly queried popover per - * attempt and must do all its work against it synchronously. - * - * @param getActionButton query for the actions menu toggle of the row - * @param tryFinish called with the freshly queried popover, reports completion - * @param failureMessage error message when the time budget is exhausted - */ -function pollActionsMenu( - getActionButton: () => Cypress.Chainable>, - tryFinish: ($menu: JQuery) => boolean, - failureMessage: string, -) { - const poll = (elapsed: number) => { - getActionButton().then(($toggle) => { - const menuId = $toggle.attr('aria-controls') - if (menuId && tryFinish(Cypress.$(`#${CSS.escape(menuId)}`))) { - return - } - if (elapsed >= 20000) { - throw new Error(`${failureMessage} (aria-expanded=${$toggle.attr('aria-expanded')})`) - } - if ($toggle.attr('aria-expanded') !== 'true') { - cy.wrap($toggle).click({ force: true }) // force to avoid issues with overlaying file list header - } - // eslint-disable-next-line cypress/no-unnecessary-waiting -- give the popover a moment to open/position before re-checking - cy.wait(250) - poll(elapsed + 250) - }) - } - poll(0) -} - -/** - * Open the actions menu of a file row and wait until it is displayed. - * - * @param getActionButton query for the actions menu toggle of the row - */ -export function openActionsMenu(getActionButton: () => Cypress.Chainable>) { - pollActionsMenu(getActionButton, ($menu) => $menu.is(':visible'), 'Actions menu did not open') -} - -/** - * Open the actions menu of a file row and click the given action in it. - * - * Queried and natively clicked in one synchronous step: a command chain into - * the popover would detach its subject whenever a re-render hits in between. - * - * @param getActionButton query for the actions menu toggle of the row - * @param actionId id of the action to click - */ -function triggerActionInMenu(getActionButton: () => Cypress.Chainable>, actionId: string) { - pollActionsMenu( - getActionButton, - ($menu) => { - const button = $menu.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] button:visible`).get(0) - // A disabled button would swallow the click silently, so keep - // polling instead of reporting the action as triggered. - if (!button || (button as HTMLButtonElement).disabled) { - return false - } - button.click() - return true - }, - `Action "${actionId}" did not become clickable`, - ) -} - -/** - * - * @param fileid - * @param actionId - */ -export function triggerActionForFileId(fileid: number, actionId: string) { - getActionButtonForFileId(fileid) - .scrollIntoView() - triggerActionInMenu(() => getActionButtonForFileId(fileid), actionId) -} - -/** - * - * @param filename - * @param actionId - */ -export function triggerActionForFile(filename: string, actionId: string) { - getActionButtonForFile(filename) - .scrollIntoView() - triggerActionInMenu(() => getActionButtonForFile(filename), actionId) -} - -/** - * - * @param fileid - * @param actionId - */ -export function triggerInlineActionForFileId(fileid: number, actionId: string) { - getActionsForFileId(fileid) - .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) - .should('exist') - .click() -} -/** - * - * @param filename - * @param actionId - */ -export function triggerInlineActionForFile(filename: string, actionId: string) { - getActionsForFile(filename) - .find(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) - .should('exist') - .click() -} - -/** - * - */ -export function selectAllFiles() { - cy.get('[data-cy-files-list-selection-checkbox]') - .findByRole('checkbox', { checked: false }) - .click({ force: true }) -} -/** - * - */ -export function deselectAllFiles() { - cy.get('[data-cy-files-list-selection-checkbox]') - .findByRole('checkbox', { checked: true }) - .click({ force: true }) -} - -/** - * - * @param filename - * @param options - */ -export function selectRowForFile(filename: string, options: Partial = {}) { - getRowForFile(filename) - .find('[data-cy-files-list-row-checkbox]') - .findByRole('checkbox') - // don't use click to avoid triggering side effects events - .trigger('change', { ...options, force: true }) - .should('be.checked') - cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').should('satisfy', (elements) => { - return elements.length === 1 && (elements[0].checked === true || elements[0].indeterminate === true) - }) -} - -export const getSelectionActionButton = () => cy.get('[data-cy-files-list-selection-actions]').findByRole('button', { name: 'Actions' }) -export const getSelectionActionEntry = (actionId: string) => cy.get(`[data-cy-files-list-selection-action="${CSS.escape(actionId)}"]`) -/** - * - * @param actionId - */ -export function triggerSelectionAction(actionId: string) { - // Even if it's inline, we open the action menu to get all actions visible - getSelectionActionButton().click({ force: true }) - // the entry might already be a button or a button might its child - getSelectionActionEntry(actionId) - .then(($el) => $el.is('button') ? cy.wrap($el) : cy.wrap($el).findByRole('menuitem').last()) - .should('exist') - .click() -} - -/** - * Skip the current test when the known FilePicker race swallows the confirm: - * the picker's aborted initial load clears the loading state of its - * successor, so the dialog confirms with no selection and no MOVE/COPY - * request is ever sent. Fixed upstream by - * https://github.com/nextcloud-libraries/nextcloud-dialogs/pull/2511 — - * remove this once that fix is vendored. Any other error still fails. - * - * @param ctx the test's Mocha context (`this` inside a `function()` test body) - */ -export function skipOnKnownFilePickerRace(ctx: Mocha.Context) { - cy.on('fail', (error) => { - if (/`(copyFile|moveFile)`\. No request ever occurred/.test(error.message)) { - ctx.skip() - } - throw error - }) -} - -/** - * Confirm the file picker. - * - * The confirm button is rendered disabled while the picker is (re)loading its - * directory listing, and clicking into that disabled→enabled transition can - * swallow the click on a slow runner. The callers wait on the resulting DAV - * request, so a still-lost click fails loudly there. - * - * @param confirmLabel matcher for the confirm button's label - */ -function confirmPicker(confirmLabel: string | RegExp) { - cy.contains('button', confirmLabel) - .should('be.visible') - .and('be.enabled') - .click() -} - -/** - * Inside the file picker, navigate to the home root and confirm the copy/move. - * - * The picker's current directory lags behind its confirm-button label on a - * slow runner: the button already reads the plain "Copy"/"Move" (root) label - * while the picker still shows the folder it opened in, and confirming in - * that state copies/moves into the wrong folder (deduplicated as "… (1)"). - * Only the picker's own root PROPFIND proves the navigation happened. - * - * @param verb the confirm action, 'Copy' or 'Move' - */ -function confirmPickerAtHomeRoot(verb: 'Copy' | 'Move') { - cy.get('.breadcrumb').then(($breadcrumb) => { - const inSubfolder = $breadcrumb.find('button, a').toArray() - .some((crumb) => { - const label = crumb.textContent?.trim() - return !!label && label !== 'All files' - }) - - if (!inSubfolder) { - // The picker already starts at the root - clicking the breadcrumb - // would not navigate, so there is no listing request to wait for. - return - } - - // Match only the root listing: the picker's initial fetch of the folder - // it opened in can still be in flight and must not satisfy the wait. - cy.intercept('PROPFIND', /\/(remote|public)\.php\/dav\/files\/[^/]+\/?$/).as('pickerNavigation') - cy.get('.breadcrumb') - .findByRole('button', { name: 'All files' }) - .should('be.visible') - .click() - cy.wait('@pickerNavigation') - }) - - confirmPicker(new RegExp(`^\\s*${verb}\\s*$`)) -} - -/** - * - * @param fileName - * @param dirPath - */ -export function moveFile(fileName: string, dirPath: string) { - getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, ACTION_COPY_MOVE) - - cy.get('.file-picker').within(() => { - // intercept the copy so we can wait for it - cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') - - if (dirPath === '/') { - confirmPickerAtHomeRoot('Move') - } else if (dirPath === '.') { - // click move - confirmPicker('Copy') - } else { - const directories = dirPath.split('/') - directories.forEach((directory) => { - // select the folder - cy.get(`[data-filename="${directory}"]`).should('be.visible').click() - }) - - // click move - confirmPicker(`Move to ${directories.at(-1)}`) - } - - cy.wait('@moveFile') - }) -} - -/** - * - * @param fileName - * @param dirPath - */ -export function copyFile(fileName: string, dirPath: string) { - getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, ACTION_COPY_MOVE) - - cy.get('.file-picker').within(() => { - // intercept the copy so we can wait for it - cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile') - - if (dirPath === '/') { - confirmPickerAtHomeRoot('Copy') - } else if (dirPath === '.') { - // click copy - confirmPicker('Copy') - } else { - const directories = dirPath.split('/') - directories.forEach((directory) => { - // select the folder - cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click() - }) - - // click copy - confirmPicker(`Copy to ${directories.at(-1)}`) - } - - cy.wait('@copyFile') - }) -} - -/** - * - * @param fileName - * @param newFileName - */ -export function renameFile(fileName: string, newFileName: string) { - getRowForFile(fileName) - .should('exist') - .scrollIntoView() - - triggerActionForFile(fileName, 'rename') - - // intercept the move so we can wait for it - cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') - - getRowForFile(fileName) - .find('[data-cy-files-list-row-name] input') - .type(`{selectAll}${newFileName}{enter}`) - - cy.wait('@moveFile') -} - -/** - * - * @param dirPath - */ -export function navigateToFolder(dirPath: string) { - const directories = dirPath.split('/') - for (const directory of directories) { - if (directory === '') { - continue - } - - getRowForFile(directory).should('be.visible').find('[data-cy-files-list-row-name-link]').click() - } -} - -/** - * Close the sidebar - */ -export function closeSidebar() { - // {force: true} as it might be hidden behind toasts - cy.get('[data-cy-sidebar] .app-sidebar__close') - .click({ force: true }) - cy.get('[data-cy-sidebar]') - .should('not.be.visible') - // eslint-disable-next-line cypress/no-unnecessary-waiting -- wait for the animation to finish - cy.wait(500) - cy.url() - .should('not.contain', 'opendetails') - // close all toasts - cy.get('.toast-success') - .if() - .findAllByRole('button') - .click({ force: true, multiple: true }) -} - -/** - * - * @param label - */ -export function clickOnBreadcrumbs(label: string) { - cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') - cy.get('[data-cy-files-content-breadcrumbs]').contains(label).click() - cy.wait('@propfind') -} - -/** - * - * @param folderName - */ -export function createFolder(folderName: string) { - cy.intercept('MKCOL', /\/remote.php\/dav\/files\//).as('createFolder') - - // TODO: replace by proper data-cy selectors - cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click() - cy.get('[data-cy-upload-picker-menu-entry="newFolder"] button').click() - cy.get('[data-cy-files-new-node-dialog]').should('be.visible') - cy.get('[data-cy-files-new-node-dialog-input]').type(`{selectall}${folderName}`) - cy.get('[data-cy-files-new-node-dialog-submit]').click() - - cy.wait('@createFolder') - - getRowForFile(folderName).should('be.visible') -} - -/** - * Check validity of an input element - * - * @param validity The expected validity message (empty string means it is valid) - * @example - * ```js - * cy.findByRole('textbox') - * .should(haveValidity(/must not be empty/i)) - * ``` - */ -export function haveValidity(validity: string | RegExp) { - if (typeof validity === 'string') { - return (el: JQuery) => expect((el.get(0) as HTMLInputElement).validationMessage).to.equal(validity) - } - return (el: JQuery) => expect((el.get(0) as HTMLInputElement).validationMessage).to.match(validity) -} - -/** - * - * @param user - * @param path - */ -export function deleteFileWithRequest(user: User, path: string) { - // Ensure path starts with a slash and has no double slashes - path = `/${path}`.replace(/\/+/g, '/') - - cy.request('/csrftoken').then(({ body }) => { - const requestToken = body.token - cy.request({ - method: 'DELETE', - url: `${Cypress.env('baseUrl')}/remote.php/dav/files/${user.userId}${path}`, - auth: { - user: user.userId, - password: user.password, - }, - headers: { - requestToken, - }, - retryOnStatusCodeFailure: true, - }) - }) -} - -/** - * - * @param actionId - */ -export function triggerFileListAction(actionId: string) { - cy.get(`button[data-cy-files-list-action="${CSS.escape(actionId)}"]`).last() - .should('exist').click({ force: true }) -} - -/** - * Reloads the current folder - * - * @param intercept if true this will wait for the PROPFIND to complete before it resolves - */ -export function reloadCurrentFolder(intercept = true) { - cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') - cy.findByRole('navigation', { name: 'Current directory path' }) - .findAllByRole('button') - .filter('[aria-haspopup="menu"]') - .click() - cy.findByRole('menu') - .should('be.visible') - .findByRole('menuitem', { name: 'Reload content' }) - .click() - - if (intercept) { - cy.wait('@propfind') - } -} - -/** - * Enable the grid mode for the files list. - * Will fail if already enabled! - */ -export function enableGridMode() { - cy.intercept('**/apps/files/api/v1/config/grid_view').as('setGridMode') - cy.findByRole('button', { name: 'Switch to grid view' }) - .should('be.visible') - .click() - cy.wait('@setGridMode') -} - -/** - * Calculate the needed viewport height to limit the visible rows of the file list. - * Requires a logged in user. - * - * @param rows The number of rows that should be displayed at the same time - */ -export function calculateViewportHeight(rows: number): Cypress.Chainable { - cy.visit('/apps/files') - - cy.get('[data-cy-files-list]') - .should('be.visible') - - cy.get('[data-cy-files-list-tbody] tr', { timeout: 5000 }) - .and('be.visible') - - return cy.get('[data-cy-files-list]') - .should('be.visible') - .then((filesList) => { - const windowHeight = Cypress.$('body').outerHeight()! - // Size of other page elements - const outerHeight = Math.ceil(windowHeight - filesList.outerHeight()!) - // Size of before and filters - const beforeHeight = Math.ceil(Cypress.$('.files-list__before').outerHeight()!) - const filterHeight = Math.ceil(Cypress.$('.files-list__filters').outerHeight()!) - // Size of the table header - const tableHeaderHeight = Math.ceil(Cypress.$('[data-cy-files-list-thead]').outerHeight()!) - // table row height - const rowHeight = Math.ceil(Cypress.$('[data-cy-files-list-tbody] tr').outerHeight()!) - - // sum it up - const viewportHeight = outerHeight + beforeHeight + filterHeight + tableHeaderHeight + rows * rowHeight - cy.log(`Calculated viewport height: ${viewportHeight} (${outerHeight} + ${beforeHeight} + ${filterHeight} + ${tableHeaderHeight} + ${rows} * ${rowHeight})`) - return cy.wrap(viewportHeight) - }) -} diff --git a/cypress/e2e/files/LivePhotosUtils.ts b/cypress/e2e/files/LivePhotosUtils.ts deleted file mode 100644 index b551cbbdd9db3..0000000000000 --- a/cypress/e2e/files/LivePhotosUtils.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' - -type SetupInfo = { - snapshot: string - jpgFileId: number - movFileId: number - fileName: string - user: User -} - -/** - * @param user - * @param fileName - * @param requesttoken - * @param metadata - */ -function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) { - const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '') - cy.request({ - method: 'PROPPATCH', - url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`, - auth: { user: user.userId, pass: user.password }, - headers: { - requesttoken, - }, - body: ` - - - - ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}`).join('\n')} - - - `, - }) -} - -/** - * - * @param enable - */ -export function setShowHiddenFiles(enable: boolean) { - cy.request('/csrftoken').then(({ body }) => { - const requestToken = body.token - const url = `${Cypress.config('baseUrl')}/apps/files/api/v1/config/show_hidden` - cy.request({ - method: 'PUT', - url, - headers: { - 'Content-Type': 'application/json', - requesttoken: requestToken, - }, - body: { value: enable }, - }) - }) - cy.reload() -} - -/** - * - */ -export function setupLivePhotos(): Cypress.Chainable { - return cy.task('getVariable', { key: 'live-photos-data' }) - .then((_setupInfo) => { - const setupInfo = _setupInfo as SetupInfo || {} - if (setupInfo.snapshot) { - cy.restoreState(setupInfo.snapshot) - } else { - let requesttoken: string - - setupInfo.fileName = randomString(10) - - cy.createRandomUser().then((_user) => { - setupInfo.user = _user - }) - - cy.then(() => { - cy.uploadContent(setupInfo.user, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${setupInfo.fileName}.jpg`) - .then((response) => { setupInfo.jpgFileId = parseInt(response.headers['oc-fileid']) }) - cy.uploadContent(setupInfo.user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${setupInfo.fileName}.mov`) - .then((response) => { setupInfo.movFileId = parseInt(response.headers['oc-fileid']) }) - - cy.login(setupInfo.user) - }) - - cy.visit('/apps/files') - - cy.get('head').invoke('attr', 'data-requesttoken').then((_requesttoken) => { - requesttoken = _requesttoken as string - }) - - cy.then(() => { - setMetadata(setupInfo.user, `${setupInfo.fileName}.jpg`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.movFileId }) - setMetadata(setupInfo.user, `${setupInfo.fileName}.mov`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.jpgFileId }) - }) - - cy.then(() => { - cy.saveState().then((value) => { - setupInfo.snapshot = value - }) - cy.task('setVariable', { key: 'live-photos-data', value: setupInfo }) - }) - } - return cy.then(() => { - cy.login(setupInfo.user) - cy.visit('/apps/files') - return cy.wrap(setupInfo) - }) - }) -} diff --git a/cypress/e2e/files/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts deleted file mode 100644 index 6f648aca8d238..0000000000000 --- a/cypress/e2e/files/drag-n-drop.cy.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile, navigateToFolder } from './FilesUtils.ts' - -describe('files: Drag and Drop', { testIsolation: true }, () => { - beforeEach(() => { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - cy.visit('/apps/files') - }) - - it('can drop a file', () => { - const dataTransfer = new DataTransfer() - dataTransfer.items.add(new File([], 'single-file.txt')) - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - // Make sure the drop notice is not visible - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - - // Trigger the drop notice - cy.get('main.app-content').trigger('dragover', { dataTransfer }) - cy.get('[data-cy-files-drag-drop-area]').should('be.visible') - - // Upload drop a file - cy.get('[data-cy-files-drag-drop-area]').selectFile({ - fileName: 'single-file.txt', - contents: ['hello '.repeat(1024)], - }, { action: 'drag-drop' }) - - cy.wait('@uploadFile') - - // Make sure the upload is finished - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - cy.get('@uploadFile.all').should('have.length', 1) - - getRowForFile('single-file.txt').should('be.visible') - getRowForFile('single-file.txt').find('[data-cy-files-list-row-size]').should('contain', '6 KB') - }) - - it('can drop multiple files', () => { - const dataTransfer = new DataTransfer() - dataTransfer.items.add(new File([], 'first.txt')) - dataTransfer.items.add(new File([], 'second.txt')) - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - // Make sure the drop notice is not visible - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - - // Trigger the drop notice - cy.get('main.app-content').trigger('dragover', { dataTransfer }) - cy.get('[data-cy-files-drag-drop-area]').should('be.visible') - - // Upload drop a file - cy.get('[data-cy-files-drag-drop-area]').selectFile([ - { - fileName: 'first.txt', - contents: ['Hello'], - }, - { - fileName: 'second.txt', - contents: ['World'], - }, - ], { action: 'drag-drop' }) - - cy.wait('@uploadFile') - - // Make sure the upload is finished - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - cy.get('@uploadFile.all').should('have.length', 2) - - getRowForFile('first.txt').should('be.visible') - getRowForFile('second.txt').should('be.visible') - }) - - it('will ignore legacy Folders', () => { - cy.window().then((win) => { - // Remove the Filesystem API to force the legacy File API - // See how cypress mocks the Filesystem API in https://github.com/cypress-io/cypress/blob/74109094a92df3bef073dda15f17194f31850d7d/packages/driver/src/cy/commands/actions/selectFile.ts#L24-L37 - Object.defineProperty(win.DataTransferItem.prototype, 'getAsEntry', { get: undefined }) - Object.defineProperty(win.DataTransferItem.prototype, 'webkitGetAsEntry', { get: undefined }) - }) - - const dataTransfer = new DataTransfer() - dataTransfer.items.add(new File([], 'first.txt')) - dataTransfer.items.add(new File([], 'second.txt')) - - // Legacy File API (not FileSystem API), will treat Folders as Files - // with empty type and empty content - dataTransfer.items.add(new File([], 'Foo', { type: 'httpd/unix-directory' })) - dataTransfer.items.add(new File([], 'Bar')) - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - // Make sure the drop notice is not visible - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - - // Trigger the drop notice - cy.get('main.app-content').trigger('dragover', { dataTransfer }) - cy.get('[data-cy-files-drag-drop-area]').should('be.visible') - - // Upload drop a file - cy.get('[data-cy-files-drag-drop-area]').selectFile([ - { - fileName: 'first.txt', - contents: ['Hello'], - }, - { - fileName: 'second.txt', - contents: ['World'], - }, - { - fileName: 'Foo', - contents: {}, - }, - { - fileName: 'Bar', - contents: { mimeType: 'httpd/unix-directory' }, - }, - ], { action: 'drag-drop' }) - - cy.wait('@uploadFile') - - // Make sure the upload is finished - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - cy.get('@uploadFile.all').should('have.length', 2) - - // see the warning - cy.get('.toast-warning').should('exist') - - // close all toasts - cy.get('.toastify') - .findAllByRole('button', { name: 'Close' }) - .click({ multiple: true }) - - getRowForFile('first.txt').should('be.visible') - getRowForFile('second.txt').should('be.visible') - getRowForFile('Foo').should('not.exist') - getRowForFile('Bar').should('not.exist') - }) -}) - -// Regression coverage for https://github.com/nextcloud/server/issues/60139 -// The per-row drop handler in FileEntryMixin used to pass raw FileSystemEntry -// objects to @nextcloud/upload's batchUpload; on some Chromium builds the -// instanceof-based conversion silently failed and the chunk uploader crashed -// with "e.slice is not a function". The fix routes the per-row drop through -// the same dataTransferToFileTree pipeline as the main file-list drop. -// -// Sibling describe (not nested) so the outer suite's `beforeEach` doesn't -// spin up an unused user before each test in this block. -describe('files: Drag and Drop onto a folder row', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then((u) => { - user = u - cy.mkdir(user, '/subfolder') - cy.login(user) - }) - cy.visit('/apps/files') - getRowForFile('subfolder').should('be.visible') - }) - - it('can drop a single file onto a subfolder row', () => { - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - getRowForFile('subfolder').selectFile({ - fileName: 'dropped-into-subfolder.txt', - contents: ['hello '.repeat(1024)], - }, { action: 'drag-drop' }) - - cy.wait('@uploadFile').its('request.url') - .should('match', /\/subfolder\/dropped-into-subfolder\.txt$/) - - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - - navigateToFolder('/subfolder') - getRowForFile('dropped-into-subfolder.txt').should('be.visible') - }) - - it('can drop multiple files onto a subfolder row', () => { - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - getRowForFile('subfolder').selectFile([ - { fileName: 'one.txt', contents: ['A'.repeat(1024)] }, - { fileName: 'two.txt', contents: ['B'.repeat(1024)] }, - ], { action: 'drag-drop' }) - - // Both files must land under the subfolder, not the current dir. - cy.wait(['@uploadFile', '@uploadFile']).then((intercepts) => { - const urls = intercepts.map((i) => i.request.url).sort() - expect(urls).to.have.length(2) - urls.forEach((url) => { - expect(url).to.match(/\/subfolder\/(one|two)\.txt$/) - }) - }) - - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - - navigateToFolder('/subfolder') - getRowForFile('one.txt').should('be.visible') - getRowForFile('two.txt').should('be.visible') - }) - - it('opens the conflict picker when dropping a colliding name onto a subfolder row', () => { - // Pre-populate the subfolder with a file the drop will collide with. - // cy.uploadContent internally clears session cookies, so re-login - // before revisiting so it matches the uploadContent > login > visit - // pattern used elsewhere in the suite. - cy.uploadContent(user, new Blob(['original']), 'text/plain', '/subfolder/collide.txt') - cy.login(user) - - // Reload so the pre-populated file lands in the store before the drop. - // The drop handler reads filesStore.getNodesByPath first and only - // fetches fresh contents when the cache is empty, so a stale cache - // from the beforeEach visit would let the upload proceed without - // triggering the conflict picker. If this ever flaps on CI, replace - // the visit with cy.reload() + an explicit wait on store settlement. - cy.visit('/apps/files') - getRowForFile('subfolder').should('be.visible') - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - getRowForFile('subfolder').selectFile({ - fileName: 'collide.txt', - contents: ['replacement '.repeat(1024)], - }, { action: 'drag-drop' }) - - // Wait for the conflict picker to appear, then assert no PUT has - // fired yet — chained so the upload-count check happens *after* the - // dialog is visible, enforcing the "dialog blocks upload" invariant. - cy.findByRole('dialog').should('be.visible').then(() => { - cy.get('@uploadFile.all').should('have.length', 0) - }) - }) -}) diff --git a/cypress/e2e/files/duplicated-node-regression.cy.ts b/cypress/e2e/files/duplicated-node-regression.cy.ts deleted file mode 100644 index 14355a62b9d93..0000000000000 --- a/cypress/e2e/files/duplicated-node-regression.cy.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { createFolder, getRowForFile, triggerActionForFile } from './FilesUtils.ts' - -before(() => { - cy.createRandomUser() - .then((user) => { - cy.mkdir(user, '/only once') - cy.login(user) - cy.visit('/apps/files') - }) -}) - -/** - * Regression test for https://github.com/nextcloud/server/issues/47904 - */ -it('Ensure nodes are not duplicated in the file list', () => { - // See the folder - getRowForFile('only once').should('be.visible') - // Delete the folder - cy.intercept('DELETE', '**/remote.php/dav/**').as('deleteFolder') - triggerActionForFile('only once', 'delete') - cy.wait('@deleteFolder') - getRowForFile('only once').should('not.exist') - // Create the folder again - createFolder('only once') - // See folder exists only once - getRowForFile('only once') - .should('have.length', 1) -}) diff --git a/cypress/e2e/files/favorites.cy.ts b/cypress/e2e/files/favorites.cy.ts deleted file mode 100644 index e992b7e578318..0000000000000 --- a/cypress/e2e/files/favorites.cy.ts +++ /dev/null @@ -1,163 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { closeSidebar, getActionButtonForFile, getRowForFile, triggerActionForFile } from './FilesUtils.ts' - -describe('files: Favorites', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.mkdir(user, '/new folder') - cy.login(user) - cy.visit('/apps/files') - }) - }) - - it('Mark file as favorite', () => { - // See file exists - getRowForFile('file.txt') - .should('exist') - - cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites') - // Click actions - getActionButtonForFile('file.txt').click({ force: true }) - // See action is called 'Add to favorites' - cy.get('[data-cy-files-list-row-action="favorite"] > button').last() - .should('exist') - .and('contain.text', 'Add to favorites') - .click({ force: true }) - cy.wait('@addToFavorites') - // See favorites star - getRowForFile('file.txt') - .findByRole('img', { name: 'Favorite' }) - .should('exist') - }) - - it('Un-mark file as favorite', () => { - // See file exists - getRowForFile('file.txt') - .should('exist') - - cy.intercept('POST', '**/apps/files/api/v1/files/file.txt').as('addToFavorites') - // toggle favorite - triggerActionForFile('file.txt', 'favorite') - cy.wait('@addToFavorites') - - // See favorites star - getRowForFile('file.txt') - .findByRole('img', { name: 'Favorite' }) - .should('be.visible') - - // Remove favorite - // click action button - getActionButtonForFile('file.txt').click({ force: true }) - // See action is called 'Remove from favorites' - cy.get('[data-cy-files-list-row-action="favorite"] > button').last() - .should('exist') - .and('have.text', 'Remove from favorites') - .click({ force: true }) - cy.wait('@addToFavorites') - // See no favorites star anymore - getRowForFile('file.txt') - .findByRole('img', { name: 'Favorite' }) - .should('not.exist') - }) - - it('See favorite folders in navigation', () => { - cy.intercept('POST', '**/apps/files/api/v1/files/new%20folder').as('addToFavorites') - - // see navigation has no entry - cy.get('[data-cy-files-navigation-item="favorites"]') - .should('be.visible') - .contains('new folder') - .should('not.exist') - - // toggle favorite - triggerActionForFile('new folder', 'favorite') - cy.wait('@addToFavorites') - - // See in navigation - cy.get('[data-cy-files-navigation-item="favorites"]') - .should('be.visible') - .contains('new folder') - .should('exist') - - // toggle favorite - triggerActionForFile('new folder', 'favorite') - cy.wait('@addToFavorites') - - // See no longer in navigation - cy.get('[data-cy-files-navigation-item="favorites"]') - .should('be.visible') - .contains('new folder') - .should('not.exist') - }) - - it('Mark file as favorite using the sidebar', () => { - // See file exists - getRowForFile('new folder') - .should('exist') - // see navigation has no entry - cy.get('[data-cy-files-navigation-item="favorites"]') - .should('be.visible') - .contains('new folder') - .should('not.exist') - - cy.intercept('POST', '**/apps/files/api/v1/files/new%20folder').as('addToFavorites') - // open sidebar - triggerActionForFile('new folder', 'details') - cy.get('[data-cy-sidebar]') - .should('be.visible') - - // open sidebar actions - cy.get('[data-cy-sidebar]') - .findByRole('button', { name: 'Actions' }) - .click() - // trigger menu button - cy.findAllByRole('menu') - .findByRole('menuitem', { name: 'Favorite' }) - .should('be.visible') - .click() - cy.wait('@addToFavorites') - - // close sidebar - closeSidebar() - - // See favorites star - getRowForFile('new folder') - .findByRole('img', { name: 'Favorite' }) - .should('be.visible') - - cy.reload() - getRowForFile('new folder') - .should('be.visible') - - // can unfavorite - triggerActionForFile('new folder', 'details') - cy.get('[data-cy-sidebar]') - .should('be.visible') - - cy.get('[data-cy-sidebar]') - .findByRole('button', { name: 'Actions' }) - .click() - // trigger menu button - cy.findAllByRole('menu') - .findByRole('menuitem', { name: 'Unfavorite' }) - .should('be.visible') - .click() - - cy.wait('@addToFavorites') - closeSidebar() - - getRowForFile('new folder') - .findByRole('img', { name: 'Favorite' }) - .should('not.exist') - }) -}) diff --git a/cypress/e2e/files/files-actions.cy.ts b/cypress/e2e/files/files-actions.cy.ts deleted file mode 100644 index f717213c15a8c..0000000000000 --- a/cypress/e2e/files/files-actions.cy.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getActionButtonForFileId, getActionEntryForFileId, getRowForFile, getSelectionActionButton, getSelectionActionEntry, selectRowForFile } from './FilesUtils.ts' - -const ACTION_DELETE = 'delete' -const ACTION_COPY_MOVE = 'move-copy' -const ACTION_DETAILS = 'details' - -// Those two arrays doesn't represent the full list of actions -// the goal is to test a few, we're not trying to match the full feature set -const expectedDefaultActionsIDs = [ - ACTION_COPY_MOVE, - ACTION_DELETE, - ACTION_DETAILS, -] -const expectedDefaultSelectionActionsIDs = [ - ACTION_COPY_MOVE, - ACTION_DELETE, -] - -describe('Files: Actions', { testIsolation: true }, () => { - let user: User - let fileId: number = 0 - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.uploadContent(user, new Blob([]), 'image/jpeg', '/image.jpg').then((response) => { - fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - }) - cy.login(user) - })) - - it('Show some standard actions', () => { - cy.visit('/apps/files') - getRowForFile('image.jpg').should('be.visible') - - expectedDefaultActionsIDs.forEach((actionId) => { - // Open the menu - getActionButtonForFileId(fileId).click({ force: true }) - // Check the action is visible - getActionEntryForFileId(fileId, actionId).should('be.visible') - // Close the menu - cy.get('body').click({ force: true }) - }) - }) - - it('Show some actions for a selection', () => { - cy.visit('/apps/files') - getRowForFile('image.jpg').should('be.visible') - - selectRowForFile('image.jpg') - - cy.get('[data-cy-files-list-selection-actions]').should('be.visible') - getSelectionActionButton().should('be.visible') - - // Open the menu - getSelectionActionButton().click({ force: true }) - - // Check the action is visible - expectedDefaultSelectionActionsIDs.forEach((actionId) => { - getSelectionActionEntry(actionId).should('be.visible') - }) - }) -}) diff --git a/cypress/e2e/files/files-copy-move.cy.ts b/cypress/e2e/files/files-copy-move.cy.ts deleted file mode 100644 index 6c720a42b3ffb..0000000000000 --- a/cypress/e2e/files/files-copy-move.cy.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { copyFile, getRowForFile, moveFile, navigateToFolder, skipOnKnownFilePickerRace } from './FilesUtils.ts' - -describe('Files: Move or copy files', { testIsolation: true }, () => { - let currentUser - beforeEach(() => { - cy.createRandomUser().then((user) => { - currentUser = user - cy.login(user) - }) - }) - afterEach(() => { - // nice to have cleanup - cy.deleteUser(currentUser) - }) - - it('Can copy a file to new folder', () => { - // Prepare initial state - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/new-folder') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', 'new-folder') - - navigateToFolder('new-folder') - - cy.url().should('contain', 'dir=/new-folder') - getRowForFile('original.txt').should('be.visible') - getRowForFile('new-folder').should('not.exist') - }) - - it('Can move a file to new folder', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/new-folder') - cy.login(currentUser) - cy.visit('/apps/files') - - moveFile('original.txt', 'new-folder') - - // wait until visible again - getRowForFile('new-folder').should('be.visible') - - // original should be moved -> not exist anymore - getRowForFile('original.txt').should('not.exist') - navigateToFolder('new-folder') - - cy.url().should('contain', 'dir=/new-folder') - getRowForFile('original.txt').should('be.visible') - getRowForFile('new-folder').should('not.exist') - }) - - /** - * Test for https://github.com/nextcloud/server/issues/41768 - */ - it('Can move a file to folder with similar name', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original') - .mkdir(currentUser, '/original folder') - cy.login(currentUser) - cy.visit('/apps/files') - - moveFile('original', 'original folder') - - // wait until visible again - getRowForFile('original folder').should('be.visible') - - // original should be moved -> not exist anymore - getRowForFile('original').should('not.exist') - navigateToFolder('original folder') - - cy.url().should('contain', 'dir=/original%20folder') - getRowForFile('original').should('be.visible') - getRowForFile('original folder').should('not.exist') - }) - - it('Can move a file to its parent folder', () => { - cy.mkdir(currentUser, '/new-folder') - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/new-folder/original.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - navigateToFolder('new-folder') - cy.url().should('contain', 'dir=/new-folder') - - moveFile('original.txt', '/') - - // wait until visible again - cy.get('main').contains('No files in here').should('be.visible') - - // original should be moved -> not exist anymore - getRowForFile('original.txt').should('not.exist') - - cy.visit('/apps/files') - getRowForFile('new-folder').should('be.visible') - getRowForFile('original.txt').should('be.visible') - }) - - it('Can copy a file to same folder', function() { - skipOnKnownFilePickerRace(this) - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', '.') - - getRowForFile('original.txt').should('be.visible') - getRowForFile('original (1).txt').should('be.visible') - }) - - it('Can copy a file multiple times to same folder', function() { - skipOnKnownFilePickerRace(this) - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (1).txt') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', '.') - - getRowForFile('original.txt').should('be.visible') - getRowForFile('original (2).txt').should('be.visible') - }) - - /** - * Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (1)') - * Test for: https://github.com/nextcloud/server/issues/43843 - */ - it('Can copy a folder to same folder', function() { - skipOnKnownFilePickerRace(this) - cy.mkdir(currentUser, '/foo.bar') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('foo.bar', '.') - - getRowForFile('foo.bar').should('be.visible') - getRowForFile('foo.bar (1)').should('be.visible') - }) - - /** Test for https://github.com/nextcloud/server/issues/43329 */ - context('escaping file and folder names', () => { - it('Can handle files with special characters', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/can\'t say') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', 'can\'t say') - - navigateToFolder('can\'t say') - - cy.url().should('contain', 'dir=/can%27t%20say') - getRowForFile('original.txt').should('be.visible') - getRowForFile('can\'t say').should('not.exist') - }) - - /** - * If escape is set to false (required for test above) then "foo" would result in "foo" if sanitizing is not disabled - * We should disable it as vue already escapes the text when using v-text - */ - it('does not incorrectly sanitize file names', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') - .mkdir(currentUser, '/foo') - cy.login(currentUser) - cy.visit('/apps/files') - - copyFile('original.txt', 'foo') - - navigateToFolder('foo') - - cy.url().should('contain', 'dir=/%3Ca%20href%3D%22%23%22%3Efoo') - getRowForFile('original.txt').should('be.visible') - getRowForFile('foo').should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files/files-delete.cy.ts b/cypress/e2e/files/files-delete.cy.ts deleted file mode 100644 index b1af310d9b6dd..0000000000000 --- a/cypress/e2e/files/files-delete.cy.ts +++ /dev/null @@ -1,70 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile, triggerSelectionAction } from './FilesUtils.ts' - -describe('files: Delete files using file actions', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - }) - - it('can delete file', () => { - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - - // The file must exist and the preview loaded as it locks the file - getRowForFile('file.txt') - .should('be.visible') - .find('.files-list__row-icon-preview--loaded') - .should('exist') - - cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') - - triggerActionForFile('file.txt', 'delete') - cy.wait('@deleteFile').its('response.statusCode').should('eq', 204) - }) - - it('can delete multiple files', () => { - cy.mkdir(user, '/root') - for (let i = 0; i < 5; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/root/file${i}.txt`) - } - cy.login(user) - cy.visit('/apps/files') - navigateToFolder('/root') - - // The file must exist and the preview loaded as it locks the file - cy.get('.files-list__row-icon-preview--loaded') - .should('have.length', 5) - - cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') - - // select all - selectAllFiles() - triggerSelectionAction('delete') - - // see dialog for confirmation - cy.findByRole('dialog', { name: 'Confirm deletion' }) - .findByRole('button', { name: 'Delete files' }) - .click() - - cy.wait('@deleteFile') - cy.get('@deleteFile.all') - .should('have.length', 5) - - .should((all: any) => { - for (const call of all) { - expect(call.response.statusCode).to.equal(204) - } - }) - }) -}) diff --git a/cypress/e2e/files/files-download.cy.ts b/cypress/e2e/files/files-download.cy.ts deleted file mode 100644 index 4fcbcbb267c97..0000000000000 --- a/cypress/e2e/files/files-download.cy.ts +++ /dev/null @@ -1,333 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { zipFileContains } from '../../support/utils/assertions.ts' -import { deleteDownloadsFolderBeforeEach } from '../../support/utils/deleteDownloadsFolder.ts' -import { randomString } from '../../support/utils/randomString.ts' -import { getRowForFile, navigateToFolder, triggerActionForFile, triggerSelectionAction } from './FilesUtils.ts' - -describe('files: Download files using file actions', { testIsolation: true }, () => { - let user: User - - deleteDownloadsFolderBeforeEach() - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - }) - - it('can download file', () => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'download') - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - it('can download folder', () => { - cy.mkdir(user, '/subfolder') - cy.uploadContent(user, new Blob(['']), 'text/plain', '/subfolder/file.txt') - - cy.login(user) - cy.visit('/apps/files') - getRowForFile('subfolder') - .should('be.visible') - - triggerActionForFile('subfolder', 'download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'subfolder/', - 'subfolder/file.txt', - ])) - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/44855 - */ - it('can download file with hash name', () => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/#file.txt') - cy.login(user) - cy.visit('/apps/files') - - triggerActionForFile('#file.txt', 'download') - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/44855 - */ - it('can download file from folder with hash name', () => { - cy.mkdir(user, '/#folder') - .uploadContent(user, new Blob(['']), 'text/plain', '/#folder/file.txt') - cy.login(user) - cy.visit('/apps/files') - - navigateToFolder('#folder') - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'download') - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) -}) - -describe('files: Download files using default action', { testIsolation: true }, () => { - let user: User - - deleteDownloadsFolderBeforeEach() - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - }) - - it('can download file', () => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - - getRowForFile('file.txt') - .should('be.visible') - .findByRole('button', { name: /^Download(:|$)/ }) - .click() - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/44855 - */ - it('can download file with hash name', () => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/#file.txt') - cy.login(user) - cy.visit('/apps/files') - - getRowForFile('#file.txt') - .should('be.visible') - .findByRole('button', { name: /^Download(:|$)/ }) - .click() - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/#file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/44855 - */ - it('can download file from folder with hash name', () => { - cy.mkdir(user, '/#folder') - .uploadContent(user, new Blob(['']), 'text/plain', '/#folder/file.txt') - cy.login(user) - cy.visit('/apps/files') - - navigateToFolder('#folder') - // All are visible by default - getRowForFile('file.txt') - .should('be.visible') - .findByRole('button', { name: /^Download(:|$)/ }) - .click() - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) -}) - -describe('files: Download files using selection', () => { - deleteDownloadsFolderBeforeEach() - - it('can download selected files', () => { - cy.createRandomUser().then((user) => { - cy.mkdir(user, '/subfolder') - cy.uploadContent(user, new Blob(['']), 'text/plain', '/subfolder/file.txt') - cy.login(user) - cy.visit('/apps/files') - }) - - getRowForFile('subfolder') - .should('be.visible') - - getRowForFile('subfolder') - .findByRole('checkbox') - .check({ force: true }) - - // see that two files are selected - cy.get('[data-cy-files-list]').within(() => { - cy.contains('1 selected').should('be.visible') - }) - - // click download - triggerSelectionAction('download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'subfolder/', - 'subfolder/file.txt', - ])) - }) - - it('can download multiple selected files', () => { - cy.createRandomUser().then((user) => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob(['']), 'text/plain', '/other file.txt') - cy.login(user) - cy.visit('/apps/files') - }) - - getRowForFile('file.txt') - .should('be.visible') - .findByRole('checkbox') - .check({ force: true }) - - getRowForFile('other file.txt') - .should('be.visible') - .findByRole('checkbox') - .check({ force: true }) - - cy.get('[data-cy-files-list]').within(() => { - // see that two files are selected - cy.contains('2 selected').should('be.visible') - }) - - // click download - triggerSelectionAction('download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'file.txt', - 'other file.txt', - ])) - }) - - /** - * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 - */ - it('can download selected files with special characters', () => { - cy.createRandomUser().then((user) => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/1+1.txt') - cy.uploadContent(user, new Blob(['']), 'text/plain', '/some@other.txt') - cy.login(user) - cy.visit('/apps/files') - }) - - getRowForFile('some@other.txt') - .should('be.visible') - .findByRole('checkbox') - .check({ force: true }) - - getRowForFile('1+1.txt') - .should('be.visible') - .findByRole('checkbox') - .check({ force: true }) - - cy.get('[data-cy-files-list]').within(() => { - // see that two files are selected - cy.contains('2 selected').should('be.visible') - }) - - // click download - triggerSelectionAction('download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - '1+1.txt', - 'some@other.txt', - ])) - }) - - /** - * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 - */ - it('can download selected files with email uid', () => { - const name = `${randomString(5)}@${randomString(3)}` - const user: User = { userId: name, password: name, language: 'en' } - - cy.createUser(user).then(() => { - cy.uploadContent(user, new Blob(['']), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob(['']), 'text/plain', '/other file.txt') - cy.login(user) - cy.visit('/apps/files') - }) - - getRowForFile('file.txt') - .should('be.visible') - .findByRole('checkbox') - .check({ force: true }) - - getRowForFile('other file.txt') - .should('be.visible') - .findByRole('checkbox') - .check({ force: true }) - - cy.get('[data-cy-files-list]').within(() => { - // see that two files are selected - cy.contains('2 selected').should('be.visible') - }) - - // click download - triggerSelectionAction('download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/download.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'file.txt', - 'other file.txt', - ])) - }) -}) diff --git a/cypress/e2e/files/files-filtering.cy.ts b/cypress/e2e/files/files-filtering.cy.ts deleted file mode 100644 index 0b6397a2b73a7..0000000000000 --- a/cypress/e2e/files/files-filtering.cy.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { FilesFilterPage } from '../../pages/FilesFilters.ts' -import { FilesNavigationPage } from '../../pages/FilesNavigation.ts' -import { getRowForFile, navigateToFolder } from './FilesUtils.ts' - -describe('files: Filter in files list', { testIsolation: true }, () => { - const appNavigation = new FilesNavigationPage() - const filesFilters = new FilesFilterPage() - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob([]), 'text/csv', '/spreadsheet.csv') - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/text.txt') - cy.login(user) - cy.visit('/apps/files') - })) - - it('filters current view by name', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - getRowForFile('spreadsheet.csv').should('not.exist') - }) - - it('can reset name filter', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // reset the filter - appNavigation.searchInput().should('have.value', 'folder') - appNavigation.searchClearButton().should('exist').click() - appNavigation.searchInput().should('have.value', '') - - // All are visible again - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - }) - - it('filters current view by type', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - getRowForFile('spreadsheet.csv').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .and('have.attr', 'aria-pressed', 'false') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See that only the spreadsheet is visible - getRowForFile('spreadsheet.csv').should('be.visible') - getRowForFile('file.txt').should('not.exist') - getRowForFile('folder').should('not.exist') - }) - - it('can reset filter by type', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See folder is not visible - getRowForFile('folder').should('not.exist') - - // clear filter - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .and('have.attr', 'aria-pressed', 'true') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'false') - - filesFilters.closeFilterMenu() - - // See folder is visible again - getRowForFile('folder').should('be.visible') - }) - - it('can reset filter by clicking chip button', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See folder is not visible - getRowForFile('folder').should('not.exist') - - // clear filter - filesFilters.removeFilter('Spreadsheets') - - // See folder is visible again - getRowForFile('folder').should('be.visible') - }) - - it('keeps type filter when changing the directory', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Folders' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // see filter is active - filesFilters.activeFilters().contains(/Folder/).should('be.visible') - - // go to that folder - navigateToFolder('folder') - - // see filter is still active - filesFilters.activeFilters().contains(/Folder/).should('be.visible') - - // see that the folder is filtered - getRowForFile('text.txt').should('not.exist') - }) - - /** Regression test of https://github.com/nextcloud/server/issues/47251 */ - it('keeps filter state when changing the directory', () => { - // files are visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // enable type filter for folders - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Folders' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See the chips are active - filesFilters.activeFilters() - .should('have.length', 1) - .contains(/Folder/).should('be.visible') - - // See that folder is visible but file not - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // Change the directory - navigateToFolder('folder') - getRowForFile('folder').should('not.exist') - - // See that the chip is still active - filesFilters.activeFilters() - .should('have.length', 1) - .contains(/Folder/).should('be.visible') - // And also the button should be active - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Folders' }) - .should('be.visible') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - }) - - /** Regression test of https://github.com/nextcloud/server/issues/53038 */ - it('resets name filter when changing the directory', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // go to that folder - navigateToFolder('folder') - - // see the search is cleared - appNavigation.searchInput() - .should('have.value', '') - - // see that the folder content is showed - getRowForFile('text.txt').should('be.visible') - }) - - it('resets filter when changing the view', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // go to other view - appNavigation.views() - .findByRole('link', { name: /personal files/i }) - .click() - // wait for view changed - cy.url().should('match', /apps\/files\/personal/) - - // see that the folder is not filtered - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // see the filter bar is gone - appNavigation.searchInput().should('have.value', '') - }) -}) diff --git a/cypress/e2e/files/files-navigation.cy.ts b/cypress/e2e/files/files-navigation.cy.ts deleted file mode 100644 index 9fd74097debb4..0000000000000 --- a/cypress/e2e/files/files-navigation.cy.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile, navigateToFolder } from './FilesUtils.ts' - -describe('files: Navigate through folders and observe behavior', () => { - let user: User - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/foo') - cy.mkdir(user, '/foo/bar') - cy.mkdir(user, '/foo/bar/baz') - }) - }) - - it('Shows root folder and we can navigate to the last folder', () => { - cy.login(user) - cy.visit('/apps/files/') - - getRowForFile('foo').should('be.visible') - navigateToFolder('/foo/bar/baz') - - // Last folder is empty - cy.get('[data-cy-files-list-row-fileid]').should('not.exist') - }) - - it('Highlight the previous folder when navigating back', () => { - cy.go('back') - getRowForFile('baz').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('back') - getRowForFile('bar').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('back') - getRowForFile('foo').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - }) - - it('Can navigate forward again', () => { - cy.go('forward') - getRowForFile('bar').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('forward') - getRowForFile('baz').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - }) -}) diff --git a/cypress/e2e/files/files-renaming.cy.ts b/cypress/e2e/files/files-renaming.cy.ts deleted file mode 100644 index 200bf6779dc7e..0000000000000 --- a/cypress/e2e/files/files-renaming.cy.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils.ts' - -describe('files: Rename nodes', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - - // remove welcome file - cy.rm(user, '/welcome.txt') - // create a file called "file.txt" - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - - // login and visit files app - cy.login(user) - }) - cy.visit('/apps/files') - }) - - it('can rename a file', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{enter}') - - // See it is renamed - getRowForFile('other.txt').should('be.visible') - }) - - /** - * If this test gets flaky than we have a problem: - * It means that the selection is not reliable set to the basename - */ - it('only selects basename of file', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .should((el) => { - const input = el.get(0) as HTMLInputElement - expect(input.selectionStart).to.equal(0) - expect(input.selectionEnd).to.equal('file'.length) - }) - }) - - it('show validation error on file rename', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}.htaccess') - // See validity - .should(haveValidity(/reserved name/i)) - }) - - it('shows accessible loading information', () => { - const { resolve, promise } = Promise.withResolvers() - - getRowForFile('file.txt').should('be.visible') - - // intercept the rename (MOVE) - // the callback will wait until the promise resolve (so we have time to check the loading state) - cy.intercept( - 'MOVE', - /\/remote.php\/dav\/files\//, - (request) => { - // we need to wait in the onResponse handler as the intercept handler times out otherwise - request.on('response', async () => { - await promise - }) - }, - ).as('moveFile') - - // Start the renaming - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}new-name.txt{enter}') - - // Loading state is visible - getRowForFile('new-name.txt') - .findByRole('img', { name: 'File is loading' }) - .should('be.visible') - // checkbox is not visible - getRowForFile('new-name.txt') - .findByRole('checkbox', { name: /^Toggle selection/ }) - .should('not.exist') - - cy.log('Resolve promise to preoceed with MOVE request') - .then(() => resolve()) - - // Ensure the request is done (file renamed) - cy.wait('@moveFile') - - // checkbox visible again - getRowForFile('new-name.txt') - .findByRole('checkbox', { name: /^Toggle selection/ }) - .should('exist') - // see the loading state is gone - getRowForFile('new-name.txt') - .findByRole('img', { name: 'File is loading' }) - .should('not.exist') - }) - - it('cancel renaming on esc press', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{esc}') - - // See it is not renamed - getRowForFile('other.txt').should('not.exist') - getRowForFile('file.txt') - .should('be.visible') - .find('input[type="text"]') - .should('not.exist') - }) - - it('cancel on enter if no new name is entered', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{enter}') - - // See it is not renamed - getRowForFile('file.txt') - .should('be.visible') - .find('input[type="text"]') - .should('not.exist') - }) - - /** - * This is a regression test of: https://github.com/nextcloud/server/issues/47438 - * The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list - * due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling). - */ - it('correctly resets renaming state', () => { - // Create 19 additional files - for (let i = 1; i <= 19; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`) - } - - // Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered - cy.viewport(768, 500) - cy.login(user) - calculateViewportHeight(4) - .then((height) => cy.viewport(768, height)) - - cy.visit('/apps/files') - - getRowForFile('file.txt') - .should('be.visible') - // Z so it is shown last - renameFile('file.txt', 'zzz.txt') - // not visible any longer - getRowForFile('zzz.txt') - .should('not.exist') - // scroll file list to bottom - cy.get('[data-cy-files-list]') - .scrollTo('bottom') - cy.screenshot() - // The file is no longer in rename state - getRowForFile('zzz.txt') - .should('be.visible') - .findByRole('textbox', { name: 'Filename' }) - .should('not.exist') - }) - - it('shows warning on extension change - select new extension', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}file.md') - .type('{enter}') - - // See warning dialog - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Use .md' }) - .click() - - // See it is renamed - getRowForFile('file.md').should('be.visible') - }) - - it('shows warning on extension change - select old extension', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}document.md') - .type('{enter}') - - // See warning dialog - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Keep .txt' }) - .click() - - // See it is renamed - getRowForFile('document.txt').should('be.visible') - }) - - it('shows warning on extension removal', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}file') - .type('{enter}') - - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Keep .txt' }) - .should('be.visible') - cy.findByRole('dialog', { name: 'Change file extension' }) - .findByRole('button', { name: 'Remove extension' }) - .should('be.visible') - .click() - - // See it is renamed - getRowForFile('file').should('be.visible') - getRowForFile('file.txt').should('not.exist') - }) - - it('does not show warning on folder renaming with a dot', () => { - createFolder('folder.2024') - - getRowForFile('folder.2024').should('be.visible') - - triggerActionForFile('folder.2024', 'rename') - getRowForFile('folder.2024') - .findByRole('textbox', { name: 'Folder name' }) - .should('be.visible') - .type('{selectAll}folder.2025') - .should(haveValidity('')) - .type('{enter}') - - // See warning dialog - cy.get('[role=dialog]').should('not.exist') - - // See it is not renamed - getRowForFile('folder.2025').should('be.visible') - }) -}) diff --git a/cypress/e2e/files/files-selection.cy.ts b/cypress/e2e/files/files-selection.cy.ts deleted file mode 100644 index 7600d2c78f8f7..0000000000000 --- a/cypress/e2e/files/files-selection.cy.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { deselectAllFiles, selectAllFiles, selectRowForFile } from './FilesUtils.ts' - -const files = { - 'image.jpg': 'image/jpeg', - 'document.pdf': 'application/pdf', - 'archive.zip': 'application/zip', - 'audio.mp3': 'audio/mpeg', - 'video.mp4': 'video/mp4', - 'readme.md': 'text/markdown', - 'welcome.txt': 'text/plain', -} -const filesCount = Object.keys(files).length - -describe('files: Select all files', { testIsolation: true }, () => { - let user: User - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - Object.keys(files).forEach((file) => { - cy.uploadContent(user, new Blob(), files[file], '/' + file) - }) - }) - }) - - beforeEach(() => { - cy.login(user) - cy.visit('/apps/files') - }) - - it('Can select and unselect all files', () => { - cy.get('[data-cy-files-list-row-fileid]').should('have.length', filesCount) - cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount) - - selectAllFiles() - - cy.get('.files-list__selected').should('contain.text', '7 selected') - cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('be.checked') - - deselectAllFiles() - - cy.get('.files-list__selected').should('not.exist') - cy.get('[data-cy-files-list-row-checkbox]').findByRole('checkbox').should('not.be.checked') - }) - - it('Can select some files randomly', () => { - const randomFiles = Object.keys(files).reduce((acc, file) => { - if (Math.random() > 0.1) { - acc.push(file) - } - return acc - }, [] as string[]) - - randomFiles.forEach((name) => selectRowForFile(name)) - - cy.get('.files-list__selected').should('contain.text', `${randomFiles.length} selected`) - cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', randomFiles.length) - }) - - it('Can select range of files with shift key', () => { - cy.get('[data-cy-files-list-row-checkbox]').should('have.length', filesCount) - selectRowForFile('audio.mp3') - cy.window().trigger('keydown', { key: 'ShiftLeft', shiftKey: true }) - selectRowForFile('readme.md') - cy.window().trigger('keyup', { key: 'ShiftLeft', shiftKey: true }) - - cy.get('.files-list__selected').should('contain.text', '4 selected') - cy.get('[data-cy-files-list-row-checkbox] input[type="checkbox"]:checked').should('have.length', 4) - }) -}) diff --git a/cypress/e2e/files/files-settings.cy.ts b/cypress/e2e/files/files-settings.cy.ts deleted file mode 100644 index 299609e9d691b..0000000000000 --- a/cypress/e2e/files/files-settings.cy.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile } from './FilesUtils.ts' - -describe('files: Set default view', { testIsolation: true }, () => { - beforeEach(() => { - cy.createRandomUser().then(($user) => { - cy.login($user) - }) - }) - - it('Defaults to the "files" view', () => { - cy.visit('/apps/files') - - // See URL and current view - cy.url().should('match', /\/apps\/files\/files/) - cy.findByRole('navigation', { name: 'Current directory path' }) - .findAllByRole('button') - .first() - .should('have.text', 'All files') - - // See the option is also selected - // Open the files settings - cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) - // Toggle the setting - cy.findByRole('dialog', { name: 'Files settings' }) - .should('be.visible') - .within(() => { - cy.findByRole('group', { name: 'Default view' }) - .findByRole('radio', { name: 'All files' }) - .should('be.checked') - }) - }) - - it('Can set it to personal files', () => { - cy.visit('/apps/files') - - // Open the files settings - cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) - // Toggle the setting - cy.findByRole('dialog', { name: 'Files settings' }) - .should('be.visible') - .within(() => { - cy.findByRole('group', { name: 'Default view' }) - .findByRole('radio', { name: 'Personal files' }) - .check({ force: true }) - }) - - cy.visit('/apps/files') - cy.url().should('match', /\/apps\/files\/personal/) - cy.findByRole('navigation', { name: 'Current directory path' }) - .findAllByRole('button') - .first() - .should('have.text', 'Personal files') - }) -}) - -describe('files: Hide or show hidden files', { testIsolation: true }, () => { - let user: User - - const setupFiles = () => cy.createRandomUser().then(($user) => { - user = $user - - cy.uploadContent(user, new Blob([]), 'text/plain', '/.file') - cy.uploadContent(user, new Blob([]), 'text/plain', '/visible-file') - cy.mkdir(user, '/.folder') - cy.login(user) - }) - - context('view: All files', { testIsolation: false }, () => { - before(setupFiles) - - it('hides dot-files by default', () => { - cy.visit('/apps/files') - - getRowForFile('visible-file').should('be.visible') - getRowForFile('.file').should('not.exist') - getRowForFile('.folder').should('not.exist') - }) - - it('can show hidden files', () => { - showHiddenFiles() - // Now the files should be visible - getRowForFile('.file').should('be.visible') - getRowForFile('.folder').should('be.visible') - }) - }) - - context('view: Personal files', { testIsolation: false }, () => { - before(setupFiles) - - it('hides dot-files by default', () => { - cy.visit('/apps/files/personal') - - getRowForFile('visible-file').should('be.visible') - getRowForFile('.file').should('not.exist') - getRowForFile('.folder').should('not.exist') - }) - - it('can show hidden files', () => { - showHiddenFiles() - // Now the files should be visible - getRowForFile('.file').should('be.visible') - getRowForFile('.folder').should('be.visible') - }) - }) - - context('view: Recent files', { testIsolation: false }, () => { - before(() => { - setupFiles().then(() => { - // also add hidden file in hidden folder - cy.uploadContent(user, new Blob([]), 'text/plain', '/.folder/other-file') - cy.login(user) - }) - }) - - it('hides dot-files by default', () => { - cy.visit('/apps/files/recent') - - getRowForFile('visible-file').should('be.visible') - getRowForFile('.file').should('not.exist') - getRowForFile('.folder').should('not.exist') - getRowForFile('other-file').should('not.exist') - }) - - it('can show hidden files', () => { - showHiddenFiles() - - getRowForFile('visible-file').should('be.visible') - // Now the files should be visible - getRowForFile('.file').should('be.visible') - getRowForFile('.folder').should('be.visible') - getRowForFile('other-file').should('be.visible') - }) - }) -}) - -/** - * Helper to toggle the hidden files settings - */ -function showHiddenFiles() { - // Open the files settings - cy.get('[data-cy-files-navigation-settings-button] a').click({ force: true }) - // Toggle the hidden files setting - cy.findByRole('switch', { name: /show hidden files/i }) - .as('hiddenFiles') - .scrollIntoView() - cy.get('@hiddenFiles') - .should('not.be.checked') - .check({ force: true }) - - // Close the dialog - cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click() -} diff --git a/cypress/e2e/files/files-sidebar.cy.ts b/cypress/e2e/files/files-sidebar.cy.ts deleted file mode 100644 index 69be3473a808d..0000000000000 --- a/cypress/e2e/files/files-sidebar.cy.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { assertNotExistOrNotVisible } from '../settings/usersUtils.ts' -import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils.ts' - -describe('Files: Sidebar', { testIsolation: true }, () => { - let user: User - let fileId: number = 0 - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file').then((response) => { - fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - }) - cy.login(user) - })) - - it('opens the sidebar', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'file' }) - .should('be.visible') - }) - - it('changes the current fileid', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]').should('be.visible') - cy.url().should('contain', `apps/files/files/${fileId}`) - }) - - it('changes the sidebar content on other file', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'file' }) - .should('be.visible') - - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - triggerActionForFile('folder', 'details') - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'folder' }) - .should('be.visible') - }) - - it('closes the sidebar on navigation', () => { - cy.visit('/apps/files') - - getRowForFile('file').should('be.visible') - getRowForFile('folder').should('be.visible') - - // open the sidebar - triggerActionForFile('file', 'details') - // validate it is open - cy.get('[data-cy-sidebar]') - .should('be.visible') - - // if we navigate to the folder - navigateToFolder('folder') - // the sidebar should not be visible anymore - cy.get('[data-cy-sidebar]') - .should(assertNotExistOrNotVisible) - }) - - it('closes the sidebar on delete', () => { - cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/file`).as('deleteFile') - // visit the files app - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - // open the sidebar - triggerActionForFile('file', 'details') - // validate it is open - cy.get('[data-cy-sidebar]') - .should('be.visible') - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - // delete the file - triggerActionForFile('file', 'delete') - cy.wait('@deleteFile', { timeout: 10000 }) - // see the sidebar is closed - cy.get('[data-cy-sidebar]') - .should(assertNotExistOrNotVisible) - }) - - it('changes the fileid on delete', () => { - cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/folder/other`).as('deleteFile') - - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/other').then((response) => { - const otherFileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - cy.login(user) - cy.visit('/apps/files') - - getRowForFile('folder').should('be.visible') - navigateToFolder('folder') - getRowForFile('other').should('be.visible') - - // open the sidebar - triggerActionForFile('other', 'details') - // validate it is open - cy.get('[data-cy-sidebar]').should('be.visible') - cy.url().should('contain', `apps/files/files/${otherFileId}`) - - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - triggerActionForFile('other', 'delete') - cy.wait('@deleteFile') - - cy.get('[data-cy-sidebar]').should('not.be.visible') - // Ensure the URL is changed - cy.url().should('not.contain', `apps/files/files/${otherFileId}`) - }) - }) -}) diff --git a/cypress/e2e/files/files-sorting.cy.ts b/cypress/e2e/files/files-sorting.cy.ts deleted file mode 100644 index a7e699f055083..0000000000000 --- a/cypress/e2e/files/files-sorting.cy.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -describe('Files: Sorting the file list', { testIsolation: true }, () => { - let currentUser - beforeEach(() => { - cy.createRandomUser().then((user) => { - currentUser = user - cy.login(user) - }) - }) - - it('Files are sorted by name ascending by default', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 first.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/z last.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/A.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/Ä.txt') - .mkdir(currentUser, '/m') - .mkdir(currentUser, '/4') - cy.login(currentUser) - cy.visit('/apps/files') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('4') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('m') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 first.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('A.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('Ä.txt') - break - case 5: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 6: expect($row.attr('data-cy-files-list-row-name')).to.eq('z last.txt') - break - } - }) - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/45829 - */ - it('Filesnames with numbers are sorted by name ascending by default', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/name.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/name_03.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/name_02.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/name_01.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('name.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_01.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_02.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_03.txt') - break - } - }) - }) - - it('Can sort by size', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt') - .mkdir(currentUser, '/folder') - cy.login(currentUser) - cy.visit('/apps/files') - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - } - }) - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - } - }) - }) - - it('Can sort by mtime', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000) - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400) - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500) - cy.login(currentUser) - cy.visit('/apps/files') - - // click sort button - cy.get('th').contains('button', 'Modified').click() - // sorting is set - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago - break - } - }) - - // reverse order - cy.get('th').contains('button', 'Modified').click() - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending') - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes - break - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago - break - } - }) - }) - - it('Favorites are sorted first', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000) - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400) - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500) - .setFileAsFavorite(currentUser, '/a.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - cy.log('By name - ascending') - cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By name - descending') - cy.get('th').contains('button', 'Name').click() - cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'descending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By size - ascending') - cy.get('th').contains('button', 'Size').click() - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By size - descending') - cy.get('th').contains('button', 'Size').click() - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By mtime - ascending') - cy.get('th').contains('button', 'Modified').click() - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - } - }) - - cy.log('By mtime - descending') - cy.get('th').contains('button', 'Modified').click() - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - } - }) - }) - - it('Sorting works after switching view twice', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt') - .mkdir(currentUser, '/folder') - cy.login(currentUser) - cy.visit('/apps/files') - - // click sort button twice - cy.get('th').contains('button', 'Size').click() - cy.get('th').contains('button', 'Size').click() - - // switch to personal and click sort button twice again - cy.get('[data-cy-files-navigation-item="personal"]').click() - cy.get('th').contains('button', 'Size').click() - cy.get('th').contains('button', 'Size').click() - - // switch back to files view and do actual assertions - cy.get('[data-cy-files-navigation-item="files"]').click() - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - } - }) - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - } - }) - }) -}) diff --git a/cypress/e2e/files/files-xml-regression.cy.ts b/cypress/e2e/files/files-xml-regression.cy.ts deleted file mode 100644 index a961b78e2f493..0000000000000 --- a/cypress/e2e/files/files-xml-regression.cy.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { getRowForFile, triggerActionForFile } from './FilesUtils.ts' - -/** - * This is a regression test for https://github.com/nextcloud/server/issues/43331 - * Where files with XML entities in their names were wrongly displayed and could no longer be renamed / deleted etc. - */ -describe('Files: Can handle XML entities in file names', { testIsolation: false }, () => { - before(() => { - cy.createRandomUser().then((user) => { - cy.uploadContent(user, new Blob(), 'text/plain', '/and.txt') - cy.login(user) - cy.visit('/apps/files/') - }) - }) - - it('Can reanme to a file name containing XML entities', () => { - cy.intercept('MOVE', /\/remote.php\/dav\/files\//).as('renameFile') - triggerActionForFile('and.txt', 'rename') - getRowForFile('and.txt') - .find('form[aria-label="Rename file"] input') - .type('{selectAll}&.txt{enter}') - - cy.wait('@renameFile') - getRowForFile('&.txt').should('be.visible') - }) - - it('After a reload the filename is preserved', () => { - cy.reload() - getRowForFile('&.txt').should('be.visible') - getRowForFile('&.txt').should('not.exist') - }) - - it('Can delete the file', () => { - cy.intercept('DELETE', /\/remote.php\/dav\/files\//).as('deleteFile') - triggerActionForFile('&.txt', 'delete') - cy.wait('@deleteFile') - - cy.contains('.toast-success', /Delete .* done/) - .should('be.visible') - getRowForFile('&.txt').should('not.exist') - - cy.reload() - getRowForFile('&.txt').should('not.exist') - getRowForFile('&.txt').should('not.exist') - }) -}) diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts deleted file mode 100644 index 745c330c54319..0000000000000 --- a/cypress/e2e/files/files.cy.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -describe('Files', { testIsolation: true }, () => { - let currentUser: User - - beforeEach(() => { - cy.createRandomUser().then((user) => { - currentUser = user - }) - }) - - it('Login with a user and open the files app', () => { - cy.login(currentUser) - cy.visit('/apps/files') - cy.get('[data-cy-files-list] [data-cy-files-list-row-name="welcome.txt"]').should('be.visible') - }) - - it('Opens a valid file shows it as active', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt').then((response) => { - const fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - - cy.login(currentUser) - cy.visit('/apps/files/files/' + fileId) - - cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) - .should('be.visible') - cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) - .invoke('attr', 'data-cy-files-list-row-name').should('eq', 'original.txt') - cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) - .invoke('attr', 'class').should('contain', 'active') - cy.contains('The file could not be found').should('not.exist') - }) - }) - - it('Opens a valid folder shows its content', () => { - cy.mkdir(currentUser, '/folder').then(() => { - cy.login(currentUser) - cy.visit('/apps/files/files?dir=/folder') - - cy.get('[data-cy-files-content-breadcrumbs]').contains('folder').should('be.visible') - cy.contains('The file could not be found').should('not.exist') - }) - }) - - it('Opens an unknown file show an error', () => { - cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') - cy.login(currentUser) - cy.visit('/apps/files/files/123456') - - cy.wait('@propfind') - // The toast should be visible - cy.contains('The file could not be found', { timeout: 5000 }).should('be.visible') - }) -}) diff --git a/cypress/e2e/files/hotkeys.cy.ts b/cypress/e2e/files/hotkeys.cy.ts deleted file mode 100644 index c68c006db7dda..0000000000000 --- a/cypress/e2e/files/hotkeys.cy.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { getRowForFileId } from './FilesUtils.ts' - -describe('Files hotkey handling', () => { - before(() => { - cy.createRandomUser().then((user) => { - cy.mkdir(user, '/abcd') - cy.mkdir(user, '/zyx') - cy.rm(user, '/welcome.txt') - cy.login(user) - }) - }) - - beforeEach(() => cy.visit('/apps/files')) - - it('Pressing "arrow down" should go to first file', () => { - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.DOWN) - - cy.url() - .should('match', /\/apps\/files\/files\/\d+/) - .then((url) => new URL(url).pathname.split('/').at(-1)) - .then((fileId) => getRowForFileId(fileId) - .should('exist') - .and('have.attr', 'data-cy-files-list-row-name', 'abcd')) - }) - - it('Pressing "arrow up" should go to first file', () => { - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.UP) - - cy.url() - .should('match', /\/apps\/files\/files\/\d+/) - .then((url) => new URL(url).pathname.split('/').at(-1)) - .then((fileId) => getRowForFileId(fileId) - .should('exist') - .and('have.attr', 'data-cy-files-list-row-name', 'zyx')) - }) - - it('Pressing D should open the sidebar once', () => { - activateFirstRow() - cy.get('[data-cy-files-list]') - .press('d') - - cy.get('[data-cy-sidebar]') - .should('exist') - .and('be.visible') - }) - - it('Pressing F2 should rename the file', () => { - activateFirstRow() - cy.get('[data-cy-files-list]') - .should('exist') - .then(($el) => { - const el = $el.get(0) - // manually dispatch as Cypress refuses to press F-keys for "security reasons" - cy.log('Dispatching F2 keydown/keyup events') - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', bubbles: true })) - el.dispatchEvent(new KeyboardEvent('keyup', { key: 'F2', code: 'F2', bubbles: true })) - el.dispatchEvent(new KeyboardEvent('keypress', { key: 'F2', code: 'F2', bubbles: true })) - }) - - cy.get('[data-cy-files-list-row-name]') - .first() - .findByRole('textbox', { name: /Folder name/ }) - .should('exist') - }) - - it('Pressing S should toggle favorite', () => { - activateFirstRow() - cy.get('[data-cy-files-list]') - .press('s') - - cy.get('[data-cy-files-list-row-name]') - .first() - .as('firstRow') - .findByRole('img', { name: /Favorite/ }) - .should('exist') - - cy.get('[data-cy-files-list]') - .press('s') - - cy.get('@firstRow') - .findByRole('img', { name: /Favorite/ }) - .should('not.exist') - }) - - it('Pressing DELETE should delete the folder', () => { - activateFirstRow() - cy.get('td[data-cy-files-list-row-name]') - .should('have.length', 2) - - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.DELETE) - - cy.get('td[data-cy-files-list-row-name]') - .should('have.length', 1) - }) -}) - -/** - * Activates the first row in the files list by simulating a press of the down arrow key. - */ -function activateFirstRow() { - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.DOWN) - cy.url() - .should('match', /\/apps\/files\/files\/\d+/) -} diff --git a/cypress/e2e/files/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts deleted file mode 100644 index ee2e69f2c063f..0000000000000 --- a/cypress/e2e/files/live_photos.cy.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { - copyFile, - createFolder, - getRowForFile, - getRowForFileId, - moveFile, - navigateToFolder, - reloadCurrentFolder, - renameFile, - skipOnKnownFilePickerRace, - triggerActionForFile, - triggerInlineActionForFileId, -} from './FilesUtils.ts' -import { setShowHiddenFiles, setupLivePhotos } from './LivePhotosUtils.ts' - -describe('Files: Live photos', { testIsolation: true }, () => { - let user: User - let randomFileName: string - let jpgFileId: number - let movFileId: number - - beforeEach(() => { - setupLivePhotos() - .then((setupInfo) => { - user = setupInfo.user - randomFileName = setupInfo.fileName - jpgFileId = setupInfo.jpgFileId - movFileId = setupInfo.movFileId - }) - }) - - it('Only renders the .jpg file', () => { - getRowForFileId(jpgFileId).should('have.length', 1) - getRowForFileId(movFileId).should('have.length', 0) - }) - - context("'Show hidden files' is enabled", () => { - beforeEach(() => { - setShowHiddenFiles(true) - }) - - it("Shows both files when 'Show hidden files' is enabled", () => { - getRowForFileId(jpgFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.jpg`) - getRowForFileId(movFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.mov`) - }) - - it('Copies both files when copying the .jpg', function() { - skipOnKnownFilePickerRace(this) - copyFile(`${randomFileName}.jpg`, '.') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1) - }) - - it('Copies both files when copying the .mov', function() { - skipOnKnownFilePickerRace(this) - copyFile(`${randomFileName}.mov`, '.') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1) - }) - - it('Keeps live photo link when copying folder', function() { - skipOnKnownFilePickerRace(this) - createFolder('folder') - moveFile(`${randomFileName}.jpg`, 'folder') - copyFile('folder', '.') - navigateToFolder('folder (1)') - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - - setShowHiddenFiles(false) - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - }) - - it('Block copying live photo in a folder containing a mov file with the same name', function() { - skipOnKnownFilePickerRace(this) - createFolder('folder') - cy.uploadContent(user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/folder/${randomFileName}.mov`) - cy.login(user) - cy.visit('/apps/files') - copyFile(`${randomFileName}.jpg`, 'folder') - navigateToFolder('folder') - - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 0) - }) - - it('Moves files when moving the .jpg', () => { - renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`) - reloadCurrentFolder() - - getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) - getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) - }) - - it('Moves files when moving the .mov', () => { - renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`) - reloadCurrentFolder() - - getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) - getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) - }) - - it('Deletes files when deleting the .jpg', () => { - triggerActionForFile(`${randomFileName}.jpg`, 'delete') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - - cy.visit('/apps/files/trashbin') - - getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.jpg\\.d[0-9]+$`)) - getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.mov\\.d[0-9]+$`)) - }) - - it('Block deletion when deleting the .mov', () => { - triggerActionForFile(`${randomFileName}.mov`, 'delete') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - - cy.visit('/apps/files/trashbin') - - getRowForFileId(jpgFileId).should('have.length', 0) - getRowForFileId(movFileId).should('have.length', 0) - }) - - it('Restores files when restoring the .jpg', () => { - triggerActionForFile(`${randomFileName}.jpg`, 'delete') - cy.visit('/apps/files/trashbin') - - triggerInlineActionForFileId(jpgFileId, 'restore') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - - cy.visit('/apps/files') - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - }) - - it('Blocks restoration when restoring the .mov', () => { - triggerActionForFile(`${randomFileName}.jpg`, 'delete') - cy.visit('/apps/files/trashbin') - - triggerInlineActionForFileId(movFileId, 'restore') - reloadCurrentFolder() - - getRowForFileId(jpgFileId).should('have.length', 1) - getRowForFileId(movFileId).should('have.length', 1) - - cy.visit('/apps/files') - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - }) - }) -}) diff --git a/cypress/e2e/files/new-menu.cy.ts b/cypress/e2e/files/new-menu.cy.ts deleted file mode 100644 index 554b023bef093..0000000000000 --- a/cypress/e2e/files/new-menu.cy.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { createFolder, getRowForFile, haveValidity, navigateToFolder } from './FilesUtils.ts' - -describe('"New"-menu', { testIsolation: true }, () => { - beforeEach(() => { - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/apps/files') - }) - }) - - it('Create new folder', () => { - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // Create a folder - cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('A new folder{enter}') - cy.wait('@mkdir') - // See the folder is visible - getRowForFile('A new folder') - .should('be.visible') - }) - - it('Does not allow creating forbidden folder names', () => { - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // enter folder name - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('.htaccess') - // See that input has invalid state set - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .should(haveValidity(/reserved name/i)) - // See that it can not create - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('button', { name: 'Create' }) - .should('be.disabled') - }) - - it('Does not allow creating folders with already existing names', () => { - createFolder('already exists') - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // enter folder name - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('already exists') - // See that input has invalid state set - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .should(haveValidity(/already in use/i)) - // See that it can not create - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('button', { name: 'Create' }) - .should('be.disabled') - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/47530 - */ - it('Create same folder in child folder', () => { - // setup other folders - createFolder('folder') - createFolder('other folder') - navigateToFolder('folder') - - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // enter folder name - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('other folder') - // See that creating is allowed - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .should(haveValidity('')) - // can create - cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('button', { name: 'Create' }) - .click() - cy.wait('@mkdir') - // see it is created - getRowForFile('other folder') - .should('be.visible') - }) -}) diff --git a/cypress/e2e/files/recent-view.cy.ts b/cypress/e2e/files/recent-view.cy.ts deleted file mode 100644 index 3dd6fb2a8a63b..0000000000000 --- a/cypress/e2e/files/recent-view.cy.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile, triggerActionForFile } from './FilesUtils.ts' - -describe('files: Recent view', { testIsolation: true }, () => { - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - })) - - it('see the recently created file in the recent view', () => { - cy.visit('/apps/files/recent') - // All are visible by default - getRowForFile('file.txt').should('be.visible') - }) - - /** - * Regression test: There was a bug that the files were correctly loaded but with invalid source - * so the delete action failed. - */ - it('can delete a file in the recent view', () => { - cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') - - cy.visit('/apps/files/recent') - // See the row - getRowForFile('file.txt').should('be.visible') - // delete the file - triggerActionForFile('file.txt', 'delete') - cy.wait('@deleteFile') - // See it is not visible anymore - getRowForFile('file.txt').should('not.exist') - // also not existing in default view after reload - cy.visit('/apps/files') - getRowForFile('file.txt').should('not.exist') - }) -}) diff --git a/cypress/e2e/files/router-query.cy.ts b/cypress/e2e/files/router-query.cy.ts deleted file mode 100644 index 56d0937dca4a4..0000000000000 --- a/cypress/e2e/files/router-query.cy.ts +++ /dev/null @@ -1,185 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { join } from '@nextcloud/paths' -import { getRowForFileId } from './FilesUtils.ts' - -/** - * Check that the sidebar is opened for a specific file - * @param name The name of the file - */ -function sidebarIsOpen(name: string): void { - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name }) - .should('be.visible') -} - -/** - * Skip a test without viewer installed - */ -function skipIfViewerDisabled(this: Mocha.Context): void { - cy.runOccCommand('app:list --enabled --output json') - .then((exec) => exec.stdout) - .then((output) => JSON.parse(output)) - .then((obj) => 'viewer' in obj.enabled) - .then((enabled) => { - if (!enabled) { - this.skip() - } - }) -} - -/** - * Check a file was not downloaded - * @param filename The expected filename - */ -function fileNotDownloaded(filename: string): void { - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(join(downloadsFolder, filename)).should('not.exist') -} - -describe('Check router query flags:', function() { - let user: User - let imageId: number - let archiveId: number - let folderId: number - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.uploadFile(user, 'image.jpg') - .then((response) => { imageId = Number.parseInt(response.headers['oc-fileid']) }) - cy.mkdir(user, '/folder') - .then((response) => { folderId = Number.parseInt(response.headers['oc-fileid']) }) - cy.uploadContent(user, new Blob([]), 'application/zstd', '/archive.zst') - .then((response) => { archiveId = Number.parseInt(response.headers['oc-fileid']) }) - cy.login(user) - }) - }) - - describe('"opendetails"', () => { - it('open details for known file type', () => { - cy.visit(`/apps/files/files/${imageId}?opendetails`) - - // see sidebar - sidebarIsOpen('image.jpg') - - // but no viewer - cy.findByRole('dialog', { name: 'image.jpg' }) - .should('not.exist') - - // and no download - fileNotDownloaded('image.jpg') - }) - - it('open details for unknown file type', () => { - cy.visit(`/apps/files/files/${archiveId}?opendetails`) - - // see sidebar - sidebarIsOpen('archive.zst') - - // but no viewer - cy.findByRole('dialog', { name: 'archive.zst' }) - .should('not.exist') - - // and no download - fileNotDownloaded('archive.zst') - }) - - it('open details for folder', () => { - cy.visit(`/apps/files/files/${folderId}?opendetails`) - - // see sidebar - sidebarIsOpen('folder') - - // but no viewer - cy.findByRole('dialog', { name: 'folder' }) - .should('not.exist') - - // and no download - fileNotDownloaded('folder') - }) - }) - - describe('"openfile"', function() { - /** Check the viewer is open and shows the image */ - function viewerShowsImage(): void { - cy.findByRole('dialog', { name: 'image.jpg' }) - .should('be.visible') - // The viewer falls back to the original file when generating the - // preview fails or dawdles (e.g. on a loaded server) — do not - // couple the assertion to the delivery mechanism. - cy.findByRole('dialog', { name: 'image.jpg' }) - .find('img') - .should('be.visible') - } - - it('opens files with default action', function() { - skipIfViewerDisabled.call(this) - - cy.visit(`/apps/files/files/${imageId}?openfile`) - viewerShowsImage() - }) - - it('opens files with default action using explicit query state', function() { - skipIfViewerDisabled.call(this) - - cy.visit(`/apps/files/files/${imageId}?openfile=true`) - viewerShowsImage() - }) - - it('does not open files with default action when using explicitly query value `false`', function() { - skipIfViewerDisabled.call(this) - - cy.visit(`/apps/files/files/${imageId}?openfile=false`) - getRowForFileId(imageId) - .should('be.visible') - .and('have.class', 'files-list__row--active') - - cy.findByRole('dialog', { name: 'image.jpg' }) - .should('not.exist') - }) - - it('does not open folders but shows details', () => { - cy.visit(`/apps/files/files/${folderId}?openfile`) - - // See the URL was replaced - cy.url() - .should('match', /[?&]opendetails(&|=|$)/) - .and('not.match', /openfile/) - - // See the sidebar is correctly opened - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'folder' }) - .should('be.visible') - - // see the folder was not changed - getRowForFileId(imageId).should('exist') - }) - - it('does not open unknown file types but shows details', () => { - cy.visit(`/apps/files/files/${archiveId}?openfile`) - - // See the URL was replaced - cy.url() - .should('match', /[?&]opendetails(&|=|$)/) - .and('not.match', /openfile/) - - // See the sidebar is correctly opened - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'archive.zst' }) - .should('be.visible') - - // See no file was downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(join(downloadsFolder, 'archive.zst')).should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files/scrolling.cy.ts b/cypress/e2e/files/scrolling.cy.ts deleted file mode 100644 index d1647e6df3ec2..0000000000000 --- a/cypress/e2e/files/scrolling.cy.ts +++ /dev/null @@ -1,215 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { beFullyInViewport, notBeFullyInViewport } from '../core-utils.ts' -import { calculateViewportHeight, enableGridMode, getRowForFile } from './FilesUtils.ts' - -describe('files: Scrolling to selected file in file list', () => { - const fileIds = new Map() - let viewportHeight: number - - before(() => { - initFilesAndViewport(fileIds) - .then((_viewportHeight) => { - cy.log(`Saving viewport height to ${_viewportHeight}px`) - viewportHeight = _viewportHeight - }) - }) - - beforeEach(() => { - cy.viewport(1200, viewportHeight) - }) - - it('Can see first file in list', () => { - cy.visit(`/apps/files/files/${fileIds.get(1)}`) - - // See file is visible - getRowForFile('1.txt') - .should('be.visible') - - // we expect also element 6 to be visible - getRowForFile('6.txt') - .should('be.visible') - // but not element 7 - though it should exist (be buffered) - getRowForFile('7.txt') - .should('exist') - .and('not.be.visible') - }) - - // For files already in the visible buffer, scrolling is skipped to prevent jumping - // So we only verify the file exists and is in the DOM - for (let i = 2; i <= 5; i++) { - it(`correctly scrolls to row ${i}`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should exist in the DOM (scroll is skipped when already in visible buffer) - getRowForFile(`${i}.txt`) - .should('exist') - }) - } - - // Row 6 is at the edge of the initial visible buffer, scroll may be skipped - it('correctly scrolls to row 6', () => { - cy.visit(`/apps/files/files/${fileIds.get(6)}`) - - // File should exist in the DOM (scroll may be skipped when in visible buffer) - getRowForFile('6.txt') - .should('exist') - }) - - // For the last "page" of entries we can not scroll further - // so we show all of the last 4 entries - for (let i = 7; i <= 10; i++) { - it(`correctly scrolls to row ${i}`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // See file is visible - getRowForFile(`${i}.txt`) - .should('be.visible') - .and(notBeOverlappedByTableHeader) - - // there are only max. 4 rows left so also row 6+ should be visible - getRowForFile('6.txt') - .should('be.visible') - getRowForFile('10.txt') - .should('be.visible') - // Also the footer is visible - cy.get('tfoot') - .contains('10 files') - .should(beFullyInViewport) - }) - } -}) - -describe('files: Scrolling to selected file in file list (GRID MODE)', () => { - const fileIds = new Map() - let viewportHeight: number - - before(() => { - initFilesAndViewport(fileIds, true) - .then((_viewportHeight) => { viewportHeight = _viewportHeight }) - }) - - beforeEach(() => { - cy.viewport(768, viewportHeight) - }) - - // First row - for (let i = 1; i <= 3; i++) { - it(`Can see files in first row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - for (let j = 1; j <= 3; j++) { - // See all files of that row are visible - getRowForFile(`${j}.txt`) - .should('be.visible') - // we expect also the second row to be visible - getRowForFile(`${j + 3}.txt`) - .should('be.visible') - // Because there is no half row on top we also see the third row - getRowForFile(`${j + 6}.txt`) - .should('be.visible') - // But not the forth row - getRowForFile(`${j + 9}.txt`) - .should('exist') - .and(notBeFullyInViewport) - } - }) - } - - // Second row - files already in visible buffer, scroll is skipped - for (let i = 4; i <= 6; i++) { - it(`correctly scrolls to second row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should exist in the DOM (scroll is skipped when in visible buffer) - getRowForFile(`${i}.txt`) - .should('exist') - }) - } - - // Third row - files may be in visible buffer, scroll may be skipped - for (let i = 7; i <= 9; i++) { - it(`correctly scrolls to third row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should exist in the DOM (scroll may be skipped when in visible buffer) - getRowForFile(`${i}.txt`) - .should('exist') - }) - } - - // Forth row - scrolling happens for files outside initial visible buffer - for (let i = 10; i <= 12; i++) { - it(`correctly scrolls to forth row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should be visible after scrolling - getRowForFile(`${i}.txt`) - .should('be.visible') - }) - } -}) - -/// Some helpers - -/** - * Assert that an element is overlapped by the table header - * @param $el The element - * @param expected if it should be overlapped or NOT - */ -function beOverlappedByTableHeader($el: JQuery, expected = true) { - const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect() - const elementRect = $el.get(0)!.getBoundingClientRect() - const overlap = !(headerRect.right < elementRect.left - || headerRect.left > elementRect.right - || headerRect.bottom < elementRect.top - || headerRect.top > elementRect.bottom) - - if (expected) { - expect(overlap, 'Overlapped by table header').to.be.true - } else { - expect(overlap, 'Not overlapped by table header').to.be.false - } -} - -/** - * Assert that an element is not overlapped by the table header - * @param $el The element - */ -function notBeOverlappedByTableHeader($el: JQuery) { - return beOverlappedByTableHeader($el, false) -} - -function initFilesAndViewport(fileIds: Map, gridMode = false): Cypress.Chainable { - return cy.createRandomUser().then((user) => { - cy.rm(user, '/welcome.txt') - - // Create files with names 1.txt, 2.txt, ..., 10.txt - const count = gridMode ? 12 : 10 - for (let i = 1; i <= count; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`) - .then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString())) - } - - cy.login(user) - cy.viewport(1200, 800) - - cy.visit('/apps/files') - - // If grid mode is requested, enable it - if (gridMode) { - enableGridMode() - } - - // Calculate height to ensure that those 10 elements can not be rendered in one list (only 6 will fit the screen, 3 in grid mode) - return calculateViewportHeight(gridMode ? 3 : 6) - .then((height) => { - // Set viewport height to the calculated height - cy.log(`Setting viewport height to ${height}px`) - cy.wrap(height) - }) - }) -} diff --git a/cypress/e2e/files/search.cy.ts b/cypress/e2e/files/search.cy.ts deleted file mode 100644 index 2800a71a539ee..0000000000000 --- a/cypress/e2e/files/search.cy.ts +++ /dev/null @@ -1,217 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { FilesNavigationPage } from '../../pages/FilesNavigation.ts' -import { getRowForFile, navigateToFolder, reloadCurrentFolder } from './FilesUtils.ts' - -describe('files: search', () => { - let user: User - - const navigation = new FilesNavigationPage() - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/some folder') - cy.mkdir(user, '/some folder/nested folder') - cy.mkdir(user, '/other folder') - cy.mkdir(user, '/12345') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a second file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/nested folder/deep file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/other folder/another file.txt') - cy.login(user) - }) - }) - - beforeEach(() => { - cy.visit('/apps/files') - }) - - it('updates the query on the URL', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - - navigation.searchInput().type('file') - cy.url().should('match', /query=file($|&)/) - }) - - it('can search globally', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('file') - - getRowForFile('file.txt').should('be.visible') - getRowForFile('a file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('another file.txt').should('be.visible') - }) - - it('filter does also search locally', () => { - navigateToFolder('some folder') - getRowForFile('a file.txt').should('be.visible') - - navigation.searchInput().type('file') - - getRowForFile('a file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('deep file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) - }) - - it('See "search everywhere" button', () => { - // Not visible initially - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('not.to.exist') - - // add a filter - navigation.searchInput().type('file') - - // see its visible - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('be.visible') - - // clear the filter - navigation.searchClearButton().click() - - // see its not visible again - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('not.to.exist') - }) - - it('can make local search a global search', () => { - navigateToFolder('some folder') - getRowForFile('a file.txt').should('be.visible') - - navigation.searchInput().type('file') - - // see local results - getRowForFile('a file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('deep file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) - - // toggle global search - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('be.visible') - .click() - - // see global results - getRowForFile('file.txt').should('be.visible') - getRowForFile('a file.txt').should('be.visible') - getRowForFile('deep file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('another file.txt').should('be.visible') - }) - - it('shows empty content when there are no results', () => { - navigateToFolder('some folder') - getRowForFile('a file.txt').should('be.visible') - - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('xyz') - - // see the empty content message - cy.contains('[role="note"]', /No search results for .xyz./) - .should('be.visible') - .within(() => { - // see within there is a search box with the same value - cy.findByRole('searchbox', { name: /search for files/i }) - .should('be.visible') - .and('have.value', 'xyz') - }) - }) - - it('can alter search', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('other') - - getRowForFile('another file.txt').should('be.visible') - getRowForFile('other folder').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) - - navigation.searchInput().type(' file') - navigation.searchInput().should('have.value', 'other file') - getRowForFile('another file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) - }) - - it('returns to file list if search is cleared', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('other') - - getRowForFile('another file.txt').should('be.visible') - getRowForFile('other folder').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) - - navigation.searchClearButton().click() - navigation.searchInput().should('have.value', '') - getRowForFile('file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 5) - }) - - /** - * Problem: - * 1. Being on the search view - * 2. Press the refresh button (name of the current view) - * 3. See that the router link does not preserve the query - * - * We fix this with a navigation guard and need to verify that it works - */ - it('keeps the query in the URL', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('file') - - // see that the search view is loaded - getRowForFile('a file.txt').should('be.visible') - // see the correct url - cy.url().should('match', /query=file($|&)/) - - cy.intercept('SEARCH', '**/remote.php/dav/').as('search') - // refresh the view - reloadCurrentFolder(false) // no PROPFIND intercept here as we want to wait for SEARCH - // wait for the request - cy.wait('@search') - // see that the search view is reloaded - getRowForFile('a file.txt').should('be.visible') - // see the correct url - cy.url().should('match', /query=file($|&)/) - }) -}) diff --git a/cypress/e2e/files_external/StorageUtils.ts b/cypress/e2e/files_external/StorageUtils.ts deleted file mode 100644 index 49b3f582d51bb..0000000000000 --- a/cypress/e2e/files_external/StorageUtils.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -export type StorageConfig = { - [key: string]: string -} - -export type StorageMountOption = { - readonly: boolean -} - -export enum StorageBackend { - DAV = 'dav', - SMB = 'smb', - SFTP = 'sftp', - LOCAL = 'local', -} - -export enum AuthBackend { - GlobalAuth = 'password::global', - LoginCredentials = 'password::logincredentials', - Password = 'password::password', - SessionCredentials = 'password::sessioncredentials', - UserGlobalAuth = 'password::global::user', - UserProvided = 'password::userprovided', - Null = 'null::null', -} - -/** - * Create a storage via occ - * - * @param mountPoint - * @param storageBackend - * @param authBackend - * @param configs - * @param user - */ -export function createStorageWithConfig(mountPoint: string, storageBackend: StorageBackend, authBackend: AuthBackend, configs: StorageConfig, user?: User): Cypress.Chainable { - const configsFlag = Object.keys(configs).map((key) => `--config "${key}=${configs[key]}"`).join(' ') - const userFlag = user ? `--user ${user.userId}` : '' - - const command = `files_external:create "${mountPoint}" "${storageBackend}" "${authBackend}" ${configsFlag} ${userFlag}` - - cy.log(`Creating storage with command: ${command}`) - return cy.runOccCommand(command) - .then(({ stdout }) => { - return stdout.replace('Storage created with id ', '') - }) -} - -/** - * - * @param mountId - * @param options - */ -export function setStorageMountOptions(mountId: string, options: StorageMountOption) { - for (const [key, value] of Object.entries(options)) { - cy.runOccCommand(`files_external:option ${mountId} ${key} ${value}`) - } -} - -/** - * - */ -export function deleteAllExternalStorages() { - cy.runOccCommand('files_external:list --all --output=json').then(({ stdout }) => { - const list = JSON.parse(stdout) - list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false }) - }) -} diff --git a/cypress/e2e/files_external/files-external-failed.cy.ts b/cypress/e2e/files_external/files-external-failed.cy.ts deleted file mode 100644 index 47e0cbcfbd3e5..0000000000000 --- a/cypress/e2e/files_external/files-external-failed.cy.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile } from '../files/FilesUtils.ts' -import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils.ts' - -const CRON_TIMEOUT = 240000 - -describe('Files user credentials', { testIsolation: true }, () => { - let currentUser: User - - before(() => { - cy.runOccCommand('app:enable files_external') - cy.createRandomUser().then((user) => { - currentUser = user - }) - // The first cron run on a fresh instance drains the initial background - // job queue and takes over a minute, exceeding cypress' 60s - // `execTimeout` default - and failing here skips the whole suite, as - // `before all` hooks are not retried. - cy.runCommand('php ./cron.php', { timeout: CRON_TIMEOUT }) - }) - - afterEach(() => { - // Cleanup global storages - cy.runOccCommand('files_external:list --output=json').then(({ stdout }) => { - const list = JSON.parse(stdout) - list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false }) - }) - }) - - after(() => { - cy.runOccCommand('app:disable files_external') - }) - - it('Create a failed user storage with invalid url', () => { - const url = 'http://cloud.domain.com/remote.php/dav/files/abcdef123456' - createStorageWithConfig('Storage1', StorageBackend.DAV, AuthBackend.LoginCredentials, { host: url.replace('index.php/', ''), secure: 'false' }).then((id) => { - cy.runOccCommand(`files_external:verify ${id}`) - }) - - cy.login(currentUser) - cy.visit('/apps/files') - - // TODO: Why does the first PROPFIND does not return it? - getRowForFile('Storage1') - .if('not.exist') - .reload() - - // Ensure the row is visible and marked as unavailable - getRowForFile('Storage1').as('row').should('be.visible') - cy.get('@row').find('[data-cy-files-list-row-name-link]') - .should('have.attr', 'title', 'This node is unavailable') - - // Ensure clicking on the location does not open the folder - cy.location().then((loc) => { - cy.get('@row').find('[data-cy-files-list-row-name-link]').click() - cy.location('href').should('eq', loc.href) - }) - }) - - it('Create a failed user storage with invalid login credentials', () => { - const url = 'http://cloud.domain.com/remote.php/dav/files/abcdef123456' - createStorageWithConfig('Storage2', StorageBackend.DAV, AuthBackend.Password, { - host: url.replace('index.php/', ''), - user: 'invaliduser', - password: 'invalidpassword', - secure: 'false', - }).then((id) => { - cy.runOccCommand(`files_external:verify ${id}`) - }) - - cy.login(currentUser) - cy.visit('/apps/files') - - // Ensure the row is visible and marked as unavailable - getRowForFile('Storage2').as('row').should('be.visible') - cy.get('@row').find('[data-cy-files-list-row-name-link]') - .should('have.attr', 'title', 'This node is unavailable') - - // Ensure clicking on the location does not open the folder - cy.location().then((loc) => { - cy.get('@row').find('[data-cy-files-list-row-name-link]').click() - cy.location('href').should('eq', loc.href) - }) - }) -}) diff --git a/cypress/e2e/files_external/files-user-credentials.cy.ts b/cypress/e2e/files_external/files-user-credentials.cy.ts deleted file mode 100644 index a6d52015b3629..0000000000000 --- a/cypress/e2e/files_external/files-user-credentials.cy.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getInlineActionEntryForFile, getRowForFile, navigateToFolder, triggerInlineActionForFile } from '../files/FilesUtils.ts' -import { handlePasswordConfirmation } from '../settings/usersUtils.ts' -import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils.ts' - -const ACTION_CREDENTIALS_EXTERNAL_STORAGE = 'credentials-external-storage' - -describe('Files user credentials', { testIsolation: true }, () => { - let user1: User - let user2: User - let storageUser: User - - before(() => { - cy.runOccCommand('app:enable files_external') - - // Create some users - cy.createRandomUser().then((user) => { - user1 = user - }) - cy.createRandomUser().then((user) => { - user2 = user - }) - - // This user will hold the webdav storage - cy.createRandomUser().then((user) => { - storageUser = user - cy.uploadFile(user, 'image.jpg') - }) - }) - - after(() => { - // Cleanup global storages - cy.runOccCommand('files_external:list --output=json').then(({ stdout }) => { - const list = JSON.parse(stdout) - list.forEach((storage) => cy.runOccCommand(`files_external:delete --yes ${storage.mount_id}`), { failOnNonZeroExit: false }) - }) - - cy.runOccCommand('app:disable files_external') - }) - - it('Create a user storage with user credentials', () => { - // Its not the public server address but the address so the server itself can connect to it - const base = 'http://localhost' - const host = `${base}/remote.php/dav/files/${storageUser.userId}` - createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host, secure: 'false' }) - - cy.login(user1) - cy.visit('/apps/files/extstoragemounts') - getRowForFile(storageUser.userId).should('be.visible') - - cy.intercept('PUT', '**/apps/files_external/userglobalstorages/*').as('setCredentials') - - triggerInlineActionForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE) - - // See credentials dialog - cy.findByRole('dialog', { name: 'Storage credentials' }).as('storageDialog') - cy.get('@storageDialog').should('be.visible') - cy.get('@storageDialog').findByRole('textbox', { name: 'Login' }).type(storageUser.userId) - cy.get('@storageDialog').get('input[type="password"]').type(storageUser.password) - cy.get('@storageDialog').get('button').contains('Confirm').click() - cy.get('@storageDialog').should('not.exist') - - // Storage dialog now closed, the user auth dialog should be visible - cy.findByRole('dialog', { name: 'Authentication required' }).as('authDialog') - cy.get('@authDialog').should('be.visible') - handlePasswordConfirmation(user1.password) - - // Wait for the credentials to be set - cy.wait('@setCredentials') - - // Auth dialog should be closed and the set credentials button should be gone - cy.get('@authDialog').should('not.exist', { timeout: 2000 }) - - getInlineActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE) - .should('not.exist') - - // Finally, the storage should be accessible - cy.visit('/apps/files') - navigateToFolder(storageUser.userId) - getRowForFile('image.jpg').should('be.visible') - }) - - it('Create a user storage with GLOBAL user credentials', () => { - // Its not the public server address but the address so the server itself can connect to it - const base = 'http://localhost' - const host = `${base}/remote.php/dav/files/${storageUser.userId}` - createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' }) - - cy.login(user2) - cy.visit('/apps/files/extstoragemounts') - getRowForFile('storage1').should('be.visible') - - cy.intercept('PUT', '**/apps/files_external/userglobalstorages/*').as('setCredentials') - - triggerInlineActionForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE) - - // See credentials dialog - cy.findByRole('dialog', { name: 'Storage credentials' }).as('storageDialog') - cy.get('@storageDialog').should('be.visible') - cy.get('@storageDialog').findByRole('textbox', { name: 'Login' }).type(storageUser.userId) - cy.get('@storageDialog').get('input[type="password"]').type(storageUser.password) - cy.get('@storageDialog').get('button').contains('Confirm').click() - cy.get('@storageDialog').should('not.exist') - - // Storage dialog now closed, the user auth dialog should be visible - cy.findByRole('dialog', { name: 'Authentication required' }).as('authDialog') - cy.get('@authDialog').should('be.visible') - handlePasswordConfirmation(user2.password) - - // Wait for the credentials to be set - cy.wait('@setCredentials') - - // Auth dialog should be closed and the set credentials button should be gone - cy.get('@authDialog').should('not.exist', { timeout: 2000 }) - getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') - - // Finally, the storage should be accessible - cy.visit('/apps/files') - navigateToFolder('storage1') - getRowForFile('image.jpg').should('be.visible') - }) - - it('Create another user storage while reusing GLOBAL user credentials', () => { - // Its not the public server address but the address so the server itself can connect to it - const base = 'http://localhost' - const host = `${base}/remote.php/dav/files/${storageUser.userId}` - createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' }) - - cy.login(user2) - cy.visit('/apps/files/extstoragemounts') - getRowForFile('storage2').should('be.visible') - - // Since we already have set the credentials, the action should not be present - getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') - getInlineActionEntryForFile('storage2', ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist') - - // Finally, the storage should be accessible - cy.visit('/apps/files') - navigateToFolder('storage2') - getRowForFile('image.jpg').should('be.visible') - }) -}) diff --git a/cypress/e2e/files_external/home-folder-root-mount-permissions.cy.ts b/cypress/e2e/files_external/home-folder-root-mount-permissions.cy.ts deleted file mode 100644 index 731f37ac41ed9..0000000000000 --- a/cypress/e2e/files_external/home-folder-root-mount-permissions.cy.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { AuthBackend, createStorageWithConfig, deleteAllExternalStorages, setStorageMountOptions, StorageBackend } from './StorageUtils.ts' - -describe('Home folder root mount permissions', { testIsolation: true }, () => { - let user1: User - - before(() => { - cy.runOccCommand('app:enable files_external') - cy.createRandomUser().then((user) => { - user1 = user - }) - }) - - after(() => { - deleteAllExternalStorages() - cy.runOccCommand('app:disable files_external') - }) - - it('Does not show write actions on read-only storage mounted at the root of the user\'s home folder', () => { - cy.login(user1) - cy.visit('/apps/files/') - cy.runOccCommand('config:app:get files overwrites_home_folders --default-value=[]') - .then(({ stdout }) => assert.equal(stdout.trim(), '[]')) - - cy.get('[data-cy-upload-picker=""]').should('exist') - - createStorageWithConfig('/', StorageBackend.LOCAL, AuthBackend.Null, { datadir: '/tmp' }) - .then((id) => setStorageMountOptions(id, { readonly: true })) - // HACK: somehow, we need to create an external folder targeting a subpath for the previous one to show. - createStorageWithConfig('/a', StorageBackend.LOCAL, AuthBackend.Null, { datadir: '/tmp' }) - cy.visit('/apps/files/') - cy.visit('/apps/files/') - cy.runOccCommand('config:app:get files overwrites_home_folders') - .then(({ stdout }) => assert.equal(stdout.trim(), '["files_external"]')) - cy.get('[data-cy-upload-picker=""]').should('not.exist') - - deleteAllExternalStorages() - cy.visit('/apps/files/') - cy.runOccCommand('config:app:get files overwrites_home_folders') - .then(({ stdout }) => assert.equal(stdout.trim(), '[]')) - cy.get('[data-cy-upload-picker=""]').should('exist') - }) -}) diff --git a/cypress/e2e/files_external/settings.cy.ts b/cypress/e2e/files_external/settings.cy.ts deleted file mode 100644 index 03fd05d2eba90..0000000000000 --- a/cypress/e2e/files_external/settings.cy.ts +++ /dev/null @@ -1,158 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { handlePasswordConfirmation } from '../settings/usersUtils.ts' - -describe('files_external settings', () => { - before(() => { - cy.runOccCommand('app:enable files_external') - cy.login({ language: 'en', password: 'admin', userId: 'admin' }) - }) - - beforeEach(() => { - cy.runOccCommand('files_external:list --output json') - .then((exec) => { - const list = JSON.parse(exec.stdout) - for (const { mount_id: mountId } of list) { - cy.runOccCommand('files_external:delete ' + mountId + ' --yes') - } - }) - cy.visit('/settings/admin/externalstorages') - }) - - it('can see the settings section', () => { - cy.findByRole('heading', { name: /External storage/, level: 2 }) - .should('be.visible') - cy.findByRole('table', { name: 'External storages' }) - .should('be.visible') - }) - - it('can see the dialog', () => { - openDialog() - - cy.findByRole('dialog', { name: 'Add storage' }) - .within(() => { - cy.findByRole('textbox', { name: 'Folder name' }) - .should('be.visible') - - getComboBox(/External storage/) - .should('be.visible') - getComboBox(/Authentication/) - .should('be.visible') - getComboBox(/Restrict to/) - .should('be.visible') - cy.findByRole('button', { name: 'Create' }) - .should('be.visible') - .and('have.attr', 'type', 'submit') - }) - }) - - it('can create storage using the dialog', () => { - openDialog() - - cy.findByRole('dialog', { name: 'Add storage' }) - .within(() => { - cy.findByRole('textbox', { name: 'Folder name' }) - .should('be.visible') - .type('My Storage') - - getComboBox(/External storage/) - .should('be.visible') - .click() - cy.root().closest('body') - .findByRole('option', { name: 'WebDAV' }) - .should('be.visible') - .click() - - getComboBox(/Authentication/) - .should('be.visible') - .as('authComboBox') - .click() - cy.root().closest('body') - .findByRole('option', { name: /Login and password/ }) - .should('be.visible') - .click() - - cy.findByRole('textbox', { name: 'Login' }).as('login') - cy.get('@login').scrollIntoView() - cy.get('@login').should('be.visible') - .type('admin') - - cy.get('input[type="password"]').as('password') - cy.get('@password').scrollIntoView() - cy.get('@password').should('be.visible') - .type('admin') - - cy.findByRole('button', { name: 'Create' }) - .should('be.visible') - .click() - - cy.findByRole('textbox', { name: 'URL' }) - .should('be.visible') - .and((el) => el.is(':invalid')) - .type('http://localhost/remote.php/dav/files/admin') - - cy.findByRole('checkbox', { name: /Secure/ }) - .uncheck({ force: true }) - - cy.findByRole('button', { name: 'Create' }) - .should('be.visible') - .click() - }) - handlePasswordConfirmation('admin') - - cy.findAllByRole('dialog').should('not.exist') - - getTable() - .findAllByRole('row') - .should('have.length', 1) - getTable() - .findByRole('row') - .as('storageRow') - .findByRole('cell', { name: /My Storage/ }) - .should('be.visible') - - cy.get('@storageRow') - .findByRole('cell', { name: /WebDAV/ }) - .should('be.visible') - cy.get('@storageRow') - .findByRole('cell', { name: /Login and password/ }) - .should('be.visible') - cy.get('@storageRow') - .findByRole('button', { name: /Edit/ }) - .should('be.visible') - cy.get('@storageRow') - .findByRole('button', { name: /Delete/ }) - .should('be.visible') - .as('deleteButton') - - cy.get('@deleteButton') - .click() - handlePasswordConfirmation('admin') - - getTable() - .findByRole('row') - .should('not.exist') - }) -}) - -/** - * Get the external storages table - */ -function getTable() { - return cy.findByRole('table', { name: 'External storages' }) - .find('tbody') -} - -function openDialog() { - cy.findByRole('button', { name: 'Add external storage' }).click() - cy.findByRole('dialog', { name: 'Add storage' }).should('be.visible') -} - -function getComboBox(match: RegExp) { - return cy.contains('label', match) - .should('be.visible') - .then((el) => Cypress.$(`#${el.attr('for')}`)) -} diff --git a/cypress/e2e/files_sharing/FilesSharingUtils.ts b/cypress/e2e/files_sharing/FilesSharingUtils.ts deleted file mode 100644 index 432b8b5ddbfab..0000000000000 --- a/cypress/e2e/files_sharing/FilesSharingUtils.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { closeSidebar, triggerActionForFile } from '../files/FilesUtils.ts' - -export interface ShareSetting { - read: boolean - update: boolean - delete: boolean - create: boolean - share: boolean - download: boolean - note: string - expiryDate: Date -} - -export function createShare(fileName: string, username: string, shareSettings: Partial = {}) { - openSharingPanel(fileName) - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - - cy.get('#app-sidebar-vue').within(() => { - cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch') - cy.findByRole('combobox', { name: /Search for internal recipients/i }) - .type(`{selectAll}${username}`) - cy.wait('@userSearch') - }) - - cy.get(`[user="${username}"]`).click() - - // HACK: Save the share and then update it, as permissions changes are currently not saved for new share. - cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) - cy.wait('@createShare') - closeSidebar() - - updateShare(fileName, 0, shareSettings) -} - -export function openSharingDetails(index: number) { - cy.get('#app-sidebar-vue').within(() => { - cy.findAllByRole('button', { name: /open sharing details/i }) - .should('have.length.at.least', index + 1) - .eq(index) - .click({ force: true }) - cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]') - .click() - }) -} - -export function updateShare(fileName: string, index: number, shareSettings: Partial = {}) { - openSharingPanel(fileName) - openSharingDetails(index) - - cy.intercept({ times: 1, method: 'PUT', url: '**/apps/files_sharing/api/v1/shares/*' }).as('updateShare') - - cy.get('#app-sidebar-vue').within(() => { - if (shareSettings.download !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox') - if (shareSettings.download) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@downloadCheckbox') - .check({ force: true, scrollBehavior: 'nearest' }) - cy.get('@downloadCheckbox') - .should('be.checked') - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@downloadCheckbox') - .uncheck({ force: true, scrollBehavior: 'nearest' }) - cy.get('@downloadCheckbox') - .should('not.be.checked') - } - } - - if (shareSettings.read !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="read"]').find('input').as('readCheckbox') - if (shareSettings.read) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@readCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@readCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.update !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="update"]').find('input').as('updateCheckbox') - if (shareSettings.update) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@updateCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@updateCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.create !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="create"]').find('input').as('createCheckbox') - if (shareSettings.create) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@createCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@createCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.delete !== undefined) { - cy.get('[data-cy-files-sharing-share-permissions-checkbox="delete"]').find('input').as('deleteCheckbox') - if (shareSettings.delete) { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@deleteCheckbox').check({ force: true, scrollBehavior: 'nearest' }) - } else { - // Force:true because the checkbox is hidden by the pretty UI. - cy.get('@deleteCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' }) - } - } - - if (shareSettings.note !== undefined) { - cy.findByRole('checkbox', { name: /note to recipient/i }).check({ force: true, scrollBehavior: 'nearest' }) - cy.findByRole('textbox', { name: /note to recipient/i }).type(shareSettings.note) - } - - if (shareSettings.expiryDate !== undefined) { - cy.findByRole('checkbox', { name: /expiration date/i }) - .check({ force: true, scrollBehavior: 'nearest' }) - cy.get('#share-date-picker') - .type(`${shareSettings.expiryDate.getFullYear()}-${String(shareSettings.expiryDate.getMonth() + 1).padStart(2, '0')}-${String(shareSettings.expiryDate.getDate()).padStart(2, '0')}`) - } - - cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' }) - - cy.wait('@updateShare') - }) - closeSidebar() -} - -export function openSharingPanel(fileName: string) { - triggerActionForFile(fileName, 'details') - - cy.get('[data-cy-sidebar]') - .as('sidebar') - .should('be.visible') - cy.get('@sidebar') - .find('[aria-controls="tab-sharing"]') - .click() -} - -type FileRequestOptions = { - label?: string - note?: string - password?: string - /* YYYY-MM-DD format */ - expiration?: string -} - -/** - * Create a file request for a folder - * - * @param path The path of the folder, leading slash is required - * @param options The options for the file request - */ -export function createFileRequest(path: string, options: FileRequestOptions = {}) { - if (!path.startsWith('/')) { - throw new Error('Path must start with a slash') - } - - // Navigate to the folder - cy.visit('/apps/files/files?dir=' + path) - - // Open the file request dialog - cy.get('[data-cy-upload-picker] .action-item__menutoggle').first().click() - cy.contains('.upload-picker__menu-entry button', 'Create file request').click() - cy.get('[data-cy-file-request-dialog]').should('be.visible') - - // Check and fill the first page options - cy.get('[data-cy-file-request-dialog-fieldset="label"]').should('be.visible') - cy.get('[data-cy-file-request-dialog-fieldset="destination"]').should('be.visible') - cy.get('[data-cy-file-request-dialog-fieldset="note"]').should('be.visible') - - cy.get('[data-cy-file-request-dialog-fieldset="destination"] input').should('contain.value', path) - if (options.label) { - cy.get('[data-cy-file-request-dialog-fieldset="label"] input').type(`{selectall}${options.label}`) - } - if (options.note) { - cy.get('[data-cy-file-request-dialog-fieldset="note"] textarea').type(`{selectall}${options.note}`) - } - - // Go to the next page - cy.get('[data-cy-file-request-dialog-controls="next"]').click() - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').should('exist') - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').should('not.exist') - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').should('exist') - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').should('not.exist') - if (options.expiration) { - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="checkbox"]').check({ force: true }) - cy.get('[data-cy-file-request-dialog-fieldset="expiration"] input[type="date"]').type(`{selectall}${options.expiration}`) - } - if (options.password) { - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="checkbox"]').check({ force: true }) - cy.get('[data-cy-file-request-dialog-fieldset="password"] input[type="password"]').type(`{selectall}${options.password}`) - } - - // Create the file request - cy.get('[data-cy-file-request-dialog-controls="next"]').click() - - // Get the file request URL - cy.get('[data-cy-file-request-dialog-fieldset="link"]').then(($link) => { - const url = $link.val() - cy.log(`File request URL: ${url}`) - cy.wrap(url).as('fileRequestUrl') - }) - - // Close - cy.get('[data-cy-file-request-dialog-controls="finish"]').click() -} diff --git a/cypress/e2e/files_sharing/ShareOptionsType.ts b/cypress/e2e/files_sharing/ShareOptionsType.ts deleted file mode 100644 index 6771590f24429..0000000000000 --- a/cypress/e2e/files_sharing/ShareOptionsType.ts +++ /dev/null @@ -1,18 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -export type ShareOptions = { - enforcePassword?: boolean - enforceExpirationDate?: boolean - alwaysAskForPassword?: boolean - defaultExpirationDateSet?: boolean -} - -export const defaultShareOptions: ShareOptions = { - enforcePassword: false, - enforceExpirationDate: false, - alwaysAskForPassword: false, - defaultExpirationDateSet: false, -} diff --git a/cypress/e2e/files_sharing/expiry-date.cy.ts b/cypress/e2e/files_sharing/expiry-date.cy.ts deleted file mode 100644 index 0055b499d38d8..0000000000000 --- a/cypress/e2e/files_sharing/expiry-date.cy.ts +++ /dev/null @@ -1,130 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { closeSidebar } from '../files/FilesUtils.ts' -import { createShare, openSharingDetails, openSharingPanel, updateShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Expiry date', () => { - const expectedDefaultDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000) - const expectedDefaultDateString = `${expectedDefaultDate.getFullYear()}-${String(expectedDefaultDate.getMonth() + 1).padStart(2, '0')}-${String(expectedDefaultDate.getDate()).padStart(2, '0')}` - const fortnight = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) - const fortnightString = `${fortnight.getFullYear()}-${String(fortnight.getMonth() + 1).padStart(2, '0')}-${String(fortnight.getDate()).padStart(2, '0')}` - - let alice: User - let bob: User - - before(() => { - // Ensure we have the admin setting setup for default dates with 2 days in the future - cy.runOccCommand('config:app:set --value yes core shareapi_default_internal_expire_date') - cy.runOccCommand('config:app:set --value 2 core shareapi_internal_expire_after_n_days') - - cy.createRandomUser().then((user) => { - alice = user - cy.login(alice) - }) - cy.createRandomUser().then((user) => { - bob = user - }) - }) - - after(() => { - cy.runOccCommand('config:app:delete core shareapi_default_internal_expire_date') - cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date') - cy.runOccCommand('config:app:delete core shareapi_internal_expire_after_n_days') - }) - - beforeEach(() => { - cy.runOccCommand('config:app:delete core shareapi_enforce_internal_expire_date') - }) - - it('See default expiry date is set and enforced', () => { - // Enforce the date - cy.runOccCommand('config:app:set --value yes core shareapi_enforce_internal_expire_date') - const dir = 'defaultExpiryDateEnforced' - prepareDirectory(dir) - - validateExpiryDate(dir, expectedDefaultDateString) - cy.findByRole('checkbox', { name: /expiration date/i }) - .should('be.checked') - .and('be.disabled') - }) - - it('See default expiry date is set also if not enforced', () => { - const dir = 'defaultExpiryDate' - prepareDirectory(dir) - - validateExpiryDate(dir, expectedDefaultDateString) - cy.findByRole('checkbox', { name: /expiration date/i }) - .should('be.checked') - .and('not.be.disabled') - .check({ force: true, scrollBehavior: 'nearest' }) - }) - - it('Can set custom expiry date', () => { - const dir = 'customExpiryDate' - prepareDirectory(dir) - updateShare(dir, 0, { expiryDate: fortnight }) - validateExpiryDate(dir, fortnightString) - }) - - it('Custom expiry date survives reload', () => { - const dir = 'customExpiryDateReload' - prepareDirectory(dir) - updateShare(dir, 0, { expiryDate: fortnight }) - validateExpiryDate(dir, fortnightString) - - cy.visit('/apps/files') - validateExpiryDate(dir, fortnightString) - }) - - /** - * Regression test for https://github.com/nextcloud/server/pull/50192 - * Ensure that admin default settings do not always override the user set value. - */ - it('Custom expiry date survives unrelated update', () => { - const dir = 'customExpiryUnrelatedChanges' - prepareDirectory(dir) - updateShare(dir, 0, { expiryDate: fortnight }) - validateExpiryDate(dir, fortnightString) - closeSidebar() - - cy.log('Upadate share and validate expiry date is kept') - updateShare(dir, 0, { note: 'Only note changed' }) - validateExpiryDate(dir, fortnightString) - - cy.log('Reload page and validate expiry date is kept') - cy.visit('/apps/files') - validateExpiryDate(dir, fortnightString) - }) - - /** - * Prepare directory, login and share to bob - * - * @param name The directory name - */ - function prepareDirectory(name: string) { - cy.mkdir(alice, `/${name}`) - cy.login(alice) - cy.visit('/apps/files') - createShare(name, bob.userId) - } - - /** - * Validate expiry date on a share - * - * @param filename The filename to validate - * @param expectedDate The expected date in YYYY-MM-dd - */ - function validateExpiryDate(filename: string, expectedDate: string) { - openSharingPanel(filename) - openSharingDetails(0) - - cy.get('#share-date-picker') - .should('exist') - .and('have.value', expectedDate) - } -}) diff --git a/cypress/e2e/files_sharing/file-request.cy.ts b/cypress/e2e/files_sharing/file-request.cy.ts deleted file mode 100644 index 76965f320bb70..0000000000000 --- a/cypress/e2e/files_sharing/file-request.cy.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { createFolder, getRowForFile, navigateToFolder } from '../files/FilesUtils.ts' -import { createFileRequest } from './FilesSharingUtils.ts' - -function enterGuestName(name: string) { - cy.findByRole('dialog', { name: /Upload files to/ }) - .should('be.visible') - .within(() => { - cy.findByRole('textbox', { name: 'Name' }) - .should('be.visible') - - cy.findByRole('textbox', { name: 'Name' }) - .type(`{selectall}${name}`) - - cy.findByRole('button', { name: 'Submit name' }) - .should('be.visible') - .click() - }) - - cy.findByRole('dialog', { name: /Upload files to/ }) - .should('not.exist') -} - -describe('Files', { testIsolation: true }, () => { - const folderName = 'test-folder' - let user: User - let url = '' - - it('Login with a user and create a file request', () => { - cy.createRandomUser().then((_user) => { - user = _user - cy.login(user) - }) - - cy.visit('/apps/files') - createFolder(folderName) - - createFileRequest(`/${folderName}`) - cy.get('@fileRequestUrl').should('contain', '/s/').then((_url: string) => { - cy.logout() - url = _url - }) - }) - - it('Open the file request as a guest', () => { - cy.visit(url) - enterGuestName('Guest') - - // Check various elements on the page - cy.contains(`Upload files to ${folderName}`) - .should('be.visible') - cy.findByRole('button', { name: 'Upload' }) - .should('be.visible') - - cy.intercept('PUT', '/public.php/dav/files/*/*').as('uploadFile') - - // Upload a file - cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') - .should('exist') - .selectFile({ - contents: Cypress.Buffer.from('abcdef'), - fileName: 'file.txt', - mimeType: 'text/plain', - lastModified: Date.now(), - }, { force: true }) - - cy.wait('@uploadFile').its('response.statusCode').should('eq', 201) - }) - - it('Check the uploaded file', () => { - cy.login(user) - cy.visit(`/apps/files/files?dir=/${folderName}`) - getRowForFile('Guest') - .should('be.visible') - navigateToFolder('Guest') - getRowForFile('file.txt').should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/files-copy-move.cy.ts b/cypress/e2e/files_sharing/files-copy-move.cy.ts deleted file mode 100644 index 16d07c3d40c20..0000000000000 --- a/cypress/e2e/files_sharing/files-copy-move.cy.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { - copyFile, - getRowForFile, - navigateToFolder, - triggerActionForFile, -} from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -const ACTION_COPY_MOVE = 'move-copy' - -export function copyFileForbidden(fileName: string, dirPath: string) { - getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, ACTION_COPY_MOVE) - - cy.get('.file-picker').within(() => { - // intercept the copy so we can wait for it - cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile') - - const directories = dirPath.split('/') - directories.forEach((directory) => { - // select the folder - cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click() - }) - - // check copy button - cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.disabled') - }) -} - -export function moveFileForbidden(fileName: string, dirPath: string) { - getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, ACTION_COPY_MOVE) - - cy.get('.file-picker').within(() => { - // intercept the copy so we can wait for it - cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') - - // select home folder - cy.get('.breadcrumb') - .findByRole('button', { name: 'All files' }) - .should('be.visible') - .click() - - const directories = dirPath.split('/') - directories.forEach((directory) => { - // select the folder - cy.get(`[data-filename="${directory}"]`).should('be.visible').click() - }) - - // click move - cy.contains('button', `Move to ${directories.at(-1)}`).should('not.exist') - }) -} - -describe('files_sharing: Move or copy files', { testIsolation: true }, () => { - let user: User - let sharee: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - sharee = $user - }) - }) - - it('can create a file in a shared folder', () => { - // share the folder - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - createShare('folder', sharee.userId, { read: true, download: true }) - cy.logout() - - // Now for the sharee - cy.uploadContent(sharee, new Blob([]), 'text/plain', '/folder/file.txt') - cy.login(sharee) - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('folder').should('be.visible') - navigateToFolder('folder') - // Content of the shared folder - getRowForFile('file.txt').should('be.visible') - }) - - it('can copy a file to a shared folder', () => { - // share the folder - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - createShare('folder', sharee.userId, { read: true, download: true }) - cy.logout() - - // Now for the sharee - cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt') - cy.login(sharee) - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('folder').should('be.visible') - // copy file to a shared folder - copyFile('file.txt', 'folder') - // click on the folder should open it in files - navigateToFolder('folder') - // Content of the shared folder - getRowForFile('file.txt').should('be.visible') - }) - - it('can not copy a file to a shared folder with no create permissions', () => { - // share the folder - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - createShare('folder', sharee.userId, { read: true, download: true, create: false }) - cy.logout() - - // Now for the sharee - cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt') - cy.login(sharee) - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('folder').should('be.visible') - copyFileForbidden('file.txt', 'folder') - }) - - it('can not move a file from a shared folder with no delete permissions', () => { - // share the folder - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file.txt') - cy.login(user) - cy.visit('/apps/files') - createShare('folder', sharee.userId, { read: true, download: true, delete: false }) - cy.logout() - - // Now for the sharee - cy.mkdir(sharee, '/folder-own') - cy.login(sharee) - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('folder').should('be.visible') - navigateToFolder('folder') - getRowForFile('file.txt').should('be.visible') - moveFileForbidden('file.txt', 'folder-own') - }) -}) diff --git a/cypress/e2e/files_sharing/files-download.cy.ts b/cypress/e2e/files_sharing/files-download.cy.ts deleted file mode 100644 index 9ee3bda069996..0000000000000 --- a/cypress/e2e/files_sharing/files-download.cy.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { - getActionButtonForFile, - getActionEntryForFile, - getRowForFile, -} from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Download forbidden', { testIsolation: true }, () => { - let user: User - let sharee: User - - beforeEach(() => { - cy.runOccCommand('config:app:set --value yes core shareapi_allow_view_without_download') - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - sharee = $user - }) - }) - - after(() => { - cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download') - }) - - it('cannot download a folder if disabled', () => { - // share the folder - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - createShare('folder', sharee.userId, { read: true, download: false }) - cy.logout() - - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getActionButtonForFile('folder') - .should('be.visible') - // open the action menu - .click({ force: true }) - // see no download action - getActionEntryForFile('folder', 'download') - .should('not.exist') - - // Disable view without download option - cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download') - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('folder').should('be.visible') - getActionButtonForFile('folder') - .should('be.visible') - // open the action menu - .click({ force: true }) - getActionEntryForFile('folder', 'download').should('not.exist') - }) - - it('cannot download a file if disabled', () => { - // share the folder - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - createShare('file.txt', sharee.userId, { read: true, download: false }) - cy.logout() - - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getActionButtonForFile('file.txt') - .should('be.visible') - // open the action menu - .click({ force: true }) - // see no download action - getActionEntryForFile('file.txt', 'download') - .should('not.exist') - - // Disable view without download option - cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download') - - // visit shared files view - cy.visit('/apps/files') - // see the shared folder - getRowForFile('file.txt').should('be.visible') - getActionButtonForFile('file.txt') - .should('be.visible') - // open the action menu - .click({ force: true }) - getActionEntryForFile('file.txt', 'download').should('not.exist') - }) -}) diff --git a/cypress/e2e/files_sharing/files-shares-view.cy.ts b/cypress/e2e/files_sharing/files-shares-view.cy.ts deleted file mode 100644 index c20fad87a67ba..0000000000000 --- a/cypress/e2e/files_sharing/files-shares-view.cy.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile } from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Files view', { testIsolation: true }, () => { - let user: User - let sharee: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - sharee = $user - }) - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/46108 - */ - it('opens a shared folder when clicking on it', () => { - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true }) - // visit the own shares - cy.visit('/apps/files/sharingout') - // see the shared folder - getRowForFile('folder').should('be.visible') - // click on the folder should open it in files - getRowForFile('folder').findByRole('button', { name: /open in files/i }).click() - // See the URL has changed - cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/) - // Content of the shared folder - getRowForFile('file').should('be.visible') - - cy.logout() - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files/sharingin') - // see the shared folder - getRowForFile('folder').should('be.visible') - // click on the folder should open it in files - getRowForFile('folder').findByRole('button', { name: /open in files/i }).click() - // See the URL has changed - cy.url().should('match', /apps\/files\/files\/.+dir=\/folder/) - // Content of the shared folder - getRowForFile('file').should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/limit_to_same_group.cy.ts b/cypress/e2e/files_sharing/limit_to_same_group.cy.ts deleted file mode 100644 index 21e37ae745ef0..0000000000000 --- a/cypress/e2e/files_sharing/limit_to_same_group.cy.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('Limit to sharing to people in the same group', () => { - let alice: User - let bob: User - let randomFileName1 = '' - let randomFileName2 = '' - let randomGroupName = '' - let randomGroupName2 = '' - let randomGroupName3 = '' - - before(() => { - randomFileName1 = randomString(10) + '.txt' - randomFileName2 = randomString(10) + '.txt' - randomGroupName = randomString(10) - randomGroupName2 = randomString(10) - randomGroupName3 = randomString(10) - - cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value yes') - - cy.createRandomUser() - .then((user) => { - alice = user - }) - cy.createRandomUser() - .then((user) => { - bob = user - - cy.runOccCommand(`group:add ${randomGroupName}`) - cy.runOccCommand(`group:add ${randomGroupName2}`) - cy.runOccCommand(`group:add ${randomGroupName3}`) - cy.runOccCommand(`group:adduser ${randomGroupName} ${alice.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName} ${bob.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName2} ${alice.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName2} ${bob.userId}`) - cy.runOccCommand(`group:adduser ${randomGroupName3} ${bob.userId}`) - - cy.uploadContent(alice, new Blob(['share to bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName1}`) - cy.uploadContent(bob, new Blob(['share by bob'], { type: 'text/plain' }), 'text/plain', `/${randomFileName2}`) - - cy.login(alice) - cy.visit('/apps/files') - createShare(randomFileName1, bob.userId) - cy.logout() - - cy.login(bob) - cy.visit('/apps/files') - createShare(randomFileName2, alice.userId) - cy.logout() - }) - }) - - after(() => { - cy.runOccCommand('config:app:set core shareapi_only_share_with_group_members --value no') - }) - - it('Alice can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist') - }) - - it('Bob can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist') - }) - - context('Bob is removed from the first group', () => { - before(() => { - cy.runOccCommand(`group:removeuser ${randomGroupName} ${bob.userId}`) - }) - - it('Alice can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('exist') - }) - - it('Bob can see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('exist') - }) - }) - - context('Bob is removed from the second group', () => { - before(() => { - cy.runOccCommand(`group:removeuser ${randomGroupName2} ${bob.userId}`) - }) - - it('Alice cannot see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName2}"]`).should('not.exist') - }) - - it('Bob cannot see the shared file', () => { - cy.login(alice) - cy.visit('/apps/files') - cy.get(`[data-cy-files-list] [data-cy-files-list-row-name="${randomFileName1}"]`).should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/note-to-recipient.cy.ts b/cypress/e2e/files_sharing/note-to-recipient.cy.ts deleted file mode 100644 index 989155a6608f9..0000000000000 --- a/cypress/e2e/files_sharing/note-to-recipient.cy.ts +++ /dev/null @@ -1,92 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { navigateToFolder } from '../files/FilesUtils.ts' -import { createShare, openSharingPanel } from './FilesSharingUtils.ts' - -describe('files_sharing: Note to recipient', { testIsolation: true }, () => { - let user: User - let sharee: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - sharee = $user - }) - }) - - it('displays the note to the sharee', () => { - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) - - cy.logout() - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - navigateToFolder('folder') - cy.get('.note-to-recipient') - .should('be.visible') - .and('contain.text', 'Hello, this is the note.') - }) - - it('displays the note to the sharee even if the file list is empty', () => { - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) - - cy.logout() - // Now for the sharee - cy.login(sharee) - - // visit shared files view - cy.visit('/apps/files') - navigateToFolder('folder') - cy.get('.note-to-recipient') - .should('be.visible') - .and('contain.text', 'Hello, this is the note.') - }) - - /** - * Regression test for https://github.com/nextcloud/server/issues/46188 - */ - it('shows an existing note when editing a share', () => { - cy.mkdir(user, '/folder') - cy.login(user) - cy.visit('/apps/files') - - // share the folder - createShare('folder', sharee.userId, { read: true, download: true, note: 'Hello, this is the note.' }) - - // reload just to be sure - cy.visit('/apps/files') - - // open the sharing tab - openSharingPanel('folder') - - cy.get('[data-cy-sidebar]').within(() => { - // Open the share - cy.get('[data-cy-files-sharing-share-actions]').first().click({ force: true }) - - cy.findByRole('checkbox', { name: /note to recipient/i }) - .and('be.checked') - cy.findByRole('textbox', { name: /note to recipient/i }) - .should('be.visible') - .and('have.value', 'Hello, this is the note.') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts b/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts deleted file mode 100644 index 0577c0120fede..0000000000000 --- a/cypress/e2e/files_sharing/public-share/PublicShareUtils.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' -import type { ShareOptions } from '../ShareOptionsType.ts' - -import { openSharingPanel } from '../FilesSharingUtils.ts' - -export interface ShareContext { - user: User - url?: string -} - -const defaultShareContext: ShareContext = { - user: {} as User, - url: undefined, -} - -/** - * Retrieves the URL of the share. - * Throws an error if the share context is not initialized properly. - * - * @param context The current share context (defaults to `defaultShareContext` if not provided). - * @return The share URL. - * @throws {Error} if the share context has no URL. - */ -export function getShareUrl(context: ShareContext = defaultShareContext): string { - if (!context.url) { - throw new Error('You need to setup the share first!') - } - return context.url -} - -/** - * Setup the available data - * - * @param user The current share context - * @param shareName The name of the shared folder - */ -export function setupData(user: User, shareName: string): void { - cy.mkdir(user, `/${shareName}`) - cy.mkdir(user, `/${shareName}/subfolder`) - cy.uploadContent(user, new Blob(['foo']), 'text/plain', `/${shareName}/foo.txt`) - cy.uploadContent(user, new Blob(['bar']), 'text/plain', `/${shareName}/subfolder/bar.txt`) -} - -/** - * Check the password state based on enforcement and default presence. - * - * @param enforced Whether the password is enforced. - * @param alwaysAskForPassword Wether the password should always be asked for. - */ -function checkPasswordState(enforced: boolean, alwaysAskForPassword: boolean) { - if (enforced) { - cy.contains('Password protection (enforced)').should('exist') - } else if (alwaysAskForPassword) { - cy.contains('Password protection').should('exist') - } - cy.contains('Enter a password') - .should('exist') - .and('not.be.disabled') -} - -/** - * Check the expiration date state based on enforcement and default presence. - * - * @param enforced Whether the expiration date is enforced. - * @param hasDefault Whether a default expiration date is set. - */ -function checkExpirationDateState(enforced: boolean, hasDefault: boolean) { - if (enforced) { - cy.contains('Enable link expiration (enforced)').should('exist') - } else if (hasDefault) { - cy.contains('Enable link expiration').should('exist') - } - cy.contains('Enter expiration date') - .should('exist') - .and('not.be.disabled') - cy.get('input[data-cy-files-sharing-expiration-date-input]').should('exist') - cy.get('input[data-cy-files-sharing-expiration-date-input]') - .invoke('val') - .then((val) => { - expect(val).to.not.be.undefined - - const inputDate = new Date(typeof val === 'number' ? val : String(val)) - const expectedDate = new Date() - expectedDate.setDate(expectedDate.getDate() + 2) - expect(inputDate.toDateString()).to.eq(expectedDate.toDateString()) - }) -} - -/** - * Create a public link share - * - * @param context The current share context - * @param shareName The name of the shared folder - * @param options The share options - */ -export function createLinkShare(context: ShareContext, shareName: string, options: ShareOptions | null = null): Cypress.Chainable { - cy.login(context.user) - cy.visit('/apps/files') - openSharingPanel(shareName) - - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createLinkShare') - cy.findByRole('button', { name: 'Create a new share link' }).click() - // Conduct optional checks based on the provided options - if (options) { - cy.get('.sharing-entry__actions').should('be.visible') // Wait for the dialog to open - checkPasswordState(options.enforcePassword ?? false, options.alwaysAskForPassword ?? false) - checkExpirationDateState(options.enforceExpirationDate ?? false, options.defaultExpirationDateSet ?? false) - cy.findByRole('button', { name: 'Create share' }).click() - } - - return cy.wait('@createLinkShare') - .should(({ response }) => { - expect(response?.statusCode).to.eq(200) - const url = response?.body?.ocs?.data?.url - expect(url).to.match(/^https?:\/\//) - context.url = url - }) - .then(() => cy.wrap(context.url as string)) -} - -/** - * open link share details for specific index - * - * @param index - */ -export function openLinkShareDetails(index: number) { - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .eq(index) - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }).click() -} - -/** - * Adjust share permissions to be editable - */ -function adjustSharePermission(): void { - openLinkShareDetails(0) - - cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click() - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }).click() - cy.wait('@updateShare').its('response.statusCode').should('eq', 200) -} - -/** - * Setup a public share and backup the state. - * If the setup was already done in another run, the state will be restored. - * - * @param shareName The name of the shared folder - * @return The URL of the share - */ -export function setupPublicShare(shareName = 'shared'): Cypress.Chainable { - return cy.task('getVariable', { key: `public-share-data--${shareName}` }) - .then((data) => { - const { dataSnapshot, shareUrl } = data as any || {} - if (dataSnapshot) { - cy.restoreState(dataSnapshot) - defaultShareContext.url = shareUrl - return cy.wrap(shareUrl as string) - } else { - const shareData: Record = {} - return cy.createRandomUser() - .then((user) => { - defaultShareContext.user = user - }) - .then(() => setupData(defaultShareContext.user, shareName)) - .then(() => createLinkShare(defaultShareContext, shareName)) - .then((url) => { - shareData.shareUrl = url - }) - .then(() => adjustSharePermission()) - .then(() => cy.saveState().then((snapshot) => { - shareData.dataSnapshot = snapshot - })) - .then(() => cy.task('setVariable', { key: `public-share-data--${shareName}`, value: shareData })) - .then(() => cy.log(`Public share setup, URL: ${shareData.shareUrl}`)) - .then(() => cy.wrap(defaultShareContext.url)) - } - }) -} diff --git a/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts b/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts deleted file mode 100644 index 524cf3d3f8acd..0000000000000 --- a/cypress/e2e/files_sharing/public-share/copy-move-files.cy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { copyFile, getRowForFile, moveFile, navigateToFolder } from '../../files/FilesUtils.ts' -import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - copy and move files', { testIsolation: true }, () => { - beforeEach(() => { - setupPublicShare() - .then(() => cy.logout()) - .then(() => cy.visit(getShareUrl())) - }) - - it('Can copy a file to new folder', () => { - getRowForFile('foo.txt').should('be.visible') - getRowForFile('subfolder').should('be.visible') - - copyFile('foo.txt', 'subfolder') - - // still visible - getRowForFile('foo.txt').should('be.visible') - navigateToFolder('subfolder') - - cy.url().should('contain', 'dir=/subfolder') - getRowForFile('foo.txt').should('be.visible') - getRowForFile('bar.txt').should('be.visible') - getRowForFile('subfolder').should('not.exist') - }) - - it('Can move a file to new folder', () => { - getRowForFile('foo.txt').should('be.visible') - getRowForFile('subfolder').should('be.visible') - - moveFile('foo.txt', 'subfolder') - - // wait until visible again - getRowForFile('subfolder').should('be.visible') - - // file should be moved -> not exist anymore - getRowForFile('foo.txt').should('not.exist') - navigateToFolder('subfolder') - - cy.url().should('contain', 'dir=/subfolder') - getRowForFile('foo.txt').should('be.visible') - getRowForFile('subfolder').should('not.exist') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/default-view.cy.ts b/cypress/e2e/files_sharing/public-share/default-view.cy.ts deleted file mode 100644 index c09c6de5085c0..0000000000000 --- a/cypress/e2e/files_sharing/public-share/default-view.cy.ts +++ /dev/null @@ -1,103 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile } from '../../files/FilesUtils.ts' -import { createLinkShare, setupData } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - setting the default view mode', () => { - let user: User - - beforeEach(() => { - cy.createRandomUser() - .then(($user) => (user = $user)) - .then(() => setupData(user, 'shared')) - }) - - it('is by default in list view', () => { - const context = { user } - createLinkShare(context, 'shared') - .then((url) => { - cy.logout() - cy.visit(url!) - - // See file is visible - getRowForFile('foo.txt').should('be.visible') - // See we are in list view - cy.findByRole('button', { name: 'Switch to grid view' }) - .should('be.visible') - .and('not.be.disabled') - }) - }) - - it('can be toggled by user', () => { - const context = { user } - createLinkShare(context, 'shared') - .then((url) => { - cy.logout() - cy.visit(url!) - - // See file is visible - getRowForFile('foo.txt') - .should('be.visible') - // See we are in list view - .find('.files-list__row-icon') - .should(($el) => expect($el.outerWidth()).to.be.lessThan(99)) - - // See the grid view toggle - cy.findByRole('button', { name: 'Switch to grid view' }) - .should('be.visible') - .and('not.be.disabled') - // And can change to grid view - .click() - - // See we are in grid view - getRowForFile('foo.txt') - .find('.files-list__row-icon') - .should(($el) => expect($el.outerWidth()).to.be.greaterThan(99)) - - // See the grid view toggle is now the list view toggle - cy.findByRole('button', { name: 'Switch to list view' }) - .should('be.visible') - .and('not.be.disabled') - }) - }) - - it('can be changed to default grid view', () => { - const context = { user } - createLinkShare(context, 'shared') - .then((url) => { - // Can set the "grid" view checkbox - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }).click() - cy.findByRole('button', { name: /Advanced settings/i }).click() - cy.findByRole('checkbox', { name: /Show files in grid view/i }) - .scrollIntoView() - cy.findByRole('checkbox', { name: /Show files in grid view/i }) - .should('not.be.checked') - .check({ force: true }) - - // Wait for the share update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }).click() - cy.wait('@updateShare').its('response.statusCode').should('eq', 200) - - // Logout and visit the share - cy.logout() - cy.visit(url!) - - // See file is visible - getRowForFile('foo.txt').should('be.visible') - // See we are in list view - cy.findByRole('button', { name: 'Switch to list view' }) - .should('be.visible') - .and('not.be.disabled') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/download.cy.ts b/cypress/e2e/files_sharing/public-share/download.cy.ts deleted file mode 100644 index 5c9c0ed7302bf..0000000000000 --- a/cypress/e2e/files_sharing/public-share/download.cy.ts +++ /dev/null @@ -1,272 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' -import type { ShareContext } from './PublicShareUtils.ts' - -import { zipFileContains } from '../../../support/utils/assertions.ts' -import { deleteDownloadsFolderBeforeEach } from '../../../support/utils/deleteDownloadsFolder.ts' -import { getRowForFile, getRowForFileId, triggerActionForFile, triggerActionForFileId } from '../../files/FilesUtils.ts' -import { createLinkShare, getShareUrl, openLinkShareDetails, setupPublicShare } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - downloading files', { testIsolation: true }, () => { - // in general there is no difference except downloading - // as file shares have the source of the share token but a different displayname - describe('file share', () => { - let fileId: number - - before(() => { - cy.createRandomUser().then((user) => { - const context: ShareContext = { user } - cy.uploadContent(user, new Blob(['foo']), 'text/plain', '/file.txt') - .then(({ headers }) => { fileId = Number.parseInt(headers['oc-fileid']) }) - cy.login(user) - createLinkShare(context, 'file.txt') - .then(() => cy.logout()) - .then(() => cy.visit(context.url!)) - }) - }) - - it('can download the file', () => { - getRowForFileId(fileId) - .should('be.visible') - getRowForFileId(fileId) - .find('[data-cy-files-list-row-name]') - .should((el) => expect(el.text()).to.match(/file\s*\.txt/)) // extension is sparated so there might be a space between - triggerActionForFileId(fileId, 'download') - // check a file is downloaded with the correct name - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'foo') - }) - }) - - describe('folder share', () => { - const shareName = 'a-folder-share' - - before(() => setupPublicShare(shareName)) - - deleteDownloadsFolderBeforeEach() - - beforeEach(() => { - cy.logout() - cy.visit(getShareUrl()) - }) - - it('Can download all files', () => { - getRowForFile('foo.txt').should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - cy.findByRole('checkbox', { name: /Toggle selection for all files/i }) - .should('exist') - .check({ force: true }) - - // see that two files are selected - cy.contains('2 selected').should('be.visible') - - // click download - cy.findByRole('button', { name: 'Download (selected)' }) - .should('be.visible') - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/${shareName}.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'foo.txt', - 'subfolder/', - 'subfolder/bar.txt', - ])) - }) - }) - - it('Can download selected files', () => { - getRowForFile('subfolder') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - getRowForFile('subfolder') - .findByRole('checkbox') - .check({ force: true }) - - // see that two files are selected - cy.contains('1 selected').should('be.visible') - - // click download - cy.findByRole('button', { name: 'Download (selected)' }) - .should('be.visible') - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'subfolder/', - 'subfolder/bar.txt', - ])) - }) - }) - - it('Can download folder by action', () => { - getRowForFile('subfolder') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - triggerActionForFile('subfolder', 'download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/subfolder.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'subfolder/', - 'subfolder/bar.txt', - ])) - }) - }) - - it('Can download file by action', () => { - getRowForFile('foo.txt') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - triggerActionForFile('foo.txt', 'download') - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'foo') - }) - }) - - it('Can download file by selection', () => { - getRowForFile('foo.txt') - .should('be.visible') - - cy.get('[data-cy-files-list]').within(() => { - getRowForFile('foo.txt') - .findByRole('checkbox') - .check({ force: true }) - - cy.findByRole('button', { name: 'Download (selected)' }) - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'foo') - }) - }) - }) - - describe('download permission - link share', () => { - let context: ShareContext - beforeEach(() => { - cy.createRandomUser().then((user) => { - cy.mkdir(user, '/test') - - context = { user } - createLinkShare(context, 'test') - cy.login(context.user) - cy.visit('/apps/files') - }) - }) - - deleteDownloadsFolderBeforeEach() - - it('download permission is retained', () => { - getRowForFile('test').should('be.visible') - triggerActionForFile('test', 'details') - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update') - - cy.findByRole('checkbox', { name: /hide download/i }) - .should('exist') - .and('not.be.checked') - .check({ force: true }) - cy.findByRole('checkbox', { name: /hide download/i }) - .should('be.checked') - cy.findByRole('button', { name: /update share/i }) - .click() - - cy.wait('@update') - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('checkbox', { name: /hide download/i }) - .should('be.checked') - - cy.reload() - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('checkbox', { name: /hide download/i }) - .should('be.checked') - }) - }) - - describe('download permission - mail share', () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/test') - cy.login(user) - cy.visit('/apps/files') - }) - }) - - it('download permission is retained', () => { - getRowForFile('test').should('be.visible') - triggerActionForFile('test', 'details') - - cy.findByRole('combobox', { name: /Enter external recipients/i }) - .type('test@example.com') - - cy.get('.option[sharetype="4"][user="test@example.com"]') - .parent('li') - .click() - cy.findByRole('button', { name: /advanced settings/i }) - .should('be.visible') - .click() - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('update') - - cy.findByRole('checkbox', { name: /hide download/i }) - .should('exist') - .and('not.be.checked') - .check({ force: true }) - cy.findByRole('button', { name: /save share/i }) - .click() - - cy.wait('@update') - - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }) - .click() - cy.findByRole('checkbox', { name: /hide download/i }) - .should('exist') - .and('be.checked') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts b/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts deleted file mode 100644 index c986ce634ac56..0000000000000 --- a/cypress/e2e/files_sharing/public-share/header-avatar.cy.ts +++ /dev/null @@ -1,194 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { ShareContext } from './PublicShareUtils.ts' - -import { createLinkShare, setupData } from './PublicShareUtils.ts' - -/** - * This tests ensures that on public shares the header avatar menu correctly works - */ -describe('files_sharing: Public share - header avatar menu', { testIsolation: true }, () => { - let context: ShareContext - let firstPublicShareUrl = '' - let secondPublicShareUrl = '' - - before(() => { - cy.createRandomUser() - .then((user) => { - context = { - user, - url: undefined, - } - setupData(context.user, 'public1') - setupData(context.user, 'public2') - createLinkShare(context, 'public1').then((shareUrl) => { - firstPublicShareUrl = shareUrl - cy.log(`Created first share with URL: ${shareUrl}`) - }) - createLinkShare(context, 'public2').then((shareUrl) => { - secondPublicShareUrl = shareUrl - cy.log(`Created second share with URL: ${shareUrl}`) - }) - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(firstPublicShareUrl) - }) - - it('See the undefined avatar menu', () => { - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - // Note that current guest user is not identified - cy.get('@headerMenu') - .should('be.visible') - .findByRole('note') - .should('be.visible') - .should('contain', 'not identified') - - // Button to set guest name - cy.get('@headerMenu') - .findByRole('link', { name: /Set public name/i }) - .should('be.visible') - }) - - it('Can set public name', () => { - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .as('userMenuButton') - - // Open the user menu - cy.get('@userMenuButton').click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - cy.get('@headerMenu') - .findByRole('link', { name: /Set public name/i }) - .should('be.visible') - .click() - - // Check the dialog is visible - cy.findByRole('dialog', { name: /Guest identification/i }) - .should('be.visible') - .as('guestIdentificationDialog') - - // Check the note is visible - cy.get('@guestIdentificationDialog') - .findByRole('note') - .should('contain', 'not identified') - - // Check the input is visible - cy.get('@guestIdentificationDialog') - .findByRole('textbox', { name: /Name/i }) - .should('be.visible') - .type('{selectAll}John Doe{enter}') - - // Check that the dialog is closed - cy.get('@guestIdentificationDialog') - .should('not.exist') - - // Check that the avatar changed - cy.get('@userMenuButton') - .find('img') - .invoke('attr', 'src') - .should('include', 'avatar/guest/John%20Doe') - }) - - it('Guest name us persistent and can be changed', () => { - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .as('userMenuButton') - - // Open the user menu - cy.get('@userMenuButton').click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - cy.get('@headerMenu') - .findByRole('link', { name: /Set public name/i }) - .should('be.visible') - .click() - - // Check the dialog is visible - cy.findByRole('dialog', { name: /Guest identification/i }) - .should('be.visible') - .as('guestIdentificationDialog') - - // Set the name - cy.get('@guestIdentificationDialog') - .findByRole('textbox', { name: /Name/i }) - .should('be.visible') - .type('{selectAll}Jane Doe{enter}') - - // Check that the dialog is closed - cy.get('@guestIdentificationDialog') - .should('not.exist') - - // Create another share - cy.visit(secondPublicShareUrl) - - cy.get('header') - .findByRole('navigation', { name: /User menu/i }) - .should('be.visible') - .findByRole('button', { name: /User menu/i }) - .should('be.visible') - .as('userMenuButton') - - // Open the user menu - cy.get('@userMenuButton').click() - cy.get('#header-menu-public-page-user-menu') - .as('headerMenu') - - // See the note with the current name - cy.get('@headerMenu') - .findByRole('note') - .should('contain', 'Your guest name: Jane Doe') - - cy.get('@headerMenu') - .findByRole('link', { name: /Change public name/i }) - .should('be.visible') - .click() - - // Check the dialog is visible - cy.findByRole('dialog', { name: /Guest identification/i }) - .should('be.visible') - .as('guestIdentificationDialog') - - // Check that the note states the current name - // cy.get('@guestIdentificationDialog') - // .findByRole('note') - // .should('contain', 'are currently identified as Jane Doe') - - // Change the name - cy.get('@guestIdentificationDialog') - .findByRole('textbox', { name: /Name/i }) - .should('be.visible') - .type('{selectAll}Foo Bar{enter}') - - // Check that the dialog is closed - cy.get('@guestIdentificationDialog') - .should('not.exist') - - // Check that the avatar changed with the second name - cy.get('@userMenuButton') - .find('img') - .invoke('attr', 'src') - .should('include', 'avatar/guest/Foo%20Bar') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/header-menu.cy.ts b/cypress/e2e/files_sharing/public-share/header-menu.cy.ts deleted file mode 100644 index 56da2d2e2e000..0000000000000 --- a/cypress/e2e/files_sharing/public-share/header-menu.cy.ts +++ /dev/null @@ -1,201 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { haveValidity, zipFileContains } from '../../../support/utils/assertions.ts' -import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' - -/** - * This tests ensures that on public shares the header actions menu correctly works - */ -describe('files_sharing: Public share - header actions menu', { testIsolation: true }, () => { - before(() => setupPublicShare()) - beforeEach(() => { - cy.logout() - cy.visit(getShareUrl()) - }) - - it('Can download all files', () => { - cy.get('header') - .findByRole('button', { name: 'Download' }) - .should('be.visible') - cy.get('header') - .findByRole('button', { name: 'Download' }) - .click() - - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/shared.zip`, null, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 30) - // Check all files are included - .and(zipFileContains([ - 'shared/', - 'shared/foo.txt', - 'shared/subfolder/', - 'shared/subfolder/bar.txt', - ])) - }) - - it('Can copy direct link', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .click() - // See the menu - cy.findByRole('menu', { name: /More action/i }) - .should('be.visible') - // see correct link in item - cy.findByRole('menuitem', { name: 'Direct link' }) - .should('be.visible') - .and('have.attr', 'href') - .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`))) - // see menu closes on click - cy.findByRole('menuitem', { name: 'Direct link' }) - .click() - cy.findByRole('menu', { name: /More actions/i }) - .should('not.exist') - }) - - it('Can create federated share', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .click() - // See the menu - cy.findByRole('menu', { name: /More action/i }) - .should('be.visible') - // see correct button - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - // see the dialog - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }) - .should('be.visible') - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).within(() => { - cy.findByRole('textbox') - .type('user@nextcloud.local') - // create share - cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare') - .as('createFederatedShare') - cy.findByRole('button', { name: 'Create share' }) - .click() - cy.wait('@createFederatedShare') - }) - }) - - it('Has user feedback while creating federated share', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - .click() - // see correct button - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - // see the dialog - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => { - cy.findByRole('textbox') - .type('user@nextcloud.local') - // intercept request, the request is continued when the promise is resolved - const { promise, resolve } = Promise.withResolvers() - cy.intercept('POST', '**/apps/federatedfilesharing/createFederatedShare', (request) => { - // we need to wait in the onResponse handler as the intercept handler times out otherwise - request.on('response', async (response) => { - await promise - response.statusCode = 503 - }) - }).as('createFederatedShare') - - // create the share - cy.findByRole('button', { name: 'Create share' }) - .click() - // see that while the share is created the button is disabled - cy.findByRole('button', { name: 'Create share' }) - .should('be.disabled') - .then(() => { - // continue the request - resolve(null) - }) - cy.wait('@createFederatedShare') - // see that the button is no longer disabled - cy.findByRole('button', { name: 'Create share' }) - .should('not.be.disabled') - }) - }) - - it('Has input validation for federated share', () => { - // Check the button - cy.get('header') - .findByRole('button', { name: /More actions/i }) - .should('be.visible') - .click() - // see correct button - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - // see the dialog - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible').within(() => { - // Check domain only - cy.findByRole('textbox') - .type('nextcloud.local') - cy.findByRole('textbox') - .should(haveValidity(/user/i)) - // Check no valid domain - cy.findByRole('textbox') - .type('{selectAll}user@invalid') - cy.findByRole('textbox') - .should(haveValidity(/invalid.+url/i)) - }) - }) - - it('See primary action is moved to menu on small screens', () => { - cy.viewport(490, 490) - // Check the button does not exist - cy.get('header').within(() => { - cy.findByRole('button', { name: 'Direct link' }) - .should('not.exist') - cy.findByRole('button', { name: 'Download' }) - .should('not.exist') - cy.findByRole('button', { name: /Add to your/i }) - .should('not.exist') - // Open the menu - cy.findByRole('button', { name: /More actions/i }) - .should('be.visible') - .click() - }) - - // See correct number of menu item - cy.findByRole('menu', { name: 'More actions' }) - .findAllByRole('menuitem') - .should('have.length', 3) - cy.findByRole('menu', { name: 'More actions' }) - .within(() => { - // See that download, federated share and direct link are moved to the menu - cy.findByRole('menuitem', { name: /^Download/ }) - .should('be.visible') - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - cy.findByRole('menuitem', { name: 'Direct link' }) - .should('be.visible') - - // See that direct link works - cy.findByRole('menuitem', { name: 'Direct link' }) - .should('be.visible') - .and('have.attr', 'href') - .then((attribute) => expect(attribute).to.match(new RegExp(`^${Cypress.env('baseUrl')}/public.php/dav/files/.+/?accept=zip$`))) - // See remote share works - cy.findByRole('menuitem', { name: /Add to your/i }) - .should('be.visible') - .click() - }) - cy.findByRole('dialog', { name: /Add to your Nextcloud/i }).should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/rename-files.cy.ts b/cypress/e2e/files_sharing/public-share/rename-files.cy.ts deleted file mode 100644 index a0fa5492be571..0000000000000 --- a/cypress/e2e/files_sharing/public-share/rename-files.cy.ts +++ /dev/null @@ -1,31 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getRowForFile, haveValidity, triggerActionForFile } from '../../files/FilesUtils.ts' -import { getShareUrl, setupPublicShare } from './PublicShareUtils.ts' - -describe('files_sharing: Public share - renaming files', { testIsolation: true }, () => { - beforeEach(() => { - setupPublicShare() - .then(() => cy.logout()) - .then(() => cy.visit(getShareUrl())) - }) - - it('can rename a file', () => { - // All are visible by default - getRowForFile('foo.txt').should('be.visible') - - triggerActionForFile('foo.txt', 'rename') - - getRowForFile('foo.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{enter}') - - // See it is renamed - getRowForFile('other.txt').should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts b/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts deleted file mode 100644 index dd11bd0b2cfac..0000000000000 --- a/cypress/e2e/files_sharing/public-share/required-before-create.cy.ts +++ /dev/null @@ -1,191 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { ShareOptions } from '../ShareOptionsType.ts' -import type { ShareContext } from './PublicShareUtils.ts' - -import { defaultShareOptions } from '../ShareOptionsType.ts' -import { createLinkShare, setupData } from './PublicShareUtils.ts' - -describe('files_sharing: Before create checks', () => { - let shareContext: ShareContext - - before(() => { - // Setup data for the shared folder once before all tests - cy.createRandomUser().then((randomUser) => { - shareContext = { - user: randomUser, - } - }) - }) - - afterEach(() => { - cy.runOccCommand('config:app:delete core shareapi_enable_link_password_by_default') - cy.runOccCommand('config:app:delete core shareapi_enforce_links_password') - cy.runOccCommand('config:app:delete core shareapi_default_expire_date') - cy.runOccCommand('config:app:delete core shareapi_enforce_expire_date') - cy.runOccCommand('config:app:delete core shareapi_expire_after_n_days') - }) - - const applyShareOptions = (options: ShareOptions = defaultShareOptions): void => { - cy.runOccCommand(`config:app:set --value ${options.alwaysAskForPassword ? 'yes' : 'no'} core shareapi_enable_link_password_by_default`) - cy.runOccCommand(`config:app:set --value ${options.enforcePassword ? 'yes' : 'no'} core shareapi_enforce_links_password`) - cy.runOccCommand(`config:app:set --value ${options.enforceExpirationDate ? 'yes' : 'no'} core shareapi_enforce_expire_date`) - cy.runOccCommand(`config:app:set --value ${options.defaultExpirationDateSet ? 'yes' : 'no'} core shareapi_default_expire_date`) - if (options.defaultExpirationDateSet) { - cy.runOccCommand('config:app:set --value 2 core shareapi_expire_after_n_days') - } - } - - it('Checks if user can create share when both password and expiration date are enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: true, - enforceExpirationDate: true, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - const shareName = 'passwordAndExpireEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share when password is enforced and expiration date has a default set', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: true, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - const shareName = 'passwordEnforcedDefaultExpire' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share when password is optionally requested and expiration date is enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - defaultExpirationDateSet: true, - enforceExpirationDate: true, - } - applyShareOptions(shareOptions) - const shareName = 'defaultPasswordExpireEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share when password is optionally requested and expiration date have defaults set', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - const shareName = 'defaultPasswordAndExpire' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password enforced and expiration date set but not enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: true, - defaultExpirationDateSet: true, - enforceExpirationDate: false, - } - applyShareOptions(shareOptions) - const shareName = 'passwordEnforcedExpireSetNotEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create a share when both password and expiration date have default values but are both not enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - enforceExpirationDate: false, - } - applyShareOptions(shareOptions) - const shareName = 'defaultPasswordAndExpirationNotEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password not enforced but expiration date enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - enforceExpirationDate: true, - } - applyShareOptions(shareOptions) - const shareName = 'noPasswordExpireEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password not enforced and expiration date has a default set', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - enforceExpirationDate: false, - } - applyShareOptions(shareOptions) - const shareName = 'defaultExpireNoPasswordEnforced' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with expiration date set and password not enforced', () => { - const shareOptions: ShareOptions = { - alwaysAskForPassword: true, - enforcePassword: false, - defaultExpirationDateSet: true, - } - applyShareOptions(shareOptions) - - const shareName = 'noPasswordExpireDefault' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, shareOptions).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) - - it('Checks if user can create share with password not enforced, expiration date not enforced, and no defaults set', () => { - applyShareOptions() - const shareName = 'noPasswordNoExpireNoDefaults' - setupData(shareContext.user, shareName) - createLinkShare(shareContext, shareName, null).then((shareUrl) => { - shareContext.url = shareUrl - cy.log(`Created share with URL: ${shareUrl}`) - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts b/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts deleted file mode 100644 index 0430ca9b2d186..0000000000000 --- a/cypress/e2e/files_sharing/public-share/sidebar-tab.cy.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { createLinkShare, openLinkShareDetails } from './PublicShareUtils.ts' - -describe('files_sharing: sidebar tab', () => { - let alice: User - - beforeEach(() => { - cy.createRandomUser() - .then((user) => { - alice = user - cy.mkdir(user, '/test') - cy.login(user) - cy.visit('/apps/files') - }) - }) - - /** - * Regression tests of https://github.com/nextcloud/server/issues/53566 - * Where the ' char was shown as ' - */ - it('correctly lists shares by label with special characters', () => { - createLinkShare({ user: alice }, 'test') - openLinkShareDetails(0) - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('textbox', { name: /share label/i }) - .should('be.visible') - .type('Alice\' share') - - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('PUT') - cy.findByRole('button', { name: /update share/i }).click() - cy.wait('@PUT') - - // see the label is shown correctly - cy.findByRole('list', { name: /link shares/i }) - .findAllByRole('listitem') - .should('have.length', 1) - .first() - .should('contain.text', 'Share link (Alice\' share)') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts deleted file mode 100644 index f6749f1a7d43d..0000000000000 --- a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts +++ /dev/null @@ -1,182 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getRowForFile } from '../../files/FilesUtils.ts' -import { openSharingPanel } from '../FilesSharingUtils.ts' - -describe('files_sharing: Public share - File drop', { testIsolation: true }, () => { - let shareUrl: string - let user: string - const shareName = 'shared' - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user.userId - cy.mkdir($user, `/${shareName}`) - cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) - cy.login($user) - // open the files app - cy.visit('/apps/files') - // open the sidebar - openSharingPanel(shareName) - // create the share - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Create a new share link' }) - .click() - // extract the link - cy.wait('@createShare').should(({ response }) => { - const { ocs } = response?.body ?? {} - shareUrl = ocs?.data.url - expect(shareUrl).to.match(/^http:\/\//) - }) - - // Update the share to be a file drop - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }) - .should('be.visible') - .click() - cy.get('[data-cy-files-sharing-share-permissions-bundle]') - .should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="file-drop"]') - .click() - - // save the update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }) - .click() - cy.wait('@updateShare') - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(shareUrl) - }) - - it('Cannot see share content', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - // foo exists - cy.userFileExists(user, `${shareName}/foo.txt`).should('be.gt', 0) - // but is not visible - getRowForFile('foo.txt') - .should('not.exist') - }) - - it('Can only see upload files and upload folders menu entries', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - cy.findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // See upload actions - cy.findByRole('menuitem', { name: 'Upload files' }) - .should('be.visible') - cy.findByRole('menuitem', { name: 'Upload folders' }) - .should('be.visible') - // But no other - cy.findByRole('menu') - .findAllByRole('menuitem') - .should('have.length', 2) - }) - - it('Can only see dedicated upload button', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - cy.findByRole('button', { name: 'Upload' }) - .should('be.visible') - .click() - // See upload actions - cy.findByRole('menuitem', { name: 'Upload files' }) - .should('be.visible') - cy.findByRole('menuitem', { name: 'Upload folders' }) - .should('be.visible') - // But no other - cy.findByRole('menu') - .findAllByRole('menuitem') - .should('have.length', 2) - }) - - it('Can upload files', () => { - cy.contains(`Upload files to ${shareName}`) - .should('be.visible') - - const { promise, resolve } = Promise.withResolvers() - cy.intercept('PUT', '**/public.php/dav/files/**', (request) => { - if (request.url.includes('first.txt')) { - // just continue the first one - request.continue() - } else { - // We delay the second one until we checked that the progress bar is visible - request.on('response', async () => { - await promise - }) - } - }).as('uploadFile') - - cy.get('[data-cy-files-sharing-file-drop] input[type="file"]') - .should('exist') - .selectFile([ - { fileName: 'first.txt', contents: Buffer.from('8 bytes!') }, - { fileName: 'second.md', contents: Buffer.from('x'.repeat(128)) }, - ], { force: true }) - - cy.wait('@uploadFile') - - // More than one progressbar can exist (upload picker and file drop - // view) and some of them stay hidden. - cy.findAllByRole('progressbar') - .should(($bars) => { - const visible = $bars.toArray().filter((el) => Cypress.$(el).is(':visible')) - const summary = $bars.toArray() - .map((el) => `${el.tagName}[value=${el.getAttribute('value')} visible=${Cypress.$(el).is(':visible')}]`) - .join(', ') - expect(visible.length, `visible progressbar (${summary})`).to.be.gte(1) - const values = visible.map((el) => Number.parseInt(el.getAttribute('value') ?? '0')) - expect(Math.max(...values), `upload progress (${summary})`).to.be.gte(50) - }) - // continue second request - .then(() => resolve(null)) - - cy.wait('@uploadFile') - - // Check files uploaded - cy.userFileExists(user, `${shareName}/first.txt`).should('eql', 8) - cy.userFileExists(user, `${shareName}/second.md`).should('eql', 128) - }) - - describe('Terms of service', { testIsolation: true }, () => { - before(() => cy.runOccCommand('config:app:set --value \'TEST: Some disclaimer text\' --type string core shareapi_public_link_disclaimertext')) - beforeEach(() => cy.visit(shareUrl)) - after(() => cy.runOccCommand('config:app:delete core shareapi_public_link_disclaimertext')) - - it('shows ToS on file-drop view', () => { - cy.get('[data-cy-files-sharing-file-drop]') - .contains(`Upload files to ${shareName}`) - .should('be.visible') - cy.get('[data-cy-files-sharing-file-drop]') - .contains('agree to the terms of service') - .should('be.visible') - cy.findByRole('button', { name: /Terms of service/i }) - .should('be.visible') - .click() - - cy.findByRole('dialog', { name: 'Terms of service' }) - .should('contain.text', 'TEST: Some disclaimer text') - // close - .findByRole('button', { name: 'Close' }) - .click() - - cy.findByRole('dialog', { name: 'Terms of service' }) - .should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts deleted file mode 100644 index 0545e7d88db6d..0000000000000 --- a/cypress/e2e/files_sharing/public-share/view_view-only-no-download.cy.ts +++ /dev/null @@ -1,100 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' -import { openSharingPanel } from '../FilesSharingUtils.ts' - -describe('files_sharing: Public share - View only', { testIsolation: true }, () => { - let shareUrl: string - const shareName = 'shared' - - before(() => { - cy.createRandomUser().then(($user) => { - cy.mkdir($user, `/${shareName}`) - cy.mkdir($user, `/${shareName}/subfolder`) - cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/foo.txt`) - cy.uploadContent($user, new Blob([]), 'text/plain', `/${shareName}/subfolder/bar.txt`) - cy.login($user) - // open the files app - cy.visit('/apps/files') - // open the sidebar - openSharingPanel(shareName) - // create the share - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Create a new share link' }) - .click() - // extract the link - cy.wait('@createShare').should(({ response }) => { - const { ocs } = response?.body ?? {} - shareUrl = ocs?.data.url - expect(shareUrl).to.match(/^http:\/\//) - }) - - // Update the share to be a view-only-no-download share - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }) - .should('be.visible') - .click() - cy.get('[data-cy-files-sharing-share-permissions-bundle]') - .should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') - .click() - cy.findByRole('button', { name: /advanced settings/i }).click() - cy.findByRole('checkbox', { name: 'Hide download' }) - .check({ force: true }) - // save the update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }) - .click() - cy.wait('@updateShare') - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(shareUrl) - }) - - it('Can see the files list', () => { - // foo exists - getRowForFile('foo.txt') - .should('be.visible') - }) - - it('But no actions available', () => { - // foo exists - getRowForFile('foo.txt') - .should('be.visible') - // but no actions - getActionButtonForFile('foo.txt') - .should('not.exist') - - // TODO: We really need Viewer in the server repo. - // So we could at least test viewing images - }) - - it('Can navigate to subfolder', () => { - getRowForFile('subfolder') - .should('be.visible') - - navigateToFolder('subfolder') - - getRowForFile('bar.txt') - .should('be.visible') - - // but also no actions - getActionButtonForFile('bar.txt') - .should('not.exist') - }) - - it('Cannot upload files', () => { - // wait for file list to be ready - getRowForFile('foo.txt') - .should('be.visible') - }) -}) diff --git a/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts b/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts deleted file mode 100644 index f2363defcee82..0000000000000 --- a/cypress/e2e/files_sharing/public-share/view_view-only.cy.ts +++ /dev/null @@ -1,102 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts' -import { openSharingPanel } from '../FilesSharingUtils.ts' - -describe('files_sharing: Public share - View only', { testIsolation: true }, () => { - let shareUrl: string - const shareName = 'shared' - - before(() => { - cy.createRandomUser().then(($user) => { - cy.mkdir($user, `/${shareName}`) - cy.mkdir($user, `/${shareName}/subfolder`) - cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/foo.txt`) - cy.uploadContent($user, new Blob(['content']), 'text/plain', `/${shareName}/subfolder/bar.txt`) - cy.login($user) - // open the files app - cy.visit('/apps/files') - // open the sidebar - openSharingPanel(shareName) - // create the share - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Create a new share link' }) - .click() - // extract the link - cy.wait('@createShare').should(({ response }) => { - const { ocs } = response?.body ?? {} - shareUrl = ocs?.data.url - expect(shareUrl).to.match(/^http:\/\//) - }) - - // Update the share to be a view-only-no-download share - cy.findByRole('list', { name: 'Link shares' }) - .findAllByRole('listitem') - .first() - .findByRole('button', { name: /Actions/i }) - .click() - cy.findByRole('menuitem', { name: /Customize link/i }) - .should('be.visible') - .click() - cy.get('[data-cy-files-sharing-share-permissions-bundle]') - .should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="read-only"]') - .click() - // save the update - cy.intercept('PUT', '**/ocs/v2.php/apps/files_sharing/api/v1/shares/*').as('updateShare') - cy.findByRole('button', { name: 'Update share' }) - .click() - cy.wait('@updateShare') - }) - }) - - beforeEach(() => { - cy.logout() - cy.visit(shareUrl) - }) - - it('Can see the files list', () => { - // foo exists - getRowForFile('foo.txt') - .should('be.visible') - }) - - it('Can navigate to subfolder', () => { - getRowForFile('subfolder') - .should('be.visible') - - navigateToFolder('subfolder') - - getRowForFile('bar.txt') - .should('be.visible') - }) - - it('Cannot upload files', () => { - // wait for file list to be ready - getRowForFile('foo.txt') - .should('be.visible') - }) - - it('Only download action is actions available', () => { - getActionButtonForFile('foo.txt') - .should('be.visible') - .click() - - // Only the download action - cy.findByRole('menuitem', { name: 'Download' }) - .should('be.visible') - cy.findAllByRole('menuitem') - .should('have.length', 1) - - // Can download - cy.findByRole('menuitem', { name: 'Download' }).click() - // check a file is downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/foo.txt`, 'utf-8', { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 5) - .and('contain', 'content') - }) -}) diff --git a/cypress/e2e/files_sharing/share-permissions-bundle.cy.ts b/cypress/e2e/files_sharing/share-permissions-bundle.cy.ts deleted file mode 100644 index dec8173c4a9c1..0000000000000 --- a/cypress/e2e/files_sharing/share-permissions-bundle.cy.ts +++ /dev/null @@ -1,111 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { openSharingPanel } from './FilesSharingUtils.ts' - -describe('files_sharing: Share permissions bundle configuration', () => { - let alice: User - let bob: User - - before(() => { - cy.createRandomUser().then(($user) => { - alice = $user - }) - cy.createRandomUser().then(($user) => { - bob = $user - }) - }) - - beforeEach(() => { - cy.runOccCommand('config:app:delete files_sharing shareapi_exclude_reshare_from_edit') - }) - - after(() => { - cy.runOccCommand('config:app:delete files_sharing shareapi_exclude_reshare_from_edit') - }) - - /** - * Helper to create a user share and select "Allow editing" - */ - function createUserShareWithEdit(itemName: string) { - openSharingPanel(itemName) - - cy.get('#app-sidebar-vue').within(() => { - cy.intercept('GET', '**/apps/files_sharing/api/v1/sharees?*').as('shareeSearch') - cy.findByRole('combobox', { name: /Search for internal recipients/i }) - .type(`{selectAll}${bob.userId}`) - cy.wait('@shareeSearch') - }) - - cy.get(`[user="${bob.userId}"]`).click() - - // Select "Allow editing" permission bundle - cy.get('[data-cy-files-sharing-share-permissions-bundle]').should('be.visible') - cy.get('[data-cy-files-sharing-share-permissions-bundle="upload-edit"]').click() - - cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare') - cy.findByRole('button', { name: 'Save share' }).click() - - return cy.wait('@createShare') - } - - describe('Default behavior (SHARE included in edit)', () => { - it('Creates user share with "Allow editing" with SHARE permission for folders', () => { - const folderName = 'test-folder-with-share' - cy.mkdir(alice, `/${folderName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(folderName).should(({ response }) => { - // Verify permission value is 31 (ALL with SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8 + SHARE=16) - expect(response?.body?.ocs?.data?.permissions).to.equal(31) - }) - }) - - it('Creates user share with "Allow editing" with SHARE permission for files', () => { - const fileName = 'test-file-with-share.txt' - cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(fileName).should(({ response }) => { - // Verify permission value is 19 (ALL_FILE with SHARE: READ=1 + UPDATE=2 + SHARE=16) - expect(response?.body?.ocs?.data?.permissions).to.equal(19) - }) - }) - }) - - describe('With SHARE excluded from edit (config enabled)', () => { - beforeEach(() => { - cy.runOccCommand('config:app:set --value yes files_sharing shareapi_exclude_reshare_from_edit') - }) - - it('Creates user share with "Allow editing" without SHARE permission for folders', () => { - const folderName = 'test-folder-no-share' - cy.mkdir(alice, `/${folderName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(folderName).should(({ response }) => { - // Verify permission value is 15 (ALL without SHARE: READ=1 + UPDATE=2 + CREATE=4 + DELETE=8) - expect(response?.body?.ocs?.data?.permissions).to.equal(15) - }) - }) - - it('Creates user share with "Allow editing" without SHARE permission for files', () => { - const fileName = 'test-file-no-share.txt' - cy.uploadContent(alice, new Blob(['content']), 'text/plain', `/${fileName}`) - cy.login(alice) - cy.visit('/apps/files') - - createUserShareWithEdit(fileName).should(({ response }) => { - // Verify permission value is 3 (ALL_FILE without SHARE: READ=1 + UPDATE=2) - expect(response?.body?.ocs?.data?.permissions).to.equal(3) - }) - }) - }) -}) diff --git a/cypress/e2e/files_sharing/share-status-action.cy.ts b/cypress/e2e/files_sharing/share-status-action.cy.ts deleted file mode 100644 index 30a76334b091c..0000000000000 --- a/cypress/e2e/files_sharing/share-status-action.cy.ts +++ /dev/null @@ -1,124 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { closeSidebar, enableGridMode, getActionButtonForFile, getActionsForFile, getInlineActionEntryForFile, getRowForFile } from '../files/FilesUtils.ts' -import { createShare } from './FilesSharingUtils.ts' - -describe('files_sharing: Sharing status action', { testIsolation: true }, () => { - /** - * Regression test of https://github.com/nextcloud/server/issues/45723 - */ - it('No "shared" tag when user ID is purely numerical but there are no shares', () => { - const user = { - language: 'en', - password: 'test1234', - userId: String(Math.floor(Math.random() * 1000)), - } as User - cy.createUser(user) - cy.mkdir(user, '/folder') - cy.login(user) - - cy.visit('/apps/files') - - getRowForFile('folder').should('be.visible') - getActionsForFile('folder') - .findByRole('button', { name: 'Shared' }) - .should('not.exist') - }) - - it('Render quick option for sharing', () => { - cy.createRandomUser().then((user) => { - cy.mkdir(user, '/folder') - cy.login(user) - - cy.visit('/apps/files') - }) - - getRowForFile('folder').should('be.visible') - getActionsForFile('folder') - .findByRole('button', { name: /Sharing options/ }) - .should('be.visible') - .click({ force: true }) - - // check the click opened the sidebar - cy.get('[data-cy-sidebar]') - .should('be.visible') - // and ensure the sharing tab is selected - .findByRole('tab', { name: 'Sharing', selected: true }) - .should('exist') - }) - - describe('Sharing inline status action handling', () => { - let user: User - let sharee: User - - before(() => { - cy.createRandomUser().then(($user) => { - sharee = $user - }) - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/folder') - cy.login(user) - - cy.visit('/apps/files') - getRowForFile('folder').should('be.visible') - - createShare('folder', sharee.userId) - closeSidebar() - }) - cy.logout() - }) - - it('Render inline status action for sharer', () => { - cy.login(user) - cy.visit('/apps/files') - - getInlineActionEntryForFile('folder', 'sharing-status') - .should('have.attr', 'aria-label', `Shared with ${sharee.userId}`) - .should('have.attr', 'title', `Shared with ${sharee.userId}`) - .should('be.visible') - }) - - it('Render status action in gridview for sharer', () => { - cy.login(user) - cy.visit('/apps/files') - enableGridMode() - - getRowForFile('folder') - .should('be.visible') - getActionButtonForFile('folder') - .click() - cy.findByRole('menu') - .findByRole('menuitem', { name: /shared with/i }) - .should('be.visible') - }) - - it('Render inline status action for sharee', () => { - cy.login(sharee) - cy.visit('/apps/files') - - getInlineActionEntryForFile('folder', 'sharing-status') - .should('have.attr', 'aria-label', `Shared by ${user.userId}`) - .should('be.visible') - }) - - it('Render status action in grid view for sharee', () => { - cy.login(sharee) - cy.visit('/apps/files') - - enableGridMode() - - getRowForFile('folder') - .should('be.visible') - getActionButtonForFile('folder') - .click() - cy.findByRole('menu') - .findByRole('menuitem', { name: `Shared by ${user.userId}` }) - .should('be.visible') - }) - }) -}) diff --git a/cypress/e2e/files_trashbin/files-trash-action.cy.ts b/cypress/e2e/files_trashbin/files-trash-action.cy.ts deleted file mode 100644 index fff87776c91ff..0000000000000 --- a/cypress/e2e/files_trashbin/files-trash-action.cy.ts +++ /dev/null @@ -1,69 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { deleteFileWithRequest, triggerFileListAction } from '../files/FilesUtils.ts' - -const FILE_COUNT = 5 -describe('files_trashbin: Empty trashbin action', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - // create 5 fake files and move them to trash - for (let index = 0; index < FILE_COUNT; index++) { - cy.uploadContent(user, new Blob(['']), 'text/plain', `/file${index}.txt`) - deleteFileWithRequest(user, `/file${index}.txt`) - } - // login - cy.login(user) - }) - }) - - it('Can empty trashbin', () => { - cy.visit('/apps/files') - // Home have no files (or the default welcome file) - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) - cy.get('[data-cy-files-list-action="empty-trash"]').should('not.exist') - - // Go to trashbin, and see our deleted files - cy.visit('/apps/files/trashbin') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) - - // Empty trashbin - cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash') - triggerFileListAction('empty-trash') - - // Confirm dialog - cy.get('[role=dialog]').should('be.visible') - .findByRole('button', { name: 'Empty deleted files' }).click() - - // Wait for the request to finish - cy.wait('@emptyTrash').its('response.statusCode').should('eq', 204) - cy.get('@emptyTrash.all').should('have.length', 1) - - // Trashbin should be empty - cy.get('[data-cy-files-list-row-fileid]').should('not.exist') - }) - - it('Cancelling empty trashbin action does not delete anything', () => { - // Go to trashbin, and see our deleted files - cy.visit('/apps/files/trashbin') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) - - // Empty trashbin - cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash') - triggerFileListAction('empty-trash') - - // Cancel dialog - cy.get('[role=dialog]').should('be.visible') - .findByRole('button', { name: 'Cancel' }).click() - - // request was never sent - cy.get('@emptyTrash').should('not.exist') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) - }) -}) diff --git a/cypress/e2e/files_trashbin/files.cy.ts b/cypress/e2e/files_trashbin/files.cy.ts deleted file mode 100644 index f3acfb770ab61..0000000000000 --- a/cypress/e2e/files_trashbin/files.cy.ts +++ /dev/null @@ -1,143 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { ShareType } from '@nextcloud/sharing' -import { deleteDownloadsFolderBeforeEach } from '../../support/utils/deleteDownloadsFolder.ts' -import { randomString } from '../../support/utils/randomString.ts' -import { deleteFileWithRequest, getRowForFileId, selectAllFiles, triggerActionForFileId } from '../files/FilesUtils.ts' - -describe('files_trashbin: download files', { testIsolation: true }, () => { - let user: User - const fileids: [number, number] = [0, 0] - - deleteDownloadsFolderBeforeEach() - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - - cy.uploadContent(user, new Blob(['']), 'text/plain', '/file.txt') - .then(({ headers }) => fileids[0] = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(user, '/file.txt')) - cy.uploadContent(user, new Blob(['']), 'text/plain', '/other-file.txt') - .then(({ headers }) => fileids[1] = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(user, '/other-file.txt')) - }) - }) - - beforeEach(() => { - cy.login(user) - cy.visit('/apps/files/trashbin') - }) - - it('can download file', () => { - getRowForFileId(fileids[0]).should('be.visible') - getRowForFileId(fileids[1]).should('be.visible') - - triggerActionForFileId(fileids[0], 'download') - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - it('can download a file using default action', () => { - getRowForFileId(fileids[0]) - .should('be.visible') - .findByRole('button', { name: /^Download(:|$)/ }) - .click({ force: true }) - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - // TODO: Fix this as this dependens on the webdav zip folder plugin not working for trashbin (and never worked with old NC legacy download ajax as well) - it('does not offer bulk download', () => { - cy.get('[data-cy-files-list-row-checkbox]').should('have.length', 2) - selectAllFiles() - cy.get('.files-list__selected').should('contain.text', '2 selected') - cy.get('[data-cy-files-list-selection-action="restore"]').should('be.visible') - cy.get('[data-cy-files-list-selection-action="download"]').should('not.exist') - }) -}) - -describe('files_trashbin: file row', { testIsolation: true }, () => { - let alice: User - let bob: User - let randomGroupName: string - let fileId: number - - before(() => { - randomGroupName = randomString(10) - cy.runOccCommand(`group:add ${randomGroupName}`) - - cy.createRandomUser().then((user) => { - alice = user - - cy.modifyUser(alice, 'display', 'Alice') - - cy.mkdir(alice, '/Shared') - }) - - cy.createRandomUser().then((user) => { - bob = user - - cy.modifyUser(bob, 'display', 'Bob') - - cy.runOccCommand(`group:adduser ${randomGroupName} ${bob.userId}`) - }) - }) - - it('shows data for file deleted by owner', () => { - cy.uploadContent(alice, new Blob(['']), 'text/plain', '/test-file.txt') - .then(({ headers }) => fileId = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(alice, '/test-file.txt')) - - cy.login(alice) - cy.visit('/apps/files/trashbin') - - // `fileId` is assigned in the `.then()` above, so it is still undefined - // while the row selectors below are queued. - cy.then(() => { - getRowForFileId(fileId).should('be.visible') - // The full name includes one span for the name and one span for the - // extension, so text() returns a space when composing them even if it - // will not be visible when rendered in the browser. - getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'All files') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'You') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') - }) - }) - - it('shows data for file deleted by sharee in a folder shared with a group', () => { - cy.createShare(alice, '/Shared', ShareType.Group, randomGroupName) - - cy.uploadContent(alice, new Blob(['']), 'text/plain', '/Shared/test-file.txt') - .then(({ headers }) => fileId = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(bob, '/Shared/test-file.txt')) - - cy.login(alice) - cy.visit('/apps/files/trashbin') - - cy.then(() => { - getRowForFileId(fileId).should('be.visible') - // The full name includes one span for the name and one span for the - // extension, so text() returns a space when composing them even if it - // will not be visible when rendered in the browser. - getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'Shared') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'Bob') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') - }) - }) -}) diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts deleted file mode 100644 index 1b7a6ffce1093..0000000000000 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' -import type { ShareSetting } from '../files_sharing/FilesSharingUtils.ts' - -import { basename } from '@nextcloud/paths' -import { openActionsMenu, triggerActionForFile } from '../files/FilesUtils.ts' -import { createShare } from '../files_sharing/FilesSharingUtils.ts' - -export function uploadThreeVersions(user: User, fileName: string) { - // A version is identified by the file's modification time at second - // resolution (files_versions/.v), so two uploads within the - // same second collapse into a single version. Wall-clock spacing (cy.wait) - // is racy on slow runners — the mtime is set server side at write time — - // so pin explicit, distinct mtimes (sent as X-OC-MTime) instead. Take the - // clock from the server, so a lagging client cannot date them into its - // future. - cy.runCommand('date +%s').then(({ stdout }) => { - const baseMtime = Number.parseInt(stdout.trim()) - 5 - cy.uploadContent(user, new Blob(['v1'], { type: 'text/plain' }), 'text/plain', `/${fileName}`, baseMtime) - cy.uploadContent(user, new Blob(['v2'], { type: 'text/plain' }), 'text/plain', `/${fileName}`, baseMtime + 2) - cy.uploadContent(user, new Blob(['v3'], { type: 'text/plain' }), 'text/plain', `/${fileName}`, baseMtime + 4) - }) - cy.login(user) -} - -export function openVersionsPanel(fileName: string) { - // Detect the versions list fetch - cy.intercept('PROPFIND', '**/dav/versions/*/versions/**').as('getVersions') - - triggerActionForFile(basename(fileName), 'details') - cy.get('[data-cy-sidebar]') - .as('sidebar') - .should('be.visible') - cy.get('@sidebar') - .find('[aria-controls="tab-files_versions"]') - .click() - - // Wait for the versions list to be fetched - cy.wait('@getVersions') - cy.get('#tab-files_versions').should('be.visible', { timeout: 10000 }) -} - -function getVersionMenuToggle(index: number) { - return cy.get('#tab-files_versions [data-files-versions-version]') - .eq(index) - .find('button') -} - -export function openVersionMenu(index: number) { - openActionsMenu(() => getVersionMenuToggle(index)) -} - -export function closeVersionMenu(index: number) { - getVersionMenuToggle(index).then(($toggle) => { - if ($toggle.attr('aria-expanded') === 'true') { - cy.wrap($toggle).click({ force: true }) - } - }) -} - -export function triggerVersionAction(index: number, actionName: string) { - openVersionMenu(index) - cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click() -} - -export function nameVersion(index: number, name: string) { - cy.intercept('PROPPATCH', '**/dav/versions/*/versions/**').as('labelVersion') - triggerVersionAction(index, 'label') - // `cy.focused()` would type into whatever holds focus at that moment, which - // on a slow runner is still the menu toggle the dialog was opened from. - cy.findByRole('dialog', { name: 'Name this version' }) - .findByRole('textbox', { name: 'Version name' }) - .type(`${name}{enter}`) - cy.wait('@labelVersion') -} - -export function restoreVersion(index: number) { - cy.intercept('MOVE', '**/dav/versions/*/versions/**').as('restoreVersion') - triggerVersionAction(index, 'restore') - cy.wait('@restoreVersion') -} - -export function deleteVersion(index: number) { - cy.intercept('DELETE', '**/dav/versions/*/versions/**').as('deleteVersion') - triggerVersionAction(index, 'delete') - cy.wait('@deleteVersion') -} - -export function doesNotHaveAction(index: number, actionName: string) { - openVersionMenu(index) - cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).should('not.exist') - // Close the menu again so its entries do not leak into the next assertion - // (the action query above is global). - closeVersionMenu(index) -} - -export function assertVersionContent(index: number, expectedContent: string) { - cy.intercept({ method: 'GET', times: 1, url: 'remote.php/**' }).as('downloadVersion') - triggerVersionAction(index, 'download') - cy.wait('@downloadVersion') - .then(({ response }) => expect(response?.body).to.equal(expectedContent)) -} - -export function setupTestSharedFileFromUser(owner: User, randomFileName: string, shareOptions: Partial) { - return cy.createRandomUser() - .then((recipient) => { - cy.login(owner) - cy.visit('/apps/files') - createShare(randomFileName, recipient.userId, shareOptions) - - cy.login(recipient) - cy.visit('/apps/files') - // On a slow backend the freshly created share can be missing from the - // recipient's first directory listing: the mount cache is updated a - // moment after the share is committed, and the file list does not - // refetch on its own. - reloadUntilFileVisible(basename(randomFileName)) - return cy.wrap(recipient) - }) -} - -/** - * Reload the current file list until the given file appears in it. - * - * @param fileName Name of the file expected in the current directory - * @param attemptsLeft Remaining reloads before giving up - */ -function reloadUntilFileVisible(fileName: string, attemptsLeft = 5) { - // The list has rendered once at least one row is present (a new user always - // has welcome.txt), so we can reliably tell "file missing" from "still loading". - cy.get('[data-cy-files-list-row-name]').should('have.length.at.least', 1) - cy.get('body').then(($body) => { - if ($body.find(`[data-cy-files-list-row-name="${CSS.escape(fileName)}"]`).length > 0) { - return - } - if (attemptsLeft === 0) { - throw new Error(`Shared file "${fileName}" never appeared in the recipient's file list after reloading`) - } - cy.reload() - reloadUntilFileVisible(fileName, attemptsLeft - 1) - }) -} diff --git a/cypress/e2e/files_versions/version_creation.cy.ts b/cypress/e2e/files_versions/version_creation.cy.ts deleted file mode 100644 index f1c578f1f0cd9..0000000000000 --- a/cypress/e2e/files_versions/version_creation.cy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randomString } from '../../support/utils/randomString.ts' -import { openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions creation', () => { - let randomFileName = '' - - before(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((user) => { - uploadThreeVersions(user, randomFileName) - cy.login(user) - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - }) - }) - - it('Opens the versions panel and sees the versions', () => { - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 3) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version') - }) - }) - - it('See yourself as version author', () => { - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.findByRole('tabpanel', { name: 'Versions' }) - .findByRole('list', { name: 'File versions' }) - .findAllByRole('listitem') - .should('have.length', 3) - .first() - .find('[data-cy-files-version-author-name]') - .should('exist') - .and('contain.text', 'You') - }) -}) diff --git a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts deleted file mode 100644 index d09b9c6aac933..0000000000000 --- a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { clickOnBreadcrumbs, closeSidebar, copyFile, moveFile, navigateToFolder } from '../files/FilesUtils.ts' -import { assertVersionContent, nameVersion, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions cross share move and copy', () => { - let randomSharedFolderName = '' - let randomFileName = '' - let randomFilePath = '' - let alice: User - let bob: User - - before(() => { - randomSharedFolderName = randomString(10) - - cy.createRandomUser() - .then((user) => { - alice = user - cy.mkdir(alice, `/${randomSharedFolderName}`) - setupTestSharedFileFromUser(alice, randomSharedFolderName, {}) - }) - .then((user) => { bob = user }) - }) - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - randomFilePath = `${randomSharedFolderName}/${randomFileName}` - uploadThreeVersions(alice, randomFilePath) - - cy.login(bob) - cy.visit('/apps/files') - navigateToFolder(randomSharedFolderName) - openVersionsPanel(randomFilePath) - nameVersion(2, 'v1') - closeSidebar() - }) - - it('Also moves versions when bob moves the file out of a received share', () => { - moveFile(randomFileName, '/') - assertVersionsContent(randomFileName) - // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata - cy.get('[data-files-versions-version]').eq(2).contains('v1') - }) - - it('Also copies versions when bob copies the file out of a received share', () => { - copyFile(randomFileName, '/') - assertVersionsContent(randomFileName) - }) - - context('When a file is in a subfolder', () => { - let randomSubFolderName - let randomSubSubFolderName - - beforeEach(() => { - randomSubFolderName = randomString(10) - randomSubSubFolderName = randomString(10) - clickOnBreadcrumbs('All files') - cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}`) - cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}/${randomSubSubFolderName}`) - cy.login(bob) - navigateToFolder(randomSharedFolderName) - moveFile(randomFileName, `${randomSubFolderName}/${randomSubSubFolderName}`) - }) - - it('Also moves versions when bob moves the containing folder out of a received share', () => { - moveFile(randomSubFolderName, '/') - assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) - // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata - cy.get('[data-files-versions-version]').eq(2).contains('v1') - }) - - it('Also copies versions when bob copies the containing folder out of a received share', () => { - copyFile(randomSubFolderName, '/') - assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) - }) - }) -}) - -/** - * @param filePath - */ -function assertVersionsContent(filePath: string) { - const path = filePath.split('/').slice(0, -1).join('/') - - clickOnBreadcrumbs('All files') - - if (path !== '') { - navigateToFolder(path) - } - - openVersionsPanel(filePath) - - cy.get('[data-files-versions-version]').should('have.length', 3) - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v2') - assertVersionContent(2, 'v1') -} diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts deleted file mode 100644 index c2779d7e8032c..0000000000000 --- a/cypress/e2e/files_versions/version_deletion.cy.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { navigateToFolder } from '../files/FilesUtils.ts' -import { deleteVersion, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions deletion', () => { - const folderName = 'shared_folder' - const randomFileName = randomString(10) + '.txt' - const randomFilePath = `/${folderName}/${randomFileName}` - let user: User - let versionCount = 0 - - beforeEach(() => { - cy.createRandomUser() - .then((_user) => { - user = _user - cy.mkdir(user, `/${folderName}`) - uploadThreeVersions(user, randomFilePath) - versionCount = 3 - cy.login(user) - cy.visit('/apps/files') - }) - }) - - it('Delete initial version', () => { - navigateToFolder(folderName) - openVersionsPanel(randomFilePath) - - cy.get('[data-files-versions-version]') - .should('have.length', versionCount) - deleteVersion(--versionCount) - cy.get('[data-files-versions-version]') - .should('have.length', versionCount) - }) - - it('Delete versions of shared file with delete permission', () => { - setupTestSharedFileFromUser(user, folderName, { delete: true }) - navigateToFolder(folderName) - openVersionsPanel(randomFilePath) - - cy.get('[data-files-versions-version]').should('have.length', versionCount) - deleteVersion(--versionCount) - cy.get('[data-files-versions-version]').should('have.length', versionCount) - }) - - it('Delete versions of shared file without delete permission', () => { - setupTestSharedFileFromUser(user, folderName, { delete: false }) - navigateToFolder(folderName) - openVersionsPanel(randomFilePath) - - doesNotHaveAction(0, 'delete') - doesNotHaveAction(1, 'delete') - doesNotHaveAction(2, 'delete') - }) -}) diff --git a/cypress/e2e/files_versions/version_download.cy.ts b/cypress/e2e/files_versions/version_download.cy.ts deleted file mode 100644 index 6aa51a89a1737..0000000000000 --- a/cypress/e2e/files_versions/version_download.cy.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions download', () => { - let randomFileName = '' - let user: User - - before(() => cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download')) - after(() => { - cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download') - }) - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((_user) => { - user = _user - uploadThreeVersions(user, randomFileName) - }) - }) - - it('Download versions and assert their content', () => { - cy.login(user) - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v2') - assertVersionContent(2, 'v1') - }) - - it('Download versions of shared file with download permission', () => { - setupTestSharedFileFromUser(user, randomFileName, { download: true }) - openVersionsPanel(randomFileName) - - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v2') - assertVersionContent(2, 'v1') - }) - - it('Does not show action without download permission', () => { - setupTestSharedFileFromUser(user, randomFileName, { download: false }) - openVersionsPanel(randomFileName) - - cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist') - cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="download"]').should('not.exist') - - doesNotHaveAction(1, 'download') - doesNotHaveAction(2, 'download') - }) -}) diff --git a/cypress/e2e/files_versions/version_expiration.cy.ts b/cypress/e2e/files_versions/version_expiration.cy.ts deleted file mode 100644 index 2a974052fe340..0000000000000 --- a/cypress/e2e/files_versions/version_expiration.cy.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randomString } from '../../support/utils/randomString.ts' -import { assertVersionContent, nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions expiration', () => { - let randomFileName = '' - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((user) => { - uploadThreeVersions(user, randomFileName) - cy.login(user) - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - }) - }) - - it('Expire all versions', () => { - cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'') - cy.runOccCommand('versions:expire') - cy.runOccCommand('config:system:set versions_retention_obligation --value auto') - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 1) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - }) - - assertVersionContent(0, 'v3') - }) - - it('Expire versions v2', () => { - nameVersion(2, 'v1') - - cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'') - cy.runOccCommand('versions:expire') - cy.runOccCommand('config:system:set versions_retention_obligation --value auto') - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 2) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(1).contains('v1') - }) - - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v1') - }) -}) diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts deleted file mode 100644 index 3d5d80bc3d19e..0000000000000 --- a/cypress/e2e/files_versions/version_naming.cy.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { navigateToFolder } from '../files/FilesUtils.ts' -import { doesNotHaveAction, nameVersion, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions naming', () => { - let randomFileName = '' - let user: User - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((_user) => { - user = _user - cy.mkdir(_user, '/share') - uploadThreeVersions(user, `share/${randomFileName}`) - }) - }) - - it('Names the versions', () => { - cy.login(user) - cy.visit('/apps/files') - navigateToFolder('share') - openVersionsPanel(randomFileName) - - nameVersion(2, 'v1') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(2).contains('v1') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - - nameVersion(1, 'v2') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(1).contains('v2') - }) - - nameVersion(0, 'v3') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(0).contains('v3 (Current version)') - }) - }) - - it('Name versions of shared file with edit permission', () => { - setupTestSharedFileFromUser(user, 'share', { update: true }) - - navigateToFolder('share') - openVersionsPanel(randomFileName) - - nameVersion(2, 'v1 - shared') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(2).contains('v1 - shared') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - - nameVersion(1, 'v2 - shared') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(1).contains('v2 - shared') - }) - - nameVersion(0, 'v3 - shared') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(0).contains('v3 - shared (Current version)') - }) - }) - - it('Name versions without edit permission fails', () => { - setupTestSharedFileFromUser(user, 'share', { update: false }) - - navigateToFolder('share') - openVersionsPanel(randomFileName) - - cy.get('[data-files-versions-version]') - .eq(0) - .as('firstVersion') - .find('.action-item__menutoggle') - .should('not.exist') - cy.get('@firstVersion') - .find('[data-cy-version-action="label"]') - .should('not.exist') - - doesNotHaveAction(1, 'label') - doesNotHaveAction(2, 'label') - }) -}) diff --git a/cypress/e2e/files_versions/version_restoration.cy.ts b/cypress/e2e/files_versions/version_restoration.cy.ts deleted file mode 100644 index 2d7e66068d90a..0000000000000 --- a/cypress/e2e/files_versions/version_restoration.cy.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { navigateToFolder } from '../files/FilesUtils.ts' -import { assertVersionContent, doesNotHaveAction, openVersionsPanel, restoreVersion, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions restoration', () => { - let randomFileName = '' - let user: User - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((_user) => { - user = _user - cy.mkdir(_user, '/share') - uploadThreeVersions(user, `share/${randomFileName}`) - cy.login(user) - cy.visit('/apps/files') - }) - }) - - it('Restores initial version', () => { - navigateToFolder('share') - openVersionsPanel(randomFileName) - // Current version does not have restore action - doesNotHaveAction(0, 'restore') - restoreVersion(2) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 3) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - - // Downloads versions and assert there content - assertVersionContent(0, 'v1') - assertVersionContent(1, 'v3') - assertVersionContent(2, 'v2') - }) - - it('Restore versions of shared file with update permission', () => { - setupTestSharedFileFromUser(user, 'share', { update: true }) - navigateToFolder('share') - openVersionsPanel(randomFileName) - - restoreVersion(2) - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 3) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - assertVersionContent(0, 'v1') - assertVersionContent(1, 'v3') - assertVersionContent(2, 'v2') - }) - - it('Does not show action without delete permission', () => { - setupTestSharedFileFromUser(user, 'share', { update: false }) - navigateToFolder('share') - openVersionsPanel(randomFileName) - - cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist') - cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="restore"]').should('not.exist') - - doesNotHaveAction(2, 'restore') - doesNotHaveAction(1, 'restore') - }) -}) diff --git a/cypress/e2e/files_versions/version_sharing.cy.ts b/cypress/e2e/files_versions/version_sharing.cy.ts deleted file mode 100644 index 4aa762cd28cf1..0000000000000 --- a/cypress/e2e/files_versions/version_sharing.cy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' -import { navigateToFolder, triggerActionForFile } from '../files/FilesUtils.ts' -import { setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions on shares', () => { - const randomSharedFolderName = randomString(10) - const randomFileName = randomString(10) + '.txt' - const randomFilePath = `${randomSharedFolderName}/${randomFileName}` - let alice: User - let bob: User - - before(() => { - cy.createRandomUser() - .then((user) => { - alice = user - }) - .then(() => { - cy.mkdir(alice, `/${randomSharedFolderName}`) - return setupTestSharedFileFromUser(alice, randomSharedFolderName, {}) - }) - .then((user) => { bob = user }) - .then(() => uploadThreeVersions(alice, randomFilePath)) - }) - - it('See sharees display name as author', () => { - cy.login(bob) - cy.visit('/apps/files') - - navigateToFolder(randomSharedFolderName) - - triggerActionForFile(randomFileName, 'details') - cy.findByRole('tab', { name: 'Versions' }).click() - - cy.findByRole('tabpanel', { name: 'Versions' }) - .findByRole('list', { name: 'File versions' }) - .findAllByRole('listitem') - .first() - .find('[data-cy-files-version-author-name]') - .should('be.visible') - .and('contain.text', alice.userId) - }) -}) diff --git a/cypress/e2e/login/login-redirect.cy.ts b/cypress/e2e/login/login-redirect.cy.ts deleted file mode 100644 index 0c7db13ad6b83..0000000000000 --- a/cypress/e2e/login/login-redirect.cy.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Test that when a session expires / the user logged out in another tab, - * the user gets redirected to the login on the next request. - */ -describe('Logout redirect ', { testIsolation: true }, () => { - let user - - before(() => { - cy.createRandomUser() - .then(($user) => { - user = $user - }) - }) - - it('Redirects to login if session timed out', () => { - // Login and see settings - cy.login(user) - cy.visit('/settings/user#profile') - cy.findByRole('checkbox', { name: /Enable profile/i }) - .should('exist') - - // clear session - cy.clearAllCookies() - - // trigger an request - cy.findByRole('checkbox', { name: /Enable profile/i }) - .click({ force: true }) - - // See that we are redirected - cy.url() - .should('match', /\/login/i) - .and('include', `?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) - - cy.get('form[name="login"]').should('be.visible') - }) - - it('Redirect from login works', () => { - cy.logout() - // visit the login - cy.visit(`/login?redirect_url=${encodeURIComponent('/index.php/settings/user#profile')}`) - - // see login - cy.get('form[name="login"]').should('be.visible') - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(user.password) - cy.contains('button[data-login-form-submit]', 'Log in').click() - }) - - // see that we are correctly redirected - cy.url().should('include', '/index.php/settings/user#profile') - cy.findByRole('checkbox', { name: /Enable profile/i }) - .should('exist') - }) -}) diff --git a/cypress/e2e/login/login.cy.ts b/cypress/e2e/login/login.cy.ts deleted file mode 100644 index 09b871792e465..0000000000000 --- a/cypress/e2e/login/login.cy.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts' - -describe('Login', () => { - let user: User - let disabledUser: User - - after(() => cy.deleteUser(user)) - before(() => { - // disable brute force protection - cy.runOccCommand('config:system:set auth.bruteforce.protection.enabled --value false --type bool') - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.createRandomUser().then(($user) => { - disabledUser = $user - cy.runOccCommand(`user:disable '${disabledUser.userId}'`) - }) - }) - - beforeEach(() => { - cy.logout() - }) - - it('log in with valid account and password', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with a valid account - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(user.password) - cy.contains('button[data-login-form-submit]', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Files app - cy.url().should('match', /apps\/dashboard(\/|$)/) - }) - - it('try to log in with valid account and invalid password', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with a valid account but invalid password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(`${user.password}--wrong`) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that a wrong password message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to log in with valid account and invalid password', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with a valid account but invalid password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(user.userId) - cy.get('input[name="password"]').type(`${user.password}--wrong`) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that a wrong password message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to log in with invalid account', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // I log in with an invalid user but valid password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(`${user.userId}--wrong`) - cy.get('input[name="password"]').type(user.password) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that a wrong password message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Wrong.+password/i).and.to.match(/Wrong.+login/)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to log in as disabled account', () => { - // Given I visit the Home page - cy.visit('/') - // I see the login page - cy.get('form[name="login"]').should('be.visible') - // When I log in with user disabledUser and password - cy.get('form[name="login"]').within(() => { - cy.get('input[name="user"]').type(disabledUser.userId) - cy.get('input[name="password"]').type(disabledUser.password) - cy.contains('button', 'Log in').click() - }) - - // see that the login is done - cy.get('[data-login-form-submit]').if().should('not.contain', 'Logging in') - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - // And I see that the disabled account message is shown - cy.get('form[name="login"]').then(($el) => expect($el.text()).to.match(/Account.+disabled/i)) - cy.get('input[name="password"]:invalid').should('exist') - }) - - it('try to logout', () => { - cy.login(user) - - // Given I visit the Home page - cy.visit('/') - // I see the dashboard - cy.url().should('match', /apps\/dashboard(\/|$)/) - - // When click logout - getNextcloudUserMenuToggle().should('exist').click() - getNextcloudUserMenu().contains('a', 'Log out').click() - - // Then I see that the current page is the Login page - cy.url().should('match', /\/login/) - }) -}) diff --git a/cypress/e2e/login/webauth.cy.ts b/cypress/e2e/login/webauth.cy.ts deleted file mode 100644 index 4d8d3acc20a60..0000000000000 --- a/cypress/e2e/login/webauth.cy.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -interface IChromeVirtualAuthenticator { - authenticatorId: string -} - -/** - * Create a virtual authenticator using chrome debug protocol - */ -async function createAuthenticator(): Promise { - await Cypress.automation('remote:debugger:protocol', { - command: 'WebAuthn.enable', - }) - const authenticator = await Cypress.automation('remote:debugger:protocol', { - command: 'WebAuthn.addVirtualAuthenticator', - params: { - options: { - protocol: 'ctap2', - ctap2Version: 'ctap2_1', - hasUserVerification: true, - transport: 'usb', - automaticPresenceSimulation: true, - isUserVerified: true, - }, - }, - }) - return authenticator -} - -/** - * Delete a virtual authenticator using chrome devbug protocol - * - * @param authenticator the authenticator object - */ -async function deleteAuthenticator(authenticator: IChromeVirtualAuthenticator) { - await Cypress.automation('remote:debugger:protocol', { - command: 'WebAuthn.removeVirtualAuthenticator', - params: { - ...authenticator, - }, - }) -} - -describe('Login using WebAuthn', () => { - let authenticator: IChromeVirtualAuthenticator - let user: User - - afterEach(() => { - cy.deleteUser(user) - .then(() => deleteAuthenticator(authenticator)) - }) - - beforeEach(() => { - cy.createRandomUser() - .then(($user) => { - user = $user - cy.login(user) - }) - .then(() => createAuthenticator()) - .then(($authenticator) => { - authenticator = $authenticator - cy.log('Created virtual authenticator') - }) - }) - - it('add and delete WebAuthn', () => { - cy.intercept('**/settings/api/personal/webauthn/registration').as('webauthn') - cy.visit('/settings/user/security') - - cy.contains('[role="note"]', /No devices configured/i).should('be.visible') - - cy.findByRole('button', { name: /Add WebAuthn device/i }) - .should('be.visible') - .click() - - cy.wait('@webauthn') - - cy.findByRole('textbox', { name: /Device name/i }) - .should('be.visible') - .type('test device{enter}') - - cy.wait('@webauthn') - - cy.contains('[role="note"]', /No devices configured/i).should('not.exist') - - cy.findByRole('list', { name: /following devices are configured for your account/i }) - .should('be.visible') - .contains('li', 'test device') - .should('be.visible') - .findByRole('button', { name: /Actions/i }) - .click() - - cy.findByRole('menuitem', { name: /Delete/i }) - .should('be.visible') - .click() - - cy.contains('[role="note"]', /No devices configured/i).should('be.visible') - cy.findByRole('list', { name: /following devices are configured for your account/i }) - .should('not.exist') - - cy.reload() - cy.contains('[role="note"]', /No devices configured/i).should('be.visible') - }) - - it('add WebAuthn and login', () => { - cy.intercept('GET', '**/settings/api/personal/webauthn/registration').as('webauthnSetupInit') - cy.intercept('POST', '**/settings/api/personal/webauthn/registration').as('webauthnSetupDone') - cy.intercept('POST', '**/login/webauthn/start').as('webauthnLogin') - - cy.visit('/settings/user/security') - - cy.findByRole('button', { name: /Add WebAuthn device/i }) - .should('be.visible') - .click() - cy.wait('@webauthnSetupInit') - - cy.findByRole('textbox', { name: /Device name/i }) - .should('be.visible') - .type('test device{enter}') - cy.wait('@webauthnSetupDone') - - cy.findByRole('list', { name: /following devices are configured for your account/i }) - .should('be.visible') - .findByText('test device') - .should('be.visible') - - cy.logout() - cy.visit('/login') - - cy.findByRole('button', { name: /Log in with a device/i }) - .should('be.visible') - .click() - - cy.findByRole('form', { name: /Log in with a device/i }) - .should('be.visible') - .findByRole('textbox', { name: /Login or email/i }) - .should('be.visible') - .type(`{selectAll}${user.userId}`) - - cy.findByRole('button', { name: /Log in/i }) - .click() - cy.wait('@webauthnLogin') - - // Then I see that the current page is the Files app - cy.url().should('match', /apps\/dashboard(\/|$)/) - }) -}) diff --git a/cypress/e2e/settings/access-levels.cy.ts b/cypress/e2e/settings/access-levels.cy.ts deleted file mode 100644 index a940e3e145aca..0000000000000 --- a/cypress/e2e/settings/access-levels.cy.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: Ensure only administrator can see the administration settings section', { testIsolation: true }, () => { - beforeEach(() => { - clearState() - }) - - it('Regular users cannot see admin-level items on the Settings page', () => { - // Given I am logged in - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/') - }) - - // I open the settings menu - getNextcloudUserMenuToggle().click() - // I navigate to the settings panel - getNextcloudUserMenu() - .findByRole('link', { name: /settings/i }) - .click() - cy.url().should('match', /\/settings\/user$/) - - cy.get('#app-navigation').should('be.visible').within(() => { - // I see the personal section is NOT shown - cy.get('#app-navigation-caption-personal').should('not.exist') - // I see the admin section is NOT shown - cy.get('#app-navigation-caption-administration').should('not.exist') - - // I see that the "Personal info" entry in the settings panel is shown - cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible') - }) - }) - - it('Admin users can see admin-level items on the Settings page', () => { - // Given I am logged in - cy.login(admin) - cy.visit('/') - - // I open the settings menu - getNextcloudUserMenuToggle().click() - // I navigate to the settings panel - getNextcloudUserMenu() - .findByRole('link', { name: /Personal settings/i }) - .click() - cy.url().should('match', /\/settings\/user$/) - - cy.get('#app-navigation').should('be.visible').within(() => { - // I see the personal section is shown - cy.get('#app-navigation-caption-personal').should('be.visible') - // I see the admin section is shown - cy.get('#app-navigation-caption-administration').should('be.visible') - - // I see that the "Personal info" entry in the settings panel is shown - cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible') - }) - }) -}) diff --git a/cypress/e2e/settings/apps.cy.ts b/cypress/e2e/settings/apps.cy.ts deleted file mode 100644 index c3da18dcb4364..0000000000000 --- a/cypress/e2e/settings/apps.cy.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { handlePasswordConfirmation } from './usersUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: App management', { testIsolation: true }, () => { - after(() => { - // 'Limit app usage to group' deselects the admin group without untoggling - // the group-limit switch, leaving Dashboard with an empty allow-list and - // hiding it from non-admin users. Re-enabling rewrites the app's `enabled` - // flag back to `yes`, which restores the `/` redirect to dashboard for - // subsequent specs. - cy.runOccCommand('app:enable dashboard') - }) - - beforeEach(() => { - // disable QA if already enabled - cy.runOccCommand('app:disable -n testing') - // enable notification if already disabled - cy.runOccCommand('app:enable -n updatenotification') - - // I am logged in as the admin - cy.login(admin) - - // Intercept the apps list request - cy.intercept('GET', '*/settings/apps/list').as('fetchAppsList') - - // I open the Apps management - cy.visit('/settings/apps/installed') - - // Wait for the apps list to load - cy.wait('@fetchAppsList') - }) - - it('Can enable an installed app', () => { - cy.get('#apps-list').should('exist') - // Wait for the app list to load - .contains('tr', 'QA testing', { timeout: 10000 }) - .should('exist') - // I enable the "QA testing" app - .contains('button', 'Enable') - .click({ force: true }) - - handlePasswordConfirmation(admin.password) - - // Wait until we see the disable button for the app - cy.get('#apps-list').should('exist') - .contains('tr', 'QA testing') - .should('exist') - // I see the disable button for the app - .contains('button', 'Disable', { timeout: 10000 }) - - // Change to enabled apps view - cy.get('#app-category-enabled a').click({ force: true }) - cy.url().should('match', /settings\/apps\/enabled$/) - // I see that the "QA testing" app has been enabled - cy.get('#apps-list').contains('tr', 'QA testing') - }) - - it('Can disable an installed app', () => { - cy.get('#apps-list') - .should('exist') - // Wait for the app list to load - .contains('tr', 'Update notification', { timeout: 10000 }) - .should('exist') - // I disable the "Update notification" app - .contains('button', 'Disable') - .click({ force: true }) - - handlePasswordConfirmation(admin.password) - - // Wait until we see the disable button for the app - cy.get('#apps-list').should('exist') - .contains('tr', 'Update notification') - .should('exist') - // I see the enable button for the app - .contains('button', 'Enable', { timeout: 10000 }) - - // Change to disabled apps view - cy.get('#app-category-disabled a').click({ force: true }) - cy.url().should('match', /settings\/apps\/disabled$/) - // I see that the "Update notification" app has been disabled - cy.get('#apps-list').contains('tr', 'Update notification') - }) - - it('Browse enabled apps', () => { - // When I open the "Active apps" section - cy.get('#app-category-enabled a') - .should('contain', 'Active apps') - .click({ force: true }) - // Then I see that the current section is "Active apps" - cy.url().should('match', /settings\/apps\/enabled$/) - cy.get('#app-category-enabled').find('.active').should('exist') - // I see that there are only enabled apps - cy.get('#apps-list') - .should('exist') - .find('tr button') - .each(($action) => { - cy.wrap($action).should('not.contain', 'Enable') - }) - }) - - it('Browse disabled apps', () => { - // When I open the "Active apps" section - cy.get('#app-category-disabled a') - .should('contain', 'Disabled apps') - .click({ force: true }) - // Then I see that the current section is "Active apps" - cy.url().should('match', /settings\/apps\/disabled$/) - cy.get('#app-category-disabled').find('.active').should('exist') - // I see that there are only disabled apps - cy.get('#apps-list') - .should('exist') - .find('tr button') - .each(($action) => { - cy.wrap($action).should('not.contain', 'Disable') - }) - }) - - it('Browse app bundles', () => { - // When I open the "App bundles" section - cy.get('#app-category-your-bundles a') - .should('contain', 'App bundles') - .click({ force: true }) - // Then I see that the current section is "App bundles" - cy.url().should('match', /settings\/apps\/app-bundles$/) - cy.get('#app-category-your-bundles').find('.active').should('exist') - // I see the app bundles - cy.get('#apps-list').contains('tr', 'Enterprise bundle') - cy.get('#apps-list').contains('tr', 'Education bundle') - // I see that the "Enterprise bundle" is disabled - cy.get('#apps-list').contains('tr', 'Enterprise bundle').contains('button', 'Download and enable all') - }) - - it('View app details', () => { - // When I click on the "QA testing" app - cy.get('#apps-list').contains('a', 'QA testing').click({ force: true }) - // I see that the app details are shown - cy.get('#app-sidebar-vue') - .should('be.visible') - .find('.app-sidebar-header__info') - .should('contain', 'QA testing') - cy.get('#app-sidebar-vue').contains('a', 'View in store').should('exist') - cy.get('#app-sidebar-vue').find('input[type="button"][value="Enable"]').should('be.visible') - cy.get('#app-sidebar-vue').find('input[type="button"][value="Remove"]').should('be.visible') - cy.get('#app-sidebar-vue').contains(/Version \d+\.\d+\.\d+/).should('be.visible') - }) - - it('Limit app usage to group', () => { - // When I open the "Active apps" section - cy.get('#app-category-enabled a') - .should('contain', 'Active apps') - .click({ force: true }) - // Then I see that the current section is "Active apps" - cy.url().should('match', /settings\/apps\/enabled$/) - cy.get('#app-category-enabled').find('.active').should('exist') - // Then I select the app - cy.get('#apps-list') - .should('exist') - .contains('tr', 'Dashboard', { timeout: 10000 }) - .click() - // Then I enable "limit app to group" - cy.get('[for="groups_enable_dashboard"]').click() - // Then I select a group - cy.get('#limitToGroups').click() - cy.get('ul[role="listbox"]') - .find('span') - .contains('admin') - .click() - - handlePasswordConfirmation(admin.password) - - cy.get('span.name-parts__first') - .contains('admin') - .should('be.visible') - // Then I disable the group limitation - cy.get('button[title="Deselect admin"]').click() - }) - - /* - * TODO: Improve testing with app store as external API - * The following scenarios require the files_antivirus and calendar app - * being present in the app store with support for the current server version - * Ideally we would have either a dummy app store endpoint with some test apps - * or even an app store instance running somewhere to properly test this. - * This is also a requirement to properly test updates of apps - */ - // TODO: View app details for app store apps - // TODO: Install an app from the app store - // TODO: Show section from app store -}) diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts deleted file mode 100644 index 5be6290f717ef..0000000000000 --- a/cypress/e2e/settings/personal-info.cy.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { handlePasswordConfirmation } from './usersUtils.ts' - -let user: User - -enum Visibility { - Private = 'Private', - Local = 'Local', - Federated = 'Federated', - Public = 'Published', -} - -const ALL_VISIBILITIES = [Visibility.Public, Visibility.Private, Visibility.Local, Visibility.Federated] - -/** - * Get the input connected to a specific label - * @param label The content of the label - */ -const inputForLabel = (label: string) => cy.contains('label', label).then((el) => cy.get(`#${el.attr('for')}`)) - -/** - * Get the property visibility button - * @param property The property to which to look for the button - */ -const getVisibilityButton = (property: string) => cy.get(`button[aria-label*="Change scope level of ${property.toLowerCase()}"`) - -/** - * Validate a specifiy visibility is set for a property - * @param property The property - * @param active The active visibility - */ -function validateActiveVisibility(property: string, active: Visibility) { - getVisibilityButton(property) - .should('have.attr', 'aria-label') - .and('match', new RegExp(`current scope is ${active}`, 'i')) - getVisibilityButton(property) - .click() - cy.get('ul[role="menu"]') - .contains('button', active) - .should('have.attr', 'aria-checked', 'true') - - // close menu - getVisibilityButton(property) - .click() -} - -/** - * Set a specific visibility for a property - * @param property The property - * @param active The visibility to set - */ -function setActiveVisibility(property: string, active: Visibility) { - getVisibilityButton(property) - .click() - cy.get('ul[role="menu"]') - .contains('button', active) - .click({ force: true }) - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') -} - -/** - * Helper to check that setting all visibilities on a property is possible - * @param property The property to test - * @param defaultVisibility The default visibility of that property - * @param allowedVisibility Visibility that is allowed and need to be checked - */ -function checkSettingsVisibility(property: string, defaultVisibility: Visibility = Visibility.Local, allowedVisibility: Visibility[] = ALL_VISIBILITIES) { - getVisibilityButton(property) - .scrollIntoView() - - validateActiveVisibility(property, defaultVisibility) - - allowedVisibility.forEach((active) => { - setActiveVisibility(property, active) - - cy.reload() - getVisibilityButton(property).scrollIntoView() - - validateActiveVisibility(property, active) - }) - - // TODO: Fix this in vue library then enable this test again - /* // Test that not allowed options are disabled - ALL_VISIBILITIES.filter((v) => !allowedVisibility.includes(v)).forEach((disabled) => { - getVisibilityButton(property) - .click() - cy.get('ul[role="dialog"') - .contains('button', disabled) - .should('exist') - .and('have.attr', 'disabled', 'true') - }) */ -} - -const genericProperties = [ - ['Location', 'Berlin'], - ['X (formerly Twitter)', 'nextclouders'], - ['Fediverse', 'nextcloud@mastodon.xyz'], -] -const nonfederatedProperties = ['Organisation', 'Role', 'Headline', 'About'] - -describe('Settings: Change personal information', { testIsolation: true }, () => { - let snapshot: string = '' - - before(() => { - // make sure the fediverse check does not do http requests - cy.runOccCommand('config:system:set has_internet_connection --type bool --value false') - // ensure we can set locale and language - cy.runOccCommand('config:system:delete force_language') - cy.runOccCommand('config:system:delete force_locale') - cy.createRandomUser().then(($user) => { - user = $user - cy.modifyUser(user, 'language', 'en') - cy.modifyUser(user, 'locale', 'en_US') - - // Make sure the user is logged in at least once - // before the snapshot is taken to speed up the tests - cy.login(user) - cy.visit('/settings/user') - - cy.saveState().then(($snapshot) => { - snapshot = $snapshot - }) - }) - }) - - after(() => { - cy.runOccCommand('config:system:delete has_internet_connection') - - cy.runOccCommand('config:system:set force_language --value en') - cy.runOccCommand('config:system:set force_locale --value en_US') - }) - - beforeEach(() => { - cy.login(user) - cy.visit('/settings/user') - cy.intercept('PUT', /ocs\/v2.php\/cloud\/users\//).as('submitSetting') - }) - - afterEach(() => { - cy.restoreState(snapshot) - }) - - it('Can dis- and enable the profile', () => { - cy.visit(`/u/${user.userId}`) - cy.contains('h2', user.userId).should('be.visible') - - cy.visit('/settings/user') - cy.contains('Enable profile').click() - handlePasswordConfirmation(user.password) - cy.wait('@submitSetting') - - cy.visit(`/u/${user.userId}`, { failOnStatusCode: false }) - cy.contains('h2', 'Profile not found').should('be.visible') - - cy.visit('/settings/user') - cy.contains('Enable profile').click() - handlePasswordConfirmation(user.password) - cy.wait('@submitSetting') - - cy.visit(`/u/${user.userId}`, { failOnStatusCode: false }) - cy.contains('h2', user.userId).should('be.visible') - }) - - it('Can change language', () => { - cy.intercept('GET', /settings\/user/).as('reload') - inputForLabel('Language').scrollIntoView() - inputForLabel('Language').type('Ned') - cy.contains('li[role="option"]', 'Nederlands') - .click() - cy.wait('@reload') - - // expect language changed - inputForLabel('Taal').scrollIntoView() - cy.contains('section', 'Help met vertalen') - }) - - it('Can change locale', () => { - cy.intercept('GET', /settings\/user/).as('reload') - cy.clock(new Date(2024, 0, 10)) - - // Default is US - cy.contains('section', '01/10/2024') - - inputForLabel('Locale').scrollIntoView() - inputForLabel('Locale').type('German') - cy.contains('li[role="option"]', 'German (Germany') - .click() - cy.wait('@reload') - - // expect locale changed - inputForLabel('Locale').scrollIntoView() - cy.contains('section', '10.01.2024') - }) - - it('Can set primary email and change its visibility', () => { - cy.contains('label', 'Email').scrollIntoView() - // Check invalid input - inputForLabel('Email').type('foo bar') - inputForLabel('Email').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false) - // handle valid input - inputForLabel('Email').type('{selectAll}hello@example.com') - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Email').should('have.value', 'hello@example.com') - - checkSettingsVisibility( - 'Email', - Visibility.Federated, - // It is not possible to set it as private - ALL_VISIBILITIES.filter((v) => v !== Visibility.Private), - ) - - // check it is visible on the profile - cy.visit(`/u/${user.userId}`) - cy.contains('a', 'hello@example.com').should('be.visible').and('have.attr', 'href', 'mailto:hello@example.com') - }) - - it('Can delete primary email', () => { - cy.contains('label', 'Email').scrollIntoView() - inputForLabel('Email').type('{selectAll}hello@example.com') - handlePasswordConfirmation(user.password) - cy.wait('@submitSetting') - - // check after reload - cy.reload() - inputForLabel('Email').should('have.value', 'hello@example.com') - - // delete email - cy.get('button[aria-label="Remove primary email"]').click({ force: true }) - cy.wait('@submitSetting') - - // check after reload - cy.reload() - inputForLabel('Email').should('have.value', '') - }) - - it('Can set and delete additional emails', () => { - cy.get('button[aria-label="Add additional email"]').should('be.disabled') - // we need a primary email first - cy.contains('label', 'Email').scrollIntoView() - inputForLabel('Email').type('{selectAll}primary@example.com') - handlePasswordConfirmation(user.password) - cy.wait('@submitSetting') - - // add new email - cy.get('button[aria-label="Add additional email"]') - .click() - - // without any value we should not be able to add a second additional - cy.get('button[aria-label="Add additional email"]').should('be.disabled') - - // fill the first additional - inputForLabel('Additional email address 1') - .type('1@example.com') - handlePasswordConfirmation(user.password) - cy.wait('@submitSetting') - - // add second additional email - cy.get('button[aria-label="Add additional email"]') - .click() - - // fill the second additional - inputForLabel('Additional email address 2') - .type('2@example.com') - handlePasswordConfirmation(user.password) - cy.wait('@submitSetting') - - // check the content is saved - cy.reload() - inputForLabel('Additional email address 1') - .should('have.value', '1@example.com') - inputForLabel('Additional email address 2') - .should('have.value', '2@example.com') - - // delete the first - cy.get('button[aria-label="Options for additional email address 1"]') - .click({ force: true }) - cy.contains('button[role="menuitem"]', 'Delete email') - .click({ force: true }) - handlePasswordConfirmation(user.password) - - cy.reload() - inputForLabel('Additional email address 1') - .should('have.value', '2@example.com') - }) - - it('Can set Full name and change its visibility', () => { - cy.contains('label', 'Full name').scrollIntoView() - // handle valid input - inputForLabel('Full name').type('{selectAll}Jane Doe') - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Full name').should('have.value', 'Jane Doe') - - checkSettingsVisibility( - 'Full name', - Visibility.Federated, - // It is not possible to set it as private - ALL_VISIBILITIES.filter((v) => v !== Visibility.Private), - ) - - // check it is visible on the profile - cy.visit(`/u/${user.userId}`) - cy.contains('h2', 'Jane Doe').should('be.visible') - }) - - it('Can set Phone number and its visibility', () => { - cy.contains('label', 'Phone number').scrollIntoView() - // Check invalid input - inputForLabel('Phone number').type('foo bar') - inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error') - // handle valid input - inputForLabel('Phone number').type('{selectAll}+49 89 721010 99701') - inputForLabel('Phone number').should('have.attr', 'class').and('not.contain', '--error') - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Phone number').should('have.value', '+498972101099701') - - checkSettingsVisibility('Phone number') - - // check it is visible on the profile - cy.visit(`/u/${user.userId}`) - cy.get('a[href="tel:+498972101099701"]').should('be.visible') - }) - - it('Can set phone number with phone region', () => { - cy.contains('label', 'Phone number').scrollIntoView() - inputForLabel('Phone number').type('{selectAll}0 40 428990') - inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error') - - cy.runOccCommand('config:system:set default_phone_region --value DE') - cy.reload() - - cy.contains('label', 'Phone number').scrollIntoView() - inputForLabel('Phone number').type('{selectAll}0 40 428990') - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Phone number').should('have.value', '+4940428990') - }) - - it('Can reset phone number', () => { - cy.contains('label', 'Phone number').scrollIntoView() - inputForLabel('Phone number').type('{selectAll}+49 40 428990') - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Phone number').should('have.value', '+4940428990') - - inputForLabel('Phone number').clear() - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Phone number').should('have.value', '') - }) - - it('Can reset social media property', () => { - cy.contains('label', 'Fediverse').scrollIntoView() - inputForLabel('Fediverse').type('{selectAll}@nextcloud@mastodon.social') - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Fediverse').should('have.value', 'nextcloud@mastodon.social') - - inputForLabel('Fediverse').clear() - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Fediverse').should('have.value', '') - }) - - it('Can set Website and change its visibility', () => { - cy.contains('label', 'Website').scrollIntoView() - // Check invalid input - inputForLabel('Website').type('foo bar') - inputForLabel('Website').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false) - // handle valid input - inputForLabel('Website').type('{selectAll}http://example.com') - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel('Website').should('have.value', 'http://example.com') - - checkSettingsVisibility('Website') - - // check it is visible on the profile - cy.visit(`/u/${user.userId}`) - cy.contains('http://example.com').should('be.visible') - }) - - // Check generic properties that allow any visibility and any value - genericProperties.forEach(([property, value]) => { - it(`Can set ${property} and change its visibility`, () => { - cy.contains('label', property).scrollIntoView() - inputForLabel(property).type(value) - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel(property).should('have.value', value) - - checkSettingsVisibility(property) - - // check it is visible on the profile - cy.visit(`/u/${user.userId}`) - cy.contains(value).should('be.visible') - }) - }) - - // Check non federated properties - those where we need special configuration and only support local visibility - nonfederatedProperties.forEach((property) => { - it(`Can set ${property} and change its visibility`, () => { - const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}` - cy.contains('label', property).scrollIntoView() - inputForLabel(property).type(uniqueValue) - handlePasswordConfirmation(user.password) - - cy.wait('@submitSetting') - cy.reload() - inputForLabel(property).should('have.value', uniqueValue) - - checkSettingsVisibility(property, Visibility.Local, [Visibility.Private, Visibility.Local]) - - // check it is visible on the profile - cy.visit(`/u/${user.userId}`) - cy.contains(uniqueValue).should('be.visible') - }) - }) -}) diff --git a/cypress/e2e/settings/users-group-admin.cy.ts b/cypress/e2e/settings/users-group-admin.cy.ts deleted file mode 100644 index 932e699d0dc18..0000000000000 --- a/cypress/e2e/settings/users-group-admin.cy.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { randomString } from '../../support/utils/randomString.ts' -import { getUserListRow, handlePasswordConfirmation } from './usersUtils.ts' - -const admin = new User('admin', 'admin') -const john = new User('john', '123456') - -/** - * Make a user subadmin of a group. - * - * @param user - The user to make subadmin - * @param group - The group the user should be subadmin of - */ -function makeSubAdmin(user: User, group: string): void { - cy.request({ - url: `${Cypress.config('baseUrl')!.replace('/index.php', '')}/ocs/v2.php/cloud/users/${user.userId}/subadmins`, - method: 'POST', - auth: { - user: admin.userId, - password: admin.userId, - }, - headers: { - 'OCS-ApiRequest': 'true', - }, - body: { - groupid: group, - }, - }) -} - -describe('Settings: Create accounts as a group admin', function() { - let subadmin: User - let group: string - - beforeEach(() => { - group = randomString(7) - cy.deleteUser(john) - cy.createRandomUser().then((user) => { - subadmin = user - cy.runOccCommand(`group:add '${group}'`) - cy.runOccCommand(`group:adduser '${group}' '${subadmin.userId}'`) - makeSubAdmin(subadmin, group) - }) - }) - - it('Can create a user with prefilled single group', () => { - cy.login(subadmin) - // open the User settings - cy.visit('/settings/users') - - // open the New user modal - cy.get('button#new-user-button').click() - - cy.get('form[data-test="form"]').within(() => { - // see that the correct group is preselected - cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible') - // see that the username is "" - cy.get('input[data-test="username"]').should('exist').and('have.value', '') - // set the username to john - cy.get('input[data-test="username"]').type(john.userId) - // see that the username is john - cy.get('input[data-test="username"]').should('have.value', john.userId) - // see that the password is "" - cy.get('input[type="password"]').should('exist').and('have.value', '') - // set the password to 123456 - cy.get('input[type="password"]').type(john.password) - // see that the password is 123456 - cy.get('input[type="password"]').should('have.value', john.password) - }) - - cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { - // submit the new user form - cy.get('button[type="submit"]').click({ force: true }) - }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the created user is in the list - getUserListRow(john.userId) - // see that the list of users contains the user john - .contains(john.userId).should('exist') - }) - - // Skiping as this crash the webengine in the CI - it.skip('Can create a new user when member of multiple groups', () => { - const group2 = randomString(7) - cy.runOccCommand(`group:add '${group2}'`) - cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`) - makeSubAdmin(subadmin, group2) - - cy.login(subadmin) - // open the User settings - cy.visit('/settings/users') - - // open the New user modal - cy.get('button#new-user-button').click() - - cy.get('form[data-test="form"]').within(() => { - // see that no group is pre-selected - cy.get('[data-test="groups"] .vs__selected').should('not.exist') - // see both groups are available - cy.findByRole('combobox', { name: /member of the following groups/i }) - .should('be.visible') - .click() - // can select both groups - cy.document().its('body') - .findByRole('listbox', { name: 'Options' }) - .should('be.visible') - .as('options') - .findAllByRole('option') - .should('have.length', 2) - .get('@options') - .findByRole('option', { name: group }) - .should('be.visible') - .get('@options') - .findByRole('option', { name: group2 }) - .should('be.visible') - .click() - // see group is selected - cy.contains('[data-test="groups"] .vs__selected', group2).should('be.visible') - - // see that the username is "" - cy.get('input[data-test="username"]').should('exist').and('have.value', '') - // set the username to john - cy.get('input[data-test="username"]').type(john.userId) - // see that the username is john - cy.get('input[data-test="username"]').should('have.value', john.userId) - // see that the password is "" - cy.get('input[type="password"]').should('exist').and('have.value', '') - // set the password to 123456 - cy.get('input[type="password"]').type(john.password) - // see that the password is 123456 - cy.get('input[type="password"]').should('have.value', john.password) - }) - - cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { - // submit the new user form - cy.get('button[type="submit"]').click({ force: true }) - }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the created user is in the list - getUserListRow(john.userId) - // see that the list of users contains the user john - .contains(john.userId).should('exist') - }) - - it.skip('Only sees groups they are subadmin of', () => { - const group2 = randomString(7) - cy.runOccCommand(`group:add '${group2}'`) - cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`) - // not a subadmin! - - cy.login(subadmin) - // open the User settings - cy.visit('/settings/users') - - // open the New user modal - cy.get('button#new-user-button').click() - - cy.get('form[data-test="form"]').within(() => { - // see that the subadmin group is pre-selected - cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible') - // see only the subadmin group is available - cy.findByRole('combobox', { name: /member of the following groups/i }) - .should('be.visible') - .click() - // can select both groups - cy.document().its('body') - .findByRole('listbox', { name: 'Options' }) - .should('be.visible') - .as('options') - .findAllByRole('option') - .should('have.length', 1) - }) - }) -}) diff --git a/cypress/e2e/settings/users.cy.ts b/cypress/e2e/settings/users.cy.ts deleted file mode 100644 index ec4123bf77b82..0000000000000 --- a/cypress/e2e/settings/users.cy.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -/// - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { getUserListRow, handlePasswordConfirmation } from './usersUtils.ts' - -const admin = new User('admin', 'admin') -const john = new User('john', '123456') - -describe('Settings: Create and delete accounts', function() { - beforeEach(function() { - cy.listUsers().then((users) => { - if ((users as string[]).includes(john.userId)) { - // ensure created user is deleted - cy.deleteUser(john) - } - }) - cy.login(admin) - // open the User settings - cy.visit('/settings/users') - }) - - it('Can create a user', function() { - // open the New user modal - cy.get('button#new-user-button').click() - - cy.get('form[data-test="form"]').within(() => { - // see that the username is "" - cy.get('input[data-test="username"]').should('exist').and('have.value', '') - // set the username to john - cy.get('input[data-test="username"]').type(john.userId) - // see that the username is john - cy.get('input[data-test="username"]').should('have.value', john.userId) - // see that the password is "" - cy.get('input[type="password"]').should('exist').and('have.value', '') - // set the password to 123456 - cy.get('input[type="password"]').type(john.password) - // see that the password is 123456 - cy.get('input[type="password"]').should('have.value', john.password) - }) - - cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { - // submit the new user form - cy.get('button[type="submit"]').click({ force: true }) - }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the created user is in the list - getUserListRow(john.userId) - // see that the list of users contains the user john - .contains(john.userId).should('exist') - }) - - it('Can create a user with additional field data', function() { - // open the New user modal - cy.get('button#new-user-button').click() - - cy.get('form[data-test="form"]').within(() => { - // set the username - cy.get('input[data-test="username"]').should('exist').and('have.value', '') - cy.get('input[data-test="username"]').type(john.userId) - cy.get('input[data-test="username"]').should('have.value', john.userId) - // set the display name - cy.get('input[data-test="displayName"]').should('exist').and('have.value', '') - cy.get('input[data-test="displayName"]').type('John Smith') - cy.get('input[data-test="displayName"]').should('have.value', 'John Smith') - // set the email - cy.get('input[data-test="email"]').should('exist').and('have.value', '') - cy.get('input[data-test="email"]').type('john@example.org') - cy.get('input[data-test="email"]').should('have.value', 'john@example.org') - // set the password - cy.get('input[type="password"]').should('exist').and('have.value', '') - cy.get('input[type="password"]').type(john.password) - cy.get('input[type="password"]').should('have.value', john.password) - }) - - cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => { - // submit the new user form - cy.get('button[type="submit"]').click({ force: true }) - }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the created user is in the list - getUserListRow(john.userId) - // see that the list of users contains the user john - .contains(john.userId) - .should('exist') - }) - - it('Can delete a user', function() { - let testUser - // create user - cy.createRandomUser() - .then(($user) => { - testUser = $user - }) - cy.login(admin) - // ensure created user is present - cy.reload().then(() => { - // see that the user is in the list - getUserListRow(testUser.userId).within(() => { - // see that the list of users contains the user testUser - cy.contains(testUser.userId).should('exist') - // open the actions menu for the user - cy.get('[data-cy-user-list-cell-actions]') - .find('button.action-item__menutoggle') - .click({ force: true }) - }) - - // The "Delete account" action in the actions menu is shown and clicked - cy.get('.action-item__popper .action').contains('Delete account').should('exist').click({ force: true }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // And confirmation dialog accepted - cy.get('.nc-generic-dialog button').contains(`Delete ${testUser.userId}`).click({ force: true }) - - // deleted clicked the user is not shown anymore - getUserListRow(testUser.userId).should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/settings/usersUtils.ts b/cypress/e2e/settings/usersUtils.ts deleted file mode 100644 index c718fff1a190d..0000000000000 --- a/cypress/e2e/settings/usersUtils.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -/** - * Assert that `element` does not exist or is not visible - * Useful in cases such as when NcModal is opened/closed rapidly - * - * @param element Element that is inspected - */ -export function assertNotExistOrNotVisible(element: JQuery) { - const doesNotExist = element.length === 0 - const isNotVisible = !element.is(':visible') - - expect(doesNotExist || isNotVisible, 'does not exist or is not visible').to.be.true -} - -/** - * Get the settings users list - * - * @return Cypress chainable object - */ -export function getUserList() { - return cy.get('[data-cy-user-list]') -} - -/** - * Get the row entry for given userId within the settings users list - * - * @param userId the user to query - * @return Cypress chainable object - */ -export function getUserListRow(userId: string) { - return getUserList().find(`[data-cy-user-row="${userId}"]`) -} - -/** - * - * @param selector - */ -export function waitLoading(selector: string) { - // We need to make sure the element is loading, otherwise the "done loading" will succeed even if we did not start loading. - // But Cypress might also be simply too slow to catch the loading phase. Thats why we need to wait in this case. - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.get(`${selector}[data-loading]`).if().should('exist').else().wait(1000) - // https://github.com/NoriSte/cypress-wait-until/issues/75#issuecomment-572685623 - cy.waitUntil(() => Cypress.$(selector).length > 0 && !Cypress.$(selector).attr('data-loading')?.length, { timeout: 10000 }) -} - -/** - * Toggle the edit button of the user row - * - * @param user The user row to edit - * @param toEdit True if it should be switch to edit mode, false to switch to read-only - */ -export function toggleEditButton(user: User, toEdit = true) { - // see that the list of users contains the user - getUserListRow(user.userId).should('exist') - // toggle the edit mode for the user - .find('[data-cy-user-list-cell-actions]') - .find(`[data-cy-user-list-action-toggle-edit="${!toEdit}"]`) - .if() - .click({ force: true }) - .else() - // otherwise ensure the button is already in edit mode - .then(() => getUserListRow(user.userId) - .find(`[data-cy-user-list-action-toggle-edit="${toEdit}"]`) - .should('exist')) -} - -/** - * Handle the confirm password dialog (if needed) - * - * @param adminPassword The admin password for the dialog - */ -export function handlePasswordConfirmation(adminPassword = 'admin') { - const handleModal = (context: Cypress.Chainable) => { - return context.contains('.modal-container', 'Authentication required') - .if() - .within(() => { - cy.get('input[type="password"]') - .type(adminPassword) - cy.findByRole('button', { name: 'Confirm' }) - .click() - }) - } - - return cy.get('body') - .if() - .then(() => handleModal(cy.get('body'))) - .else() - // Handle if inside a cy.within - .root().closest('body') - .then(($body) => handleModal(cy.wrap($body))) -} diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts deleted file mode 100644 index ad7db65c4f8c1..0000000000000 --- a/cypress/e2e/settings/users_columns.cy.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { assertNotExistOrNotVisible, getUserList } from './usersUtils.js' - -const admin = new User('admin', 'admin') - -describe('Settings: Show and hide columns', function() { - before(function() { - cy.login(admin) - // open the User settings - cy.visit('/settings/users') - }) - - beforeEach(function() { - // open the settings dialog - cy.contains('button', 'Account management settings').click() - // reset all visibility toggles - cy.get('.modal-container #settings-section_visibility-settings input[type="checkbox"]').uncheck({ force: true }) - - cy.contains('.modal-container', 'Account management settings').within(() => { - // enable the last login toggle - cy.get('[data-test="showLastLogin"] input[type="checkbox"]').check({ force: true }) - // close the settings dialog - cy.get('button.modal-container__close').click() - }) - cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el))) - }) - - it('Can show a column', function() { - // see that the language column is not in the header - cy.get('[data-cy-user-list-header-languages]').should('not.exist') - - // see that the language column is not in all user rows - cy.get('tbody.user-list__body tr').each(($row) => { - cy.wrap($row).get('[data-test="language"]').should('not.exist') - }) - - // open the settings dialog - cy.contains('button', 'Account management settings').click() - - cy.contains('.modal-container', 'Account management settings').within(() => { - // enable the language toggle - cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('not.be.checked') - cy.get('[data-test="showLanguages"] input[type="checkbox"]').check({ force: true }) - cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('be.checked') - // close the settings dialog - cy.get('button.modal-container__close').click() - }) - cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el))) - - // see that the language column is in the header - cy.get('[data-cy-user-list-header-languages]').should('exist') - - // see that the language column is in all user rows - getUserList().find('tbody tr').each(($row) => { - cy.wrap($row).get('[data-cy-user-list-cell-language]').should('exist') - }) - - // Clear local storage and reload to verify user settings DB persistence - cy.clearLocalStorage() - cy.reload() - cy.get('[data-cy-user-list-header-languages]').should('exist') - }) - - it('Can hide a column', function() { - // see that the last login column is in the header - cy.get('[data-cy-user-list-header-last-login]').should('exist') - - // see that the last login column is in all user rows - getUserList().find('tbody tr').each(($row) => { - cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('exist') - }) - - // open the settings dialog - cy.contains('button', 'Account management settings').click() - - cy.contains('.modal-container', 'Account management settings').within(() => { - // disable the last login toggle - cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('be.checked') - cy.get('[data-test="showLastLogin"] input[type="checkbox"]').uncheck({ force: true }) - cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('not.be.checked') - // close the settings dialog - cy.get('button.modal-container__close').click() - }) - cy.waitUntil(() => cy.contains('.modal-container', 'Account management settings').should((el) => assertNotExistOrNotVisible(el))) - - // see that the last login column is not in the header - cy.get('[data-cy-user-list-header-last-login]').should('not.exist') - - // see that the last login column is not in all user rows - getUserList().find('tbody tr').each(($row) => { - cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/settings/users_disable.cy.ts b/cypress/e2e/settings/users_disable.cy.ts deleted file mode 100644 index 23b0397aa9908..0000000000000 --- a/cypress/e2e/settings/users_disable.cy.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState } from '../../support/commonUtils.ts' -import { getUserListRow } from './usersUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: Disable and enable users', function() { - let testUser: User - - beforeEach(function() { - clearState() - cy.createRandomUser().then(($user) => { - testUser = $user - }) - cy.login(admin) - // open the User settings - cy.visit('/settings/users') - }) - - // Not guranteed to run but would be nice to cleanup - after(() => { - cy.deleteUser(testUser) - }) - - it('Can disable the user', function() { - // ensure user is enabled - cy.enableUser(testUser) - - // see that the user is in the list of active users - getUserListRow(testUser.userId).within(() => { - // see that the list of users contains the user testUser - cy.contains(testUser.userId).should('exist') - // open the actions menu for the user - cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' }) - }) - - // The "Disable account" action in the actions menu is shown and clicked - cy.get('.action-item__popper .action').contains('Disable account').should('exist').click() - // When clicked the section is not shown anymore - getUserListRow(testUser.userId).should('not.exist') - // But the disabled user section now exists - cy.get('#disabled').should('exist') - // Open disabled users section - cy.get('#disabled a').click() - cy.url().should('match', /\/disabled/) - // The list of disabled users should now contain the user - getUserListRow(testUser.userId).should('exist') - }) - - it('Can enable the user', function() { - // ensure user is disabled - cy.enableUser(testUser, false).reload() - - // Open disabled users section - cy.get('#disabled a').click() - cy.url().should('match', /\/disabled/) - - // see that the user is in the list of active users - getUserListRow(testUser.userId).within(() => { - // see that the list of disabled users contains the user testUser - cy.contains(testUser.userId).should('exist') - // open the actions menu for the user - cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' }) - }) - - // The "Enable account" action in the actions menu is shown and clicked - cy.get('.action-item__popper .action').contains('Enable account').should('exist').click() - // When clicked the section is not shown anymore - cy.get('#disabled').should('not.exist') - // Make sure it is still gone after the reload reload - cy.reload().login(admin) - cy.get('#disabled').should('not.exist') - }) -}) diff --git a/cypress/e2e/settings/users_groups.cy.ts b/cypress/e2e/settings/users_groups.cy.ts deleted file mode 100644 index 76a18ca9d4f23..0000000000000 --- a/cypress/e2e/settings/users_groups.cy.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState } from '../../support/commonUtils.ts' -import { randomString } from '../../support/utils/randomString.ts' -import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: Create groups', () => { - let groupName: string - - after(() => { - cy.runOccCommand(`group:delete '${groupName!}'`) - }) - - before(() => { - cy.login(admin) - cy.visit('/settings/users') - }) - - it('Can create a group', () => { - cy.intercept('POST', '**/ocs/v2.php/cloud/groups').as('createGroups') - - groupName = randomString(7) - // open the Create group menu - cy.get('button[aria-label="Create group"]').click() - - cy.get('li[data-cy-users-settings-new-group-name]').within(() => { - // see that the group name is "" - cy.get('input').should('exist').and('have.value', '') - // set the group name to foo - cy.get('input').type(groupName) - // see that the group name is foo - cy.get('input').should('have.value', groupName) - // submit the group name - cy.get('input ~ button').click() - }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - cy.wait('@createGroups').its('response.statusCode').should('eq', 200) - - // see that the created group is in the list - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - // see that the list of groups contains the group foo - cy.contains(groupName).should('exist') - }) - }) -}) - -describe('Settings: Assign user to a group', { testIsolation: false }, () => { - const groupName = randomString(7) - let testUser: User - - after(() => { - cy.deleteUser(testUser) - cy.runOccCommand(`group:delete '${groupName}'`) - }) - - before(() => { - clearState() - - cy.createRandomUser().then((user) => { - testUser = user - }) - cy.runOccCommand(`group:add '${groupName}'`) - cy.login(admin) - cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups') - cy.visit('/settings/users') - cy.wait('@loadGroups') - }) - - it('see that the group is in the list', () => { - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) - .should('exist') - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) - .find('.counter-bubble__counter') - .should('not.exist') // is hidden when 0 - }) - - it('see that the user is in the list', () => { - getUserListRow(testUser.userId) - .contains(testUser.userId) - .should('exist') - .scrollIntoView() - }) - - it('switch into user edit mode', () => { - toggleEditButton(testUser) - getUserListRow(testUser.userId) - .find('[data-cy-user-list-input-groups]') - .should('exist') - }) - - it('assign the group', () => { - // focus inside the input - getUserListRow(testUser.userId) - .find('[data-cy-user-list-input-groups] input') - .click({ force: true }) - // enter the group name - getUserListRow(testUser.userId) - .find('[data-cy-user-list-input-groups] input') - .type(`${groupName.slice(0, 5)}`) // only type part as otherwise we would create a new one with the same name - cy.contains('li.vs__dropdown-option', groupName) - .click({ force: true }) - - handlePasswordConfirmation(admin.password) - }) - - it('leave the user edit mode', () => { - toggleEditButton(testUser, false) - }) - - it('see the group was successfully assigned', () => { - // see a new memeber - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) - .find('.counter-bubble__counter') - .should('contain', '1') - }) - - it('validate the user was added on backend', () => { - cy.runOccCommand(`user:info --output=json '${testUser.userId}'`).then((output) => { - cy.wrap(output.exitCode).should('eq', 0) - cy.wrap(JSON.parse(output.stdout)?.groups).should('include', groupName) - }) - }) -}) - -describe('Settings: Delete an empty group', { testIsolation: false }, () => { - const groupName = randomString(7) - - after(() => { - cy.runOccCommand(`group:delete '${groupName}'`, { failOnNonZeroExit: false }) - }) - before(() => { - cy.runOccCommand(`group:add '${groupName}'`) - cy.login(admin) - cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups') - cy.visit('/settings/users') - cy.wait('@loadGroups') - }) - - it('see that the group is in the list', () => { - // see that the list of groups contains the group foo - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) - .should('exist') - .scrollIntoView() - // open the actions menu for the group - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) - .find('button.action-item__menutoggle') - .click({ force: true }) - }) - - it('can delete the group', () => { - // The "Delete group" action in the actions menu is shown and clicked - cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true }) - // And confirmation dialog accepted - cy.get('.modal-container button').contains('Confirm').click({ force: true }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - }) - - it('deleted group is not shown anymore', () => { - // see that the list of groups does not contain the group - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]') - .find('li') - .not('.app-navigation-caption') - .should('not.exist') - // and also not in database - cy.runOccCommand('group:list --output=json').then(($response) => { - const groups: string[] = Object.keys(JSON.parse($response.stdout)) - expect(groups).to.not.include(groupName) - }) - }) -}) - -describe('Settings: Delete a non empty group', () => { - let testUser: User - const groupName = randomString(7) - - after(() => { - cy.runOccCommand(`group:delete '${groupName}'`, { failOnNonZeroExit: false }) - }) - - before(() => { - cy.runOccCommand(`group:add '${groupName}'`) - cy.createRandomUser().then(($user) => { - testUser = $user - cy.runOccCommand(`group:addUser '${groupName}' '${$user.userId}'`) - }) - cy.login(admin) - cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups') - cy.visit('/settings/users') - cy.wait('@loadGroups') - }) - after(() => cy.deleteUser(testUser)) - - it('see that the group is in the list', () => { - // see that the list of groups contains the group - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) - .should('exist') - .scrollIntoView() - }) - - it('can delete the group', () => { - // open the menu - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName) - .find('button.action-item__menutoggle') - .click({ force: true }) - - // The "Delete group" action in the actions menu is shown and clicked - cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true }) - // And confirmation dialog accepted - cy.get('.modal-container button').contains('Confirm').click({ force: true }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - }) - - it('deleted group is not shown anymore', () => { - // see that the list of groups does not contain the group foo - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]') - .find('li') - .not('.app-navigation-caption') - .should('not.exist') - // and also not in database - cy.runOccCommand('group:list --output=json').then(($response) => { - const groups: string[] = Object.keys(JSON.parse($response.stdout)) - expect(groups).to.not.include(groupName) - }) - }) -}) - -describe('Settings: Sort groups in the UI', () => { - before(() => { - // Clear state - clearState() - - // Add two groups and add one user to group B - cy.runOccCommand('group:add A') - cy.runOccCommand('group:add B') - cy.createRandomUser().then((user) => { - cy.runOccCommand(`group:adduser B '${user.userId}'`) - }) - - // Visit the settings as admin - cy.login(admin) - cy.visit('/settings/users') - }) - - it('Can set sort by member count', () => { - // open the settings dialog - cy.contains('button', 'Account management settings').click() - - cy.contains('.modal-container', 'Account management settings').within(() => { - cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').scrollIntoView() - cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').check({ force: true }) - // close the settings dialog - cy.get('button.modal-container__close').click() - }) - cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el))) - }) - - it('See that the groups are sorted by the member count', () => { - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'B') // 1 member - cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'A') // 0 members - }) - }) - - it('See that the order is preserved after a reload', () => { - cy.reload() - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'B') // 1 member - cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'A') // 0 members - }) - }) - - it('Can set sort by group name', () => { - // open the settings dialog - cy.contains('button', 'Account management settings').click() - - cy.contains('.modal-container', 'Account management settings').within(() => { - cy.get('[data-test="sortGroupsByName"] input[type="radio"]').scrollIntoView() - cy.get('[data-test="sortGroupsByName"] input[type="radio"]').check({ force: true }) - // close the settings dialog - cy.get('button.modal-container__close').click() - }) - cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el))) - }) - - it('See that the groups are sorted by the user count', () => { - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'A') - cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'B') - }) - }) - - it('See that the order is preserved after a reload', () => { - cy.reload() - cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => { - cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'A') - cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'B') - }) - }) -}) diff --git a/cypress/e2e/settings/users_manager.cy.ts b/cypress/e2e/settings/users_manager.cy.ts deleted file mode 100644 index 6b4ab48b570eb..0000000000000 --- a/cypress/e2e/settings/users_manager.cy.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState } from '../../support/commonUtils.ts' -import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: User Manager Management', function() { - let user: User - let manager: User - - beforeEach(function() { - clearState() - cy.createRandomUser().then(($user) => { - manager = $user - return cy.createRandomUser() - }).then(($user) => { - user = $user - cy.login(admin) - cy.intercept('PUT', `/ocs/v2.php/cloud/users/${user.userId}*`).as('updateUser') - }) - }) - - it('Can assign and remove a manager through the UI', function() { - cy.visit('/settings/users') - - toggleEditButton(user, true) - - // Scroll to manager cell and wait for it to be visible - getUserListRow(user.userId) - .find('[data-cy-user-list-cell-manager]') - .scrollIntoView() - .should('be.visible') - - // Assign a manager - getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => { - // Verify no manager is set initially - cy.get('.vs__selected').should('not.exist') - - // Open the dropdown menu - cy.get('[role="combobox"]').click({ force: true }) - - // Wait for the dropdown to be visible and initialized - waitLoading('[data-cy-user-list-input-manager]') - - // Type the manager's username to search - cy.get('input[type="search"]').type(manager.userId, { force: true }) - - // Wait for the search results to load - waitLoading('[data-cy-user-list-input-manager]') - }) - - // Now select the manager from the filtered results - // Since the dropdown is floating, we need to search globally - cy.get('.vs__dropdown-menu').find('li').contains('span', manager.userId).should('be.visible').click({ force: true }) - - // Handle password confirmation if needed - handlePasswordConfirmation(admin.password) - - // Verify the manager is selected in the UI - cy.get('.vs__selected').should('exist').and('contain.text', manager.userId) - - // Verify the PUT request was made to set the manager - cy.wait('@updateUser').then((interception) => { - // Verify the request URL and body - expect(interception.request.url).to.match(/\/cloud\/users\/.+/) - expect(interception.request.body).to.deep.equal({ - key: 'manager', - value: manager.userId, - }) - expect(interception.response?.statusCode).to.equal(200) - }) - - // Wait for the save to complete - waitLoading('[data-cy-user-list-input-manager]') - - // Verify the manager is set in the backend - cy.getUserData(user).then(($result) => { - expect($result.body).to.contain(`${manager.userId}`) - }) - - // Now remove the manager - getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => { - // Clear the manager selection - cy.get('.vs__clear').click({ force: true }) - - // Verify the manager is cleared in the UI - cy.get('.vs__selected').should('not.exist') - - // Handle password confirmation if needed - handlePasswordConfirmation(admin.password) - }) - - // Verify the PUT request was made to clear the manager - cy.wait('@updateUser').then((interception) => { - // Verify the request URL and body - expect(interception.request.url).to.match(/\/cloud\/users\/.+/) - expect(interception.request.body).to.deep.equal({ - key: 'manager', - value: '', - }) - expect(interception.response?.statusCode).to.equal(200) - }) - - // Wait for the save to complete - waitLoading('[data-cy-user-list-input-manager]') - - // Verify the manager is cleared in the backend - cy.getUserData(user).then(($result) => { - expect($result.body).to.not.contain(`${manager.userId}`) - expect($result.body).to.contain('') - }) - - // Finish editing the user - toggleEditButton(user, false) - }) -}) diff --git a/cypress/e2e/settings/users_modify.cy.ts b/cypress/e2e/settings/users_modify.cy.ts deleted file mode 100644 index 35ca65558dd54..0000000000000 --- a/cypress/e2e/settings/users_modify.cy.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { clearState } from '../../support/commonUtils.ts' -import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: Change user properties', function() { - let user: User - - beforeEach(function() { - clearState() - cy.createRandomUser().then(($user) => { - user = $user - }) - cy.login(admin) - }) - - it('Can change the display name', function() { - // open the User settings as admin - cy.visit('/settings/users') - - // toggle edit button into edit mode - toggleEditButton(user, true) - - getUserListRow(user.userId).within(() => { - // set the display name - cy.get('[data-cy-user-list-input-displayname]').should('exist').and('have.value', user.userId) - cy.get('[data-cy-user-list-input-displayname]').clear() - cy.get('[data-cy-user-list-input-displayname]').type('John Doe') - cy.get('[data-cy-user-list-input-displayname]').should('have.value', 'John Doe') - cy.get('[data-cy-user-list-input-displayname] ~ button').click() - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the display name cell is done loading - waitLoading('[data-cy-user-list-input-displayname]') - }) - - // Success message is shown - cy.get('.toastify.toast-success').contains(/Display.+name.+was.+successfully.+changed/i).should('exist') - }) - - it('Can change the password', function() { - // open the User settings as admin - cy.visit('/settings/users') - - // toggle edit button into edit mode - toggleEditButton(user, true) - - getUserListRow(user.userId).within(() => { - // see that the password of user is "" - cy.get('[data-cy-user-list-input-password]').should('exist').and('have.value', '') - // set the password for user to 123456 - cy.get('[data-cy-user-list-input-password]').type('123456') - // When I set the password for user to 123456 - cy.get('[data-cy-user-list-input-password]').should('have.value', '123456') - cy.get('[data-cy-user-list-input-password] ~ button').click() - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the password cell for user is done loading - waitLoading('[data-cy-user-list-input-password]') - // password input is emptied on change - cy.get('[data-cy-user-list-input-password]').should('have.value', '') - }) - - // Success message is shown - cy.get('.toastify.toast-success').contains(/Password.+successfully.+changed/i).should('exist') - }) - - it('Can change the email address', function() { - // open the User settings as admin - cy.visit('/settings/users') - - // toggle edit button into edit mode - toggleEditButton(user, true) - - getUserListRow(user.userId).find('[data-cy-user-list-cell-email]').within(() => { - // see that the email of user is "" - cy.get('input').should('exist').and('have.value', '') - // set the email for user to mymail@example.com - cy.get('input').type('mymail@example.com') - // When I set the password for user to mymail@example.com - cy.get('input').should('have.value', 'mymail@example.com') - cy.get('input ~ button').click() - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the password cell for user is done loading - waitLoading('[data-cy-user-list-input-email]') - }) - - // Success message is shown - cy.get('.toastify.toast-success').contains(/Email.+successfully.+changed/i).should('exist') - }) - - it('Can change the user quota to a predefined one', function() { - // open the User settings as admin - cy.visit('/settings/users') - - // toggle edit button into edit mode - toggleEditButton(user, true) - - getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView() - getUserListRow(user.userId).find('[data-cy-user-list-cell-quota] [data-cy-user-list-input-quota]').within(() => { - // see that the quota of user is unlimited - cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited') - // Open the quota selector - cy.get('[role="combobox"]').click({ force: true }) - // see that there are default options for the quota - cy.get('li').then(($options) => { - expect($options).to.have.length(5) - cy.wrap($options).contains('Default quota') - cy.wrap($options).contains('Unlimited') - cy.wrap($options).contains('1 GB') - cy.wrap($options).contains('10 GB') - // select 5 GB - cy.wrap($options).contains('5 GB').click({ force: true }) - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - }) - // see that the quota of user is 5 GB - cy.get('.vs__selected').should('exist').and('contain.text', '5 GB') - }) - - // see that the changes are loading - waitLoading('[data-cy-user-list-input-quota]') - - // finish editing the user - toggleEditButton(user, false) - - // I see that the quota was set on the backend - cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => { - expect($result.exitCode).to.equal(0) - const info = JSON.parse($result.stdout) - expect(info?.quota).to.equal('5 GB') - }) - }) - - it('Can change the user quota to a custom value', function() { - // open the User settings as admin - cy.visit('/settings/users') - - // toggle edit button into edit mode - toggleEditButton(user, true) - - getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView() - getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').within(() => { - // see that the quota of user is unlimited - cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited') - // set the quota to 4 MB - cy.get('[data-cy-user-list-input-quota] input').type('4 MB{enter}') - - // Make sure no confirmation modal is shown - handlePasswordConfirmation(admin.password) - - // see that the quota of user is 4 MB - // TODO: Enable this after the file size handling is fixed - // cy.get('.vs__selected').should('exist').and('contain.text', '4 MB') - - // see that the changes are loading - waitLoading('[data-cy-user-list-input-quota]') - }) - - // finish editing the user - toggleEditButton(user, false) - - // I see that the quota was set on the backend - cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => { - expect($result.exitCode).to.equal(0) - // TODO: Enable this after the file size handling is fixed!!!!!! - // const info = JSON.parse($result.stdout) - // expect(info?.quota).to.equal('4 MB') - }) - }) - - it('Can make user a subadmin of a group', function() { - // create a group - const groupName = 'userstestgroup' - cy.runOccCommand(`group:add '${groupName}'`) - - // open the User settings as admin - cy.visit('/settings/users') - - // toggle edit button into edit mode - toggleEditButton(user, true) - - getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').scrollIntoView() - getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').within(() => { - // see that the user is no subadmin - cy.get('.vs__selected').should('not.exist') - // Open the dropdown menu - cy.get('[role="combobox"]').click({ force: true }) - // Search for the group - cy.get('[role="combobox"]').type('userstestgroup') - // select the group - cy.contains('li', groupName).click({ force: true }) - - // handle password confirmation on time out - handlePasswordConfirmation(admin.password) - - // see that the user is subadmin of the group - cy.get('.vs__selected').should('exist').and('contain.text', groupName) - }) - - waitLoading('[data-cy-user-list-input-subadmins]') - - // finish editing the user - toggleEditButton(user, false) - - // I see that the quota was set on the backend - cy.getUserData(user).then(($response) => { - expect($response.status).to.equal(200) - const dom = (new DOMParser()).parseFromString($response.body, 'text/xml') - expect(dom.querySelector('subadmin element')?.textContent).to.contain(groupName) - }) - }) -}) diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts deleted file mode 100644 index 8dd73078e3742..0000000000000 --- a/cypress/e2e/systemtags/admin-settings.cy.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { randomString } from '../../support/utils/randomString.ts' - -const admin = new User('admin', 'admin') - -// Unique per run so left-overs of an earlier run cannot satisfy - or collide -// with - the assertions below. -const tagName = `tag-${randomString(8)}` -const updatedTagName = `tag-${randomString(8)}` - -/** - * Remove every system tag, so the dropdown only ever contains what a test made. - */ -function deleteAllTags() { - cy.runOccCommand('tag:list --output=json').then((output) => { - Object.keys(JSON.parse(output.stdout)).forEach((id) => { - cy.runOccCommand(`tag:delete ${id}`) - }) - }) -} - -/** - * Open the admin settings with the tag list already fetched. - * - * The section loads its tags asynchronously after mount, so opening the tag - * dropdown before that response arrives yields an empty list. - */ -function visitTagSettings() { - cy.intercept('PROPFIND', '**/dav/systemtags').as('fetchTags') - cy.visit('/settings/admin') - cy.wait('@fetchTags') -} - -/** - * Open one of the form's dropdowns and yield an entry of its list box. - * - * The list box is only rendered while the dropdown is open, and the dropdown - * opens on click - focussing alone leaves it closed. Querying the entry by its - * full selector keeps a list re-render retryable; resolving it from the list - * box element would bind the assertion to a detached snapshot. - * - * @param inputId id of the dropdown's input element - * @param title the entry's title attribute, omit to yield the list box itself - * @return the queried entry - */ -function openDropdown(inputId: string, title?: string) { - cy.get(`input#${inputId}`).click() - return cy.get(`input#${inputId}`) - .invoke('attr', 'aria-controls') - .then((id) => cy.get(title === undefined ? `ul#${id}` : `ul#${id} li span[title="${title}"]`)) -} - -/** - * Pick a tag from the "search for a tag to edit" dropdown. - * - * @param label the tag's entry as rendered in the list - */ -function selectTag(label: string) { - openDropdown('system-tags-input', label).click() -} - -describe('Create system tags', () => { - before(() => { - cy.login(admin) - }) - - // The suite runs with `testIsolation: false`, so a retry would otherwise - // inherit the half-filled form and the tag the failed attempt created - - // and fail with 409 on creating it again. - beforeEach(() => { - deleteAllTags() - visitTagSettings() - }) - - it('Can create a tag', () => { - cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag') - cy.get('input#system-tag-name').should('exist').and('have.value', '') - cy.get('input#system-tag-name').type(tagName) - cy.get('input#system-tag-name').should('have.value', tagName) - // submit the form - cy.get('input#system-tag-name').type('{enter}') - - // wait for the tag to be created - cy.wait('@createTag').its('response.statusCode').should('eq', 201) - - // see that the created tag is in the list - openDropdown('system-tags-input', tagName) - .should('have.length', 1) - }) -}) - -describe('Update system tags', { testIsolation: false }, () => { - before(() => { - cy.login(admin) - }) - - // Rebuild the tag for every attempt: `before()` does not re-run on a retry, - // so a failed attempt would leave the tag already renamed and the form - // already holding those values - retyping them emits no PROPPATCH at all - // and every further attempt fails. - beforeEach(() => { - deleteAllTags() - cy.runOccCommand(`tag:add '${tagName}' public`) - visitTagSettings() - }) - - it('select the tag', () => { - selectTag(tagName) - // see that the tag name matches the selected tag - cy.get('input#system-tag-name').should('exist').and('have.value', tagName) - // see that the tag level matches the selected tag - cy.get('input#system-tag-level').click() - cy.get('input#system-tag-level').siblings('.vs__selected').contains('Public').should('exist') - }) - - it('update the tag name and level', () => { - selectTag(tagName) - - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*').as('updateTag') - cy.get('input#system-tag-name').clear() - cy.get('input#system-tag-name').type(updatedTagName) - cy.get('input#system-tag-name').should('have.value', updatedTagName) - // select the new tag level - openDropdown('system-tag-level', 'Invisible').click() - // submit the form - cy.get('input#system-tag-name').type('{enter}') - // wait for the tag to be updated - cy.wait('@updateTag').its('response.statusCode').should('eq', 207) - - // see that the updated tag is in the list - openDropdown('system-tags-input', `${updatedTagName} (invisible)`) - .should('have.length', 1) - }) -}) - -describe('Delete system tags', { testIsolation: false }, () => { - before(() => { - cy.login(admin) - }) - - // Same as above: the delete below removes the tag, so every attempt needs - // its own one to operate on. - beforeEach(() => { - deleteAllTags() - cy.runOccCommand(`tag:add '${updatedTagName}' invisible`) - visitTagSettings() - }) - - it('select the tag', () => { - selectTag(`${updatedTagName} (invisible)`) - // see that the tag name matches the selected tag - cy.get('input#system-tag-name').should('exist').and('have.value', updatedTagName) - // see that the tag level matches the selected tag - cy.get('input#system-tag-level').focus() - cy.get('input#system-tag-level').siblings('.vs__selected').contains('Invisible').should('exist') - }) - - it('can delete the tag', () => { - selectTag(`${updatedTagName} (invisible)`) - - cy.intercept('DELETE', '/remote.php/dav/systemtags/*').as('deleteTag') - cy.get('.system-tag-form__row').within(() => { - cy.contains('button', 'Delete').should('be.enabled').click() - }) - // wait for the tag to be deleted - cy.wait('@deleteTag').its('response.statusCode').should('eq', 204) - - // see that the deleted tag is gone from the list - openDropdown('system-tags-input', updatedTagName) - .should('not.exist') - }) -}) diff --git a/cypress/e2e/systemtags/files-bulk-action.cy.ts b/cypress/e2e/systemtags/files-bulk-action.cy.ts deleted file mode 100644 index 491b551a09bc4..0000000000000 --- a/cypress/e2e/systemtags/files-bulk-action.cy.ts +++ /dev/null @@ -1,468 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomBytes } from 'crypto' -import { getRowForFile, selectAllFiles, selectRowForFile, triggerSelectionAction } from '../files/FilesUtils.ts' -import { createShare } from '../files_sharing/FilesSharingUtils.ts' - -let tags = {} as Record -const files = [ - 'file1.txt', - 'file2.txt', - 'file3.txt', - 'file4.txt', - 'file5.txt', -] - -describe('Systemtags: Files bulk action', { testIsolation: false }, () => { - let user1: User - let user2: User - - before(() => { - cy.createRandomUser().then((_user1) => { - user1 = _user1 - cy.createRandomUser().then((_user2) => { - user2 = _user2 - }) - - files.forEach((file) => { - cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) - }) - }) - - resetTags() - }) - - after(() => { - resetTags() - cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0') - }) - - it('Can assign tag to selection', () => { - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectRowForFile('file2.txt') - selectRowForFile('file4.txt') - - triggerTagManagementDialogAction() - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) - cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5) - - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - const tag = Object.keys(tags)[3]! - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData') - cy.wait('@assignTagData') - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - expectInlineTagForFile('file2.txt', [tag]) - expectInlineTagForFile('file4.txt', [tag]) - }) - - it('Can assign multiple tags to selection', () => { - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectAllFiles() - - triggerTagManagementDialogAction() - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) - cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5) - - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - const prevTag = Object.keys(tags)[3]! - const tag1 = Object.keys(tags)[1]! - const tag2 = Object.keys(tags)[2]! - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData') - cy.wait('@assignTagData') - cy.get('@getTagData.all').should('have.length', 2) - cy.get('@assignTagData.all').should('have.length', 2) - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - expectInlineTagForFile('file1.txt', [tag1, tag2]) - expectInlineTagForFile('file2.txt', [prevTag, tag1, tag2]) - expectInlineTagForFile('file3.txt', [tag1, tag2]) - expectInlineTagForFile('file4.txt', [prevTag, tag1, tag2]) - expectInlineTagForFile('file5.txt', [tag1, tag2]) - }) - - it('Can remove tag from selection', () => { - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectRowForFile('file1.txt') - selectRowForFile('file3.txt') - selectRowForFile('file4.txt') - - triggerTagManagementDialogAction() - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) - - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - const firstTag = Object.keys(tags)[3]! - const tag1 = Object.keys(tags)[1]! - const tag2 = Object.keys(tags)[2]! - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData') - cy.wait('@assignTagData') - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - expectInlineTagForFile('file1.txt', [tag1]) - expectInlineTagForFile('file2.txt', [firstTag, tag1, tag2]) - expectInlineTagForFile('file3.txt', [tag1]) - expectInlineTagForFile('file4.txt', [firstTag, tag1]) - expectInlineTagForFile('file5.txt', [tag1, tag2]) - }) - - it('Can remove multiple tags from selection', () => { - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectAllFiles() - - triggerTagManagementDialogAction() - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) - - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - cy.get('[data-cy-systemtags-picker-tag] input:indeterminate').should('exist') - .click({ force: true, multiple: true }) - // indeterminate became checked - cy.get('[data-cy-systemtags-picker-tag] input:checked').should('exist') - .click({ force: true, multiple: true }) - // now all are unchecked - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData') - cy.wait('@assignTagData') - cy.get('@getTagData.all').should('have.length', 3) - cy.get('@assignTagData.all').should('have.length', 3) - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - expectInlineTagForFile('file1.txt', []) - expectInlineTagForFile('file2.txt', []) - expectInlineTagForFile('file3.txt', []) - expectInlineTagForFile('file4.txt', []) - expectInlineTagForFile('file5.txt', []) - }) - - it('Can assign and remove multiple tags as a secondary user', () => { - // Create new users - cy.createRandomUser().then((_user1) => { - user1 = _user1 - cy.createRandomUser().then((_user2) => { - user2 = _user2 - }) - - files.forEach((file) => { - cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) - }) - }) - - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectAllFiles() - - triggerTagManagementDialogAction() - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) - - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData1') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData1') - - const tag1 = Object.keys(tags)[0]! - const tag2 = Object.keys(tags)[3]! - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData1') - cy.wait('@assignTagData1') - cy.get('@getTagData1.all').should('have.length', 2) - cy.get('@assignTagData1.all').should('have.length', 2) - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - expectInlineTagForFile('file1.txt', [tag1, tag2]) - expectInlineTagForFile('file2.txt', [tag1, tag2]) - expectInlineTagForFile('file3.txt', [tag1, tag2]) - expectInlineTagForFile('file4.txt', [tag1, tag2]) - expectInlineTagForFile('file5.txt', [tag1, tag2]) - - createShare('file1.txt', user2.userId) - createShare('file3.txt', user2.userId) - - cy.login(user2) - cy.visit('/apps/files') - - getRowForFile('file1.txt').should('be.visible') - getRowForFile('file3.txt').should('be.visible') - - expectInlineTagForFile('file1.txt', [tag1, tag2]) - expectInlineTagForFile('file3.txt', [tag1, tag2]) - - selectRowForFile('file1.txt') - selectRowForFile('file3.txt') - triggerTagManagementDialogAction() - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) - - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData2') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData2') - - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData2') - cy.wait('@assignTagData2') - cy.get('@getTagData2.all').should('have.length', 2) - cy.get('@assignTagData2.all').should('have.length', 2) - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - expectInlineTagForFile('file1.txt', []) - expectInlineTagForFile('file3.txt', []) - - cy.login(user1) - cy.visit('/apps/files') - - expectInlineTagForFile('file1.txt', []) - expectInlineTagForFile('file3.txt', []) - }) - - it('Can create tag and assign files to it', () => { - cy.createRandomUser().then((user1) => { - files.forEach((file) => { - cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) - }) - - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectAllFiles() - - triggerTagManagementDialogAction() - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5) - - cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag') - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - const newTag = randomBytes(8).toString('base64').slice(0, 6) - cy.get('[data-cy-systemtags-picker-input]').type(newTag) - - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 0) - cy.get('[data-cy-systemtags-picker-button-create]').should('be.visible') - cy.get('[data-cy-systemtags-picker-button-create]').click() - - cy.wait('@createTag') - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 6) - // Verify the new tag is selected by default - cy.get('[data-cy-systemtags-picker-tag]').contains(newTag) - .parents('[data-cy-systemtags-picker-tag]') - .findByRole('checkbox', { hidden: true }).should('be.checked') - - // Apply changes - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData') - cy.wait('@assignTagData') - cy.get('@getTagData.all').should('have.length', 1) - cy.get('@assignTagData.all').should('have.length', 1) - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - expectInlineTagForFile('file1.txt', [newTag]) - expectInlineTagForFile('file2.txt', [newTag]) - expectInlineTagForFile('file3.txt', [newTag]) - expectInlineTagForFile('file4.txt', [newTag]) - expectInlineTagForFile('file5.txt', [newTag]) - }) - }) - - it('Cannot create tag if restriction is in place', () => { - let tagId: string - - cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 1') - cy.runOccCommand('tag:add testTag public --output json').then(({ stdout }) => { - const tag = JSON.parse(stdout) - tagId = tag.id - }) - - cy.createRandomUser().then((user1) => { - files.forEach((file) => { - cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) - }) - - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectAllFiles() - - triggerTagManagementDialogAction() - - cy.findByRole('textbox', { name: 'Search or create tag' }).should('not.exist') - cy.findByRole('textbox', { name: 'Search tag' }).should('be.visible') - - cy.get('[data-cy-systemtags-picker-input]').type('testTag') - - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1) - cy.get('[data-cy-systemtags-picker-button-create]').should('not.exist') - cy.get('[data-cy-systemtags-picker-tag-color]').should('not.exist') - - // Assign the tag - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData') - cy.wait('@assignTagData') - - cy.get('[data-cy-systemtags-picker]').should('not.exist') - - // Finally, reset the restriction - cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0') - }) - }) - - it('Can search for tags with insensitive case', () => { - let tagId: string - resetTags() - - cy.runOccCommand('tag:add TESTTAG public --output json').then(({ stdout }) => { - const tag = JSON.parse(stdout) - tagId = tag.id - }) - - cy.createRandomUser().then((user1) => { - files.forEach((file) => { - cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file) - }) - - cy.login(user1) - cy.visit('/apps/files') - - files.forEach((file) => { - getRowForFile(file).should('be.visible') - }) - selectAllFiles() - - triggerTagManagementDialogAction() - - cy.findByRole('textbox', { name: 'Search or create tag' }).should('be.visible') - cy.findByRole('textbox', { name: 'Search tag' }).should('not.exist') - - cy.get('[data-cy-systemtags-picker-input]').type('testtag') - - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1) - cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible') - .findByRole('checkbox').should('not.be.checked') - - // Assign the tag - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible') - .findByRole('checkbox').click({ force: true }) - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@getTagData') - cy.wait('@assignTagData') - - expectInlineTagForFile('file1.txt', ['TESTTAG']) - expectInlineTagForFile('file2.txt', ['TESTTAG']) - expectInlineTagForFile('file3.txt', ['TESTTAG']) - expectInlineTagForFile('file4.txt', ['TESTTAG']) - expectInlineTagForFile('file5.txt', ['TESTTAG']) - - cy.get('[data-cy-systemtags-picker]').should('not.exist') - }) - }) -}) - -function resetTags() { - tags = {} - for (let i = 0; i < 5; i++) { - tags[randomBytes(8).toString('base64').slice(0, 6)] = 0 - } - - // delete any existing tags - cy.runOccCommand('tag:list --output=json').then((output) => { - Object.keys(JSON.parse(output.stdout)).forEach((id) => { - cy.runOccCommand(`tag:delete ${id}`) - }) - }) - - // create tags - Object.keys(tags).forEach((tag) => { - cy.runOccCommand(`tag:add ${tag} public --output=json`).then((output) => { - tags[tag] = JSON.parse(output.stdout).id as number - }) - }) - cy.log('Using tags', tags) -} - -function expectInlineTagForFile(file: string, tags: string[]) { - getRowForFile(file) - .find('[data-systemtags-fileid]') - .findAllByRole('listitem') - .should('have.length', tags.length) - .each((tag) => { - expect(tag.text()).to.be.oneOf(tags) - }) -} - -function triggerTagManagementDialogAction() { - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/').as('getTagsList') - triggerSelectionAction('systemtags:bulk') - cy.wait('@getTagsList') - cy.get('[data-cy-systemtags-picker]').should('be.visible') -} diff --git a/cypress/e2e/systemtags/files-inline-action.cy.ts b/cypress/e2e/systemtags/files-inline-action.cy.ts deleted file mode 100644 index a3225671a23b8..0000000000000 --- a/cypress/e2e/systemtags/files-inline-action.cy.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomBytes } from 'crypto' -import { getRowForFile } from '../files/FilesUtils.ts' -import { addTagToFile } from './utils.ts' - -describe('Systemtags: Files integration', { testIsolation: true }, () => { - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - })) - - it('See first assigned tag in the file list', () => { - const tag = randomBytes(8).toString('base64') - addTagToFile('file.txt', tag) - cy.reload() - - getRowForFile('file.txt') - .findByRole('list', { name: /collaborative tags/i }) - .findByRole('listitem') - .should('be.visible') - .and('contain.text', tag) - }) - - it('See two assigned tags are also shown in the file list', () => { - const tag1 = randomBytes(5).toString('base64') - const tag2 = randomBytes(5).toString('base64') - addTagToFile('file.txt', tag1) - addTagToFile('file.txt', tag2) - cy.reload() - - getRowForFile('file.txt') - .findByRole('list', { name: /collaborative tags/i }) - .children() - .should('have.length', 2) - .should('contain.text', tag1) - .should('contain.text', tag2) - }) - - it('See three assigned tags result in overflow entry', () => { - const tag1 = randomBytes(4).toString('base64') - const tag2 = randomBytes(4).toString('base64') - const tag3 = randomBytes(4).toString('base64') - addTagToFile('file.txt', tag1) - addTagToFile('file.txt', tag2) - addTagToFile('file.txt', tag3) - cy.reload() - - getRowForFile('file.txt') - .findByRole('list', { name: /collaborative tags/i }) - .children() - .then(($children) => { - expect($children.length).to.eq(4) - expect($children.get(0)).be.visible - expect($children.get(1)).be.visible - // not visible - just for accessibility - expect($children.get(2)).not.be.visible - expect($children.get(3)).not.be.visible - // Text content - expect($children.get(1)).contain.text('+2') - // Remove the '+x' element - const elements = [$children.get(0), ...$children.get().slice(2)] - .map((el) => el.innerText.trim()) - expect(elements).to.have.members([tag1, tag2, tag3]) - }) - }) -}) diff --git a/cypress/e2e/systemtags/files-sidebar.cy.ts b/cypress/e2e/systemtags/files-sidebar.cy.ts deleted file mode 100644 index fd9fd797e17ac..0000000000000 --- a/cypress/e2e/systemtags/files-sidebar.cy.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomBytes } from 'crypto' -import { getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts' -import { createNewTagInDialog } from './utils.ts' - -describe('Systemtags: Files sidebar integration', { testIsolation: true }, () => { - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - })) - - it('Can assign tags using the sidebar', () => { - const tag = randomBytes(8).toString('base64') - cy.visit('/apps/files') - - getRowForFile('file.txt').should('be.visible') - triggerActionForFile('file.txt', 'details') - - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('button', { name: 'Actions' }) - .should('be.visible') - .click() - - cy.findByRole('menuitem', { name: 'Add tags' }) - .click() - - createNewTagInDialog(tag) - }) -}) diff --git a/cypress/e2e/systemtags/files-view.cy.ts b/cypress/e2e/systemtags/files-view.cy.ts deleted file mode 100644 index 46b724fcde540..0000000000000 --- a/cypress/e2e/systemtags/files-view.cy.ts +++ /dev/null @@ -1,42 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomBytes } from 'crypto' -import { getRowForFile } from '../files/FilesUtils.ts' -import { addTagToFile } from './utils.ts' - -describe('Systemtags: Files view', { testIsolation: true }, () => { - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - })) - - it('See first assigned tag in the file list', () => { - const tag = randomBytes(8).toString('base64') - addTagToFile('folder', tag) - - // open the tags view - cy.visit('/apps/files/tags').then(() => { - // see the tag - getRowForFile('folder').should('not.exist') - getRowForFile('file.txt').should('not.exist') - cy.findByRole('cell', { name: tag }) - .should('be.visible') - .click() - - // see that the tag has its content - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/systemtags/utils.ts b/cypress/e2e/systemtags/utils.ts deleted file mode 100644 index 295a5307c6398..0000000000000 --- a/cypress/e2e/systemtags/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts' - -export function addTagToFile(fileName: string, newTag: string): void { - getRowForFile(fileName).should('be.visible') - triggerActionForFile(fileName, 'systemtags:bulk') - - createNewTagInDialog(newTag) -} - -export function createNewTagInDialog(newTag: string): void { - cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag') - cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData') - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData') - - cy.get('[data-cy-systemtags-picker-input]').type(newTag) - - cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 0) - cy.get('[data-cy-systemtags-picker-button-create]').should('be.visible') - cy.get('[data-cy-systemtags-picker-button-create]').click() - - cy.wait('@createTag') - // Verify the new tag is selected by default - cy.get('[data-cy-systemtags-picker-tag]').contains(newTag) - .parents('[data-cy-systemtags-picker-tag]') - .findByRole('checkbox', { hidden: true }).should('be.checked') - - // Apply changes - cy.get('[data-cy-systemtags-picker-button-submit]').click() - - cy.wait('@assignTagData') - cy.get('[data-cy-systemtags-picker]').should('not.exist') -} diff --git a/cypress/e2e/theming/a11y-color-contrast.cy.ts b/cypress/e2e/theming/a11y-color-contrast.cy.ts deleted file mode 100644 index 53796707b1cad..0000000000000 --- a/cypress/e2e/theming/a11y-color-contrast.cy.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast'] - -const testCases = { - 'Main text': { - foregroundColors: [ - 'color-main-text', - // 'color-text-light', deprecated - // 'color-text-lighter', deprecated - 'color-text-maxcontrast', - ], - backgroundColors: [ - 'color-main-background', - 'color-background-hover', - 'color-background-dark', - // 'color-background-darker', this should only be used for elements not for text - ], - }, - 'blurred background': { - foregroundColors: [ - 'color-main-text', - 'color-text-maxcontrast-blur', - ], - backgroundColors: [ - 'color-main-background-blur', - ], - }, - Primary: { - foregroundColors: [ - 'color-primary-text', - ], - backgroundColors: [ - // 'color-primary-default', this should only be used for elements not for text! - // 'color-primary-hover', this should only be used for elements and not for text! - 'color-primary', - ], - }, - 'Primary light': { - foregroundColors: [ - 'color-primary-light-text', - ], - backgroundColors: [ - 'color-primary-light', - 'color-primary-light-hover', - ], - }, - 'Primary element': { - foregroundColors: [ - 'color-primary-element-text', - 'color-primary-element-text-dark', - ], - backgroundColors: [ - 'color-primary-element', - 'color-primary-element-hover', - ], - }, - 'Primary element light': { - foregroundColors: [ - 'color-primary-element-light-text', - ], - backgroundColors: [ - 'color-primary-element-light', - 'color-primary-element-light-hover', - ], - }, - 'Severity information texts': { - foregroundColors: [ - 'color-error-text', - 'color-warning-text', - 'color-success-text', - 'color-info-text', - ], - backgroundColors: [ - 'color-main-background', - 'color-background-hover', - ], - }, - // only most important severity colors are supported on the blur - 'Severity information on blur': { - foregroundColors: [ - 'color-error-text', - 'color-success-text', - ], - backgroundColors: [ - 'color-main-background-blur', - ], - }, -} - -/** - * Create a wrapper element with color and background set - * - * @param foreground The foreground color (css variable without leading --) - * @param background The background color - */ -function createTestCase(foreground: string, background: string) { - const wrapper = document.createElement('div') - wrapper.style.padding = '14px' - wrapper.style.color = `var(--${foreground})` - wrapper.style.backgroundColor = `var(--${background})` - if (background.includes('blur')) { - wrapper.style.backdropFilter = 'var(--filter-background-blur)' - } - - const testCase = document.createElement('div') - testCase.innerText = `${foreground} ${background}` - testCase.setAttribute('data-cy-testcase', '') - - wrapper.appendChild(testCase) - return wrapper -} - -describe('Accessibility of Nextcloud theming colors', () => { - for (const theme of themesToTest) { - context(`Theme: ${theme}`, () => { - before(() => { - cy.createRandomUser().then(($user) => { - // set user theme - cy.runOccCommand(`user:setting -- '${$user.userId}' theming enabled-themes '[\\"${theme}\\"]'`) - cy.login($user) - cy.visit('/') - cy.injectAxe({ axeCorePath: 'node_modules/axe-core/axe.min.js' }) - }) - }) - - beforeEach(() => { - cy.document().then((doc) => { - // Unset background image and thus use background-color for testing blur background (images do not work with axe-core) - doc.body.style.backgroundImage = 'unset' - - const root = doc.querySelector('#content') - if (root === null) { - throw new Error('No test root found') - } - root.innerHTML = '' - }) - }) - - for (const [name, { backgroundColors, foregroundColors }] of Object.entries(testCases)) { - context(`Accessibility of CSS color variables for ${name}`, () => { - for (const foreground of foregroundColors) { - for (const background of backgroundColors) { - it(`color contrast of ${foreground} on ${background}`, () => { - cy.document().then((doc) => { - const element = createTestCase(foreground, background) - const root = doc.querySelector('#content') - - expect(root).not.to.be.undefined - - root!.appendChild(element) - - cy.checkA11y('[data-cy-testcase]', { - runOnly: ['color-contrast'], - }) - }) - }) - } - } - }) - } - }) - } -}) diff --git a/cypress/e2e/theming/admin-settings_background.cy.ts b/cypress/e2e/theming/admin-settings_background.cy.ts deleted file mode 100644 index e4ec661486fe2..0000000000000 --- a/cypress/e2e/theming/admin-settings_background.cy.ts +++ /dev/null @@ -1,379 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { - defaultBackground, - defaultPrimary, - pickColor, - validateBodyThemingCss, - validateUserThemingDefaultCss, -} from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Remove the default background and restore it', { testIsolation: false }, function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - cy.findByRole('checkbox', { name: /remove background image/i }) - .should('exist') - .should('not.be.checked') - .check({ force: true }) - - cy.wait('@removeBackground') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - cy.waitUntil(() => cy.window().then((win) => { - const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background') - return backgroundPlain !== '' - })) - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss()) - cy.screenshot() - }) -}) - -describe('Remove the default background with a custom background color', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - defaultBackground, - selectedColor, - )) - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - - cy.findByRole('checkbox', { name: /remove background image/i }) - .should('exist') - .should('not.be.checked') - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor)) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss()) - cy.screenshot() - }) -}) - -describe('Remove the default background with a bright color', function() { - const navigationHeader = new NavigationHeader() - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.resetUserTheming(admin) - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.findByRole('checkbox', { name: /remove background image/i }) - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ }), 4) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor)) - }) - - it('See the header being inverted', function() { - cy.waitUntil(() => navigationHeader - .getNavigationEntries() - .find('img') - .then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'invert(1)' - }) - return ret - })) - }) -}) - -describe('Disable user theming and enable it back', function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Disable user background theming', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming') - - cy.findByRole('checkbox', { name: /Disable user theming/ }) - .should('exist') - .and('not.be.checked') - .check({ force: true }) - - cy.wait('@disableUserTheming') - }) - - it('Login as user', function() { - cy.logout() - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('User cannot not change background settings', function() { - cy.visit('/settings/user/theming') - cy.contains('Customization has been disabled by your administrator').should('exist') - }) -}) - -describe('The user default background settings reflect the admin theming settings', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - after(function() { - cy.resetAdminTheming() - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the default background', function() { - cy.intercept('*/apps/theming/ajax/uploadImage').as('setBackground') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - cy.fixture('image.jpg', null).as('background') - cy.get('input[type="file"][name="background"]') - .should('exist') - .selectFile('@background', { force: true }) - - cy.wait('@setBackground') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - null, - )) - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - selectedColor, - )) - }) - - it('Login page should match admin theming settings', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - selectedColor, - )) - }) - - it('Login as user', function() { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .scrollIntoView() - }) - - it('Default user background settings should match admin theming settings', function() { - cy.findByRole('button', { name: 'Default background' }) - .should('exist') - .and('have.attr', 'aria-pressed', 'true') - - cy.window() - .should(() => validateUserThemingDefaultCss( - selectedColor, - '/apps/theming/image/background?v=', - )) - }) -}) - -describe('The user default background settings reflect the admin theming settings with background removed', function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - after(function() { - cy.resetAdminTheming() - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.findByRole('checkbox', { name: /remove background image/i }) - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Login page should match admin theming settings', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - }) - - it('Login as user', function() { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .scrollIntoView() - }) - - it('Default user background settings should match admin theming settings', function() { - cy.findByRole('button', { name: 'Default background' }) - .should('exist') - .and('have.attr', 'aria-pressed', 'true') - - cy.window() - .should(() => validateUserThemingDefaultCss(defaultPrimary, null)) - }) -}) diff --git a/cypress/e2e/theming/admin-settings_branding.cy.ts b/cypress/e2e/theming/admin-settings_branding.cy.ts deleted file mode 100644 index 15bda90b757f4..0000000000000 --- a/cypress/e2e/theming/admin-settings_branding.cy.ts +++ /dev/null @@ -1,221 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' - -const admin = new User('admin', 'admin') - -describe('Admin theming: Setting custom project URLs', function() { - this.beforeEach(() => { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') - }) - - it('Setting the web link', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/path?query#fragment') - }) - - it('Setting the legal notice link', () => { - cy.findByRole('textbox', { name: /legal notice link/i }) - .should('exist') - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .type('http://example.com/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', /legal notice/i) - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/path?query#fragment') - }) - - it('Setting the privacy policy link', () => { - cy.findByRole('textbox', { name: /privacy policy link/i }) - .should('exist') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('have.attr', 'type', 'url') - .type('http://privacy.local/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', /privacy policy/i) - .should('be.visible') - .and('have.attr', 'href', 'http://privacy.local/path?query#fragment') - }) -}) - -describe('Admin theming: Web link corner cases', function() { - this.beforeEach(() => { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') - }) - - it('Already URL encoded', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/%22path%20with%20space%22{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22path%20with%20space%22') - }) - - it('URL with double quotes', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/"path"{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22path%22') - }) - - it('URL with double quotes and already encoded', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/"the%20path"{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22the%20path%22') - }) -}) - -describe('Admin theming: Change the login fields then reset them', function() { - const name = 'ABCdef123' - const url = 'https://example.com' - const slogan = 'Testing is fun' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - // Scope to level 2: the visually-hidden level-1 page heading is also - // named "Theming", and findByRole fails once both are rendered. - cy.findByRole('heading', { name: /^Theming/, level: 2 }) - .should('exist') - .scrollIntoView() - }) - - it('Change the name field', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields') - - // Name - cy.findByRole('textbox', { name: 'Name' }) - .should('be.visible') - .type(`{selectall}${name}{enter}`) - cy.wait('@updateFields') - - // Url - cy.findByRole('textbox', { name: 'Web link' }) - .should('be.visible') - .type(`{selectall}${url}{enter}`) - cy.wait('@updateFields') - - // Slogan - cy.findByRole('textbox', { name: 'Slogan' }) - .should('be.visible') - .type(`{selectall}${slogan}{enter}`) - cy.wait('@updateFields') - }) - - it('Ensure undo button presence', function() { - cy.findAllByRole('button', { name: /undo changes/i }) - .should('have.length', 3) - }) - - it('Validate login screen changes', function() { - cy.logout() - cy.visit('/') - - cy.get('[data-login-form-headline]').should('contain.text', name) - cy.get('footer p a').should('have.text', name) - cy.get('footer p a').should('have.attr', 'href', url) - cy.get('footer p').should('contain.text', `– ${slogan}`) - }) - - it('Undo theming settings', function() { - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.findAllByRole('button', { name: /undo changes/i }) - .each((button) => { - cy.intercept('*/apps/theming/ajax/undoChanges').as('undoField') - cy.wrap(button).click() - cy.wait('@undoField') - }) - cy.logout() - }) - - it('Validate login screen changes again', function() { - cy.visit('/') - - cy.get('[data-login-form-headline]').should('not.contain.text', name) - cy.get('footer p a').should('not.have.text', name) - cy.get('footer p a').should('not.have.attr', 'href', url) - cy.get('footer p').should('not.contain.text', `– ${slogan}`) - }) -}) diff --git a/cypress/e2e/theming/admin-settings_colors.cy.ts b/cypress/e2e/theming/admin-settings_colors.cy.ts deleted file mode 100644 index 6651c3a4714ca..0000000000000 --- a/cypress/e2e/theming/admin-settings_colors.cy.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { - defaultBackground, - defaultPrimary, - pickColor, - validateBodyThemingCss, -} from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Change the primary color and reset it', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the primary color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - - pickColor(cy.findByRole('button', { name: /Primary color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.waitUntil(() => validateBodyThemingCss( - selectedColor, - defaultBackground, - defaultPrimary, - )) - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.waitUntil(() => validateBodyThemingCss( - selectedColor, - defaultBackground, - defaultPrimary, - )) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.waitUntil(validateBodyThemingCss) - cy.screenshot() - }) -}) diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts deleted file mode 100644 index 23260d5ed336d..0000000000000 --- a/cypress/e2e/theming/admin-settings_default-app.cy.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' - -const admin = new User('admin', 'admin') - -/** - * Seed the global default-app config and open the theming settings on it. - * - * Every test establishes the state it needs itself: the tests mutate that - * config, and `it` bodies are re-run alone on a retry, so inheriting the state - * from the preceding test would make a single failure poison all attempts. - * - * @param defaultApps value for the `defaultapp` system config - */ -function visitSettingsWithDefaultApps(defaultApps: string) { - cy.runOccCommand(`config:system:set defaultapp --value '${defaultApps}'`) - cy.visit('/settings/admin/theming') - getDefaultAppSwitch().scrollIntoView() -} - -describe('Admin theming set default apps', () => { - const navigationHeader = new NavigationHeader() - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the current default app is the dashboard', () => { - cy.runOccCommand('config:system:set defaultapp --value \'\'') - - // check default route - cy.visit('/') - cy.url().should('match', /apps\/dashboard/) - - // Also check the top logo link - navigationHeader.logo().click() - cy.url().should('match', /apps\/dashboard/) - }) - - it('See the default app settings', () => { - visitSettingsWithDefaultApps('') - - cy.get('.settings-section').contains('Navigation bar settings').should('exist') - getDefaultAppSwitch().should('exist') - }) - - it('Toggle the "use custom default app" switch', () => { - visitSettingsWithDefaultApps('') - - getDefaultAppSwitch().should('not.be.checked') - cy.findByRole('region', { name: 'Global default app' }) - .should('not.exist') - - getDefaultAppSwitch().check({ force: true }) - getDefaultAppSwitch().should('be.checked') - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - }) - - it('See the default app combobox', () => { - visitSettingsWithDefaultApps('dashboard,files') - - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - .findByRole('combobox') - .scrollIntoView() - - // Assert the selected apps via their deselect buttons: `role="combobox"` - // sits on the search input, which has no child nodes to search for the - // app names in. - cy.findByRole('region', { name: 'Global default app' }) - .findByRole('button', { name: 'Deselect Dashboard' }) - .should('be.visible') - cy.findByRole('region', { name: 'Global default app' }) - .findByRole('button', { name: 'Deselect Files' }) - .should('be.visible') - }) - - it('See the default app order selector', () => { - visitSettingsWithDefaultApps('dashboard,files') - - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - cy.findByRole('list', { name: 'Navigation bar app order' }) - .should('exist') - .findAllByRole('listitem') - .should('have.length', 2) - .then((elements) => { - const appIDs = elements.map((idx, el) => el.innerText.trim()).get() - expect(appIDs).to.deep.eq(['Dashboard', 'Files']) - }) - }) - - it('Change the default app', () => { - visitSettingsWithDefaultApps('dashboard,files') - - cy.findByRole('list', { name: 'Navigation bar app order' }) - .should('exist') - .as('appOrderSelector') - .scrollIntoView() - - cy.get('@appOrderSelector') - .findAllByRole('listitem') - .filter((_, e) => !!e.innerText.match(/Files/i)) - .findByRole('button', { name: 'Move up' }) - .as('moveFilesUpButton') - - cy.get('@moveFilesUpButton').should('be.visible') - cy.get('@moveFilesUpButton').click() - cy.get('@moveFilesUpButton').should('not.exist') - }) - - it('See the default app is changed', () => { - visitSettingsWithDefaultApps('files,dashboard') - - cy.findByRole('list', { name: 'Navigation bar app order' }) - .findAllByRole('listitem') - .then((elements) => { - const appIDs = elements.map((idx, el) => el.innerText.trim()).get() - expect(appIDs).to.deep.eq(['Files', 'Dashboard']) - }) - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('Toggle the "use custom default app" switch back to reset the default apps', () => { - visitSettingsWithDefaultApps('files,dashboard') - - getDefaultAppSwitch().should('be.checked') - cy.intercept('PUT', '**/apps/theming/ajax/updateAppMenu').as('updateAppMenu') - getDefaultAppSwitch().uncheck({ force: true }) - getDefaultAppSwitch().should('be.not.checked') - // The uncheck persists asynchronously - cy.wait('@updateAppMenu') - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/dashboard') - }) - }) -}) - -function getDefaultAppSwitch() { - return cy.findByRole('checkbox', { name: 'Use custom default app' }) -} diff --git a/cypress/e2e/theming/themingUtils.ts b/cypress/e2e/theming/themingUtils.ts deleted file mode 100644 index 59487920e586a..0000000000000 --- a/cypress/e2e/theming/themingUtils.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { colord } from 'colord' - -export const defaultPrimary = '#00679e' -export const defaultBackground = 'jo-myoung-hee-fluid.webp' - -/** - * Check if a CSS variable is set to a specific color - * - * @param variable Variable to check - * @param expectedColor Color that is expected - */ -export function validateCSSVariable(variable: string, expectedColor: string) { - const value = window.getComputedStyle(Cypress.$('body').get(0)).getPropertyValue(variable) - console.debug(`${variable}, is: ${colord(value).toHex()} expected: ${expectedColor}`) - return colord(value).isEqual(expectedColor) -} - -/** - * Validate the current page body css variables - * - * @param expectedColor the expected primary color - * @param expectedBackground the expected background - * @param expectedBackgroundColor the expected background color (null to ignore) - */ -export function validateBodyThemingCss(expectedColor = defaultPrimary, expectedBackground: string | null = defaultBackground, expectedBackgroundColor: string | null = defaultPrimary) { - // We must use `Cypress.$` here as any assertions (get is an assertion) is not allowed in wait-until's check function, see documentation - const guestBackgroundColor = Cypress.$('body').css('background-color') - const guestBackgroundImage = Cypress.$('body').css('background-image') - - const isValidBackgroundColor = expectedBackgroundColor === null || colord(guestBackgroundColor).isEqual(expectedBackgroundColor) - const isValidBackgroundImage = !expectedBackground - ? guestBackgroundImage === 'none' - : guestBackgroundImage.includes(expectedBackground) - - console.debug({ - isValidBackgroundColor, - isValidBackgroundImage, - guestBackgroundColor: colord(guestBackgroundColor).toHex(), - guestBackgroundImage, - }) - - return isValidBackgroundColor && isValidBackgroundImage && validateCSSVariable('--color-primary', expectedColor) -} - -/** - * Check background color of element - * - * @param element JQuery element to check - * @param color expected color - */ -export function expectBackgroundColor(element: JQuery, color: string) { - expect(colord(element.css('background-color')).toHex()).equal(colord(color).toHex()) -} - -/** - * Validate the user theming default select option css - * - * @param expectedColor the expected color - * @param expectedBackground the expected background - */ -export function validateUserThemingDefaultCss(expectedColor = defaultPrimary, expectedBackground: string | null = defaultBackground) { - const backgroundImage = Cypress.$('body').css('background-image') - const backgroundColor = Cypress.$('body').css('background-color') - - const isValidBackgroundImage = !expectedBackground - ? (backgroundImage === 'none' || Cypress.$('body').css('background-image') === 'none') - : backgroundImage.includes(expectedBackground) - - console.debug({ - colorPickerOptionColor: colord(backgroundColor).toHex(), - expectedColor, - isValidBackgroundImage, - backgroundImage, - }) - - return isValidBackgroundImage && colord(backgroundColor).isEqual(expectedColor) -} - -/** - * @param trigger - The color picker trigger - * @param index - The color index to pick, if not provided a random one will be picked - */ -export function pickColor(trigger: Cypress.Chainable, index?: number): Cypress.Chainable { - // Pick one of the first 8 options - const randColour = index ?? Math.floor(Math.random() * 8) - - let oldColor = '' - trigger.as('trigger').then(($el) => { - oldColor = $el.css('background-color') - }) - - cy.get('@trigger').scrollIntoView() - cy.get('@trigger').click({ force: true }) - - // Click on random color - cy.get('.color-picker__simple-color-circle').eq(randColour).click() - - // Wait for color change - cy.get('@trigger') - .should(($el) => $el.css('background-color') !== oldColor) - - cy.findByRole('button', { name: /Choose/i }).click() - - // Get the selected color from the color preview block - return cy.get('@trigger').then(($el) => $el.css('background-color')) -} diff --git a/cypress/e2e/theming/user-settings_app-order.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts deleted file mode 100644 index 3db1a17ac4ee2..0000000000000 --- a/cypress/e2e/theming/user-settings_app-order.cy.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { SettingsAppOrderList } from '../../pages/SettingsAppOrderList.ts' -import { installTestApp, uninstallTestApp } from '../../support/commonUtils.ts' - -before(() => uninstallTestApp()) - -describe('User theming set app order', () => { - const navigationHeader = new NavigationHeader() - const appOrderList = new SettingsAppOrderList() - let user: User - - before(() => { - cy.resetAdminTheming() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => cy.deleteUser(user)) - - it('See the app order settings', () => { - visitAppOrderSettings() - }) - - it('See that the dashboard app is the first one', () => { - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order - navigationHeader.getNavigationEntries() - .each((entry, index) => expect(entry).contain.text(appOrder[index]!)) - }) - - it('Change the app order', () => { - appOrderList.interceptAppOrder() - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.getUpButtonForApp('Files') - .should('be.visible') - .click() - appOrderList.waitForAppOrderUpdate() - - appOrderList.assertAppOrder(['Files', 'Dashboard']) - }) - - it('See the app menu order is changed', () => { - cy.reload() - const appOrder = ['Files', 'Dashboard'] - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order - navigationHeader.getNavigationEntries() - .each((entry, index) => expect(entry).contain.text(appOrder[index]!)) - }) -}) - -describe('User theming set app order with default app', () => { - const appOrderList = new SettingsAppOrderList() - const navigationHeader = new NavigationHeader() - let user: User - - before(() => { - cy.resetAdminTheming() - // install a third app - installTestApp() - // set files as default app - cy.runOccCommand('config:system:set --value \'files\' defaultapp') - - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => { - cy.deleteUser(user) - uninstallTestApp() - }) - - it('See files is the default app', () => { - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('See the app order settings: files is the first one', () => { - visitAppOrderSettings() - - const appOrder = ['Files', 'Dashboard', 'Test App 2', 'Test App'] - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.assertAppOrder(appOrder) - }) - - it('Can not change the default app', () => { - appOrderList.getUpButtonForApp('Files').should('not.exist') - appOrderList.getDownButtonForApp('Files').should('not.exist') - appOrderList.getUpButtonForApp('Dashboard').should('not.exist') - // but can move down - appOrderList.getDownButtonForApp('Dashboard').should('be.visible') - }) - - it('Can see the correct buttons for other apps', () => { - appOrderList.getUpButtonForApp('Test App 2').should('be.visible') - appOrderList.getDownButtonForApp('Test App 2').should('be.visible') - appOrderList.getUpButtonForApp('Test App').should('be.visible') - appOrderList.getDownButtonForApp('Test App').should('not.exist') - }) - - it('Change the order of the other apps', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Test App').click() - appOrderList.waitForAppOrderUpdate() - appOrderList.getUpButtonForApp('Test App').click() - appOrderList.waitForAppOrderUpdate() - - // Can't get up anymore, files is enforced as default app - appOrderList.getUpButtonForApp('Test App').should('not.exist') - - // Check the app order settings UI - appOrderList.assertAppOrder(['Files', 'Test App', 'Dashboard', 'Test App 2']) - }) - - it('See the app menu order is changed', () => { - cy.reload() - - const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2'] - // Check the top app menu order - navigationHeader.getNavigationEntries() - .each((entry, index) => expect(entry).contain.text(appOrder[index]!)) - }) -}) - -describe('User theming app order list accessibility', () => { - const appOrderList = new SettingsAppOrderList() - let user: User - - before(() => { - cy.resetAdminTheming() - installTestApp() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => { - uninstallTestApp() - cy.deleteUser(user) - }) - - it('click the first button', () => { - visitAppOrderSettings() - appOrderList.interceptAppOrder() - appOrderList.getDownButtonForApp('Dashboard') - .should('be.visible') - .scrollIntoView() - appOrderList.getDownButtonForApp('Dashboard') - .focus() - appOrderList.getDownButtonForApp('Dashboard') - .click() - appOrderList.waitForAppOrderUpdate() - }) - - it('see the same app kept the focus', () => { - appOrderList.getDownButtonForApp('Dashboard').should('have.focus') - }) - - it('click the last button', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Dashboard') - .should('be.visible') - .focus() - appOrderList.getUpButtonForApp('Dashboard').click() - appOrderList.waitForAppOrderUpdate() - }) - - it('see the same app kept the focus', () => { - appOrderList.getUpButtonForApp('Dashboard').should('not.exist') - appOrderList.getDownButtonForApp('Dashboard').should('have.focus') - }) -}) - -describe('User theming reset app order', () => { - const appOrderList = new SettingsAppOrderList() - const navigationHeader = new NavigationHeader() - let user: User - - before(() => { - cy.resetAdminTheming() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => cy.deleteUser(user)) - - it('See that the dashboard app is the first one', () => { - visitAppOrderSettings() - - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order - navigationHeader.getNavigationEntries() - .each((entry, index) => expect(entry).contain.text(appOrder[index]!)) - }) - - it('See the reset button is disabled', () => { - appOrderList.getResetButton() - .scrollIntoView() - appOrderList.getResetButton() - .should('be.disabled') - }) - - it('Change the app order', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Files') - .should('be.visible') - .click() - appOrderList.waitForAppOrderUpdate() - - appOrderList.assertAppOrder(['Files', 'Dashboard']) - }) - - it('See the reset button is no longer disabled', () => { - appOrderList.getResetButton() - .scrollIntoView() - appOrderList.getResetButton() - .should('be.visible') - .and('be.enabled') - }) - - it('Reset the app order', () => { - cy.intercept('GET', '/ocs/v2.php/core/navigation/apps').as('loadApps') - appOrderList.interceptAppOrder() - appOrderList.getResetButton().click({ force: true }) - - cy.wait('@updateAppOrder') - .its('request.body') - .should('have.property', 'configValue', '[]') - cy.wait('@loadApps') - }) - - it('See the app order is restored', () => { - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - // Check the top app menu order - navigationHeader.getNavigationEntries() - .each((entry, index) => expect(entry).contain.text(appOrder[index]!)) - }) - - it('See the reset button is disabled again', () => { - appOrderList.getResetButton() - .should('be.disabled') - }) -}) - -function visitAppOrderSettings() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Navigation bar settings/ }) - .should('exist') - .scrollIntoView() -} diff --git a/cypress/e2e/theming/user-settings_background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts deleted file mode 100644 index 639de55f571f6..0000000000000 --- a/cypress/e2e/theming/user-settings_background.cy.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { defaultPrimary, pickColor, validateBodyThemingCss } from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('User default background settings', function() { - before(function() { - cy.resetAdminTheming() - cy.resetUserTheming(admin) - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Appearance and accessibility settings/ }) - .should('be.visible') - }) - - it('Default is selected on new users', function() { - cy.findByRole('button', { name: 'Default background', pressed: true }) - .should('exist') - .scrollIntoView() - }) -}) - -describe('User select shipped backgrounds and remove background', function() { - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a shipped background', function() { - const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' - const backgroundName = 'Background picture of a red-ish butterfly wing under microscope' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - // Select background - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) - }) - - it('Select a bright shipped background', function() { - const background = 'bernie-cetonia-aurata-take-off-composition.jpg' - const backgroundName = 'Montage of a cetonia aurata bug that takes off with white background' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3')) - }) -}) - -describe('User select a custom color', function() { - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom color', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 7) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#3794ac')) - }) -}) - -describe('User select a bright custom color and remove background', function() { - const navigationHeader = new NavigationHeader() - - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Remove background', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 4) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#ddcb55')) - }) - - it('See the header being inverted', function() { - cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'invert(1)' - }) - return ret - })) - }) - - it('Select another but non-bright shipped background', function() { - const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' - const backgroundName = 'Background picture of a red-ish butterfly wing under microscope' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - // Select background - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) - }) - - it('See the header NOT being inverted this time', function() { - cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'none' - }) - return ret - })) - }) -}) - -describe('User select a custom background', function() { - const image = 'image.jpg' - before(function() { - cy.createRandomUser().then((user: User) => { - cy.uploadFile(user, image, 'image/jpeg') - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom background', function() { - cy.intercept('*/apps/theming/background/custom').as('setBackground') - - // Pick background - cy.findByRole('button', { name: 'Custom background' }).click() - cy.findByRole('dialog') - .should('be.visible') - .findAllByRole('row') - .contains(image) - .click() - cy.findByRole('button', { name: 'Select background' }).click() - - // Wait for background to be set - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) - }) -}) - -describe('User changes settings and reload the page', function() { - const image = 'image.jpg' - - before(function() { - cy.createRandomUser().then((user: User) => { - cy.uploadFile(user, image, 'image/jpeg') - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom background', function() { - cy.intercept('*/apps/theming/background/custom').as('setBackground') - - // Pick background - cy.findByRole('button', { name: 'Custom background' }).click() - cy.findByRole('dialog') - .should('be.visible') - .findAllByRole('row') - .contains(image) - .click() - cy.findByRole('button', { name: 'Select background' }).click() - - // Wait for background to be set - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) - }) - - it('Select a custom background color', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 5) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872')) - }) - - it('Select a custom primary color', function() { - cy.intercept('/ocs/v2.php/apps/provisioning_api/api/v1/config/users/theming/primary_color').as('setPrimaryColor') - - pickColor(cy.findByRole('button', { name: 'Primary color' }), 2) - - cy.wait('@setPrimaryColor') - cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) - }) - - it('Reload the page and validate persistent changes', function() { - cy.reload() - cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) - }) -}) diff --git a/cypress/fixtures/appstore/apps.json b/cypress/fixtures/appstore/apps.json deleted file mode 100644 index db23c9a74eb38..0000000000000 --- a/cypress/fixtures/appstore/apps.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "apps": [ - { - "id": "calendar", - "name": "Calendar", - "isCompatible": true, - "canInstall": true - }, - { - "id": "contacts", - "name": "Contacts", - "isCompatible": true, - "canInstall": true - }, - { - "id": "mail", - "name": "Mail", - "isCompatible": true, - "canInstall": true - }, - { - "id": "spreed", - "name": "Talk", - "isCompatible": true, - "canInstall": true - }, - { - "id": "richdocuments", - "name": "Richdocuments", - "isCompatible": true, - "canInstall": true - }, - { - "id": "notes", - "name": "Notes", - "isCompatible": true, - "canInstall": true - }, - { - "id": "richdocumentscode", - "name": "Richdocuments Code", - "isCompatible": true, - "canInstall": true - } - ] -} \ No newline at end of file diff --git a/cypress/fixtures/testapp/appinfo/info.xml b/cypress/fixtures/testapp/appinfo/info.xml deleted file mode 100644 index a0deada5329c2..0000000000000 --- a/cypress/fixtures/testapp/appinfo/info.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - testapp - Test App - Test App - - 0.0.1 - agpl - Ferdinand Thiessen - TestApp - games - https://github.com/nextcloud/server/issues - - - - - - Test App - testapp.page.index - - - Test App 2 - testapp.page.index - - - diff --git a/cypress/fixtures/testapp/appinfo/routes.php b/cypress/fixtures/testapp/appinfo/routes.php deleted file mode 100644 index b5471c5a0b260..0000000000000 --- a/cypress/fixtures/testapp/appinfo/routes.php +++ /dev/null @@ -1,12 +0,0 @@ - [ - ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], - ] -]; diff --git a/cypress/fixtures/testapp/img/app.svg b/cypress/fixtures/testapp/img/app.svg deleted file mode 100644 index 42b64b58d325a..0000000000000 --- a/cypress/fixtures/testapp/img/app.svg +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - image/svg+xml - - - - - - - - diff --git a/cypress/fixtures/testapp/lib/AppInfo/Application.php b/cypress/fixtures/testapp/lib/AppInfo/Application.php deleted file mode 100644 index 8ca8f3ef52733..0000000000000 --- a/cypress/fixtures/testapp/lib/AppInfo/Application.php +++ /dev/null @@ -1,18 +0,0 @@ - -
diff --git a/cypress/pages/FilesFilters.ts b/cypress/pages/FilesFilters.ts deleted file mode 100644 index 9a10f39a5dc1e..0000000000000 --- a/cypress/pages/FilesFilters.ts +++ /dev/null @@ -1,86 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Page object model for the files filters - */ -export class FilesFilterPage { - /** - * Get the filters menu button (only on narrow and medium widths) - */ - getFiltersMenuToggle() { - return cy.get('[data-test-id="files-list-filters"]') - .findByRole('button', { name: 'Filters' }) - } - - /** - * Get and trigger the filter within the menu (only on narrow and medium widths) - * - * @param name - The name of the filter button - */ - triggerFilterMenu(name: string | RegExp) { - cy.get('[data-test-id="files-list-filters"]') - .findByRole('button', { name: 'Filters' }) - .should('be.visible') - .as('filtersMenuToggle') - .click() - - cy.get('@filtersMenuToggle') - .should('have.attr', 'aria-expanded', 'true') - - cy.findByRole('menu') - .should('be.visible') - .findByRole('menuitem', { name }) - .should('be.visible') - .click() - } - - /** - * Get and trigger the filter button if the files list is wide enough to show all filters - * - * @param name - The name of the filter button - */ - triggerFilterButton(name: string | RegExp) { - cy.get('[data-test-id="files-list-filters"]') - .findByRole('button', { name }) - .should('be.visible') - .click() - } - - triggerFilter(name: string | RegExp) { - cy.get('[data-cy-files-list]') - .should('be.visible') - .if(($el) => expect($el.get(0).clientWidth).to.be.gte(1024)) - .then(() => this.triggerFilterButton(name)) - .else() - .then(() => this.triggerFilterMenu(name)) - } - - closeFilterMenu() { - cy.get('[data-test-id="files-list-filters"]') - .findAllByRole('button') - .filter('[aria-expanded="true"]') - .click({ multiple: true }) - } - - activeFiltersList() { - return cy.findByRole('list', { name: 'Active filters' }) - } - - activeFilters() { - return this.activeFiltersList().findAllByRole('listitem') - } - - removeFilter(name: string | RegExp) { - const el = typeof name === 'string' - ? this.activeFilters().should('contain.text', name) - : this.activeFilters().should('match', name) - el.should('exist') - // click the button - el.findByRole('button', { name: 'Remove filter' }) - .should('exist') - .click({ force: true }) - } -} diff --git a/cypress/pages/FilesNavigation.ts b/cypress/pages/FilesNavigation.ts deleted file mode 100644 index b9a48d9f1333b..0000000000000 --- a/cypress/pages/FilesNavigation.ts +++ /dev/null @@ -1,44 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Page object model for the files app navigation - */ -export class FilesNavigationPage { - navigation() { - return cy.findByRole('navigation', { name: 'Files' }) - } - - searchInput() { - return this.navigation().findByRole('searchbox') - } - - searchScopeTrigger() { - return this.navigation().findByRole('button', { name: /search scope options/i }) - } - - /** - * Only available after clicking on the search scope trigger - */ - searchScopeMenu() { - return cy.findByRole('menu', { name: /search scope options/i }) - } - - searchClearButton() { - return this.navigation().findByRole('button', { name: /clear search/i }) - } - - settingsToggle() { - return this.navigation().findByRole('link', { name: 'Files settings' }) - } - - views() { - return this.navigation().findByRole('list', { name: 'Views' }) - } - - quota() { - return this.navigation().find('[data-cy-files-navigation-settings-quota]') - } -} diff --git a/cypress/pages/NavigationHeader.ts b/cypress/pages/NavigationHeader.ts deleted file mode 100644 index 330dc58eefc1c..0000000000000 --- a/cypress/pages/NavigationHeader.ts +++ /dev/null @@ -1,57 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Page object model for the Nextcloud navigation header - */ -export class NavigationHeader { - /** - * Locator of the header bar wrapper - */ - header() { - return cy.get('header#header') - } - - /** - * Locator for the logo navigation entry (entry redirects to default app) - */ - logo() { - return this.header() - .find('#nextcloud') - } - - /** - * Locator of the app navigation bar - */ - navigation() { - return this.header() - .findByRole('navigation', { name: 'Applications menu' }) - } - - /** - * The toggle for the navigation overflow menu - */ - overflowNavigationToggle() { - return this.navigation() - } - - /** - * Get all navigation entries - */ - getNavigationEntries() { - return this.navigation() - .findAllByRole('listitem') - } - - /** - * Get the navigation entry for a given app - * - * @param name The app name - */ - getNavigationEntry(name: string) { - return this.navigation() - .findByRole('listitem', { name }) - } -} diff --git a/cypress/pages/SettingsAppOrderList.ts b/cypress/pages/SettingsAppOrderList.ts deleted file mode 100644 index f95c3159b2330..0000000000000 --- a/cypress/pages/SettingsAppOrderList.ts +++ /dev/null @@ -1,43 +0,0 @@ -/*! - * SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -export class SettingsAppOrderList { - getAppOrderList() { - return cy.findByRole('list', { name: 'Navigation bar app order' }) - } - - assertAppOrder(expectedAppOrder: string[]) { - this.getAppOrderList() - .findAllByRole('listitem') - .should('have.length', expectedAppOrder.length) - .each((element, index) => expect(element).to.contain.text(expectedAppOrder[index]!)) - } - - getAppEntryByName(appName: string) { - return this.getAppOrderList() - .findAllByRole('listitem') - .filter((_, el) => el.textContent.trim() === appName) - } - - getUpButtonForApp(appName: string) { - return this.getAppEntryByName(appName).findByRole('button', { name: 'Move up', hidden: true }) - } - - getDownButtonForApp(appName: string) { - return this.getAppEntryByName(appName).findByRole('button', { name: 'Move down', hidden: true }) - } - - getResetButton() { - return cy.findByRole('button', { name: 'Reset default app order', hidden: true }) - } - - interceptAppOrder() { - cy.intercept('POST', '/ocs/v2.php/apps/provisioning_api/api/v1/config/users/core/apporder').as('updateAppOrder') - } - - waitForAppOrderUpdate() { - cy.wait('@updateAppOrder') - } -} diff --git a/cypress/pages/UnifiedSearch.ts b/cypress/pages/UnifiedSearch.ts deleted file mode 100644 index 5b35c0ef6c8eb..0000000000000 --- a/cypress/pages/UnifiedSearch.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Page object model for the UnifiedSearch - */ -export class UnifiedSearchPage { - toggleButton() { - return cy.findByRole('button', { name: 'Unified search' }) - } - - globalSearchButton() { - return cy.findByRole('button', { name: 'Search everywhere' }) - } - - localSearchInput() { - return cy.findByRole('textbox', { name: 'Search in current app' }) - } - - globalSearchInput() { - return cy.findByRole('textbox', { name: /Search apps, files/ }) - } - - globalSearchModal() { - // TODO: Broken in library - // return cy.findByRole('dialog', { name: 'Unified search' }) - return cy.get('#unified-search') - } - - // functions - - openLocalSearch() { - this.toggleButton() - .if('visible') - .click() - - this.localSearchInput().should('exist').and('not.have.css', 'display', 'none') - } - - /** - * Type in the local search (must be open before) - * Helper because the input field is overlayed by the global-search button -> cypress thinks the input is not visible - * - * @param text The text to type - * @param options Options as for `cy.type()` - */ - typeLocalSearch(text: string, options?: Partial>) { - return this.localSearchInput() - .type(text, { ...options, force: true }) - } - - openGlobalSearch() { - this.toggleButton() - .if('visible').click() - - this.globalSearchButton() - .if('visible').click() - } - - closeGlobalSearch() { - this.globalSearchModal() - .findByRole('button', { name: 'Close' }) - .click() - } - - getResults(category: string | RegExp) { - return this.globalSearchModal() - .findByRole('list', { name: category }) - .findAllByRole('listitem') - } -} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index adcaafce345e1..0000000000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { addCommands, User } from '@nextcloud/e2e-test-server/cypress' -import { basename } from '@nextcloud/paths' -import axios from 'axios' - -// Add custom commands -import '@testing-library/cypress/add-commands' -import 'cypress-if' -import 'cypress-wait-until' -addCommands() - -const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '') -Cypress.env('baseUrl', url) - -/** - * Login like `@nextcloud/e2e-test-server` does, but actually verify success. - * TODO: upstream to `@nextcloud/e2e-test-server` - * - * The packaged command never checks the POST /login response and validates - * cached sessions by requesting /apps/files *following redirects* — a - * logged-out session redirects to the login page and still yields 200, so a - * failed login (e.g. the csrf race on a slow server) passes silently and - * detonates much later in unrelated assertions. - * - * @param user the user to log in - */ -Cypress.Commands.overwrite('login', (_originalFn, user: User) => { - cy.session(user, () => { - cy.request('/csrftoken').then(({ body }) => { - cy.request({ - method: 'POST', - url: '/login', - body: { - user: user.userId, - password: user.password, - requesttoken: body.token, - }, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - // The login POST is rejected without a matching Origin header - Origin: (Cypress.config('baseUrl') ?? '').replace('index.php/', ''), - }, - followRedirect: false, - }) - }) - }, { - validate() { - // Do not follow redirects: a logged-out session would redirect to - // the login page and still return 200. - cy.request({ url: '/apps/files', followRedirect: false }) - .its('status') - .should('eq', 200) - }, - }) -}) - -/** - * Enable or disable a user - * TODO: standardize in `@nextcloud/e2e-test-server` - * - * @param {User} user the user to dis- / enable - * @param {boolean} enable True if the user should be enable, false to disable - */ -Cypress.Commands.add('enableUser', (user: User, enable = true) => { - const url = `${Cypress.config('baseUrl')}/ocs/v2.php/cloud/users/${user.userId}/${enable ? 'enable' : 'disable'}`.replace('index.php/', '') - return cy.request({ - method: 'PUT', - url, - form: true, - auth: { - user: 'admin', - password: 'admin', - }, - headers: { - 'OCS-ApiRequest': 'true', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }).then((response) => { - cy.log(`Enabled user ${user}`, response.status) - return cy.wrap(response) - }) -}) - -/** - * cy.uploadedFile - uploads a file from the fixtures folder - * TODO: standardize in `@nextcloud/e2e-test-server` - * - * @param {User} user the owner of the file, e.g. admin - * @param {string} fixture the fixture file name, e.g. image1.jpg - * @param {string} mimeType e.g. image/png - * @param {string} [target] the target of the file relative to the user root - */ -Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'image/jpeg', target = `/${fixture}`) => { - // get fixture - return cy.fixture(fixture, 'base64') - .then((file) => ( - // convert the base64 string to a blob - Cypress.Blob.base64StringToBlob(file, mimeType) - )) - .then((blob) => cy.uploadContent(user, blob, mimeType, target)) -}) - -Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite = true) => { - cy.clearAllCookies() - .then(async () => { - try { - const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` - const filePath = target.split('/').map(encodeURIComponent).join('/') - const response = await axios({ - url: `${rootPath}${filePath}`, - method: 'PROPPATCH', - auth: { - username: user.userId, - password: user.password, - }, - headers: { - 'Content-Type': 'application/xml', - }, - data: ` - - - - ${favorite ? 1 : 0} - - - `, - }) - cy.log(`Created directory ${target}`, response) - } catch (cause) { - cy.log('error', cause) - throw new Error('Unable to process fixture', { cause }) - } - }) -}) - -Cypress.Commands.add('mkdir', (user: User, target: string) => { - return cy.clearCookies() - .then(async () => { - try { - const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` - const filePath = target.split('/').map(encodeURIComponent).join('/') - const response = await axios({ - url: `${rootPath}${filePath}`, - method: 'MKCOL', - auth: { - username: user.userId, - password: user.password, - }, - // MKCOL answers 405 when the collection already exists. A - // retry re-runs the test body but not the data it created, - // so every attempt after the first would fail on set-up. - validateStatus: (status) => (status >= 200 && status < 300) || status === 405, - }) - cy.log(`Created directory ${target}`, response) - return response - } catch (cause) { - cy.log('error', cause) - const status = axios.isAxiosError(cause) ? cause.response?.status : undefined - throw new Error(`Unable to create directory ${target}${status ? ` (status ${status})` : ''}`, { cause }) - } - }) -}) - -Cypress.Commands.add('rm', (user: User, target: string) => { - cy.clearCookies() - .then(async () => { - try { - const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` - const filePath = target.split('/').map(encodeURIComponent).join('/') - const response = await axios({ - url: `${rootPath}${filePath}`, - method: 'DELETE', - auth: { - username: user.userId, - password: user.password, - }, - }) - cy.log(`delete file or directory ${target}`, response) - } catch (cause) { - cy.log('error', cause) - throw new Error('Unable to delete file or directory', { cause }) - } - }) -}) - -/** - * cy.uploadedContent - uploads a raw content - * TODO: standardize in `@nextcloud/e2e-test-server` - * - * @param {User} user the owner of the file, e.g. admin - * @param {Blob} blob the content to upload - * @param {string} mimeType e.g. image/png - * @param {string} target the target of the file relative to the user root - */ -Cypress.Commands.add('uploadContent', (user: User, blob: Blob, mimeType: string, target: string, mtime?: number) => { - cy.clearCookies() - return cy.then(async () => { - const fileName = basename(target) - - // Process paths - const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` - const filePath = target.split('/').map(encodeURIComponent).join('/') - try { - const file = new File([blob], fileName, { type: mimeType }) - const response = await axios({ - url: `${rootPath}${filePath}`, - method: 'PUT', - data: file, - headers: { - 'Content-Type': mimeType, - 'X-OC-MTime': mtime ? `${mtime}` : undefined, - }, - auth: { - username: user.userId, - password: user.password, - }, - }) - cy.log(`Uploaded content as ${fileName}`, response) - return response - } catch (cause) { - cy.log('error', cause) - throw new Error('Unable to process fixture', { cause }) - } - }) -}) - -Cypress.Commands.add('createShare', (sharer: User, path: string, shareType: number, shareWith: string) => { - return cy.clearCookies() - .then(async () => { - try { - const url = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/files_sharing/api/v1/shares` - const response = await axios({ - url, - method: 'POST', - auth: { - username: sharer.userId, - password: sharer.password, - }, - headers: { - 'OCS-ApiRequest': 'true', - }, - data: { - path, - shareType, - shareWith, - }, - }) - cy.log(`Created share for ${path} of type ${shareType} with ${shareWith}`, response) - return response - } catch (cause) { - cy.log('error', cause) - throw new Error(`Unable to create share for ${path} of type ${shareType} with ${shareWith}`, { cause }) - } - }) -}) - -/** - * Reset the admin theming entirely - */ -Cypress.Commands.add('resetAdminTheming', () => { - const admin = new User('admin', 'admin') - - cy.clearCookies() - cy.login(admin) - - // Clear all settings - cy.request('/csrftoken').then(({ body }) => { - const requestToken = body.token - - axios({ - method: 'POST', - url: '/index.php/apps/theming/ajax/undoAllChanges', - headers: { - requesttoken: requestToken, - }, - }) - }) - - // Clear admin session - cy.clearCookies() -}) - -/** - * Reset the current or provided user theming settings - * It does not reset the theme config as it is enforced in the - * server config for cypress testing. - */ -Cypress.Commands.add('resetUserTheming', (user?: User) => { - if (user) { - cy.clearCookies() - cy.login(user) - } - - // Reset background config - cy.request('/csrftoken').then(({ body }) => { - const requestToken = body.token - - cy.request({ - method: 'POST', - url: '/apps/theming/background/default', - headers: { - requesttoken: requestToken, - }, - }) - }) - - if (user) { - // Clear current session - cy.clearCookies() - } -}) - -Cypress.Commands.add('userFileExists', (user: string, path: string) => { - user.replaceAll('"', '\\"') - path.replaceAll('"', '\\"').replaceAll(/^\/+/gm, '') - return cy.runCommand(`stat --printf="%s" "data/${user}/files/${path}"`, { failOnNonZeroExit: true }) - .then((exec) => Number.parseInt(exec.stdout || '0')) -}) - -Cypress.Commands.add('runOccCommand', (command: string, options?: Partial) => { - return cy.runCommand(`php ./occ ${command}`, options) - .then((context) => { - // OCC cannot clear the APCu cache - return cy.wait(command.startsWith('app:') || command.startsWith('config:') - ? 3000 // clear APCu cache - : 0).then(() => context) - }) -}) diff --git a/cypress/support/commonUtils.ts b/cypress/support/commonUtils.ts deleted file mode 100644 index 306accc23d689..0000000000000 --- a/cypress/support/commonUtils.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Get the header navigation bar - */ -export function getNextcloudHeader() { - return cy.get('#header') -} - -/** - * Get user menu in the header navigation bar - */ -export function getNextcloudUserMenu() { - return getNextcloudHeader().find('#user-menu') -} - -/** - * Get the user menu toggle in the header navigation bar - */ -export function getNextcloudUserMenuToggle() { - return getNextcloudUserMenu().find('.header-menu__trigger').should('have.length', 1) -} - -/** - * Helper function ensure users and groups in this tests have a clean state - * Deletes all users (except admin) and groups - */ -export function clearState() { - // cleanup ignoring any failures - cy.runOccCommand('group:list --output=json').then(($result) => { - const groups = Object.keys(JSON.parse($result.stdout)).filter((name) => name !== 'admin') - groups.forEach((groupID) => cy.runOccCommand(`group:delete '${groupID}'`)) - }) - - cy.runOccCommand('user:list --output=json').then(($result) => { - const users = Object.keys(JSON.parse($result.stdout)).filter((name) => name !== 'admin') - users.forEach((userID) => cy.runOccCommand(`user:delete '${userID}'`)) - }) -} - -/** - * Install the test app - */ -export function installTestApp() { - const testAppPath = 'cypress/fixtures/testapp' - cy.runOccCommand('-V').then((output) => { - // @ts-expect-error we added this property in cypress.config.ts - const containerName = Cypress.config('dockerContainerName') - const version = output.stdout.match(/(\d\d+)\.\d+\.\d+/)?.[1] - cy.wrap(version).should('not.be.undefined') - - // @nextcloud/e2e-test-server (0.5.0+) writes config/apps.config.php, - // overriding any custom apps_paths (config/*.config.php files merge - // alphabetically, later file wins) — occ only sees the writable apps - // folder, which 0.5.1 renamed from apps_writable to apps-writable. - cy.runCommand('test -d apps-writable && echo -n apps-writable || echo -n apps_writable').then(({ stdout }) => { - const appsFolder = stdout.trim() - // Fail here rather than with an appstore error further down if the - // package ever stops providing a writable apps folder altogether. - cy.runCommand(`test -d ${appsFolder}`) - cy.exec(`docker cp '${testAppPath}' ${containerName}:/var/www/html/${appsFolder}`, { log: true }) - cy.exec(`docker exec --workdir /var/www/html ${containerName} chown -R www-data:www-data /var/www/html/${appsFolder}/testapp`) - cy.runCommand(`sed -i -e 's|-version=\\"[0-9]\\+|-version=\\"${version}|g' ${appsFolder}/testapp/appinfo/info.xml`) - cy.runOccCommand('app:enable --force testapp') - }) - }) -} - -/** - * Remove the test app - */ -export function uninstallTestApp() { - cy.runOccCommand('app:remove testapp', { failOnNonZeroExit: false }) - cy.runCommand('rm -fr apps-writable/testapp apps_writable/testapp') -} diff --git a/cypress/support/cypress-e2e.d.ts b/cypress/support/cypress-e2e.d.ts deleted file mode 100644 index 1f68f6b7a0fea..0000000000000 --- a/cypress/support/cypress-e2e.d.ts +++ /dev/null @@ -1,62 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { AxiosResponse } from 'axios' - -declare global { - - namespace Cypress { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface Chainable { - /** - * Enable or disable a given user - */ - enableUser(user: User, enable?: boolean): Cypress.Chainable> - - /** - * Upload a file from the fixtures folder to a given user storage. - * **Warning**: Using this function will reset the previous session - */ - uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable - - /** - * Upload a raw content to a given user storage. - * **Warning**: Using this function will reset the previous session - */ - uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable - - /** - * Delete a file or directory - */ - rm(user: User, target: string): Cypress.Chainable - - /** - * Create a new directory - * **Warning**: Using this function will reset the previous session - */ - mkdir(user: User, target: string): Cypress.Chainable - - /** - * Set a file as favorite (or remove from favorite) - */ - setFileAsFavorite(user: User, target: string, favorite?: boolean): Cypress.Chainable - - /** - * Reset the admin theming entirely. - * **Warning**: Using this function will reset the previous session - */ - resetAdminTheming(): Cypress.Chainable - - /** - * Reset the user theming settings. - * If provided, will clear session and login as the given user. - * **Warning**: Providing a user will reset the previous session. - */ - resetUserTheming(user?: User): Cypress.Chainable - - userFileExists(user: string, path: string): Cypress.Chainable - } - } -} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index 29845edc4f695..0000000000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import 'cypress-axe' -import './commands.ts' -// Remove with Node 22 -// Ensure that we can use `Promise.withResolvers` - works in browser but on Node we need Node 22+ -import 'core-js/actual/promise/with-resolvers.js' - -// Fix ResizeObserver loop limit exceeded happening in Cypress only -// @see https://github.com/cypress-io/cypress/issues/20341 -Cypress.on('uncaught:exception', (err) => !err.message.includes('ResizeObserver loop limit exceeded')) -Cypress.on('uncaught:exception', (err) => !err.message.includes('ResizeObserver loop completed with undelivered notifications')) diff --git a/cypress/support/utils/assertions.ts b/cypress/support/utils/assertions.ts deleted file mode 100644 index 76454b8113e07..0000000000000 --- a/cypress/support/utils/assertions.ts +++ /dev/null @@ -1,44 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { ZipReader } from '@zip.js/zip.js' - -/** - * Assert that a file contains a list of expected files - * - * @param expectedFiles List of expected filenames - * @example - * ```js - * cy.readFile('file', null, { ... }) - * .should(zipFileContains(['file.txt'])) - * ``` - */ -export function zipFileContains(expectedFiles: string[]) { - return async (buffer: Buffer) => { - const blob = new Blob([buffer]) - const zip = new ZipReader(blob.stream()) - // check the real file names - const entries = (await zip.getEntries()).map((e) => e.filename).sort() - console.info('Zip contains entries:', entries) - expect(entries).to.deep.equal(expectedFiles.sort()) - } -} - -/** - * Check validity of an input element - * - * @param validity The expected validity message (empty string means it is valid) - * @example - * ```js - * cy.findByRole('textbox') - * .should(haveValidity(/must not be empty/i)) - * ``` - */ -export function haveValidity(validity: string | RegExp) { - if (typeof validity === 'string') { - return (el: JQuery) => expect((el.get(0) as HTMLInputElement).validationMessage).to.equal(validity) - } - return (el: JQuery) => expect((el.get(0) as HTMLInputElement).validationMessage).to.match(validity) -} diff --git a/cypress/support/utils/deleteDownloadsFolder.ts b/cypress/support/utils/deleteDownloadsFolder.ts deleted file mode 100644 index 452b89464ea9a..0000000000000 --- a/cypress/support/utils/deleteDownloadsFolder.ts +++ /dev/null @@ -1,11 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/** - * Ensure the downloads folder is deleted before each test - */ -export function deleteDownloadsFolderBeforeEach() { - beforeEach(() => cy.task('deleteFolder', Cypress.config('downloadsFolder'))) -} diff --git a/cypress/support/utils/randomString.ts b/cypress/support/utils/randomString.ts deleted file mode 100644 index 120ebc048336d..0000000000000 --- a/cypress/support/utils/randomString.ts +++ /dev/null @@ -1,19 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -export function randomString(length: number) { - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' - const alphaNumeric = characters + '0123456789' - let result = '' - for (let i = 0; i < length; i++) { - // Ensure the first character is alphabetic - if (i === 0) { - result += characters.charAt(Math.floor(Math.random() * characters.length)) - continue - } - result += alphaNumeric.charAt(Math.floor(Math.random() * alphaNumeric.length)) - } - return result -} diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json deleted file mode 100644 index 62747429d7a25..0000000000000 --- a/cypress/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["./**/*.ts", "./cypress-e2e.d.ts", "./cypress-component.d.ts"], - "exclude": [], - "compilerOptions": { - "types": [ - "@testing-library/cypress", - "cypress", - "cypress-axe", - "cypress-wait-until", - "dockerode" - ], - } -} diff --git a/dist/499-499.js b/dist/499-499.js deleted file mode 100644 index 8cf5c4cdb279d..0000000000000 --- a/dist/499-499.js +++ /dev/null @@ -1,2 +0,0 @@ -"use strict";(globalThis.webpackChunknextcloud_ui_legacy||=[]).push([[499],{28069(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".share-expiry-time[data-v-c9199db0]{display:inline-flex;align-items:center;justify-content:center}.share-expiry-time .hint-icon[data-v-c9199db0]{padding:0;margin:0;width:24px;height:24px}.hint-heading[data-v-c9199db0]{text-align:center;font-size:1rem;margin-top:8px;padding-bottom:8px;margin-bottom:0;border-bottom:1px solid var(--color-border)}.hint-body[data-v-c9199db0]{padding:var(--border-radius-element);max-width:300px}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/ShareExpiryTime.vue"],names:[],mappings:"AACA,oCACI,mBAAA,CACA,kBAAA,CACA,sBAAA,CAEA,+CACI,SAAA,CACA,QAAA,CACA,UAAA,CACA,WAAA,CAIR,+BACI,iBAAA,CACA,cAAA,CACA,cAAA,CACA,kBAAA,CACA,eAAA,CACA,2CAAA,CAGJ,4BACI,oCAAA,CACA,eAAA",sourcesContent:["\n.share-expiry-time {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .hint-icon {\n padding: 0;\n margin: 0;\n width: 24px;\n height: 24px;\n }\n}\n\n.hint-heading {\n text-align: center;\n font-size: 1rem;\n margin-top: 8px;\n padding-bottom: 8px;\n margin-bottom: 0;\n border-bottom: 1px solid var(--color-border);\n}\n\n.hint-body {\n padding: var(--border-radius-element);\n max-width: 300px;\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},40749(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-fa3f3612]{display:flex;align-items:center;height:44px}.sharing-entry__summary[data-v-fa3f3612]{padding:8px;padding-inline-start:10px;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;flex:1 0;min-width:0}.sharing-entry__summary__desc[data-v-fa3f3612]{display:inline-block;padding-bottom:0;line-height:1.2em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sharing-entry__summary__desc p[data-v-fa3f3612],.sharing-entry__summary__desc small[data-v-fa3f3612]{color:var(--color-text-maxcontrast)}.sharing-entry__summary__desc-unique[data-v-fa3f3612]{color:var(--color-text-maxcontrast)}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntry.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,WAAA,CACA,yCACC,WAAA,CACA,yBAAA,CACA,YAAA,CACA,qBAAA,CACA,sBAAA,CACA,sBAAA,CACA,QAAA,CACA,WAAA,CAEA,+CACC,oBAAA,CACA,gBAAA,CACA,iBAAA,CACA,kBAAA,CACA,eAAA,CACA,sBAAA,CAEA,sGAEC,mCAAA,CAGD,sDACC,mCAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\theight: 44px;\n\t&__summary {\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tjustify-content: center;\n\t\talign-items: flex-start;\n\t\tflex: 1 0;\n\t\tmin-width: 0;\n\n\t\t&__desc {\n\t\t\tdisplay: inline-block;\n\t\t\tpadding-bottom: 0;\n\t\t\tline-height: 1.2em;\n\t\t\twhite-space: nowrap;\n\t\t\toverflow: hidden;\n\t\t\ttext-overflow: ellipsis;\n\n\t\t\tp,\n\t\t\tsmall {\n\t\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t\t}\n\n\t\t\t&-unique {\n\t\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t\t}\n\t\t}\n\t}\n\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},29199(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-731a9650]{display:flex;align-items:center;height:44px}.sharing-entry__desc[data-v-731a9650]{display:flex;flex-direction:column;justify-content:space-between;padding:8px;padding-inline-start:10px;line-height:1.2em}.sharing-entry__desc p[data-v-731a9650]{color:var(--color-text-maxcontrast)}.sharing-entry__actions[data-v-731a9650]{margin-inline-start:auto}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryInherited.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,WAAA,CACA,sCACC,YAAA,CACA,qBAAA,CACA,6BAAA,CACA,WAAA,CACA,yBAAA,CACA,iBAAA,CACA,wCACC,mCAAA,CAGF,yCACC,wBAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\theight: 44px;\n\t&__desc {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tjustify-content: space-between;\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tline-height: 1.2em;\n\t\tp {\n\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t}\n\t}\n\t&__actions {\n\t\tmargin-inline-start: auto;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},76459(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry__internal .avatar-external[data-v-6c4cb23b]{width:32px;height:32px;line-height:32px;font-size:18px;background-color:var(--color-text-maxcontrast);border-radius:50%;flex-shrink:0}.sharing-entry__internal .icon-checkmark-color[data-v-6c4cb23b]{opacity:1;color:var(--color-border-success)}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryInternal.vue"],names:[],mappings:"AAEC,2DACC,UAAA,CACA,WAAA,CACA,gBAAA,CACA,cAAA,CACA,8CAAA,CACA,iBAAA,CACA,aAAA,CAED,gEACC,SAAA,CACA,iCAAA",sourcesContent:["\n.sharing-entry__internal {\n\t.avatar-external {\n\t\twidth: 32px;\n\t\theight: 32px;\n\t\tline-height: 32px;\n\t\tfont-size: 18px;\n\t\tbackground-color: var(--color-text-maxcontrast);\n\t\tborder-radius: 50%;\n\t\tflex-shrink: 0;\n\t}\n\t.icon-checkmark-color {\n\t\topacity: 1;\n\t\tcolor: var(--color-border-success);\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},91950(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-7a5c0ee5]{display:flex;align-items:center;min-height:44px}.sharing-entry__summary[data-v-7a5c0ee5]{padding:8px;padding-inline-start:10px;display:flex;justify-content:space-between;flex:1 0;min-width:0}.sharing-entry__desc[data-v-7a5c0ee5]{display:flex;flex-direction:column;line-height:1.2em}.sharing-entry__desc p[data-v-7a5c0ee5]{color:var(--color-text-maxcontrast)}.sharing-entry__desc__title[data-v-7a5c0ee5]{text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.sharing-entry__actions[data-v-7a5c0ee5]{display:flex;align-items:center;margin-inline-start:auto}.sharing-entry:not(.sharing-entry--share) .sharing-entry__actions .new-share-link[data-v-7a5c0ee5]{border-top:1px solid var(--color-border)}.sharing-entry[data-v-7a5c0ee5] .avatar-link-share{background-color:var(--color-primary-element)}.sharing-entry .sharing-entry__action--public-upload[data-v-7a5c0ee5]{border-bottom:1px solid var(--color-border)}.sharing-entry__loading[data-v-7a5c0ee5]{width:44px;height:44px;margin:0;padding:14px;margin-inline-start:auto}.sharing-entry .action-item~.action-item[data-v-7a5c0ee5],.sharing-entry .action-item~.sharing-entry__loading[data-v-7a5c0ee5]{margin-inline-start:0}.sharing-entry__copy-icon--success[data-v-7a5c0ee5]{color:var(--color-border-success)}.qr-code-dialog[data-v-7a5c0ee5]{display:flex;width:100%;justify-content:center}.qr-code-dialog__img[data-v-7a5c0ee5]{width:100%;height:auto}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryLink.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,eAAA,CAEA,yCACC,WAAA,CACA,yBAAA,CACA,YAAA,CACA,6BAAA,CACA,QAAA,CACA,WAAA,CAGA,sCACC,YAAA,CACA,qBAAA,CACA,iBAAA,CAEA,wCACC,mCAAA,CAGD,6CACC,sBAAA,CACA,eAAA,CACA,kBAAA,CAIF,yCACC,YAAA,CACA,kBAAA,CACA,wBAAA,CAID,mGACC,wCAAA,CAIF,mDACC,6CAAA,CAGD,sEACC,2CAAA,CAGD,yCACC,UAAA,CACA,WAAA,CACA,QAAA,CACA,YAAA,CACA,wBAAA,CAOA,+HAEC,qBAAA,CAIF,oDACC,iCAAA,CAKF,iCACC,YAAA,CACA,UAAA,CACA,sBAAA,CAEA,sCACC,UAAA,CACA,WAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\tmin-height: 44px;\n\n\t&__summary {\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tflex: 1 0;\n\t\tmin-width: 0;\n\t}\n\n\t\t&__desc {\n\t\t\tdisplay: flex;\n\t\t\tflex-direction: column;\n\t\t\tline-height: 1.2em;\n\n\t\t\tp {\n\t\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t\t}\n\n\t\t\t&__title {\n\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\toverflow: hidden;\n\t\t\t\twhite-space: nowrap;\n\t\t\t}\n\t\t}\n\n\t\t&__actions {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\tmargin-inline-start: auto;\n\t\t}\n\n\t&:not(.sharing-entry--share) &__actions {\n\t\t.new-share-link {\n\t\t\tborder-top: 1px solid var(--color-border);\n\t\t}\n\t}\n\n\t:deep(.avatar-link-share) {\n\t\tbackground-color: var(--color-primary-element);\n\t}\n\n\t.sharing-entry__action--public-upload {\n\t\tborder-bottom: 1px solid var(--color-border);\n\t}\n\n\t&__loading {\n\t\twidth: 44px;\n\t\theight: 44px;\n\t\tmargin: 0;\n\t\tpadding: 14px;\n\t\tmargin-inline-start: auto;\n\t}\n\n\t// put menus to the left\n\t// but only the first one\n\t.action-item {\n\n\t\t~.action-item,\n\t\t~.sharing-entry__loading {\n\t\t\tmargin-inline-start: 0;\n\t\t}\n\t}\n\n\t&__copy-icon--success {\n\t\tcolor: var(--color-border-success);\n\t}\n}\n\n// styling for the qr-code container\n.qr-code-dialog {\n\tdisplay: flex;\n\twidth: 100%;\n\tjustify-content: center;\n\n\t&__img {\n\t\twidth: 100%;\n\t\theight: auto;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},49319(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".share-select[data-v-5ae7b89a]{display:block}.share-select[data-v-5ae7b89a] .action-item__menutoggle{color:var(--color-primary-element) !important;font-size:12.5px !important;height:auto !important;min-height:auto !important}.share-select[data-v-5ae7b89a] .action-item__menutoggle .button-vue__text{font-weight:normal !important}.share-select[data-v-5ae7b89a] .action-item__menutoggle .button-vue__icon{height:24px !important;min-height:24px !important;width:24px !important;min-width:24px !important}.share-select[data-v-5ae7b89a] .action-item__menutoggle .button-vue__wrapper{flex-direction:row-reverse !important}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue"],names:[],mappings:"AACA,+BACC,aAAA,CAIA,wDACC,6CAAA,CACA,2BAAA,CACA,sBAAA,CACA,0BAAA,CAEA,0EACC,6BAAA,CAGD,0EACC,sBAAA,CACA,0BAAA,CACA,qBAAA,CACA,yBAAA,CAGD,6EAEC,qCAAA",sourcesContent:["\n.share-select {\n\tdisplay: block;\n\n\t// TODO: NcActions should have a slot for custom trigger button like NcPopover\n\t// Overrider NcActionms button to make it small\n\t:deep(.action-item__menutoggle) {\n\t\tcolor: var(--color-primary-element) !important;\n\t\tfont-size: 12.5px !important;\n\t\theight: auto !important;\n\t\tmin-height: auto !important;\n\n\t\t.button-vue__text {\n\t\t\tfont-weight: normal !important;\n\t\t}\n\n\t\t.button-vue__icon {\n\t\t\theight: 24px !important;\n\t\t\tmin-height: 24px !important;\n\t\t\twidth: 24px !important;\n\t\t\tmin-width: 24px !important;\n\t\t}\n\n\t\t.button-vue__wrapper {\n\t\t\t// Emulate NcButton's alignment=center-reverse\n\t\t\tflex-direction: row-reverse !important;\n\t\t}\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},33176(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-13d4a0bb]{display:flex;align-items:center;min-height:44px}.sharing-entry__desc[data-v-13d4a0bb]{padding:8px;padding-inline-start:10px;line-height:1.2em;position:relative;flex:1 1;min-width:0}.sharing-entry__desc p[data-v-13d4a0bb]{color:var(--color-text-maxcontrast)}.sharing-entry__title[data-v-13d4a0bb]{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:inherit}.sharing-entry__actions[data-v-13d4a0bb]{margin-inline-start:auto !important}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntrySimple.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,eAAA,CACA,sCACC,WAAA,CACA,yBAAA,CACA,iBAAA,CACA,iBAAA,CACA,QAAA,CACA,WAAA,CACA,wCACC,mCAAA,CAGF,uCACC,kBAAA,CACA,sBAAA,CACA,eAAA,CACA,iBAAA,CAED,yCACC,mCAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\tmin-height: 44px;\n\t&__desc {\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tline-height: 1.2em;\n\t\tposition: relative;\n\t\tflex: 1 1;\n\t\tmin-width: 0;\n\t\tp {\n\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t}\n\t}\n\t&__title {\n\t\twhite-space: nowrap;\n\t\ttext-overflow: ellipsis;\n\t\toverflow: hidden;\n\t\tmax-width: inherit;\n\t}\n\t&__actions {\n\t\tmargin-inline-start: auto !important;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},24992(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-search{display:flex;flex-direction:column;margin-bottom:4px}.sharing-search label[for=sharing-search-input]{margin-bottom:2px}.sharing-search__input{width:100%;margin:10px 0}.vs__dropdown-menu span[lookup] .avatardiv{background-image:var(--icon-search-white);background-repeat:no-repeat;background-position:center;background-color:var(--color-text-maxcontrast) !important}.vs__dropdown-menu span[lookup] .avatardiv .avatardiv__initials-wrapper{display:none}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingInput.vue"],names:[],mappings:"AACA,gBACC,YAAA,CACA,qBAAA,CACA,iBAAA,CAEA,gDACC,iBAAA,CAGD,uBACC,UAAA,CACA,aAAA,CAOA,2CACC,yCAAA,CACA,2BAAA,CACA,0BAAA,CACA,yDAAA,CACA,wEACC,YAAA",sourcesContent:['\n.sharing-search {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-bottom: 4px;\n\n\tlabel[for="sharing-search-input"] {\n\t\tmargin-bottom: 2px;\n\t}\n\n\t&__input {\n\t\twidth: 100%;\n\t\tmargin: 10px 0;\n\t}\n}\n\n.vs__dropdown-menu {\n\t// properly style the lookup entry\n\tspan[lookup] {\n\t\t.avatardiv {\n\t\t\tbackground-image: var(--icon-search-white);\n\t\t\tbackground-repeat: no-repeat;\n\t\t\tbackground-position: center;\n\t\t\tbackground-color: var(--color-text-maxcontrast) !important;\n\t\t\t.avatardiv__initials-wrapper {\n\t\t\t\tdisplay: none;\n\t\t\t}\n\t\t}\n\t}\n}\n'],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},23716(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharingTabDetailsView[data-v-1e0a769c]{display:flex;flex-direction:column;width:100%;margin:0 auto;position:relative;height:100%;overflow:hidden}.sharingTabDetailsView__header[data-v-1e0a769c]{display:flex;align-items:center;box-sizing:border-box;margin:.2em}.sharingTabDetailsView__header span[data-v-1e0a769c]{display:flex;align-items:center}.sharingTabDetailsView__header span h1[data-v-1e0a769c]{font-size:15px;padding-inline-start:.3em}.sharingTabDetailsView__wrapper[data-v-1e0a769c]{position:relative;overflow:scroll;flex-shrink:1;padding:4px;padding-inline-end:12px}.sharingTabDetailsView__quick-permissions[data-v-1e0a769c]{display:flex;justify-content:center;width:100%;margin:0 auto;border-radius:0}.sharingTabDetailsView__quick-permissions div[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__quick-permissions div span span[data-v-1e0a769c]:nth-child(1){align-items:center;justify-content:center;padding:.1em}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c] label span{display:flex;flex-direction:column}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c] span.checkbox-content__text.checkbox-radio-switch__text{flex-wrap:wrap}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c] span.checkbox-content__text.checkbox-radio-switch__text .subline{display:block;flex-basis:100%}.sharingTabDetailsView__advanced-control[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__advanced-control button[data-v-1e0a769c]{margin-top:.5em}.sharingTabDetailsView__advanced[data-v-1e0a769c]{width:100%;margin-bottom:.5em;text-align:start;padding-inline-start:0}.sharingTabDetailsView__advanced section textarea[data-v-1e0a769c],.sharingTabDetailsView__advanced section div.mx-datepicker[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__advanced section textarea[data-v-1e0a769c]{height:80px;margin:0}.sharingTabDetailsView__advanced section span[data-v-1e0a769c] label{padding-inline-start:0 !important;background-color:initial !important;border:none !important}.sharingTabDetailsView__advanced section section.custom-permissions-group[data-v-1e0a769c]{padding-inline-start:1.5em}.sharingTabDetailsView__label[data-v-1e0a769c]{padding-block-end:6px}.sharingTabDetailsView__delete>button[data-v-1e0a769c]:first-child{color:#df0707}.sharingTabDetailsView__footer[data-v-1e0a769c]{width:100%;display:flex;position:sticky;bottom:0;flex-direction:column;justify-content:space-between;align-items:flex-start;background:linear-gradient(to bottom, rgba(255, 255, 255, 0), var(--color-main-background))}.sharingTabDetailsView__footer .button-group[data-v-1e0a769c]{display:flex;justify-content:space-between;width:100%;margin-top:16px}.sharingTabDetailsView__footer .button-group button[data-v-1e0a769c]{margin-inline-start:16px}.sharingTabDetailsView__footer .button-group button[data-v-1e0a769c]:first-child{margin-inline-start:0}","",{version:3,sources:["webpack://./apps/files_sharing/src/views/SharingDetailsTab.vue"],names:[],mappings:"AACA,wCACC,YAAA,CACA,qBAAA,CACA,UAAA,CACA,aAAA,CACA,iBAAA,CACA,WAAA,CACA,eAAA,CAEA,gDACC,YAAA,CACA,kBAAA,CACA,qBAAA,CACA,WAAA,CAEA,qDACC,YAAA,CACA,kBAAA,CAEA,wDACC,cAAA,CACA,yBAAA,CAMH,iDACC,iBAAA,CACA,eAAA,CACA,aAAA,CACA,WAAA,CACA,uBAAA,CAGD,2DACC,YAAA,CACA,sBAAA,CACA,UAAA,CACA,aAAA,CACA,eAAA,CAEA,+DACC,UAAA,CAEA,oEACC,UAAA,CAEA,sFACC,kBAAA,CACA,sBAAA,CACA,YAAA,CAGD,+EACC,YAAA,CACA,qBAAA,CAID,4HACC,cAAA,CAEA,qIACC,aAAA,CACA,eAAA,CAQL,0DACC,UAAA,CAEA,iEACC,eAAA,CAKF,kDACC,UAAA,CACA,kBAAA,CACA,gBAAA,CACA,sBAAA,CAIC,+IAEC,UAAA,CAGD,mEACC,WAAA,CACA,QAAA,CAYD,qEACC,iCAAA,CACA,mCAAA,CACA,sBAAA,CAGD,2FACC,0BAAA,CAKH,+CACC,qBAAA,CAIA,mEACC,aAAA,CAIF,gDACC,UAAA,CACA,YAAA,CACA,eAAA,CACA,QAAA,CACA,qBAAA,CACA,6BAAA,CACA,sBAAA,CACA,2FAAA,CAEA,8DACC,YAAA,CACA,6BAAA,CACA,UAAA,CACA,eAAA,CAEA,qEACC,wBAAA,CAEA,iFACC,qBAAA",sourcesContent:["\n.sharingTabDetailsView {\n\tdisplay: flex;\n\tflex-direction: column;\n\twidth: 100%;\n\tmargin: 0 auto;\n\tposition: relative;\n\theight: 100%;\n\toverflow: hidden;\n\n\t&__header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tbox-sizing: border-box;\n\t\tmargin: 0.2em;\n\n\t\tspan {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\n\t\t\th1 {\n\t\t\t\tfont-size: 15px;\n\t\t\t\tpadding-inline-start: 0.3em;\n\t\t\t}\n\n\t\t}\n\t}\n\n\t&__wrapper {\n\t\tposition: relative;\n\t\toverflow: scroll;\n\t\tflex-shrink: 1;\n\t\tpadding: 4px;\n\t\tpadding-inline-end: 12px;\n\t}\n\n\t&__quick-permissions {\n\t\tdisplay: flex;\n\t\tjustify-content: center;\n\t\twidth: 100%;\n\t\tmargin: 0 auto;\n\t\tborder-radius: 0;\n\n\t\tdiv {\n\t\t\twidth: 100%;\n\n\t\t\tspan {\n\t\t\t\twidth: 100%;\n\n\t\t\t\tspan:nth-child(1) {\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tpadding: 0.1em;\n\t\t\t\t}\n\n\t\t\t\t:deep(label span) {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t}\n\n\t\t\t\t/* Target component based style in NcCheckboxRadioSwitch slot content*/\n\t\t\t\t:deep(span.checkbox-content__text.checkbox-radio-switch__text) {\n\t\t\t\t\tflex-wrap: wrap;\n\n\t\t\t\t\t.subline {\n\t\t\t\t\t\tdisplay: block;\n\t\t\t\t\t\tflex-basis: 100%;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t}\n\t}\n\n\t&__advanced-control {\n\t\twidth: 100%;\n\n\t\tbutton {\n\t\t\tmargin-top: 0.5em;\n\t\t}\n\n\t}\n\n\t&__advanced {\n\t\twidth: 100%;\n\t\tmargin-bottom: 0.5em;\n\t\ttext-align: start;\n\t\tpadding-inline-start: 0;\n\n\t\tsection {\n\n\t\t\ttextarea,\n\t\t\tdiv.mx-datepicker {\n\t\t\t\twidth: 100%;\n\t\t\t}\n\n\t\t\ttextarea {\n\t\t\t\theight: 80px;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t/*\n\t\t\t The following style is applied out of the component's scope\n\t\t\t to remove padding from the label.checkbox-radio-switch__label,\n\t\t\t which is used to group radio checkbox items. The use of ::v-deep\n\t\t\t ensures that the padding is modified without being affected by\n\t\t\t the component's scoping.\n\t\t\t Without this achieving left alignment for the checkboxes would not\n\t\t\t be possible.\n\t\t\t*/\n\t\t\tspan :deep(label) {\n\t\t\t\tpadding-inline-start: 0 !important;\n\t\t\t\tbackground-color: initial !important;\n\t\t\t\tborder: none !important;\n\t\t\t}\n\n\t\t\tsection.custom-permissions-group {\n\t\t\t\tpadding-inline-start: 1.5em;\n\t\t\t}\n\t\t}\n\t}\n\n\t&__label {\n\t\tpadding-block-end: 6px;\n\t}\n\n\t&__delete {\n\t\t> button:first-child {\n\t\t\tcolor: rgb(223, 7, 7);\n\t\t}\n\t}\n\n\t&__footer {\n\t\twidth: 100%;\n\t\tdisplay: flex;\n\t\tposition: sticky;\n\t\tbottom: 0;\n\t\tflex-direction: column;\n\t\tjustify-content: space-between;\n\t\talign-items: flex-start;\n\t\tbackground: linear-gradient(to bottom, rgba(255, 255, 255, 0), var(--color-main-background));\n\n\t\t.button-group {\n\t\t\tdisplay: flex;\n\t\t\tjustify-content: space-between;\n\t\t\twidth: 100%;\n\t\t\tmargin-top: 16px;\n\n\t\t\tbutton {\n\t\t\t\tmargin-inline-start: 16px;\n\n\t\t\t\t&:first-child {\n\t\t\t\t\tmargin-inline-start: 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},19353(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry__inherited .avatar-shared[data-v-cedf3238]{width:32px;height:32px;line-height:32px;font-size:18px;background-color:var(--color-text-maxcontrast);border-radius:50%;flex-shrink:0}","",{version:3,sources:["webpack://./apps/files_sharing/src/views/SharingInherited.vue"],names:[],mappings:"AAEC,0DACC,UAAA,CACA,WAAA,CACA,gBAAA,CACA,cAAA,CACA,8CAAA,CACA,iBAAA,CACA,aAAA",sourcesContent:["\n.sharing-entry__inherited {\n\t.avatar-shared {\n\t\twidth: 32px;\n\t\theight: 32px;\n\t\tline-height: 32px;\n\t\tfont-size: 18px;\n\t\tbackground-color: var(--color-text-maxcontrast);\n\t\tborder-radius: 50%;\n\t\tflex-shrink: 0;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},41253(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".emptyContentWithSections[data-v-cd6ad9ee]{margin:1rem auto}.sharingTab[data-v-cd6ad9ee]{position:relative;height:100%}.sharingTab__content[data-v-cd6ad9ee]{padding:0 6px}.sharingTab__content section[data-v-cd6ad9ee]{padding-bottom:16px}.sharingTab__content section .section-header[data-v-cd6ad9ee]{margin-top:2px;margin-bottom:2px;display:flex;align-items:center;padding-bottom:4px}.sharingTab__content section .section-header h4[data-v-cd6ad9ee]{margin:0;font-size:16px}.sharingTab__content section .section-header .visually-hidden[data-v-cd6ad9ee]{display:none}.sharingTab__content section .section-header .hint-icon[data-v-cd6ad9ee]{color:var(--color-primary-element)}.sharingTab__content>section[data-v-cd6ad9ee]:not(:last-child){border-bottom:2px solid var(--color-border)}.sharingTab__additionalContent[data-v-cd6ad9ee]{margin:var(--default-clickable-area) 0}.hint-body[data-v-cd6ad9ee]{max-width:300px;padding:var(--border-radius-element)}","",{version:3,sources:["webpack://./apps/files_sharing/src/views/SharingTab.vue"],names:[],mappings:"AACA,2CACC,gBAAA,CAGD,6BACC,iBAAA,CACA,WAAA,CAEA,sCACC,aAAA,CAEA,8CACC,mBAAA,CAEA,8DACC,cAAA,CACA,iBAAA,CACA,YAAA,CACA,kBAAA,CACA,kBAAA,CAEA,iEACC,QAAA,CACA,cAAA,CAGD,+EACC,YAAA,CAGD,yEACC,kCAAA,CAOH,+DACC,2CAAA,CAKF,gDACC,sCAAA,CAIF,4BACC,eAAA,CACA,oCAAA",sourcesContent:["\n.emptyContentWithSections {\n\tmargin: 1rem auto;\n}\n\n.sharingTab {\n\tposition: relative;\n\theight: 100%;\n\n\t&__content {\n\t\tpadding: 0 6px;\n\n\t\tsection {\n\t\t\tpadding-bottom: 16px;\n\n\t\t\t.section-header {\n\t\t\t\tmargin-top: 2px;\n\t\t\t\tmargin-bottom: 2px;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tpadding-bottom: 4px;\n\n\t\t\t\th4 {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tfont-size: 16px;\n\t\t\t\t}\n\n\t\t\t\t.visually-hidden {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t}\n\n\t\t\t\t.hint-icon {\n\t\t\t\t\tcolor: var(--color-primary-element);\n\t\t\t\t}\n\n\t\t\t}\n\n\t\t}\n\n\t\t& > section:not(:last-child) {\n\t\t\tborder-bottom: 2px solid var(--color-border);\n\t\t}\n\n\t}\n\n\t&__additionalContent {\n\t\tmargin: var(--default-clickable-area) 0;\n\t}\n}\n\n.hint-body {\n\tmax-width: 300px;\n\tpadding: var(--border-radius-element);\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},70544(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,"\n.sharing-tab-external-section-legacy[data-v-3e4e67d2] {\n\twidth: 100%;\n}\n","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SidebarTabExternal/SidebarTabExternalSectionLegacy.vue"],names:[],mappings:";AAkCA;CACA,WAAA;AACA",sourcesContent:['\x3c!--\n - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n - SPDX-License-Identifier: AGPL-3.0-or-later\n--\x3e\n\n\n\n\\n\\n\\n\"],\"sourceRoot\":\"\"}]);\n// Exports\nexport default ___CSS_LOADER_EXPORT___;\n","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return (_setup.fileInfo)?_c(_setup.SharingTab,{attrs:{\"file-info\":_setup.fileInfo}}):_vm._e()\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./ContentCopy.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./ContentCopy.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./ContentCopy.vue?vue&type=template&id=0e8bd3c4\"\nimport script from \"./ContentCopy.vue?vue&type=script&lang=js\"\nexport * from \"./ContentCopy.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon content-copy-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('li',{staticClass:\"sharing-entry\"},[_vm._t(\"avatar\"),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__desc\"},[_c('span',{staticClass:\"sharing-entry__title\"},[_vm._v(_vm._s(_vm.title))]),_vm._v(\" \"),(_vm.subtitle)?_c('p',[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.subtitle)+\"\\n\\t\\t\")]):_vm._e()]),_vm._v(\" \"),(_vm.$slots['default'])?_c('NcActions',{ref:\"actionsComponent\",staticClass:\"sharing-entry__actions\",attrs:{\"menu-align\":\"right\",\"aria-expanded\":_vm.ariaExpandedValue}},[_vm._t(\"default\")],2):_vm._e()],2)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=style&index=0&id=13d4a0bb&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=style&index=0&id=13d4a0bb&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntrySimple.vue?vue&type=template&id=13d4a0bb&scoped=true\"\nimport script from \"./SharingEntrySimple.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntrySimple.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntrySimple.vue?vue&type=style&index=0&id=13d4a0bb&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"13d4a0bb\",\n null\n \n)\n\nexport default component.exports","/**\n * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getCapabilities } from '@nextcloud/capabilities';\nimport { generateUrl, getBaseUrl } from '@nextcloud/router';\n/**\n * @param fileid - The file ID to generate the direct file link for\n */\nexport function generateFileUrl(fileid) {\n const baseURL = getBaseUrl();\n const { globalscale } = getCapabilities();\n if (globalscale?.token) {\n return generateUrl('/gf/{token}/{fileid}', {\n token: globalscale.token,\n fileid,\n }, { baseURL });\n }\n return generateUrl('/f/{fileid}', {\n fileid,\n }, {\n baseURL,\n });\n}\n","\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=style&index=0&id=6c4cb23b&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=style&index=0&id=6c4cb23b&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryInternal.vue?vue&type=template&id=6c4cb23b&scoped=true\"\nimport script from \"./SharingEntryInternal.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryInternal.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryInternal.vue?vue&type=style&index=0&id=6c4cb23b&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"6c4cb23b\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('ul',[_c('SharingEntrySimple',{ref:\"shareEntrySimple\",staticClass:\"sharing-entry__internal\",attrs:{\"title\":_vm.t('files_sharing', 'Internal link'),\"subtitle\":_vm.internalLinkSubtitle},scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('div',{staticClass:\"avatar-external icon-external-white\"})]},proxy:true}])},[_vm._v(\" \"),_c('NcActionButton',{attrs:{\"title\":_vm.copyLinkTooltip,\"aria-label\":_vm.copyLinkTooltip},on:{\"click\":_vm.copyLink},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [(_vm.copied && _vm.copySuccess)?_c('CheckIcon',{staticClass:\"icon-checkmark-color\",attrs:{\"size\":20}}):_c('ClipboardIcon',{attrs:{\"size\":20}})]},proxy:true}])})],1)],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"sharing-search\"},[_c('label',{staticClass:\"hidden-visually\",attrs:{\"for\":_vm.shareInputId}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.isExternal\n\t\t\t? _vm.t('files_sharing', 'Enter external recipients')\n\t\t\t: _vm.t('files_sharing', 'Search for internal recipients'))+\"\\n\\t\")]),_vm._v(\" \"),_c('NcSelect',{ref:\"select\",staticClass:\"sharing-search__input\",attrs:{\"input-id\":_vm.shareInputId,\"disabled\":!_vm.canReshare,\"loading\":_vm.loading,\"filterable\":false,\"placeholder\":_vm.inputPlaceholder,\"clear-search-on-blur\":() => false,\"user-select\":true,\"options\":_vm.options,\"label-outside\":true},on:{\"search\":_vm.asyncFind,\"option:selected\":_vm.onSelected},scopedSlots:_vm._u([{key:\"no-options\",fn:function({ search }){return [_vm._v(\"\\n\\t\\t\\t\"+_vm._s(search ? _vm.noResultText : _vm.placeholder)+\"\\n\\t\\t\")]}}]),model:{value:(_vm.value),callback:function ($$v) {_vm.value=$$v},expression:\"value\"}})],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","/**\n * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nexport const ATOMIC_PERMISSIONS = {\n\tNONE: 0,\n\tREAD: 1,\n\tUPDATE: 2,\n\tCREATE: 4,\n\tDELETE: 8,\n\tSHARE: 16,\n}\n\nconst BUNDLED_PERMISSIONS = {\n\tREAD_ONLY: ATOMIC_PERMISSIONS.READ,\n\tUPLOAD_AND_UPDATE: ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE,\n\tFILE_DROP: ATOMIC_PERMISSIONS.CREATE,\n\tALL: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE,\n\tALL_FILE: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.SHARE,\n}\n\n/**\n * Get bundled permissions based on config.\n *\n * @param {boolean} excludeShare - Whether to exclude SHARE permission from ALL and ALL_FILE bundles.\n * @return {object}\n */\nexport function getBundledPermissions(excludeShare = false) {\n\tif (excludeShare) {\n\t\treturn {\n\t\t\t...BUNDLED_PERMISSIONS,\n\t\t\tALL: BUNDLED_PERMISSIONS.ALL & ~ATOMIC_PERMISSIONS.SHARE,\n\t\t\tALL_FILE: BUNDLED_PERMISSIONS.ALL_FILE & ~ATOMIC_PERMISSIONS.SHARE,\n\t\t}\n\t}\n\treturn BUNDLED_PERMISSIONS\n}\n\n/**\n * Return whether a given permissions set contains some permissions.\n *\n * @param {number} initialPermissionSet - the permissions set.\n * @param {number} permissionsToCheck - the permissions to check.\n * @return {boolean}\n */\nexport function hasPermissions(initialPermissionSet, permissionsToCheck) {\n\treturn initialPermissionSet !== ATOMIC_PERMISSIONS.NONE && (initialPermissionSet & permissionsToCheck) === permissionsToCheck\n}\n\n/**\n * Return whether a given permissions set is valid.\n *\n * @param {number} permissionsSet - the permissions set.\n *\n * @return {boolean}\n */\nexport function permissionsSetIsValid(permissionsSet) {\n\t// Must have at least READ or CREATE permission.\n\tif (!hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.READ) && !hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.CREATE)) {\n\t\treturn false\n\t}\n\n\t// Must have READ permission if have UPDATE or DELETE.\n\tif (!hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.READ) && (\n\t\thasPermissions(permissionsSet, ATOMIC_PERMISSIONS.UPDATE) || hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.DELETE)\n\t)) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n/**\n * Add some permissions to an initial set of permissions.\n *\n * @param {number} initialPermissionSet - the initial permissions.\n * @param {number} permissionsToAdd - the permissions to add.\n *\n * @return {number}\n */\nexport function addPermissions(initialPermissionSet, permissionsToAdd) {\n\treturn initialPermissionSet | permissionsToAdd\n}\n\n/**\n * Remove some permissions from an initial set of permissions.\n *\n * @param {number} initialPermissionSet - the initial permissions.\n * @param {number} permissionsToSubtract - the permissions to remove.\n *\n * @return {number}\n */\nexport function subtractPermissions(initialPermissionSet, permissionsToSubtract) {\n\treturn initialPermissionSet & ~permissionsToSubtract\n}\n\n/**\n * Toggle some permissions from an initial set of permissions.\n *\n * @param {number} initialPermissionSet - the permissions set.\n * @param {number} permissionsToToggle - the permissions to toggle.\n *\n * @return {number}\n */\nexport function togglePermissions(initialPermissionSet, permissionsToToggle) {\n\tif (hasPermissions(initialPermissionSet, permissionsToToggle)) {\n\t\treturn subtractPermissions(initialPermissionSet, permissionsToToggle)\n\t} else {\n\t\treturn addPermissions(initialPermissionSet, permissionsToToggle)\n\t}\n}\n\n/**\n * Return whether some given permissions can be toggled from a permission set.\n *\n * @param {number} permissionSet - the initial permissions set.\n * @param {number} permissionsToToggle - the permissions to toggle.\n *\n * @return {boolean}\n */\nexport function canTogglePermissions(permissionSet, permissionsToToggle) {\n\treturn permissionsSetIsValid(togglePermissions(permissionSet, permissionsToToggle))\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport logger from '../services/logger.ts';\nimport { isFileRequest } from '../services/SharingService.ts';\nexport default class Share {\n _share;\n /**\n * Create the share object\n *\n * @param ocsData ocs request response\n */\n constructor(ocsData) {\n if (ocsData.ocs && ocsData.ocs.data && ocsData.ocs.data[0]) {\n ocsData = ocsData.ocs.data[0];\n }\n // string to int\n if (typeof ocsData.id === 'string') {\n ocsData.id = Number.parseInt(ocsData.id);\n }\n // convert int into boolean\n ocsData.hide_download = !!ocsData.hide_download;\n ocsData.mail_send = !!ocsData.mail_send;\n if (ocsData.attributes && typeof ocsData.attributes === 'string') {\n try {\n ocsData.attributes = JSON.parse(ocsData.attributes);\n }\n catch {\n logger.warn('Could not parse share attributes returned by server', ocsData.attributes);\n }\n }\n ocsData.attributes = ocsData.attributes ?? [];\n // Pre-declared so Vue 2 makes newPassword reactive at observation time,\n // avoiding $set's property-addition path which races with async setters.\n ocsData.newPassword = ocsData.newPassword ?? undefined;\n // store state\n this._share = ocsData;\n }\n /**\n * Get the share state\n * ! used for reactivity purpose\n * Do not remove. It allow vuejs to\n * inject its watchers into the #share\n * state and make the whole class reactive\n *\n * @return the share raw state\n */\n get state() {\n return this._share;\n }\n /**\n * get the share id\n */\n get id() {\n return this._share.id;\n }\n /**\n * Get the share type\n */\n get type() {\n return this._share.share_type;\n }\n /**\n * Get the share permissions\n * See window.OC.PERMISSION_* variables\n */\n get permissions() {\n return this._share.permissions;\n }\n /**\n * Get the share attributes\n */\n get attributes() {\n return this._share.attributes || [];\n }\n /**\n * Set the share permissions\n * See window.OC.PERMISSION_* variables\n */\n set permissions(permissions) {\n this._share.permissions = permissions;\n }\n // SHARE OWNER --------------------------------------------------\n /**\n * Get the share owner uid\n */\n get owner() {\n return this._share.uid_owner;\n }\n /**\n * Get the share owner's display name\n */\n get ownerDisplayName() {\n return this._share.displayname_owner;\n }\n // SHARED WITH --------------------------------------------------\n /**\n * Get the share with entity uid\n */\n get shareWith() {\n return this._share.share_with;\n }\n /**\n * Get the share with entity display name\n * fallback to its uid if none\n */\n get shareWithDisplayName() {\n return this._share.share_with_displayname\n || this._share.share_with;\n }\n /**\n * Unique display name in case of multiple\n * duplicates results with the same name.\n */\n get shareWithDisplayNameUnique() {\n return this._share.share_with_displayname_unique\n || this._share.share_with;\n }\n /**\n * Get the share with entity link\n */\n get shareWithLink() {\n return this._share.share_with_link;\n }\n /**\n * Get the share with avatar if any\n */\n get shareWithAvatar() {\n return this._share.share_with_avatar;\n }\n // SHARED FILE OR FOLDER OWNER ----------------------------------\n /**\n * Get the shared item owner uid\n */\n get uidFileOwner() {\n return this._share.uid_file_owner;\n }\n /**\n * Get the shared item display name\n * fallback to its uid if none\n */\n get displaynameFileOwner() {\n return this._share.displayname_file_owner\n || this._share.uid_file_owner;\n }\n // TIME DATA ----------------------------------------------------\n /**\n * Get the share creation timestamp\n */\n get createdTime() {\n return this._share.stime;\n }\n /**\n * Get the expiration date\n *\n * @return date with YYYY-MM-DD format\n */\n get expireDate() {\n return this._share.expiration;\n }\n /**\n * Set the expiration date\n *\n * @param date the share expiration date with YYYY-MM-DD format\n */\n set expireDate(date) {\n this._share.expiration = date;\n }\n // EXTRA DATA ---------------------------------------------------\n /**\n * Get the public share token\n */\n get token() {\n return this._share.token;\n }\n /**\n * Set the public share token\n */\n set token(token) {\n this._share.token = token;\n }\n /**\n * Get the share note if any\n */\n get note() {\n return this._share.note;\n }\n /**\n * Set the share note if any\n */\n set note(note) {\n this._share.note = note;\n }\n /**\n * Get the share label if any\n * Should only exist on link shares\n */\n get label() {\n return this._share.label ?? '';\n }\n /**\n * Set the share label if any\n * Should only be set on link shares\n */\n set label(label) {\n this._share.label = label;\n }\n /**\n * Have a mail been sent\n */\n get mailSend() {\n return this._share.mail_send === true;\n }\n /**\n * Hide the download button on public page\n */\n get hideDownload() {\n return this._share.hide_download === true\n || this.attributes.find?.(({ scope, key, value }) => scope === 'permissions' && key === 'download' && !value) !== undefined;\n }\n /**\n * Hide the download button on public page\n */\n set hideDownload(state) {\n // disabling hide-download also enables the download permission\n // needed for regression in Nextcloud 31.0.0 until (incl.) 31.0.3\n if (!state) {\n const attribute = this.attributes.find(({ key, scope }) => key === 'download' && scope === 'permissions');\n if (attribute) {\n attribute.value = true;\n }\n }\n this._share.hide_download = state === true;\n }\n /**\n * Password protection of the share\n */\n get password() {\n return this._share.password;\n }\n /**\n * Password protection of the share\n */\n set password(password) {\n this._share.password = password;\n }\n /**\n * Unsaved password (set during share creation or editing).\n * Delegates to _share so reads/writes go through the reactive state.\n */\n get newPassword() {\n return this._share.newPassword;\n }\n set newPassword(value) {\n this._share.newPassword = value;\n }\n /**\n * Password expiration time\n *\n * @return date with YYYY-MM-DD format\n */\n get passwordExpirationTime() {\n return this._share.password_expiration_time;\n }\n /**\n * Password expiration time\n *\n * @param passwordExpirationTime date with YYYY-MM-DD format\n */\n set passwordExpirationTime(passwordExpirationTime) {\n this._share.password_expiration_time = passwordExpirationTime;\n }\n /**\n * Password protection by Talk of the share\n */\n get sendPasswordByTalk() {\n return this._share.send_password_by_talk;\n }\n /**\n * Password protection by Talk of the share\n *\n * @param sendPasswordByTalk whether to send the password by Talk or not\n */\n set sendPasswordByTalk(sendPasswordByTalk) {\n this._share.send_password_by_talk = sendPasswordByTalk;\n }\n // SHARED ITEM DATA ---------------------------------------------\n /**\n * Get the shared item absolute full path\n */\n get path() {\n return this._share.path;\n }\n /**\n * Return the item type: file or folder\n *\n * @return 'folder' | 'file'\n */\n get itemType() {\n return this._share.item_type;\n }\n /**\n * Get the shared item mimetype\n */\n get mimetype() {\n return this._share.mimetype;\n }\n /**\n * Get the shared item id\n */\n get fileSource() {\n return this._share.file_source;\n }\n /**\n * Get the target path on the receiving end\n * e.g the file /xxx/aaa will be shared in\n * the receiving root as /aaa, the fileTarget is /aaa\n */\n get fileTarget() {\n return this._share.file_target;\n }\n /**\n * Get the parent folder id if any\n */\n get fileParent() {\n return this._share.file_parent;\n }\n // PERMISSIONS Shortcuts\n /**\n * Does this share have READ permissions\n */\n get hasReadPermission() {\n return !!((this.permissions & window.OC.PERMISSION_READ));\n }\n /**\n * Does this share have CREATE permissions\n */\n get hasCreatePermission() {\n return !!((this.permissions & window.OC.PERMISSION_CREATE));\n }\n /**\n * Does this share have DELETE permissions\n */\n get hasDeletePermission() {\n return !!((this.permissions & window.OC.PERMISSION_DELETE));\n }\n /**\n * Does this share have UPDATE permissions\n */\n get hasUpdatePermission() {\n return !!((this.permissions & window.OC.PERMISSION_UPDATE));\n }\n /**\n * Does this share have SHARE permissions\n */\n get hasSharePermission() {\n return !!((this.permissions & window.OC.PERMISSION_SHARE));\n }\n /**\n * Does this share have download permissions\n */\n get hasDownloadPermission() {\n const hasDisabledDownload = (attribute) => {\n return attribute.scope === 'permissions' && attribute.key === 'download' && attribute.value === false;\n };\n return !this.attributes.some(hasDisabledDownload);\n }\n /**\n * Is this mail share a file request ?\n */\n get isFileRequest() {\n return isFileRequest(JSON.stringify(this.attributes));\n }\n set hasDownloadPermission(enabled) {\n this.setAttribute('permissions', 'download', !!enabled);\n }\n setAttribute(scope, key, value) {\n const attrUpdate = {\n scope,\n key,\n value,\n };\n // try and replace existing\n for (const i in this._share.attributes) {\n const attr = this._share.attributes[i];\n if (attr.scope === attrUpdate.scope && attr.key === attrUpdate.key) {\n this._share.attributes.splice(i, 1, attrUpdate);\n return;\n }\n }\n this._share.attributes.push(attrUpdate);\n }\n // PERMISSIONS Shortcuts for the CURRENT USER\n // ! the permissions above are the share settings,\n // ! meaning the permissions for the recipient\n /**\n * Can the current user EDIT this share ?\n */\n get canEdit() {\n return this._share.can_edit === true;\n }\n /**\n * Can the current user DELETE this share ?\n */\n get canDelete() {\n return this._share.can_delete === true;\n }\n /**\n * Top level accessible shared folder fileid for the current user\n */\n get viaFileid() {\n return this._share.via_fileid;\n }\n /**\n * Top level accessible shared folder path for the current user\n */\n get viaPath() {\n return this._share.via_path;\n }\n // TODO: SORT THOSE PROPERTIES\n get parent() {\n return this._share.parent;\n }\n get storageId() {\n return this._share.storage_id;\n }\n get storage() {\n return this._share.storage;\n }\n get itemSource() {\n return this._share.item_source;\n }\n get status() {\n return this._share.status;\n }\n /**\n * Is the share from a trusted server\n */\n get isTrustedServer() {\n return !!this._share.is_trusted_server;\n }\n}\n","/**\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n// TODO: Fix this instead of disabling ESLint!!!\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { getCurrentUser } from '@nextcloud/auth';\nimport axios from '@nextcloud/axios';\nimport { File, Folder, Permission } from '@nextcloud/files';\nimport { getRemoteURL, getRootPath } from '@nextcloud/files/dav';\nimport { generateOcsUrl } from '@nextcloud/router';\nimport logger from './logger.ts';\nconst headers = {\n 'Content-Type': 'application/json',\n};\n/**\n *\n * @param ocsEntry\n * @param unmounted whether the share is not mounted into the filesystem (pending or deleted)\n */\nasync function ocsEntryToNode(ocsEntry, unmounted = false) {\n try {\n // Federated share handling\n if (ocsEntry?.remote_id !== undefined) {\n if (!ocsEntry.mimetype) {\n const mime = (await import('mime')).default;\n // This won't catch files without an extension, but this is the best we can do\n ocsEntry.mimetype = mime.getType(ocsEntry.name);\n }\n const type = ocsEntry.type === 'dir' ? 'folder' : ocsEntry.type;\n ocsEntry.item_type = type || (ocsEntry.mimetype ? 'file' : 'folder');\n // different naming for remote shares\n ocsEntry.item_mtime = ocsEntry.mtime;\n ocsEntry.file_target = ocsEntry.file_target || ocsEntry.mountpoint;\n if (ocsEntry.file_target.includes('TemporaryMountPointName')) {\n ocsEntry.file_target = ocsEntry.name;\n }\n // If the share is not accepted yet we don't know which permissions it will have\n if (!ocsEntry.accepted) {\n // Need to set permissions to NONE for federated shares\n ocsEntry.item_permissions = Permission.NONE;\n ocsEntry.permissions = Permission.NONE;\n }\n ocsEntry.uid_owner = ocsEntry.owner;\n // TODO: have the real display name stored somewhere\n ocsEntry.displayname_owner = ocsEntry.owner;\n }\n // Pending and deleted shares are not mounted into the user's filesystem,\n // so no file operation can act on them until they are accepted or restored.\n if (unmounted) {\n ocsEntry.item_permissions = Permission.NONE;\n ocsEntry.permissions = Permission.NONE;\n }\n const isFolder = ocsEntry?.item_type === 'folder';\n const hasPreview = ocsEntry?.has_preview === true;\n const Node = isFolder ? Folder : File;\n // If this is an external share that is not yet accepted,\n // we don't have an id. We can fallback to the row id temporarily\n // local shares (this server) use `file_source`, but remote shares (federated) use `file_id`\n const fileid = ocsEntry.file_source || ocsEntry.file_id || ocsEntry.id;\n // Generate path and strip double slashes\n const path = ocsEntry.path || ocsEntry.file_target || ocsEntry.name;\n const source = `${getRemoteURL()}${getRootPath()}/${path.replace(/^\\/+/, '')}`;\n let mtime = ocsEntry.item_mtime ? new Date((ocsEntry.item_mtime) * 1000) : undefined;\n // Prefer share time if more recent than item mtime\n if (ocsEntry?.stime > (ocsEntry?.item_mtime || 0)) {\n mtime = new Date((ocsEntry.stime) * 1000);\n }\n let sharees;\n if ('share_with' in ocsEntry) {\n sharees = {\n sharee: {\n id: ocsEntry.share_with,\n 'display-name': ocsEntry.share_with_displayname || ocsEntry.share_with,\n type: ocsEntry.share_type,\n },\n };\n }\n return new Node({\n id: fileid,\n source,\n owner: ocsEntry?.uid_owner,\n mime: ocsEntry?.mimetype || 'application/octet-stream',\n mtime,\n size: ocsEntry?.item_size ?? undefined,\n permissions: ocsEntry?.item_permissions || ocsEntry?.permissions,\n root: getRootPath(),\n attributes: {\n ...ocsEntry,\n // 'id' is a forbidden property name\n 'share-id': ocsEntry.id,\n 'has-preview': hasPreview,\n 'hide-download': ocsEntry?.hide_download === 1,\n // Also check the sharingStatusAction.ts code\n 'owner-id': ocsEntry?.uid_owner,\n 'owner-display-name': ocsEntry?.displayname_owner,\n 'share-types': ocsEntry?.share_type,\n 'share-attributes': ocsEntry?.attributes || '[]',\n sharees,\n favorite: ocsEntry?.tags?.includes(window.OC.TAG_FAVORITE) ? 1 : 0,\n },\n });\n }\n catch (error) {\n logger.error('Error while parsing OCS entry', { error });\n return null;\n }\n}\n/**\n *\n * @param shareWithMe\n */\nfunction getShares(shareWithMe = false) {\n const url = generateOcsUrl('apps/files_sharing/api/v1/shares');\n return axios.get(url, {\n headers,\n params: {\n shared_with_me: shareWithMe,\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getSharedWithYou() {\n return getShares(true);\n}\n/**\n *\n */\nfunction getSharedWithOthers() {\n return getShares();\n}\n/**\n *\n */\nfunction getRemoteShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getPendingShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/shares/pending');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getRemotePendingShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares/pending');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getDeletedShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n * Check if a file request is enabled\n *\n * @param attributes the share attributes json-encoded array\n */\nexport function isFileRequest(attributes = '[]') {\n const isFileRequest = (attribute) => {\n return attribute.scope === 'fileRequest' && attribute.key === 'enabled' && attribute.value === true;\n };\n try {\n const attributesArray = JSON.parse(attributes);\n return attributesArray.some(isFileRequest);\n }\n catch (error) {\n logger.error('Error while parsing share attributes', { error });\n return false;\n }\n}\n/**\n * Group an array of objects (here Nodes) by a key\n * and return an array of arrays of them.\n *\n * @param nodes Nodes to group\n * @param key The attribute to group by\n */\nfunction groupBy(nodes, key) {\n return Object.values(nodes.reduce(function (acc, curr) {\n (acc[curr[key]] = acc[curr[key]] || []).push(curr);\n return acc;\n }, {}));\n}\n/**\n *\n * @param sharedWithYou\n * @param sharedWithOthers\n * @param pendingShares\n * @param deletedshares\n * @param filterTypes\n */\nexport async function getContents(sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, filterTypes = []) {\n const requests = [];\n if (sharedWithYou) {\n requests.push({ promise: getSharedWithYou(), unmounted: false }, { promise: getRemoteShares(), unmounted: false });\n }\n if (sharedWithOthers) {\n requests.push({ promise: getSharedWithOthers(), unmounted: false });\n }\n if (pendingShares) {\n requests.push({ promise: getPendingShares(), unmounted: true }, { promise: getRemotePendingShares(), unmounted: true });\n }\n if (deletedshares) {\n requests.push({ promise: getDeletedShares(), unmounted: true });\n }\n const responses = await Promise.all(requests.map(({ promise }) => promise));\n const data = responses.flatMap((response, index) => response.data.ocs.data\n .map((entry) => ({ entry, unmounted: requests[index].unmounted })));\n let contents = (await Promise.all(data.map(({ entry, unmounted }) => ocsEntryToNode(entry, unmounted))))\n .filter((node) => node !== null);\n if (filterTypes.length > 0) {\n contents = contents.filter((node) => filterTypes.includes(node.attributes?.share_type));\n }\n // Merge duplicate shares and group their attributes\n // Also check the sharingStatusAction.ts code\n contents = groupBy(contents, 'source').map((nodes) => {\n const node = nodes[0];\n node.attributes['share-types'] = nodes.map((node) => node.attributes['share-types']);\n return node;\n });\n return {\n folder: new Folder({\n id: 0,\n source: `${getRemoteURL()}${getRootPath()}`,\n owner: getCurrentUser()?.uid || null,\n root: getRootPath(),\n }),\n contents,\n };\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getCapabilities } from '@nextcloud/capabilities';\nimport { loadState } from '@nextcloud/initial-state';\nexport default class Config {\n _capabilities;\n constructor() {\n this._capabilities = getCapabilities();\n }\n /**\n * Get default share permissions, if any\n */\n get defaultPermissions() {\n return this._capabilities.files_sharing?.default_permissions;\n }\n /**\n * Should SHARE permission be excluded from \"Allow editing\" bundled permissions\n */\n get excludeReshareFromEdit() {\n return this._capabilities.files_sharing?.exclude_reshare_from_edit === true;\n }\n /**\n * Is public upload allowed on link shares ?\n * This covers File request and Full upload/edit option.\n */\n get isPublicUploadEnabled() {\n return this._capabilities.files_sharing?.public?.upload === true;\n }\n /**\n * Get the federated sharing documentation link\n */\n get federatedShareDocLink() {\n return window.OC.appConfig.core.federatedCloudShareDoc;\n }\n /**\n * Get the default link share expiration date\n */\n get defaultExpirationDate() {\n if (this.isDefaultExpireDateEnabled && this.defaultExpireDate !== null) {\n return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate));\n }\n return null;\n }\n /**\n * Get the default internal expiration date\n */\n get defaultInternalExpirationDate() {\n if (this.isDefaultInternalExpireDateEnabled && this.defaultInternalExpireDate !== null) {\n return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate));\n }\n return null;\n }\n /**\n * Get the default remote expiration date\n */\n get defaultRemoteExpirationDateString() {\n if (this.isDefaultRemoteExpireDateEnabled && this.defaultRemoteExpireDate !== null) {\n return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate));\n }\n return null;\n }\n /**\n * Are link shares password-enforced ?\n */\n get enforcePasswordForPublicLink() {\n return window.OC.appConfig.core.enforcePasswordForPublicLink === true;\n }\n /**\n * Is password asked by default on link shares ?\n */\n get enableLinkPasswordByDefault() {\n return window.OC.appConfig.core.enableLinkPasswordByDefault === true;\n }\n /**\n * Is link shares expiration enforced ?\n */\n get isDefaultExpireDateEnforced() {\n return window.OC.appConfig.core.defaultExpireDateEnforced === true;\n }\n /**\n * Is there a default expiration date for new link shares ?\n */\n get isDefaultExpireDateEnabled() {\n return window.OC.appConfig.core.defaultExpireDateEnabled === true;\n }\n /**\n * Is internal shares expiration enforced ?\n */\n get isDefaultInternalExpireDateEnforced() {\n return window.OC.appConfig.core.defaultInternalExpireDateEnforced === true;\n }\n /**\n * Is there a default expiration date for new internal shares ?\n */\n get isDefaultInternalExpireDateEnabled() {\n return window.OC.appConfig.core.defaultInternalExpireDateEnabled === true;\n }\n /**\n * Is remote shares expiration enforced ?\n */\n get isDefaultRemoteExpireDateEnforced() {\n return window.OC.appConfig.core.defaultRemoteExpireDateEnforced === true;\n }\n /**\n * Is there a default expiration date for new remote shares ?\n */\n get isDefaultRemoteExpireDateEnabled() {\n return window.OC.appConfig.core.defaultRemoteExpireDateEnabled === true;\n }\n /**\n * Are users on this server allowed to send shares to other servers ?\n */\n get isRemoteShareAllowed() {\n return window.OC.appConfig.core.remoteShareAllowed === true;\n }\n /**\n * Is federation enabled ?\n */\n get isFederationEnabled() {\n return this._capabilities?.files_sharing?.federation?.outgoing === true;\n }\n /**\n * Is public sharing enabled ?\n */\n get isPublicShareAllowed() {\n return this._capabilities?.files_sharing?.public?.enabled === true;\n }\n /**\n * Is sharing my mail (link share) enabled ?\n */\n get isMailShareAllowed() {\n return this._capabilities?.files_sharing?.sharebymail?.enabled === true\n && this.isPublicShareAllowed === true;\n }\n /**\n * Get the default days to link shares expiration\n */\n get defaultExpireDate() {\n return window.OC.appConfig.core.defaultExpireDate;\n }\n /**\n * Get the default days to internal shares expiration\n */\n get defaultInternalExpireDate() {\n return window.OC.appConfig.core.defaultInternalExpireDate;\n }\n /**\n * Get the default days to remote shares expiration\n */\n get defaultRemoteExpireDate() {\n return window.OC.appConfig.core.defaultRemoteExpireDate;\n }\n /**\n * Is resharing allowed ?\n */\n get isResharingAllowed() {\n return window.OC.appConfig.core.resharingAllowed === true;\n }\n /**\n * Is password enforced for mail shares ?\n */\n get isPasswordForMailSharesRequired() {\n return this._capabilities.files_sharing?.sharebymail?.password?.enforced === true;\n }\n /**\n * Always show the email or userid unique sharee label if enabled by the admin\n */\n get shouldAlwaysShowUnique() {\n return this._capabilities.files_sharing?.sharee?.always_show_unique === true;\n }\n /**\n * Is sharing with groups allowed ?\n */\n get allowGroupSharing() {\n return window.OC.appConfig.core.allowGroupSharing === true;\n }\n /**\n * Get the maximum results of a share search\n */\n get maxAutocompleteResults() {\n return parseInt(window.OC.config['sharing.maxAutocompleteResults'], 10) || 25;\n }\n /**\n * Get the minimal string length\n * to initiate a share search\n */\n get minSearchStringLength() {\n return parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0;\n }\n /**\n * Get the password policy configuration\n */\n get passwordPolicy() {\n return this._capabilities?.password_policy || {};\n }\n /**\n * Returns true if custom tokens are allowed\n */\n get allowCustomTokens() {\n return this._capabilities?.files_sharing?.public?.custom_tokens;\n }\n /**\n * Show federated shares as internal shares\n *\n * @return\n */\n get showFederatedSharesAsInternal() {\n return loadState('files_sharing', 'showFederatedSharesAsInternal', false);\n }\n /**\n * Show federated shares to trusted servers as internal shares\n *\n * @return\n */\n get showFederatedSharesToTrustedServersAsInternal() {\n return loadState('files_sharing', 'showFederatedSharesToTrustedServersAsInternal', false);\n }\n /**\n * Show the external share ui\n */\n get showExternalSharing() {\n return loadState('files_sharing', 'showExternalSharing', true);\n }\n}\n","/**\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { ATOMIC_PERMISSIONS } from '../lib/SharePermissionsToolBox.js'\nimport Share from '../models/Share.ts'\nimport Config from '../services/ConfigService.ts'\nimport logger from '../services/logger.ts'\n\nexport default {\n\tmethods: {\n\t\tasync openSharingDetails(shareRequestObject) {\n\t\t\tlet share\n\t\t\t// handle externalResults from OCA.Sharing.ShareSearch\n\t\t\t// TODO : Better name/interface for handler required\n\t\t\t// For example `externalAppCreateShareHook` with proper documentation\n\t\t\tif (shareRequestObject.handler) {\n\t\t\t\tconst handlerInput = {}\n\t\t\t\tif (this.suggestions) {\n\t\t\t\t\thandlerInput.suggestions = this.suggestions\n\t\t\t\t\thandlerInput.fileInfo = this.fileInfo\n\t\t\t\t\thandlerInput.query = this.query\n\t\t\t\t}\n\t\t\t\tconst externalShareRequestObject = await shareRequestObject.handler(handlerInput)\n\t\t\t\tshare = this.mapShareRequestToShareObject(externalShareRequestObject)\n\t\t\t} else {\n\t\t\t\tshare = this.mapShareRequestToShareObject(shareRequestObject)\n\t\t\t}\n\n\t\t\tif (this.fileInfo.type !== 'dir') {\n\t\t\t\tconst originalPermissions = share.permissions\n\t\t\t\tconst strippedPermissions = originalPermissions\n\t\t\t\t\t& ~ATOMIC_PERMISSIONS.CREATE\n\t\t\t\t\t& ~ATOMIC_PERMISSIONS.DELETE\n\n\t\t\t\tif (originalPermissions !== strippedPermissions) {\n\t\t\t\t\tlogger.debug('Removed create/delete permissions from file share (only valid for folders)')\n\t\t\t\t\tshare.permissions = strippedPermissions\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst shareDetails = {\n\t\t\t\tfileInfo: this.fileInfo,\n\t\t\t\tshare,\n\t\t\t}\n\n\t\t\tthis.$emit('open-sharing-details', shareDetails)\n\t\t},\n\t\topenShareDetailsForCustomSettings(share) {\n\t\t\tshare.setCustomPermissions = true\n\t\t\tthis.openSharingDetails(share)\n\t\t},\n\t\tmapShareRequestToShareObject(shareRequestObject) {\n\t\t\tif (shareRequestObject.id) {\n\t\t\t\treturn shareRequestObject\n\t\t\t}\n\n\t\t\tconst share = {\n\t\t\t\tattributes: [\n\t\t\t\t\t{\n\t\t\t\t\t\tvalue: true,\n\t\t\t\t\t\tkey: 'download',\n\t\t\t\t\t\tscope: 'permissions',\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\thideDownload: false,\n\t\t\t\tshare_type: shareRequestObject.shareType,\n\t\t\t\tshare_with: shareRequestObject.shareWith,\n\t\t\t\tis_no_user: shareRequestObject.isNoUser,\n\t\t\t\tuser: shareRequestObject.shareWith,\n\t\t\t\tshare_with_displayname: shareRequestObject.displayName,\n\t\t\t\tsubtitle: shareRequestObject.subtitle,\n\t\t\t\tpermissions: shareRequestObject.permissions ?? new Config().defaultPermissions,\n\t\t\t\texpiration: '',\n\t\t\t}\n\n\t\t\treturn new Share(share)\n\t\t},\n\t},\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport axios, { isAxiosError } from '@nextcloud/axios'\nimport { showError } from '@nextcloud/dialogs'\nimport { emit } from '@nextcloud/event-bus'\nimport { generateOcsUrl } from '@nextcloud/router'\nimport Share from '../models/Share.ts'\nimport logger from '../services/logger.ts'\n\nconst shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')\n\nexport default {\n\tmethods: {\n\t\t/**\n\t\t * Create a new share\n\t\t *\n\t\t * @param {object} data destructuring object\n\t\t * @param {string} data.path path to the file/folder which should be shared\n\t\t * @param {number} data.shareType 0 = user; 1 = group; 3 = public link; 6 = federated cloud share\n\t\t * @param {string} data.shareWith user/group id with which the file should be shared (optional for shareType > 1)\n\t\t * @param {boolean} [data.publicUpload] allow public upload to a public shared folder\n\t\t * @param {string} [data.password] password to protect public link Share with\n\t\t * @param {number} [data.permissions] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1)\n\t\t * @param {boolean} [data.sendPasswordByTalk] send the password via a talk conversation\n\t\t * @param {string} [data.expireDate] expire the share automatically after\n\t\t * @param {string} [data.label] custom label\n\t\t * @param {string} [data.attributes] Share attributes encoded as json\n\t\t * @param {string} data.note custom note to recipient\n\t\t * @return {Share} the new share\n\t\t * @throws {Error}\n\t\t */\n\t\tasync createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes }) {\n\t\t\ttry {\n\t\t\t\tconst request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes })\n\t\t\t\tif (!request?.data?.ocs) {\n\t\t\t\t\tthrow request\n\t\t\t\t}\n\t\t\t\tconst share = new Share(request.data.ocs.data)\n\t\t\t\temit('files_sharing:share:created', { share })\n\t\t\t\treturn share\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMessage = getErrorMessage(error) ?? t('files_sharing', 'Error creating the share')\n\t\t\t\tshowError(errorMessage)\n\t\t\t\tthrow new Error(errorMessage, { cause: error })\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Delete a share\n\t\t *\n\t\t * @param {number} id share id\n\t\t * @throws {Error}\n\t\t */\n\t\tasync deleteShare(id) {\n\t\t\ttry {\n\t\t\t\tconst request = await axios.delete(shareUrl + `/${id}`)\n\t\t\t\tif (!request?.data?.ocs) {\n\t\t\t\t\tthrow request\n\t\t\t\t}\n\t\t\t\temit('files_sharing:share:deleted', { id })\n\t\t\t\treturn true\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMessage = getErrorMessage(error) ?? t('files_sharing', 'Error deleting the share')\n\t\t\t\tshowError(errorMessage)\n\t\t\t\tthrow new Error(errorMessage, { cause: error })\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Update a share\n\t\t *\n\t\t * @param {number} id share id\n\t\t * @param {object} properties key-value object of the properties to update\n\t\t */\n\t\tasync updateShare(id, properties) {\n\t\t\ttry {\n\t\t\t\tconst request = await axios.put(shareUrl + `/${id}`, properties)\n\t\t\t\temit('files_sharing:share:updated', { id })\n\t\t\t\tif (!request?.data?.ocs) {\n\t\t\t\t\tthrow request\n\t\t\t\t} else {\n\t\t\t\t\treturn request.data.ocs.data\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error('Error while updating share', { error })\n\t\t\t\tconst errorMessage = getErrorMessage(error) ?? t('files_sharing', 'Error updating the share')\n\t\t\t\t// the error will be shown in apps/files_sharing/src/mixins/SharesMixin.js\n\t\t\t\tthrow new Error(errorMessage, { cause: error })\n\t\t\t}\n\t\t},\n\t},\n}\n\n/**\n * Handle an error response from the server and show a notification with the error message if possible\n *\n * @param {unknown} error - The received error\n * @return {string|undefined} the error message if it could be extracted from the response, otherwise undefined\n */\nfunction getErrorMessage(error) {\n\tif (isAxiosError(error) && error.response.data?.ocs) {\n\t\t/** @type {import('@nextcloud/typings/ocs').OCSResponse} */\n\t\tconst response = error.response.data\n\t\tif (response.ocs.meta?.message) {\n\t\t\treturn response.ocs.meta.message\n\t\t}\n\t}\n}\n","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=style&index=0&id=0b151499&prod&lang=scss\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=style&index=0&id=0b151499&prod&lang=scss\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingInput.vue?vue&type=template&id=0b151499\"\nimport script from \"./SharingInput.vue?vue&type=script&lang=js\"\nexport * from \"./SharingInput.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingInput.vue?vue&type=style&index=0&id=0b151499&prod&lang=scss\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c(_vm.section.element,{ref:\"sectionElement\",tag:\"component\",domProps:{\"node\":_vm.node}})\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import { render, staticRenderFns } from \"./SidebarTabExternalSection.vue?vue&type=template&id=9785f99e\"\nimport script from \"./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"\nexport * from \"./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c('div',{staticClass:\"sharing-tab-external-section-legacy\"},[_c(_setup.component,{tag:\"component\",attrs:{\"file-info\":_vm.fileInfo}})],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n import API from \"!../../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../../node_modules/css-loader/dist/cjs.js!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=style&index=0&id=3e4e67d2&prod&scoped=true&lang=css\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../../node_modules/css-loader/dist/cjs.js!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=style&index=0&id=3e4e67d2&prod&scoped=true&lang=css\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SidebarTabExternalSectionLegacy.vue?vue&type=template&id=3e4e67d2&scoped=true\"\nimport script from \"./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"\nexport * from \"./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"\nimport style0 from \"./SidebarTabExternalSectionLegacy.vue?vue&type=style&index=0&id=3e4e67d2&prod&scoped=true&lang=css\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"3e4e67d2\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"sharingTabDetailsView\"},[_c('div',{staticClass:\"sharingTabDetailsView__header\"},[_c('span',[(_vm.isUserShare)?_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"is-no-user\":_vm.share.shareType !== _vm.ShareType.User,\"user\":_vm.share.shareWith,\"display-name\":_vm.share.shareWithDisplayName,\"menu-position\":\"left\",\"url\":_vm.share.shareWithAvatar}}):_vm._e(),_vm._v(\" \"),_c(_vm.getShareTypeIcon(_vm.share.type),{tag:\"component\",attrs:{\"size\":32}})],1),_vm._v(\" \"),_c('span',[_c('h1',[_vm._v(_vm._s(_vm.title))])])]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__wrapper\"},[_c('div',{ref:\"quickPermissions\",staticClass:\"sharingTabDetailsView__quick-permissions\"},[_c('div',[_c('NcCheckboxRadioSwitch',{attrs:{\"button-variant\":true,\"data-cy-files-sharing-share-permissions-bundle\":\"read-only\",\"value\":_vm.bundledPermissions.READ_ONLY.toString(),\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.toggleCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('ViewIcon',{attrs:{\"size\":20}})]},proxy:true}]),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'View only'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"button-variant\":true,\"data-cy-files-sharing-share-permissions-bundle\":\"upload-edit\",\"value\":_vm.allPermissions,\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.toggleCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('EditIcon',{attrs:{\"size\":20}})]},proxy:true}]),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[(_vm.allowsFileDrop)?[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Allow upload and editing'))+\"\\n\\t\\t\\t\\t\\t\")]:[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Allow editing'))+\"\\n\\t\\t\\t\\t\\t\")]],2),_vm._v(\" \"),(_vm.allowsFileDrop)?_c('NcCheckboxRadioSwitch',{attrs:{\"data-cy-files-sharing-share-permissions-bundle\":\"file-drop\",\"button-variant\":true,\"value\":_vm.bundledPermissions.FILE_DROP.toString(),\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.toggleCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('UploadIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,1083194048),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'File request'))+\"\\n\\t\\t\\t\\t\\t\"),_c('small',{staticClass:\"subline\"},[_vm._v(_vm._s(_vm.t('files_sharing', 'Upload only')))])]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"button-variant\":true,\"data-cy-files-sharing-share-permissions-bundle\":\"custom\",\"value\":\"custom\",\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.expandCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('DotsHorizontalIcon',{attrs:{\"size\":20}})]},proxy:true}]),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Custom permissions'))+\"\\n\\t\\t\\t\\t\\t\"),_c('small',{staticClass:\"subline\"},[_vm._v(_vm._s(_vm.customPermissionsList))])])],1)]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__advanced-control\"},[_c('NcButton',{attrs:{\"id\":\"advancedSectionAccordionAdvancedControl\",\"variant\":\"tertiary\",\"alignment\":\"end-reverse\",\"aria-controls\":\"advancedSectionAccordionAdvanced\",\"aria-expanded\":_vm.advancedControlExpandedValue},on:{\"click\":function($event){_vm.advancedSectionAccordionExpanded = !_vm.advancedSectionAccordionExpanded}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [(!_vm.advancedSectionAccordionExpanded)?_c('MenuDownIcon'):_c('MenuUpIcon')]},proxy:true}])},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Advanced settings'))+\"\\n\\t\\t\\t\\t\")])],1),_vm._v(\" \"),(_vm.advancedSectionAccordionExpanded)?_c('div',{staticClass:\"sharingTabDetailsView__advanced\",attrs:{\"id\":\"advancedSectionAccordionAdvanced\",\"aria-labelledby\":\"advancedSectionAccordionAdvancedControl\",\"role\":\"region\"}},[_c('section',[(_vm.isPublicShare)?_c('NcInputField',{staticClass:\"sharingTabDetailsView__label\",attrs:{\"autocomplete\":\"off\",\"label\":_vm.t('files_sharing', 'Share label')},model:{value:(_vm.share.label),callback:function ($$v) {_vm.$set(_vm.share, \"label\", $$v)},expression:\"share.label\"}}):_vm._e(),_vm._v(\" \"),(_vm.config.allowCustomTokens && _vm.isPublicShare && !_vm.isNewShare)?_c('NcInputField',{attrs:{\"autocomplete\":\"off\",\"label\":_vm.t('files_sharing', 'Share link token'),\"helper-text\":_vm.t('files_sharing', 'Set the public share link token to something easy to remember or generate a new token. It is not recommended to use a guessable token for shares which contain sensitive information.'),\"show-trailing-button\":\"\",\"trailing-button-label\":_vm.loadingToken ? _vm.t('files_sharing', 'Generating…') : _vm.t('files_sharing', 'Generate new token')},on:{\"trailing-button-click\":_vm.generateNewToken},scopedSlots:_vm._u([{key:\"trailing-button-icon\",fn:function(){return [(_vm.loadingToken)?_c('NcLoadingIcon'):_c('Refresh',{attrs:{\"size\":20}})]},proxy:true}],null,false,4228062821),model:{value:(_vm.share.token),callback:function ($$v) {_vm.$set(_vm.share, \"token\", $$v)},expression:\"share.token\"}}):_vm._e(),_vm._v(\" \"),(_vm.isPublicShare)?[_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":_vm.isPasswordEnforced},model:{value:(_vm.isPasswordProtected),callback:function ($$v) {_vm.isPasswordProtected=$$v},expression:\"isPasswordProtected\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Set password'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.isPasswordProtected)?_c('NcPasswordField',{attrs:{\"autocomplete\":\"new-password\",\"model-value\":_vm.share.newPassword ?? '',\"error\":_vm.passwordError,\"helper-text\":_vm.errorPasswordLabel || _vm.passwordHint,\"required\":_vm.isPasswordEnforced && _vm.isNewShare,\"label\":_vm.t('files_sharing', 'Password')},on:{\"update:value\":_vm.onPasswordChange}}):_vm._e(),_vm._v(\" \"),(_vm.isEmailShareType && _vm.passwordExpirationTime)?_c('span',{attrs:{\"icon\":\"icon-info\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Password expires {passwordExpirationTime}', { passwordExpirationTime: _vm.passwordExpirationTime }))+\"\\n\\t\\t\\t\\t\\t\")]):(_vm.isEmailShareType && _vm.passwordExpirationTime !== null)?_c('span',{attrs:{\"icon\":\"icon-error\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Password expired'))+\"\\n\\t\\t\\t\\t\\t\")]):_vm._e()]:_vm._e(),_vm._v(\" \"),(_vm.canTogglePasswordProtectedByTalkAvailable)?_c('NcCheckboxRadioSwitch',{model:{value:(_vm.isPasswordProtectedByTalk),callback:function ($$v) {_vm.isPasswordProtectedByTalk=$$v},expression:\"isPasswordProtectedByTalk\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Video verification'))+\"\\n\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":_vm.isExpiryDateEnforced},model:{value:(_vm.hasExpirationDate),callback:function ($$v) {_vm.hasExpirationDate=$$v},expression:\"hasExpirationDate\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.isExpiryDateEnforced\n\t\t\t\t\t\t? _vm.t('files_sharing', 'Expiration date (enforced)')\n\t\t\t\t\t\t: _vm.t('files_sharing', 'Set expiration date'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.hasExpirationDate)?_c('NcDateTimePickerNative',{attrs:{\"id\":\"share-date-picker\",\"model-value\":new Date(_vm.share.expireDate ?? _vm.dateTomorrow),\"min\":_vm.dateTomorrow,\"max\":_vm.maxExpirationDateEnforced,\"hide-label\":\"\",\"label\":_vm.t('files_sharing', 'Expiration date'),\"placeholder\":_vm.t('files_sharing', 'Expiration date'),\"type\":\"date\"},on:{\"input\":_vm.onExpirationChange}}):_vm._e(),_vm._v(\" \"),(_vm.isPublicShare)?_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":_vm.canChangeHideDownload},model:{value:(_vm.share.hideDownload),callback:function ($$v) {_vm.$set(_vm.share, \"hideDownload\", $$v)},expression:\"share.hideDownload\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Hide download'))+\"\\n\\t\\t\\t\\t\")]):_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetDownload,\"data-cy-files-sharing-share-permissions-checkbox\":\"download\"},model:{value:(_vm.canDownload),callback:function ($$v) {_vm.canDownload=$$v},expression:\"canDownload\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Allow download and sync'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{model:{value:(_vm.writeNoteToRecipientIsChecked),callback:function ($$v) {_vm.writeNoteToRecipientIsChecked=$$v},expression:\"writeNoteToRecipientIsChecked\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Note to recipient'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.writeNoteToRecipientIsChecked)?[_c('NcTextArea',{attrs:{\"label\":_vm.t('files_sharing', 'Note to recipient'),\"placeholder\":_vm.t('files_sharing', 'Enter a note for the share recipient')},model:{value:(_vm.share.note),callback:function ($$v) {_vm.$set(_vm.share, \"note\", $$v)},expression:\"share.note\"}})]:_vm._e(),_vm._v(\" \"),(_vm.isPublicShare && _vm.isFolder)?_c('NcCheckboxRadioSwitch',{model:{value:(_vm.showInGridView),callback:function ($$v) {_vm.showInGridView=$$v},expression:\"showInGridView\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Show files in grid view'))+\"\\n\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_vm._l((_vm.sortedExternalShareActions),function(action){return _c('SidebarTabExternalAction',{key:action.id,ref:\"externalShareActions\",refInFor:true,attrs:{\"action\":action,\"node\":_vm.fileInfo.node /* TODO: Fix once we have proper Node API */,\"share\":_vm.share}})}),_vm._v(\" \"),_vm._l((_vm.externalLegacyShareActions),function(action){return _c('SidebarTabExternalActionLegacy',{key:action.id,ref:\"externalLinkActions\",refInFor:true,attrs:{\"id\":action.id,\"action\":action,\"file-info\":_vm.fileInfo,\"share\":_vm.share}})}),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{model:{value:(_vm.setCustomPermissions),callback:function ($$v) {_vm.setCustomPermissions=$$v},expression:\"setCustomPermissions\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Custom permissions'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.setCustomPermissions)?_c('section',{staticClass:\"custom-permissions-group\"},[_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canRemoveReadPermission,\"data-cy-files-sharing-share-permissions-checkbox\":\"read\"},model:{value:(_vm.hasRead),callback:function ($$v) {_vm.hasRead=$$v},expression:\"hasRead\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Read'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.isFolder)?_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetCreate,\"data-cy-files-sharing-share-permissions-checkbox\":\"create\"},model:{value:(_vm.canCreate),callback:function ($$v) {_vm.canCreate=$$v},expression:\"canCreate\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Create'))+\"\\n\\t\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetEdit,\"data-cy-files-sharing-share-permissions-checkbox\":\"update\"},model:{value:(_vm.canEdit),callback:function ($$v) {_vm.canEdit=$$v},expression:\"canEdit\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Edit'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.resharingIsPossible)?_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetReshare,\"data-cy-files-sharing-share-permissions-checkbox\":\"share\"},model:{value:(_vm.canReshare),callback:function ($$v) {_vm.canReshare=$$v},expression:\"canReshare\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Share'))+\"\\n\\t\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetDelete,\"data-cy-files-sharing-share-permissions-checkbox\":\"delete\"},model:{value:(_vm.canDelete),callback:function ($$v) {_vm.canDelete=$$v},expression:\"canDelete\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Delete'))+\"\\n\\t\\t\\t\\t\\t\")])],1):_vm._e()],2)]):_vm._e()]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__footer\"},[_c('div',{staticClass:\"button-group\"},[_c('NcButton',{attrs:{\"data-cy-files-sharing-share-editor-action\":\"cancel\"},on:{\"click\":_vm.cancel}},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Cancel'))+\"\\n\\t\\t\\t\")]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__delete\"},[(!_vm.isNewShare)?_c('NcButton',{attrs:{\"aria-label\":_vm.t('files_sharing', 'Delete share'),\"disabled\":false,\"readonly\":false,\"variant\":\"tertiary\"},on:{\"click\":function($event){$event.preventDefault();return _vm.removeShare.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CloseIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2428343285)},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Delete share'))+\"\\n\\t\\t\\t\\t\")]):_vm._e()],1),_vm._v(\" \"),_c('NcButton',{attrs:{\"variant\":\"primary\",\"data-cy-files-sharing-share-editor-action\":\"save\",\"disabled\":_vm.creating},on:{\"click\":_vm.saveShare},scopedSlots:_vm._u([(_vm.creating)?{key:\"icon\",fn:function(){return [_c('NcLoadingIcon')]},proxy:true}:null],null,true)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.shareButtonText)+\"\\n\\t\\t\\t\\t\")])],1)])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountCircleOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountCircleOutline.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./AccountCircleOutline.vue?vue&type=template&id=5b2fe1de\"\nimport script from \"./AccountCircleOutline.vue?vue&type=script&lang=js\"\nexport * from \"./AccountCircleOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon account-circle-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M7.07,18.28C7.5,17.38 10.12,16.5 12,16.5C13.88,16.5 16.5,17.38 16.93,18.28C15.57,19.36 13.86,20 12,20C10.14,20 8.43,19.36 7.07,18.28M18.36,16.83C16.93,15.09 13.46,14.5 12,14.5C10.54,14.5 7.07,15.09 5.64,16.83C4.62,15.5 4,13.82 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,13.82 19.38,15.5 18.36,16.83M12,6C10.06,6 8.5,7.56 8.5,9.5C8.5,11.44 10.06,13 12,13C13.94,13 15.5,11.44 15.5,9.5C15.5,7.56 13.94,6 12,6M12,11A1.5,1.5 0 0,1 10.5,9.5A1.5,1.5 0 0,1 12,8A1.5,1.5 0 0,1 13.5,9.5A1.5,1.5 0 0,1 12,11Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountGroup.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountGroup.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./AccountGroup.vue?vue&type=template&id=fa2b1464\"\nimport script from \"./AccountGroup.vue?vue&type=script&lang=js\"\nexport * from \"./AccountGroup.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon account-group-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./CircleOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./CircleOutline.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./CircleOutline.vue?vue&type=template&id=c013567c\"\nimport script from \"./CircleOutline.vue?vue&type=script&lang=js\"\nexport * from \"./CircleOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon circle-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Email.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Email.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./Email.vue?vue&type=template&id=7dd7f6aa\"\nimport script from \"./Email.vue?vue&type=script&lang=js\"\nexport * from \"./Email.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon email-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C22,4.89 21.1,4 20,4Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Eye.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Eye.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Eye.vue?vue&type=template&id=4ae2345c\"\nimport script from \"./Eye.vue?vue&type=script&lang=js\"\nexport * from \"./Eye.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon eye-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./ShareCircle.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./ShareCircle.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./ShareCircle.vue?vue&type=template&id=0e958886\"\nimport script from \"./ShareCircle.vue?vue&type=script&lang=js\"\nexport * from \"./ShareCircle.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon share-circle-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M14 16V13C10.39 13 7.81 14.43 6 17C6.72 13.33 8.94 9.73 14 9V6L19 11L14 16Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./TrayArrowUp.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./TrayArrowUp.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./TrayArrowUp.vue?vue&type=template&id=ae55bf4e\"\nimport script from \"./TrayArrowUp.vue?vue&type=script&lang=js\"\nexport * from \"./TrayArrowUp.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon tray-arrow-up-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M2 12H4V17H20V12H22V17C22 18.11 21.11 19 20 19H4C2.9 19 2 18.11 2 17V12M12 2L6.46 7.46L7.88 8.88L11 5.75V15H13V5.75L16.13 8.88L17.55 7.45L12 2Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c(_vm.action.element,{key:_vm.action.id,ref:\"actionElement\",tag:\"component\",domProps:{\"share\":_vm.share,\"node\":_vm.node,\"onSave\":_setup.onSave}})\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import { render, staticRenderFns } from \"./SidebarTabExternalAction.vue?vue&type=template&id=5ea2e6c7\"\nimport script from \"./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"\nexport * from \"./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"","\n\n\n\n\n","import { render, staticRenderFns } from \"./SidebarTabExternalActionLegacy.vue?vue&type=template&id=50e2cb04\"\nimport script from \"./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"\nexport * from \"./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c(_vm.data.is,_vm._g(_vm._b({tag:\"component\"},'component',_vm.data,false),_vm.action.handlers),[_vm._v(\"\\n\\t\"+_vm._s(_vm.data.text)+\"\\n\")])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","/*!\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav';\nexport const client = getClient();\n/**\n * Fetches a node from the given path\n *\n * @param path - The path to fetch the node from\n */\nexport async function fetchNode(path) {\n const propfindPayload = getDefaultPropfind();\n const result = await client.stat(`${getRootPath()}${path}`, {\n details: true,\n data: propfindPayload,\n });\n return resultToNode(result.data);\n}\n","/**\n * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport axios from '@nextcloud/axios';\nimport { showError, showSuccess } from '@nextcloud/dialogs';\nimport { t } from '@nextcloud/l10n';\nimport Config from '../services/ConfigService.ts';\nimport logger from '../services/logger.ts';\nconst config = new Config();\n// note: some chars removed on purpose to make them human friendly when read out\nconst passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789';\n/**\n * Generate a valid policy password or request a valid password if password_policy is enabled\n *\n * @param verbose If enabled the the status is shown to the user via toast\n */\nexport default async function (verbose = false) {\n // password policy is enabled, let's request a pass\n if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {\n try {\n const request = await axios.get(config.passwordPolicy.api.generate, {\n params: { context: 'sharing' },\n });\n if (request.data.ocs.data.password) {\n if (verbose) {\n showSuccess(t('files_sharing', 'Password created successfully'));\n }\n return request.data.ocs.data.password;\n }\n }\n catch (error) {\n logger.info('Error generating password from password_policy', { error });\n if (verbose) {\n showError(t('files_sharing', 'Error generating password from password policy'));\n }\n }\n }\n const array = new Uint8Array(10);\n const ratio = passwordSet.length / 255;\n getRandomValues(array);\n let password = '';\n for (let i = 0; i < array.length; i++) {\n password += passwordSet.charAt(array[i] * ratio);\n }\n return password;\n}\n/**\n * Fills the given array with cryptographically secure random values.\n * If the crypto API is not available, it falls back to less secure Math.random().\n * Crypto API is available in modern browsers on secure contexts (HTTPS).\n *\n * @param array - The array to fill with random values.\n */\nfunction getRandomValues(array) {\n if (self?.crypto?.getRandomValues) {\n self.crypto.getRandomValues(array);\n return;\n }\n let len = array.length;\n while (len--) {\n array[len] = Math.floor(Math.random() * 256);\n }\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { getCurrentUser } from '@nextcloud/auth'\nimport { showError, showSuccess } from '@nextcloud/dialogs'\nimport { emit } from '@nextcloud/event-bus'\nimport { ShareType } from '@nextcloud/sharing'\nimport debounce from 'debounce'\nimport PQueue from 'p-queue'\nimport { fetchNode } from '../../../files/src/services/WebdavClient.ts'\nimport { getBundledPermissions } from '../lib/SharePermissionsToolBox.js'\nimport Share from '../models/Share.ts'\nimport Config from '../services/ConfigService.ts'\nimport logger from '../services/logger.ts'\nimport GeneratePassword from '../utils/GeneratePassword.ts'\nimport SharesRequests from './ShareRequests.js'\n\nexport default {\n\tmixins: [SharesRequests],\n\n\tprops: {\n\t\tfileInfo: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => { },\n\t\t\trequired: true,\n\t\t},\n\t\tshare: {\n\t\t\ttype: Share,\n\t\t\tdefault: null,\n\t\t},\n\t\tisUnique: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tconfig: new Config(),\n\t\t\tnode: null,\n\t\t\tShareType,\n\n\t\t\t// errors helpers\n\t\t\terrors: {},\n\n\t\t\t// component status toggles\n\t\t\tloading: false,\n\t\t\tsaving: false,\n\t\t\topen: false,\n\n\t\t\t/** @type {boolean | undefined} */\n\t\t\tpasswordProtectedState: undefined,\n\n\t\t\t// concurrency management queue\n\t\t\t// we want one queue per share\n\t\t\tupdateQueue: new PQueue({ concurrency: 1 }),\n\n\t\t\t/**\n\t\t\t * ! This allow vue to make the Share class state reactive\n\t\t\t * ! do not remove it ot you'll lose all reactivity here\n\t\t\t */\n\t\t\treactiveState: this.share?.state,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\tpath() {\n\t\t\treturn (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')\n\t\t},\n\t\t/**\n\t\t * Does the current share have a note\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\thasNote: {\n\t\t\tget() {\n\t\t\t\treturn this.share.note !== ''\n\t\t\t},\n\t\t\tset(enabled) {\n\t\t\t\tthis.share.note = enabled\n\t\t\t\t\t? null // enabled but user did not changed the content yet\n\t\t\t\t\t: '' // empty = no note = disabled\n\t\t\t},\n\t\t},\n\n\t\tdateTomorrow() {\n\t\t\treturn new Date(new Date().setDate(new Date().getDate() + 1))\n\t\t},\n\n\t\t// Datepicker language\n\t\tlang() {\n\t\t\tconst weekdaysShort = window.dayNamesShort\n\t\t\t\t? window.dayNamesShort // provided by Nextcloud\n\t\t\t\t: ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.']\n\t\t\tconst monthsShort = window.monthNamesShort\n\t\t\t\t? window.monthNamesShort // provided by Nextcloud\n\t\t\t\t: ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']\n\t\t\tconst firstDayOfWeek = window.firstDay ? window.firstDay : 0\n\n\t\t\treturn {\n\t\t\t\tformatLocale: {\n\t\t\t\t\tfirstDayOfWeek,\n\t\t\t\t\tmonthsShort,\n\t\t\t\t\tweekdaysMin: weekdaysShort,\n\t\t\t\t\tweekdaysShort,\n\t\t\t\t},\n\t\t\t\tmonthFormat: 'MMM',\n\t\t\t}\n\t\t},\n\t\tisNewShare() {\n\t\t\treturn !this.share.id\n\t\t},\n\t\tisFolder() {\n\t\t\treturn this.fileInfo.type === 'dir'\n\t\t},\n\t\tisPublicShare() {\n\t\t\tconst shareType = this.share.shareType ?? this.share.type\n\t\t\treturn [ShareType.Link, ShareType.Email].includes(shareType)\n\t\t},\n\t\tisRemoteShare() {\n\t\t\treturn this.share.type === ShareType.RemoteGroup || this.share.type === ShareType.Remote\n\t\t},\n\t\tisShareOwner() {\n\t\t\treturn this.share && this.share.owner === getCurrentUser().uid\n\t\t},\n\t\tisExpiryDateEnforced() {\n\t\t\tif (this.isPublicShare) {\n\t\t\t\treturn this.config.isDefaultExpireDateEnforced\n\t\t\t}\n\t\t\tif (this.isRemoteShare) {\n\t\t\t\treturn this.config.isDefaultRemoteExpireDateEnforced\n\t\t\t}\n\t\t\treturn this.config.isDefaultInternalExpireDateEnforced\n\t\t},\n\t\thasCustomPermissions() {\n\t\t\tconst basePermissions = getBundledPermissions(this.config.excludeReshareFromEdit)\n\t\t\tconst bundledPermissions = [\n\t\t\t\tbasePermissions.ALL,\n\t\t\t\tbasePermissions.ALL_FILE,\n\t\t\t\tbasePermissions.READ_ONLY,\n\t\t\t\tbasePermissions.FILE_DROP,\n\t\t\t]\n\t\t\treturn !bundledPermissions.includes(this.share.permissions)\n\t\t},\n\t\tmaxExpirationDateEnforced() {\n\t\t\tif (this.isExpiryDateEnforced) {\n\t\t\t\tif (this.isPublicShare) {\n\t\t\t\t\treturn this.config.defaultExpirationDate\n\t\t\t\t}\n\t\t\t\tif (this.isRemoteShare) {\n\t\t\t\t\treturn this.config.defaultRemoteExpirationDateString\n\t\t\t\t}\n\t\t\t\t// If it get's here then it must be an internal share\n\t\t\t\treturn this.config.defaultInternalExpirationDate\n\t\t\t}\n\t\t\treturn null\n\t\t},\n\t\t/**\n\t\t * Is the current share password protected ?\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tisPasswordProtected: {\n\t\t\tget() {\n\t\t\t\tif (this.config.enforcePasswordForPublicLink) {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tif (this.passwordProtectedState !== undefined) {\n\t\t\t\t\treturn this.passwordProtectedState\n\t\t\t\t}\n\t\t\t\treturn typeof this.share.newPassword === 'string'\n\t\t\t\t\t|| typeof this.share.password === 'string'\n\t\t\t},\n\t\t\tasync set(enabled) {\n\t\t\t\tif (enabled) {\n\t\t\t\t\tthis.passwordProtectedState = true\n\t\t\t\t\tconst generatedPassword = await GeneratePassword(true)\n\t\t\t\t\tif (!this.share.newPassword) {\n\t\t\t\t\t\tthis.$set(this.share, 'newPassword', generatedPassword)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tthis.passwordProtectedState = false\n\t\t\t\t\tthis.$set(this.share, 'newPassword', '')\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Fetch WebDAV node\n\t\t *\n\t\t * @return {Node}\n\t\t */\n\t\tasync getNode() {\n\t\t\tconst node = { path: this.path }\n\t\t\ttry {\n\t\t\t\tthis.node = await fetchNode(node.path)\n\t\t\t\tlogger.info('Fetched node:', { node: this.node })\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error('Error:', error)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Check if a share is valid before\n\t\t * firing the request\n\t\t *\n\t\t * @param {Share} share the share to check\n\t\t * @return {boolean}\n\t\t */\n\t\tcheckShare(share) {\n\t\t\tif (share.password) {\n\t\t\t\tif (typeof share.password !== 'string' || share.password.trim() === '') {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (share.newPassword) {\n\t\t\t\tif (typeof share.newPassword !== 'string') {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (share.expirationDate) {\n\t\t\t\tconst date = share.expirationDate\n\t\t\t\tif (!date.isValid()) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t},\n\n\t\t/**\n\t\t * @param {Date} date the date to format\n\t\t * @return {string} date a date with YYYY-MM-DD format\n\t\t */\n\t\tformatDateToString(date) {\n\t\t\t// Force utc time. Drop time information to be timezone-less\n\t\t\tconst utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))\n\t\t\t// Format to YYYY-MM-DD\n\t\t\treturn utcDate.toISOString().split('T')[0]\n\t\t},\n\n\t\t/**\n\t\t * Save given value to expireDate and trigger queueUpdate\n\t\t *\n\t\t * @param {Date} date\n\t\t */\n\t\tonExpirationChange(date) {\n\t\t\tif (!date) {\n\t\t\t\tthis.share.expireDate = null\n\t\t\t\tthis.$set(this.share, 'expireDate', null)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst parsedDate = (date instanceof Date) ? date : new Date(date)\n\t\t\tthis.share.expireDate = this.formatDateToString(parsedDate)\n\t\t},\n\n\t\t/**\n\t\t * Delete share button handler\n\t\t */\n\t\tasync onDelete() {\n\t\t\ttry {\n\t\t\t\tthis.loading = true\n\t\t\t\tthis.open = false\n\t\t\t\tawait this.deleteShare(this.share.id)\n\t\t\t\tlogger.debug('Share deleted', { shareId: this.share.id })\n\t\t\t\tconst path = this.share.path.replace(/^\\//, '')\n\t\t\t\tconst message = this.share.itemType === 'file'\n\t\t\t\t\t? t('files_sharing', 'File \"{path}\" has been unshared', { path })\n\t\t\t\t\t: t('files_sharing', 'Folder \"{path}\" has been unshared', { path })\n\t\t\t\tshowSuccess(message)\n\t\t\t\tthis.$emit('remove:share', this.share)\n\t\t\t\tawait this.getNode()\n\t\t\t\temit('files:node:updated', this.node)\n\t\t\t} catch {\n\t\t\t\t// re-open menu if error\n\t\t\t\tthis.open = true\n\t\t\t} finally {\n\t\t\t\tthis.loading = false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Send an update of the share to the queue\n\t\t *\n\t\t * @param {Array} propertyNames the properties to sync\n\t\t */\n\t\tqueueUpdate(...propertyNames) {\n\t\t\tif (propertyNames.length === 0) {\n\t\t\t\t// Nothing to update\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (this.share.id) {\n\t\t\t\tconst properties = {}\n\t\t\t\t// force value to string because that is what our\n\t\t\t\t// share api controller accepts\n\t\t\t\tfor (const name of propertyNames) {\n\t\t\t\t\tif (name === 'password') {\n\t\t\t\t\t\tif (this.share.newPassword !== undefined) {\n\t\t\t\t\t\t\tproperties[name] = this.share.newPassword\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this.share[name] === null || this.share[name] === undefined) {\n\t\t\t\t\t\tproperties[name] = ''\n\t\t\t\t\t} else if ((typeof this.share[name]) === 'object') {\n\t\t\t\t\t\tproperties[name] = JSON.stringify(this.share[name])\n\t\t\t\t\t} else {\n\t\t\t\t\t\tproperties[name] = this.share[name].toString()\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn this.updateQueue.add(async () => {\n\t\t\t\t\tthis.saving = true\n\t\t\t\t\tthis.errors = {}\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst updatedShare = await this.updateShare(this.share.id, properties)\n\n\t\t\t\t\t\tif (propertyNames.includes('password')) {\n\t\t\t\t\t\t\t// reset password state after sync\n\t\t\t\t\t\t\tthis.share.password = this.share.newPassword || undefined\n\t\t\t\t\t\t\tthis.$set(this.share, 'newPassword', undefined)\n\n\t\t\t\t\t\t\t// updates password expiration time after sync\n\t\t\t\t\t\t\tthis.share.passwordExpirationTime = updatedShare.password_expiration_time\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// clear any previous errors\n\t\t\t\t\t\tfor (const property of propertyNames) {\n\t\t\t\t\t\t\tthis.$delete(this.errors, property)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tshowSuccess(this.updateSuccessMessage(propertyNames))\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tlogger.error('Could not update share', { error, share: this.share, propertyNames })\n\n\t\t\t\t\t\tconst { message } = error\n\t\t\t\t\t\tif (message && message !== '') {\n\t\t\t\t\t\t\tfor (const property of propertyNames) {\n\t\t\t\t\t\t\t\tthis.onSyncError(property, message)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tshowError(message)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// We do not have information what happened, but we should still inform the user\n\t\t\t\t\t\t\tshowError(t('files_sharing', 'Could not update share'))\n\t\t\t\t\t\t}\n\t\t\t\t\t} finally {\n\t\t\t\t\t\tthis.saving = false\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// This share does not exists on the server yet\n\t\t\tlogger.debug('Updated local share', { share: this.share })\n\t\t},\n\n\t\t/**\n\t\t * @param {string[]} names Properties changed\n\t\t */\n\t\tupdateSuccessMessage(names) {\n\t\t\tif (names.length !== 1) {\n\t\t\t\treturn t('files_sharing', 'Share saved')\n\t\t\t}\n\n\t\t\tswitch (names[0]) {\n\t\t\t\tcase 'expireDate':\n\t\t\t\t\treturn t('files_sharing', 'Share expiry date saved')\n\t\t\t\tcase 'hideDownload':\n\t\t\t\t\treturn t('files_sharing', 'Share hide-download state saved')\n\t\t\t\tcase 'label':\n\t\t\t\t\treturn t('files_sharing', 'Share label saved')\n\t\t\t\tcase 'note':\n\t\t\t\t\treturn t('files_sharing', 'Share note for recipient saved')\n\t\t\t\tcase 'password':\n\t\t\t\t\treturn t('files_sharing', 'Share password saved')\n\t\t\t\tcase 'permissions':\n\t\t\t\t\treturn t('files_sharing', 'Share permissions saved')\n\t\t\t\tdefault:\n\t\t\t\t\treturn t('files_sharing', 'Share saved')\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Manage sync errors\n\t\t *\n\t\t * @param {string} property the errored property, e.g. 'password'\n\t\t * @param {string} message the error message\n\t\t */\n\t\tonSyncError(property, message) {\n\t\t\tif (property === 'password' && this.share.newPassword !== undefined) {\n\t\t\t\tif (this.share.newPassword === this.share.password) {\n\t\t\t\t\tthis.share.password = ''\n\t\t\t\t}\n\t\t\t\tthis.$set(this.share, 'newPassword', undefined)\n\t\t\t}\n\n\t\t\t// re-open menu if closed\n\t\t\tthis.open = true\n\t\t\tswitch (property) {\n\t\t\t\tcase 'password':\n\t\t\t\tcase 'pending':\n\t\t\t\tcase 'expireDate':\n\t\t\t\tcase 'label':\n\t\t\t\tcase 'note': {\n\t\t\t\t// show error\n\t\t\t\t\tthis.$set(this.errors, property, message)\n\n\t\t\t\t\tlet propertyEl = this.$refs[property]\n\t\t\t\t\tif (propertyEl) {\n\t\t\t\t\t\tif (propertyEl.$el) {\n\t\t\t\t\t\t\tpropertyEl = propertyEl.$el\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// focus if there is a focusable action element\n\t\t\t\t\t\tconst focusable = propertyEl.querySelector('.focusable')\n\t\t\t\t\t\tif (focusable) {\n\t\t\t\t\t\t\tfocusable.focus()\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tcase 'sendPasswordByTalk': {\n\t\t\t\t// show error\n\t\t\t\t\tthis.$set(this.errors, property, message)\n\n\t\t\t\t\t// Restore previous state\n\t\t\t\t\tthis.share.sendPasswordByTalk = !this.share.sendPasswordByTalk\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t/**\n\t\t * Debounce queueUpdate to avoid requests spamming\n\t\t * more importantly for text data\n\t\t *\n\t\t * @param {string} property the property to sync\n\t\t */\n\t\tdebounceQueueUpdate: debounce(function(property) {\n\t\t\tthis.queueUpdate(property)\n\t\t}, 500),\n\t},\n}\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n","/*!\n * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: GPL-3.0-or-later\n */\nimport isSvg from 'is-svg';\n/**\n * Register a new sidebar action\n *\n * @param action - The action to register\n */\nexport function registerSidebarAction(action) {\n if (!action.id) {\n throw new Error('Sidebar actions must have an id');\n }\n if (!action.element || !action.element.startsWith('oca_') || !window.customElements.get(action.element)) {\n throw new Error('Sidebar actions must provide a registered custom web component identifier');\n }\n if (typeof action.order !== 'number') {\n throw new Error('Sidebar actions must have the order property');\n }\n if (typeof action.enabled !== 'function') {\n throw new Error('Sidebar actions must implement the \"enabled\" method');\n }\n window._nc_files_sharing_sidebar_actions ??= new Map();\n if (window._nc_files_sharing_sidebar_actions.has(action.id)) {\n throw new Error(`Sidebar action with id \"${action.id}\" is already registered`);\n }\n window._nc_files_sharing_sidebar_actions.set(action.id, action);\n}\n/**\n * Register a new sidebar action\n *\n * @param action - The action to register\n */\nexport function registerSidebarInlineAction(action) {\n if (!action.id) {\n throw new Error('Sidebar actions must have an id');\n }\n if (typeof action.order !== 'number') {\n throw new Error('Sidebar actions must have the \"order\" property');\n }\n if (typeof action.iconSvg !== 'string' || !isSvg(action.iconSvg)) {\n throw new Error('Sidebar actions must have the \"iconSvg\" property');\n }\n if (typeof action.label !== 'function') {\n throw new Error('Sidebar actions must implement the \"label\" method');\n }\n if (typeof action.exec !== 'function') {\n throw new Error('Sidebar actions must implement the \"exec\" method');\n }\n if (typeof action.enabled !== 'function') {\n throw new Error('Sidebar actions must implement the \"enabled\" method');\n }\n window._nc_files_sharing_sidebar_inline_actions ??= new Map();\n if (window._nc_files_sharing_sidebar_inline_actions.has(action.id)) {\n throw new Error(`Sidebar action with id \"${action.id}\" is already registered`);\n }\n window._nc_files_sharing_sidebar_inline_actions.set(action.id, action);\n}\n/**\n * Get all registered sidebar actions\n */\nexport function getSidebarActions() {\n return [...(window._nc_files_sharing_sidebar_actions?.values() ?? [])];\n}\n/**\n * Get all registered sidebar inline actions\n */\nexport function getSidebarInlineActions() {\n return [...(window._nc_files_sharing_sidebar_inline_actions?.values() ?? [])];\n}\n","/**\n * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport axios from '@nextcloud/axios';\nimport { generateOcsUrl } from '@nextcloud/router';\n/**\n *\n */\nexport async function generateToken() {\n const { data } = await axios.get(generateOcsUrl('/apps/files_sharing/api/v1/token'));\n return data.ocs.data.token;\n}\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=style&index=0&id=1e0a769c&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=style&index=0&id=1e0a769c&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingDetailsTab.vue?vue&type=template&id=1e0a769c&scoped=true\"\nimport script from \"./SharingDetailsTab.vue?vue&type=script&lang=js\"\nexport * from \"./SharingDetailsTab.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingDetailsTab.vue?vue&type=style&index=0&id=1e0a769c&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"1e0a769c\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('ul',{attrs:{\"id\":\"sharing-inherited-shares\"}},[_c('SharingEntrySimple',{staticClass:\"sharing-entry__inherited\",attrs:{\"title\":_vm.mainTitle,\"subtitle\":_vm.subTitle,\"aria-expanded\":_vm.showInheritedShares},scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('div',{staticClass:\"avatar-shared icon-more-white\"})]},proxy:true}])},[_vm._v(\" \"),_c('NcActionButton',{attrs:{\"icon\":_vm.showInheritedSharesIcon,\"aria-label\":_vm.toggleTooltip,\"title\":_vm.toggleTooltip},on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.toggleInheritedShares.apply(null, arguments)}}})],1),_vm._v(\" \"),_vm._l((_vm.shares),function(share){return _c('SharingEntryInherited',{key:share.id,attrs:{\"file-info\":_vm.fileInfo,\"share\":share},on:{\"remove:share\":_vm.removeShare}})})],2)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=style&index=0&id=731a9650&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=style&index=0&id=731a9650&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryInherited.vue?vue&type=template&id=731a9650&scoped=true\"\nimport script from \"./SharingEntryInherited.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryInherited.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryInherited.vue?vue&type=style&index=0&id=731a9650&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"731a9650\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('SharingEntrySimple',{key:_vm.share.id,staticClass:\"sharing-entry__inherited\",attrs:{\"title\":_vm.share.shareWithDisplayName},scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"user\":_vm.share.shareWith,\"display-name\":_vm.share.shareWithDisplayName}})]},proxy:true}])},[_vm._v(\" \"),_c('NcActionText',{attrs:{\"icon\":\"icon-user\"}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Added by {initiator}', { initiator: _vm.share.ownerDisplayName }))+\"\\n\\t\")]),_vm._v(\" \"),(_vm.share.viaPath && _vm.share.viaFileid)?_c('NcActionLink',{attrs:{\"icon\":\"icon-folder\",\"href\":_vm.viaFileTargetUrl}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Via “{folder}”', { folder: _vm.viaFolderName }))+\"\\n\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.share.canDelete)?_c('NcActionButton',{attrs:{\"icon\":\"icon-close\"},on:{\"click\":function($event){$event.preventDefault();return _vm.onDelete.apply(null, arguments)}}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Unshare'))+\"\\n\\t\")]):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=style&index=0&id=cedf3238&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=style&index=0&id=cedf3238&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingInherited.vue?vue&type=template&id=cedf3238&scoped=true\"\nimport script from \"./SharingInherited.vue?vue&type=script&lang=js\"\nexport * from \"./SharingInherited.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingInherited.vue?vue&type=style&index=0&id=cedf3238&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"cedf3238\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return (_vm.canLinkShare)?_c('ul',{staticClass:\"sharing-link-list\",attrs:{\"aria-label\":_vm.t('files_sharing', 'Link shares')}},[(_vm.hasShares)?_vm._l((_vm.shares),function(share,index){return _c('SharingEntryLink',{key:share.id,attrs:{\"index\":_vm.shares.length > 1 ? index + 1 : null,\"can-reshare\":_vm.canReshare,\"share\":_vm.shares[index],\"file-info\":_vm.fileInfo},on:{\"update:share\":[function($event){return _vm.$set(_vm.shares, index, $event)},function($event){return _vm.awaitForShare(...arguments)}],\"add:share\":function($event){return _vm.addShare(...arguments)},\"remove:share\":_vm.removeShare,\"open-sharing-details\":function($event){return _vm.openSharingDetails(share)}}})}):_vm._e(),_vm._v(\" \"),(!_vm.hasLinkShares && _vm.canReshare)?_c('SharingEntryLink',{attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo},on:{\"add:share\":_vm.addShare}}):_vm._e()],2):_vm._e()\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./CalendarBlankOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./CalendarBlankOutline.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./CalendarBlankOutline.vue?vue&type=template&id=784b59e6\"\nimport script from \"./CalendarBlankOutline.vue?vue&type=script&lang=js\"\nexport * from \"./CalendarBlankOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon calendar-blank-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M19 3H18V1H16V3H8V1H6V3H5C3.89 3 3 3.9 3 5V19C3 20.11 3.9 21 5 21H19C20.11 21 21 20.11 21 19V5C21 3.9 20.11 3 19 3M19 19H5V9H19V19M19 7H5V5H19V7Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./CheckBold.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./CheckBold.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./CheckBold.vue?vue&type=template&id=5603f41f\"\nimport script from \"./CheckBold.vue?vue&type=script&lang=js\"\nexport * from \"./CheckBold.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon check-bold-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Exclamation.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Exclamation.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Exclamation.vue?vue&type=template&id=03239926\"\nimport script from \"./Exclamation.vue?vue&type=script&lang=js\"\nexport * from \"./Exclamation.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon exclamation-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M 11,4L 13,4L 13,15L 11,15L 11,4 Z M 13,18L 13,20L 11,20L 11,18L 13,18 Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./LockOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./LockOutline.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./LockOutline.vue?vue&type=template&id=54353a96\"\nimport script from \"./LockOutline.vue?vue&type=script&lang=js\"\nexport * from \"./LockOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon lock-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,17C10.89,17 10,16.1 10,15C10,13.89 10.89,13 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17M18,20V10H6V20H18M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10C4,8.89 4.89,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Plus.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Plus.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Plus.vue?vue&type=template&id=055261ec\"\nimport script from \"./Plus.vue?vue&type=script&lang=js\"\nexport * from \"./Plus.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon plus-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Qrcode.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Qrcode.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Qrcode.vue?vue&type=template&id=aba87788\"\nimport script from \"./Qrcode.vue?vue&type=script&lang=js\"\nexport * from \"./Qrcode.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon qrcode-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M3,11H5V13H3V11M11,5H13V9H11V5M9,11H13V15H11V13H9V11M15,11H17V13H19V11H21V13H19V15H21V19H19V21H17V19H13V21H11V17H15V15H17V13H15V11M19,19V15H17V19H19M15,3H21V9H15V3M17,5V7H19V5H17M3,3H9V9H3V3M5,5V7H7V5H5M3,15H9V21H3V15M5,17V19H7V17H5Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Tune.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Tune.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Tune.vue?vue&type=template&id=18d04e6a\"\nimport script from \"./Tune.vue?vue&type=script&lang=js\"\nexport * from \"./Tune.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon tune-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M3,17V19H9V17H3M3,5V7H13V5H3M13,21V19H21V17H13V15H11V21H13M7,9V11H3V13H7V15H9V9H7M21,13V11H11V13H21M15,9H17V7H21V5H17V3H15V9Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"share-expiry-time\"},[_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [(_vm.expiryTime)?_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary\",\"aria-label\":_vm.t('files_sharing', 'Share expiration: {date}', { date: new Date(_vm.expiryTime).toLocaleString() })},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('ClockIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,3754271979)}):_vm._e()]},proxy:true}])},[_vm._v(\" \"),_c('h3',{staticClass:\"hint-heading\"},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Share Expiration'))+\"\\n\\t\\t\")]),_vm._v(\" \"),(_vm.expiryTime)?_c('p',{staticClass:\"hint-body\"},[_c('NcDateTime',{attrs:{\"timestamp\":_vm.expiryTime,\"format\":_vm.timeFormat,\"relative-time\":false}}),_vm._v(\" (\"),_c('NcDateTime',{attrs:{\"timestamp\":_vm.expiryTime}}),_vm._v(\")\\n\\t\\t\")],1):_vm._e()])],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./ClockOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./ClockOutline.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./ClockOutline.vue?vue&type=template&id=1a84e403\"\nimport script from \"./ClockOutline.vue?vue&type=script&lang=js\"\nexport * from \"./ClockOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon clock-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=style&index=0&id=c9199db0&prod&scoped=true&lang=scss\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=style&index=0&id=c9199db0&prod&scoped=true&lang=scss\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./ShareExpiryTime.vue?vue&type=template&id=c9199db0&scoped=true\"\nimport script from \"./ShareExpiryTime.vue?vue&type=script&lang=js\"\nexport * from \"./ShareExpiryTime.vue?vue&type=script&lang=js\"\nimport style0 from \"./ShareExpiryTime.vue?vue&type=style&index=0&id=c9199db0&prod&scoped=true&lang=scss\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"c9199db0\",\n null\n \n)\n\nexport default component.exports","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./EyeOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./EyeOutline.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./EyeOutline.vue?vue&type=template&id=e26de6f6\"\nimport script from \"./EyeOutline.vue?vue&type=script&lang=js\"\nexport * from \"./EyeOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon eye-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C21.27,16.39 17,19.5 12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C15.76,17.5 19.17,15.36 20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./TriangleSmallDown.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./TriangleSmallDown.vue?vue&type=script&lang=js\"","\n\n","\n\n\n\n\n\n","import { render, staticRenderFns } from \"./TriangleSmallDown.vue?vue&type=template&id=1eed3dd9\"\nimport script from \"./TriangleSmallDown.vue?vue&type=script&lang=js\"\nexport * from \"./TriangleSmallDown.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon triangle-small-down-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M8 9H16L12 16\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=style&index=0&id=5ae7b89a&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=style&index=0&id=5ae7b89a&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryQuickShareSelect.vue?vue&type=template&id=5ae7b89a&scoped=true\"\nimport script from \"./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryQuickShareSelect.vue?vue&type=style&index=0&id=5ae7b89a&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"5ae7b89a\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('NcActions',{ref:\"quickShareActions\",staticClass:\"share-select\",attrs:{\"menu-name\":_vm.selectedOption,\"aria-label\":_vm.ariaLabel,\"variant\":\"tertiary-no-background\",\"disabled\":!_vm.share.canEdit,\"force-name\":\"\"},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('DropdownIcon',{attrs:{\"size\":15}})]},proxy:true}])},[_vm._v(\" \"),_vm._l((_vm.options),function(option){return _c('NcActionButton',{key:option.label,attrs:{\"type\":\"radio\",\"model-value\":option.label === _vm.selectedOption,\"close-after-click\":\"\"},on:{\"click\":function($event){return _vm.selectOption(option.label)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c(option.icon,{tag:\"component\"})]},proxy:true}],null,true)},[_vm._v(\"\\n\\t\\t\"+_vm._s(option.label)+\"\\n\\t\")])})],2)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=style&index=0&id=7a5c0ee5&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=style&index=0&id=7a5c0ee5&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryLink.vue?vue&type=template&id=7a5c0ee5&scoped=true\"\nimport script from \"./SharingEntryLink.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryLink.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryLink.vue?vue&type=style&index=0&id=7a5c0ee5&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"7a5c0ee5\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('li',{staticClass:\"sharing-entry sharing-entry__link\",class:{ 'sharing-entry--share': _vm.share }},[_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"is-no-user\":true,\"icon-class\":_vm.isEmailShareType ? 'avatar-link-share icon-mail-white' : 'avatar-link-share icon-public-white'}}),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__summary\"},[_c('div',{staticClass:\"sharing-entry__desc\"},[_c('span',{staticClass:\"sharing-entry__title\",attrs:{\"title\":_vm.title}},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.title)+\"\\n\\t\\t\\t\")]),_vm._v(\" \"),(_vm.subtitle)?_c('p',[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.subtitle)+\"\\n\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.share && _vm.share.permissions !== undefined)?_c('SharingEntryQuickShareSelect',{attrs:{\"share\":_vm.share,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":function($event){return _vm.openShareDetailsForCustomSettings(_vm.share)}}}):_vm._e()],1),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__actions\"},[(_vm.share && _vm.share.expireDate)?_c('ShareExpiryTime',{attrs:{\"share\":_vm.share}}):_vm._e(),_vm._v(\" \"),_c('div',[(_vm.share && (!_vm.isEmailShareType || _vm.isFileRequest) && _vm.share.token)?_c('NcActions',{ref:\"copyButton\",staticClass:\"sharing-entry__copy\"},[_c('NcActionButton',{attrs:{\"aria-label\":_vm.copyLinkLabel,\"title\":_vm.copySuccess ? _vm.t('files_sharing', 'Successfully copied public link') : undefined,\"href\":_vm.shareLink},on:{\"click\":function($event){$event.preventDefault();return _vm.copyLink.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('NcIconSvgWrapper',{staticClass:\"sharing-entry__copy-icon\",class:{ 'sharing-entry__copy-icon--success': _vm.copySuccess },attrs:{\"path\":_vm.copySuccess ? _vm.mdiCheck : _vm.mdiContentCopy}})]},proxy:true}],null,false,1728815133)})],1):_vm._e()],1)],1)]),_vm._v(\" \"),(!_vm.pending && _vm.pendingDataIsMissing)?_c('NcActions',{staticClass:\"sharing-entry__actions\",attrs:{\"aria-label\":_vm.actionsTooltip,\"menu-align\":\"right\",\"open\":_vm.open},on:{\"update:open\":function($event){_vm.open=$event},\"close\":_vm.onCancel}},[(_vm.errors.pending)?_c('NcActionText',{staticClass:\"error\",scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('ErrorIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,1966124155)},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.errors.pending)+\"\\n\\t\\t\")]):_c('NcActionText',{attrs:{\"icon\":\"icon-info\"}},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Please enter the following required information before creating the share'))+\"\\n\\t\\t\")]),_vm._v(\" \"),(_vm.pendingPassword)?_c('NcActionCheckbox',{staticClass:\"share-link-password-checkbox\",attrs:{\"disabled\":_vm.config.enforcePasswordForPublicLink || _vm.saving},on:{\"uncheck\":_vm.onPasswordDisable},model:{value:(_vm.isPasswordProtected),callback:function ($$v) {_vm.isPasswordProtected=$$v},expression:\"isPasswordProtected\"}},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.config.enforcePasswordForPublicLink ? _vm.t('files_sharing', 'Password protection (enforced)') : _vm.t('files_sharing', 'Password protection'))+\"\\n\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.pendingEnforcedPassword || _vm.isPasswordProtected)?_c('NcActionInput',{staticClass:\"share-link-password\",attrs:{\"label\":_vm.t('files_sharing', 'Enter a password'),\"disabled\":_vm.saving,\"required\":_vm.config.enableLinkPasswordByDefault || _vm.config.enforcePasswordForPublicLink,\"minlength\":_vm.minPasswordLength,\"autocomplete\":\"new-password\"},on:{\"submit\":function($event){return _vm.onNewLinkShare(true)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('LockIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2056568168),model:{value:(_vm.share.newPassword),callback:function ($$v) {_vm.$set(_vm.share, \"newPassword\", $$v)},expression:\"share.newPassword\"}}):_vm._e(),_vm._v(\" \"),(_vm.pendingDefaultExpirationDate)?_c('NcActionCheckbox',{staticClass:\"share-link-expiration-date-checkbox\",attrs:{\"disabled\":_vm.pendingEnforcedExpirationDate || _vm.saving},on:{\"update:model-value\":_vm.onExpirationDateToggleUpdate},model:{value:(_vm.defaultExpirationDateEnabled),callback:function ($$v) {_vm.defaultExpirationDateEnabled=$$v},expression:\"defaultExpirationDateEnabled\"}},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.config.isDefaultExpireDateEnforced ? _vm.t('files_sharing', 'Enable link expiration (enforced)') : _vm.t('files_sharing', 'Enable link expiration'))+\"\\n\\t\\t\")]):_vm._e(),_vm._v(\" \"),((_vm.pendingDefaultExpirationDate || _vm.pendingEnforcedExpirationDate) && _vm.defaultExpirationDateEnabled)?_c('NcActionInput',{staticClass:\"share-link-expire-date\",attrs:{\"data-cy-files-sharing-expiration-date-input\":\"\",\"label\":_vm.pendingEnforcedExpirationDate ? _vm.t('files_sharing', 'Enter expiration date (enforced)') : _vm.t('files_sharing', 'Enter expiration date'),\"disabled\":_vm.saving,\"is-native-picker\":true,\"hide-label\":true,\"model-value\":new Date(_vm.share.expireDate),\"type\":\"date\",\"min\":_vm.dateTomorrow,\"max\":_vm.maxExpirationDateEnforced},on:{\"update:model-value\":_vm.onExpirationChange,\"change\":_vm.expirationDateChanged},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('IconCalendarBlank',{attrs:{\"size\":20}})]},proxy:true}],null,false,3418578971)}):_vm._e(),_vm._v(\" \"),_c('NcActionButton',{attrs:{\"disabled\":_vm.pendingEnforcedPassword && !_vm.share.newPassword},on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onNewLinkShare(true)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CheckIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2630571749)},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Create share'))+\"\\n\\t\\t\")]),_vm._v(\" \"),_c('NcActionButton',{on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onCancel.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CloseIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2428343285)},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Cancel'))+\"\\n\\t\\t\")])],1):(!_vm.loading)?_c('NcActions',{staticClass:\"sharing-entry__actions\",attrs:{\"aria-label\":_vm.actionsTooltip,\"menu-align\":\"right\",\"open\":_vm.open},on:{\"update:open\":function($event){_vm.open=$event}}},[(_vm.share)?[(_vm.share.canEdit && _vm.canReshare)?[_c('NcActionButton',{attrs:{\"disabled\":_vm.saving,\"close-after-click\":true},on:{\"click\":function($event){$event.preventDefault();return _vm.openSharingDetails.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('Tune',{attrs:{\"size\":20}})]},proxy:true}],null,false,1300586850)},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Customize link'))+\"\\n\\t\\t\\t\\t\")])]:_vm._e(),_vm._v(\" \"),_c('NcActionButton',{attrs:{\"close-after-click\":true},on:{\"click\":function($event){$event.preventDefault();_vm.showQRCode = true}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('IconQr',{attrs:{\"size\":20}})]},proxy:true}],null,false,1082198240)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Generate QR code'))+\"\\n\\t\\t\\t\")]),_vm._v(\" \"),_c('NcActionSeparator'),_vm._v(\" \"),_vm._l((_vm.sortedExternalShareActions),function(action){return _c('NcActionButton',{key:action.id,on:{\"click\":function($event){return action.exec(_vm.share, _vm.fileInfo.node)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('NcIconSvgWrapper',{attrs:{\"svg\":action.iconSvg}})]},proxy:true}],null,true)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(action.label(_vm.share, _vm.fileInfo.node))+\"\\n\\t\\t\\t\")])}),_vm._v(\" \"),_vm._l((_vm.externalLegacyShareActions),function(action){return _c('SidebarTabExternalActionLegacy',{key:action.id,attrs:{\"id\":action.id,\"action\":action,\"file-info\":_vm.fileInfo,\"share\":_vm.share}})}),_vm._v(\" \"),(!_vm.isEmailShareType && _vm.canReshare)?_c('NcActionButton',{staticClass:\"new-share-link\",on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onNewLinkShare.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('PlusIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2953566425)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Add another link'))+\"\\n\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.share.canDelete)?_c('NcActionButton',{attrs:{\"disabled\":_vm.saving},on:{\"click\":function($event){$event.preventDefault();return _vm.onDelete.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CloseIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2428343285)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Unshare'))+\"\\n\\t\\t\\t\")]):_vm._e()]:(_vm.canReshare)?_c('NcActionButton',{staticClass:\"new-share-link\",attrs:{\"title\":_vm.t('files_sharing', 'Create a new share link'),\"aria-label\":_vm.t('files_sharing', 'Create a new share link'),\"icon\":_vm.loading ? 'icon-loading-small' : 'icon-add'},on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onNewLinkShare.apply(null, arguments)}}}):_vm._e()],2):_c('NcLoadingIcon',{staticClass:\"sharing-entry__loading\"}),_vm._v(\" \"),(_vm.showQRCode)?_c('NcDialog',{attrs:{\"size\":\"normal\",\"open\":_vm.showQRCode,\"name\":_vm.title,\"close-on-click-outside\":true},on:{\"update:open\":function($event){_vm.showQRCode=$event},\"close\":function($event){_vm.showQRCode = false}}},[_c('div',{staticClass:\"qr-code-dialog\"},[_c('VueQrcode',{staticClass:\"qr-code-dialog__img\",attrs:{\"tag\":\"img\",\"value\":_vm.shareLink}})],1)]):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingLinkList.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingLinkList.vue?vue&type=script&lang=js\"","\n\n\n\n\n","import { render, staticRenderFns } from \"./SharingLinkList.vue?vue&type=template&id=708b3104\"\nimport script from \"./SharingLinkList.vue?vue&type=script&lang=js\"\nexport * from \"./SharingLinkList.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=style&index=0&id=fa3f3612&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=style&index=0&id=fa3f3612&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntry.vue?vue&type=template&id=fa3f3612&scoped=true\"\nimport script from \"./SharingEntry.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntry.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntry.vue?vue&type=style&index=0&id=fa3f3612&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"fa3f3612\",\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingList.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingList.vue?vue&type=script&lang=js\"","\n\n\n\n\n","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('li',{staticClass:\"sharing-entry\"},[_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"is-no-user\":_vm.share.type !== _vm.ShareType.User,\"user\":_vm.share.shareWith,\"display-name\":_vm.share.shareWithDisplayName,\"menu-position\":\"left\",\"url\":_vm.share.shareWithAvatar}}),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__summary\"},[_c(_vm.share.shareWithLink ? 'a' : 'div',{tag:\"component\",staticClass:\"sharing-entry__summary__desc\",attrs:{\"title\":_vm.tooltip,\"aria-label\":_vm.tooltip,\"href\":_vm.share.shareWithLink}},[_c('span',[_vm._v(_vm._s(_vm.title)+\"\\n\\t\\t\\t\\t\"),(!_vm.isUnique)?_c('span',{staticClass:\"sharing-entry__summary__desc-unique\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t(\"+_vm._s(_vm.share.shareWithDisplayNameUnique)+\")\\n\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.hasStatus && _vm.share.status.message)?_c('small',[_vm._v(\"(\"+_vm._s(_vm.share.status.message)+\")\")]):_vm._e()])]),_vm._v(\" \"),_c('SharingEntryQuickShareSelect',{attrs:{\"share\":_vm.share,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":function($event){return _vm.openShareDetailsForCustomSettings(_vm.share)}}})],1),_vm._v(\" \"),(_vm.share && _vm.share.expireDate)?_c('ShareExpiryTime',{attrs:{\"share\":_vm.share}}):_vm._e(),_vm._v(\" \"),(_vm.share.canEdit)?_c('NcButton',{staticClass:\"sharing-entry__action\",attrs:{\"data-cy-files-sharing-share-actions\":\"\",\"aria-label\":_vm.t('files_sharing', 'Open Sharing Details'),\"variant\":\"tertiary\"},on:{\"click\":function($event){return _vm.openSharingDetails(_vm.share)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('DotsHorizontalIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,1700783217)}):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import { render, staticRenderFns } from \"./SharingList.vue?vue&type=template&id=7e1141c6\"\nimport script from \"./SharingList.vue?vue&type=script&lang=js\"\nexport * from \"./SharingList.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('ul',{staticClass:\"sharing-sharee-list\",attrs:{\"aria-label\":_vm.t('files_sharing', 'Shares')}},_vm._l((_vm.shares),function(share){return _c('SharingEntry',{key:share.id,attrs:{\"file-info\":_vm.fileInfo,\"share\":share,\"is-unique\":_vm.isUnique(share)},on:{\"open-sharing-details\":function($event){return _vm.openSharingDetails(share)}}})}),1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","/*!\n * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: GPL-3.0-or-later\n */\n/**\n * Register a new sidebar section inside the files sharing sidebar tab.\n *\n * @param section - The section to register\n */\nexport function registerSidebarSection(section) {\n if (!section.id) {\n throw new Error('Sidebar sections must have an id');\n }\n if (!section.element || !section.element.startsWith('oca_') || !window.customElements.get(section.element)) {\n throw new Error('Sidebar sections must provide a registered custom web component identifier');\n }\n if (typeof section.order !== 'number') {\n throw new Error('Sidebar sections must have the order property');\n }\n if (typeof section.enabled !== 'function') {\n throw new Error('Sidebar sections must implement the enabled method');\n }\n window._nc_files_sharing_sidebar_sections ??= new Map();\n if (window._nc_files_sharing_sidebar_sections.has(section.id)) {\n throw new Error(`Sidebar section with id \"${section.id}\" is already registered`);\n }\n window._nc_files_sharing_sidebar_sections.set(section.id, section);\n}\n/**\n * Get all registered sidebar sections for the files sharing sidebar tab.\n */\nexport function getSidebarSections() {\n return [...(window._nc_files_sharing_sidebar_sections?.values() ?? [])];\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { ShareType } from '@nextcloud/sharing'\n\n/**\n *\n * @param share\n */\nfunction shareWithTitle(share) {\n\tif (share.type === ShareType.Group) {\n\t\treturn t(\n\t\t\t'files_sharing',\n\t\t\t'Shared with you and the group {group} by {owner}',\n\t\t\t{\n\t\t\t\tgroup: share.shareWithDisplayName,\n\t\t\t\towner: share.ownerDisplayName,\n\t\t\t},\n\t\t\tundefined,\n\t\t\t{ escape: false },\n\t\t)\n\t} else if (share.type === ShareType.Team) {\n\t\treturn t(\n\t\t\t'files_sharing',\n\t\t\t'Shared with you and {circle} by {owner}',\n\t\t\t{\n\t\t\t\tcircle: share.shareWithDisplayName,\n\t\t\t\towner: share.ownerDisplayName,\n\t\t\t},\n\t\t\tundefined,\n\t\t\t{ escape: false },\n\t\t)\n\t} else if (share.type === ShareType.Room) {\n\t\tif (share.shareWithDisplayName) {\n\t\t\treturn t(\n\t\t\t\t'files_sharing',\n\t\t\t\t'Shared with you and the conversation {conversation} by {owner}',\n\t\t\t\t{\n\t\t\t\t\tconversation: share.shareWithDisplayName,\n\t\t\t\t\towner: share.ownerDisplayName,\n\t\t\t\t},\n\t\t\t\tundefined,\n\t\t\t\t{ escape: false },\n\t\t\t)\n\t\t} else {\n\t\t\treturn t(\n\t\t\t\t'files_sharing',\n\t\t\t\t'Shared with you in a conversation by {owner}',\n\t\t\t\t{\n\t\t\t\t\towner: share.ownerDisplayName,\n\t\t\t\t},\n\t\t\t\tundefined,\n\t\t\t\t{ escape: false },\n\t\t\t)\n\t\t}\n\t} else {\n\t\treturn t(\n\t\t\t'files_sharing',\n\t\t\t'Shared with you by {owner}',\n\t\t\t{ owner: share.ownerDisplayName },\n\t\t\tundefined,\n\t\t\t{ escape: false },\n\t\t)\n\t}\n}\n\nexport { shareWithTitle }\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=style&index=0&id=cd6ad9ee&prod&scoped=true&lang=scss\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=style&index=0&id=cd6ad9ee&prod&scoped=true&lang=scss\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingTab.vue?vue&type=template&id=cd6ad9ee&scoped=true\"\nimport script from \"./SharingTab.vue?vue&type=script&lang=js\"\nexport * from \"./SharingTab.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingTab.vue?vue&type=style&index=0&id=cd6ad9ee&prod&scoped=true&lang=scss\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"cd6ad9ee\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"sharingTab\",class:{ 'icon-loading': _vm.loading }},[(_vm.error)?_c('div',{staticClass:\"emptycontent\",class:{ emptyContentWithSections: _vm.hasExternalSections }},[_c('div',{staticClass:\"icon icon-error\"}),_vm._v(\" \"),_c('h2',[_vm._v(_vm._s(_vm.error))])]):_vm._e(),_vm._v(\" \"),_c('div',{directives:[{name:\"show\",rawName:\"v-show\",value:(!_vm.showSharingDetailsView),expression:\"!showSharingDetailsView\"}],staticClass:\"sharingTab__content\"},[(_vm.isSharedWithMe)?_c('ul',[_c('SharingEntrySimple',_vm._b({staticClass:\"sharing-entry__reshare\",scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"user\":_vm.sharedWithMe.user,\"display-name\":_vm.sharedWithMe.displayName}})]},proxy:true}],null,false,3197855346)},'SharingEntrySimple',_vm.sharedWithMe,false))],1):_vm._e(),_vm._v(\" \"),_c('section',[_c('div',{staticClass:\"section-header\"},[_c('h4',[_vm._v(_vm._s(_vm.t('files_sharing', 'Internal shares')))]),_vm._v(\" \"),_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary-no-background\",\"aria-label\":_vm.t('files_sharing', 'Internal shares explanation')},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('InfoIcon',{attrs:{\"size\":20}})]},proxy:true}])})]},proxy:true}])},[_vm._v(\" \"),_c('p',{staticClass:\"hint-body\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.internalSharesHelpText)+\"\\n\\t\\t\\t\\t\\t\")])])],1),_vm._v(\" \"),(!_vm.loading)?_c('SharingInput',{attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo,\"link-shares\":_vm.linkShares,\"reshare\":_vm.reshare,\"shares\":_vm.shares,\"placeholder\":_vm.internalShareInputPlaceholder},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(!_vm.loading)?_c('SharingList',{ref:\"shareList\",attrs:{\"shares\":_vm.shares,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(_vm.canReshare && !_vm.loading)?_c('SharingInherited',{attrs:{\"file-info\":_vm.fileInfo}}):_vm._e(),_vm._v(\" \"),_c('SharingEntryInternal',{attrs:{\"file-info\":_vm.fileInfo}})],1),_vm._v(\" \"),(_vm.config.showExternalSharing)?_c('section',[_c('div',{staticClass:\"section-header\"},[_c('h4',[_vm._v(_vm._s(_vm.t('files_sharing', 'External shares')))]),_vm._v(\" \"),_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary-no-background\",\"aria-label\":_vm.t('files_sharing', 'External shares explanation')},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('InfoIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,915383693)})]},proxy:true}],null,false,4045083138)},[_vm._v(\" \"),_c('p',{staticClass:\"hint-body\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.externalSharesHelpText)+\"\\n\\t\\t\\t\\t\\t\")])])],1),_vm._v(\" \"),(!_vm.loading)?_c('SharingInput',{attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo,\"link-shares\":_vm.linkShares,\"is-external\":true,\"placeholder\":_vm.externalShareInputPlaceholder,\"reshare\":_vm.reshare,\"shares\":_vm.shares},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(!_vm.loading)?_c('SharingList',{attrs:{\"shares\":_vm.externalShares,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(!_vm.loading && _vm.isLinkSharingAllowed)?_c('SharingLinkList',{ref:\"linkShareList\",attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo,\"shares\":_vm.linkShares},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e()],1):_vm._e(),_vm._v(\" \"),(_vm.hasExternalSections && !_vm.showSharingDetailsView)?_c('section',[_c('div',{staticClass:\"section-header\"},[_c('h4',[_vm._v(_vm._s(_vm.t('files_sharing', 'Additional shares')))]),_vm._v(\" \"),_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary-no-background\",\"aria-label\":_vm.t('files_sharing', 'Additional shares explanation')},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('InfoIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,915383693)})]},proxy:true}],null,false,880248230)},[_vm._v(\" \"),_c('p',{staticClass:\"hint-body\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.additionalSharesHelpText)+\"\\n\\t\\t\\t\\t\\t\")])])],1),_vm._v(\" \"),_vm._l((_vm.sortedExternalSections),function(section){return _c('SidebarTabExternalSection',{key:section.id,staticClass:\"sharingTab__additionalContent\",attrs:{\"section\":section,\"node\":_vm.fileInfo.node /* TODO: Fix once we have proper Node API */}})}),_vm._v(\" \"),_vm._l((_vm.legacySections),function(section,index){return _c('SidebarTabExternalSectionLegacy',{key:index,staticClass:\"sharingTab__additionalContent\",attrs:{\"file-info\":_vm.fileInfo,\"section-callback\":section}})}),_vm._v(\" \"),(_vm.projectsEnabled)?_c('div',{directives:[{name:\"show\",rawName:\"v-show\",value:(!_vm.showSharingDetailsView && _vm.fileInfo),expression:\"!showSharingDetailsView && fileInfo\"}],staticClass:\"sharingTab__additionalContent\"},[_c('NcCollectionList',{attrs:{\"id\":`${_vm.fileInfo.id}`,\"type\":\"file\",\"name\":_vm.fileInfo.name}})],1):_vm._e()],2):_vm._e()]),_vm._v(\" \"),(_vm.showSharingDetailsView)?_c('SharingDetailsTab',{attrs:{\"file-info\":_vm.shareDetailsData.fileInfo,\"share\":_vm.shareDetailsData.share},on:{\"close-sharing-details\":_vm.toggleShareDetailsView,\"add:share\":_vm.addShare,\"remove:share\":_vm.removeShare}}):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","/*!\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n/**\n * Convert Node to legacy file info\n *\n * @param node - The Node to convert\n */\nexport default function (node) {\n const rawFileInfo = {\n id: node.fileid,\n path: node.dirname,\n name: node.basename,\n mtime: node.mtime?.getTime(),\n etag: node.attributes.etag,\n size: node.size,\n hasPreview: node.attributes.hasPreview,\n isEncrypted: node.attributes.isEncrypted === 1,\n isFavourited: node.attributes.favorite === 1,\n mimetype: node.mime,\n permissions: node.permissions,\n mountType: node.attributes['mount-type'],\n sharePermissions: node.attributes['share-permissions'],\n shareAttributes: JSON.parse(node.attributes['share-attributes'] || '[]'),\n type: node.type === 'file' ? 'file' : 'dir',\n attributes: node.attributes,\n };\n const fileInfo = new OC.Files.FileInfo(rawFileInfo);\n // TODO remove when no more legacy backbone is used\n fileInfo.get = (key) => fileInfo[key];\n fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory';\n fileInfo.canEdit = () => Boolean(fileInfo.permissions & OC.PERMISSION_UPDATE);\n fileInfo.node = node;\n return fileInfo;\n}\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"","import { render, staticRenderFns } from \"./FilesSidebarTab.vue?vue&type=template&id=8a2257be\"\nimport script from \"./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"\nexport * from \"./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import { getCurrentUser, onRequestTokenUpdate, getRequestToken } from \"@nextcloud/auth\";\nimport { generateRemoteUrl } from \"@nextcloud/router\";\nimport { isPublicShare, getSharingToken } from \"@nextcloud/sharing/public\";\nimport { createClient, getPatcher } from \"webdav\";\nimport { P as Permission, s as scopedGlobals, l as logger, c as NodeStatus, a as File, b as Folder } from \"./chunks/folder-29HuacU_.mjs\";\nimport \"@nextcloud/paths\";\n/*!\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nfunction parsePermissions(permString = \"\") {\n let permissions = Permission.NONE;\n if (!permString) {\n return permissions;\n }\n if (permString.includes(\"G\")) {\n permissions |= Permission.READ;\n }\n if (permString.includes(\"W\")) {\n permissions |= Permission.WRITE;\n }\n if (permString.includes(\"CK\")) {\n permissions |= Permission.CREATE;\n }\n if (permString.includes(\"NV\")) {\n permissions |= Permission.UPDATE;\n }\n if (permString.includes(\"D\")) {\n permissions |= Permission.DELETE;\n }\n if (permString.includes(\"R\")) {\n permissions |= Permission.SHARE;\n }\n return permissions;\n}\nconst defaultDavProperties = [\n \"d:getcontentlength\",\n \"d:getcontenttype\",\n \"d:getetag\",\n \"d:getlastmodified\",\n \"d:creationdate\",\n \"d:displayname\",\n \"d:quota-available-bytes\",\n \"d:resourcetype\",\n \"nc:has-preview\",\n \"nc:is-encrypted\",\n \"nc:mount-type\",\n \"oc:comments-unread\",\n \"oc:favorite\",\n \"oc:fileid\",\n \"oc:owner-display-name\",\n \"oc:owner-id\",\n \"oc:permissions\",\n \"oc:size\"\n];\nconst defaultDavNamespaces = {\n d: \"DAV:\",\n nc: \"http://nextcloud.org/ns\",\n oc: \"http://owncloud.org/ns\",\n ocs: \"http://open-collaboration-services.org/ns\"\n};\nfunction registerDavProperty(prop, namespace = { nc: \"http://nextcloud.org/ns\" }) {\n scopedGlobals.davNamespaces ??= { ...defaultDavNamespaces };\n scopedGlobals.davProperties ??= [...defaultDavProperties];\n const namespaces = { ...scopedGlobals.davNamespaces, ...namespace };\n if (scopedGlobals.davProperties.find((search) => search === prop)) {\n logger.warn(`${prop} already registered`, { prop });\n return false;\n }\n if (prop.startsWith(\"<\") || prop.split(\":\").length !== 2) {\n logger.error(`${prop} is not valid. See example: 'oc:fileid'`, { prop });\n return false;\n }\n const ns = prop.split(\":\")[0];\n if (!namespaces[ns]) {\n logger.error(`${prop} namespace unknown`, { prop, namespaces });\n return false;\n }\n scopedGlobals.davProperties.push(prop);\n scopedGlobals.davNamespaces = namespaces;\n return true;\n}\nfunction getDavProperties() {\n scopedGlobals.davProperties ??= [...defaultDavProperties];\n return scopedGlobals.davProperties.map((prop) => `<${prop} />`).join(\" \");\n}\nfunction getDavNameSpaces() {\n scopedGlobals.davNamespaces ??= { ...defaultDavNamespaces };\n return Object.keys(scopedGlobals.davNamespaces).map((ns) => `xmlns:${ns}=\"${scopedGlobals.davNamespaces?.[ns]}\"`).join(\" \");\n}\nfunction getDefaultPropfind() {\n return `\n\t\t\n\t\t\t\n\t\t\t\t${getDavProperties()}\n\t\t\t\n\t\t`;\n}\nfunction getFavoritesReport() {\n return `\n\t\t\n\t\t\t\n\t\t\t\t${getDavProperties()}\n\t\t\t\n\t\t\t\n\t\t\t\t1\n\t\t\t\n\t\t`;\n}\nfunction getRecentSearch(lastModified) {\n return `\n\n\t\n\t\t\n\t\t\t\n\t\t\t\t${getDavProperties()}\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t/files/${getCurrentUser()?.uid}/\n\t\t\t\tinfinity\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\thttpd/unix-directory\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t0\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t${lastModified}\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t100\n\t\t\t0\n\t\t\n\t\n`;\n}\nfunction getRootPath() {\n if (isPublicShare()) {\n return `/files/${getSharingToken()}`;\n }\n return `/files/${getCurrentUser()?.uid}`;\n}\nconst defaultRootPath = getRootPath();\nfunction getRemoteURL() {\n const url = generateRemoteUrl(\"dav\");\n if (isPublicShare()) {\n return url.replace(\"remote.php\", \"public.php\");\n }\n return url;\n}\nconst defaultRemoteURL = getRemoteURL();\nfunction getClient(remoteURL = defaultRemoteURL, headers = {}) {\n const client = createClient(remoteURL, { headers });\n function setHeaders(token) {\n client.setHeaders({\n ...headers,\n // Add this so the server knows it is an request from the browser\n \"X-Requested-With\": \"XMLHttpRequest\",\n // Inject user auth\n requesttoken: token ?? \"\"\n });\n }\n onRequestTokenUpdate(setHeaders);\n setHeaders(getRequestToken());\n const patcher = getPatcher();\n patcher.patch(\"fetch\", (url, options) => {\n const headers2 = options.headers;\n if (headers2?.method) {\n options.method = headers2.method;\n delete headers2.method;\n }\n return fetch(url, options);\n });\n return client;\n}\nasync function getFavoriteNodes(options = {}) {\n const client = options.client ?? getClient();\n const path = options.path ?? \"/\";\n const davRoot = options.davRoot ?? defaultRootPath;\n const contentsResponse = await client.getDirectoryContents(`${davRoot}${path}`, {\n signal: options.signal,\n details: true,\n data: getFavoritesReport(),\n headers: {\n // see getClient for patched webdav client\n method: \"REPORT\"\n },\n includeSelf: true\n });\n return contentsResponse.data.filter((node) => node.filename !== path).map((result) => resultToNode(result, davRoot));\n}\nfunction resultToNode(node, filesRoot = defaultRootPath, remoteURL = defaultRemoteURL) {\n let userId = getCurrentUser()?.uid;\n if (isPublicShare()) {\n userId = userId ?? \"anonymous\";\n } else if (!userId) {\n throw new Error(\"No user id found\");\n }\n const props = node.props;\n const permissions = parsePermissions(props?.permissions);\n const owner = String(props?.[\"owner-id\"] || userId);\n const id = props.fileid || 0;\n const mtime = new Date(Date.parse(node.lastmod));\n const crtime = new Date(Date.parse(props.creationdate));\n const nodeData = {\n id,\n source: `${remoteURL}${node.filename}`,\n mtime: !isNaN(mtime.getTime()) && mtime.getTime() !== 0 ? mtime : void 0,\n crtime: !isNaN(crtime.getTime()) && crtime.getTime() !== 0 ? crtime : void 0,\n mime: node.mime || \"application/octet-stream\",\n // Manually cast to work around for https://github.com/perry-mitchell/webdav-client/pull/380\n displayname: props.displayname !== void 0 ? String(props.displayname) : void 0,\n size: props?.size || Number.parseInt(props.getcontentlength || \"0\"),\n // The fileid is set to -1 for failed requests\n status: id < 0 ? NodeStatus.FAILED : void 0,\n permissions,\n owner,\n root: filesRoot,\n attributes: {\n ...node,\n ...props,\n hasPreview: props?.[\"has-preview\"]\n }\n };\n delete nodeData.attributes?.props;\n return node.type === \"file\" ? new File(nodeData) : new Folder(nodeData);\n}\nexport {\n defaultDavNamespaces,\n defaultDavProperties,\n defaultRemoteURL,\n defaultRootPath,\n getClient,\n getDavNameSpaces,\n getDavProperties,\n getDefaultPropfind,\n getFavoriteNodes,\n getFavoritesReport,\n getRecentSearch,\n getRemoteURL,\n getRootPath,\n parsePermissions,\n registerDavProperty,\n resultToNode\n};\n//# sourceMappingURL=dav.mjs.map\n"],"names":["___CSS_LOADER_EXPORT___","_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default","_node_modules_css_loader_dist_runtime_sourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default","push","module","id","version","sources","names","mappings","sourcesContent","sourceRoot","__WEBPACK_DEFAULT_EXPORT__","vue_material_design_icons_ContentCopyvue_type_script_lang_js","name","emits","props","title","type","String","fillColor","default","size","Number","ContentCopy","componentNormalizer","A","_vm","this","_c","_self","_b","staticClass","attrs","role","on","click","$event","$emit","$attrs","fill","width","height","viewBox","d","_v","_s","_e","components_SharingEntrySimplevue_type_script_lang_js","components","NcActions","required","subtitle","isUnique","Boolean","ariaExpanded","computed","ariaExpandedValue","options","styleTagTransform","styleTagTransform_default","setAttributes","setAttributesWithoutAttributes_default","insert","insertBySelector_default","bind","domAPI","styleDomAPI_default","insertStyleElement","insertStyleElement_default","injectStylesIntoStyleTag_default","SharingEntrySimplevue_type_style_index_0_id_13d4a0bb_prod_lang_scss_scoped_true","locals","SharingEntrySimple","_t","$slots","ref","generateFileUrl","fileid","baseURL","getBaseUrl","globalscale","getCapabilities","token","generateUrl","components_SharingEntryInternalvue_type_script_lang_js","NcActionButton","CheckIcon","Check","ClipboardIcon","fileInfo","Object","data","copied","copySuccess","internalLink","copyLinkTooltip","t","internalLinkSubtitle","methods","copyLink","navigator","clipboard","writeText","showSuccess","$refs","shareEntrySimple","actionsComponent","$el","focus","error","logger","setTimeout","SharingEntryInternalvue_type_style_index_0_id_6c4cb23b_prod_lang_scss_scoped_true_options","SharingEntryInternalvue_type_style_index_0_id_6c4cb23b_prod_lang_scss_scoped_true","SharingEntryInternal","scopedSlots","_u","key","fn","proxy","ATOMIC_PERMISSIONS","BUNDLED_PERMISSIONS","READ_ONLY","UPLOAD_AND_UPDATE","FILE_DROP","ALL","ALL_FILE","getBundledPermissions","excludeShare","Share","constructor","ocsData","_defineProperty","ocs","parseInt","hide_download","mail_send","attributes","JSON","parse","warn","newPassword","undefined","_share","state","share_type","permissions","owner","uid_owner","ownerDisplayName","displayname_owner","shareWith","share_with","shareWithDisplayName","share_with_displayname","shareWithDisplayNameUnique","share_with_displayname_unique","shareWithLink","share_with_link","shareWithAvatar","share_with_avatar","uidFileOwner","uid_file_owner","displaynameFileOwner","displayname_file_owner","createdTime","stime","expireDate","expiration","date","note","label","mailSend","hideDownload","find","scope","value","attribute","password","passwordExpirationTime","password_expiration_time","sendPasswordByTalk","send_password_by_talk","path","itemType","item_type","mimetype","fileSource","file_source","fileTarget","file_target","fileParent","file_parent","hasReadPermission","window","OC","PERMISSION_READ","hasCreatePermission","PERMISSION_CREATE","hasDeletePermission","PERMISSION_DELETE","hasUpdatePermission","PERMISSION_UPDATE","hasSharePermission","PERMISSION_SHARE","hasDownloadPermission","some","isFileRequest","stringify","enabled","setAttribute","attrUpdate","i","attr","splice","canEdit","can_edit","canDelete","can_delete","viaFileid","via_fileid","viaPath","via_path","parent","storageId","storage_id","storage","itemSource","item_source","status","isTrustedServer","is_trusted_server","Config","_capabilities","defaultPermissions","files_sharing","default_permissions","excludeReshareFromEdit","exclude_reshare_from_edit","isPublicUploadEnabled","public","upload","federatedShareDocLink","appConfig","core","federatedCloudShareDoc","defaultExpirationDate","isDefaultExpireDateEnabled","defaultExpireDate","Date","setDate","getDate","defaultInternalExpirationDate","isDefaultInternalExpireDateEnabled","defaultInternalExpireDate","defaultRemoteExpirationDateString","isDefaultRemoteExpireDateEnabled","defaultRemoteExpireDate","enforcePasswordForPublicLink","enableLinkPasswordByDefault","isDefaultExpireDateEnforced","defaultExpireDateEnforced","defaultExpireDateEnabled","isDefaultInternalExpireDateEnforced","defaultInternalExpireDateEnforced","defaultInternalExpireDateEnabled","isDefaultRemoteExpireDateEnforced","defaultRemoteExpireDateEnforced","defaultRemoteExpireDateEnabled","isRemoteShareAllowed","remoteShareAllowed","isFederationEnabled","federation","outgoing","isPublicShareAllowed","isMailShareAllowed","sharebymail","isResharingAllowed","resharingAllowed","isPasswordForMailSharesRequired","enforced","shouldAlwaysShowUnique","sharee","always_show_unique","allowGroupSharing","maxAutocompleteResults","config","minSearchStringLength","passwordPolicy","password_policy","allowCustomTokens","custom_tokens","showFederatedSharesAsInternal","loadState","showFederatedSharesToTrustedServersAsInternal","showExternalSharing","ShareDetails","openSharingDetails","shareRequestObject","share","handler","handlerInput","suggestions","query","externalShareRequestObject","mapShareRequestToShareObject","originalPermissions","strippedPermissions","debug","shareDetails","openShareDetailsForCustomSettings","setCustomPermissions","shareType","is_no_user","isNoUser","user","displayName","shareUrl","generateOcsUrl","ShareRequests","createShare","publicUpload","request","axios","post","emit","errorMessage","getErrorMessage","showError","Error","cause","deleteShare","delete","updateShare","properties","put","isAxiosError","response","meta","message","components_SharingInputvue_type_script_lang_js","NcSelect","mixins","shares","Array","linkShares","reshare","canReshare","isExternal","placeholder","setup","shareInputId","Math","random","toString","slice","loading","recommendations","ShareSearch","OCA","Sharing","externalResults","results","inputPlaceholder","allowRemoteSharing","isValidQuery","trim","length","noResultText","mounted","getRecommendations","onSelected","option","asyncFind","debounceGetSuggestions","getSuggestions","search","lookup","query_lookup_default","remoteTypes","ShareType","Remote","RemoteGroup","showFederatedAsInternal","shouldAddRemoteTypes","Email","User","Group","Team","Room","Guest","Deck","ScienceMesh","get","params","format","perPage","exact","rawExactSuggestions","values","flat","rawSuggestions","exactSuggestions","filterOutExistingShares","filter","result","filterByTrustedServer","map","formatForMultiselect","sort","a","b","lookupEntry","lookupEnabled","condition","allSuggestions","concat","nameCounts","reduce","item","desc","debounce","args","rawRecommendations","arr","elem","getCurrentUser","uid","indexOf","sharesObj","obj","shareTypeToIcon","icon","iconTitle","Sciencemesh","subname","extra","email","server","shareWithDescription","uuid","SharingInputvue_type_style_index_0_id_0b151499_prod_lang_scss_options","SharingInputvue_type_style_index_0_id_0b151499_prod_lang_scss","SharingInput","for","disabled","filterable","clear-search-on-blur","model","callback","$$v","expression","SidebarTabExternal_SidebarTabExternalSectionvue_type_script_lang_ts_setup_true","_defineComponent","__name","node","section","__props","sectionElement","watchEffect","__sfc","SidebarTabExternalSection","_setupProxy","element","tag","domProps","SidebarTabExternal_SidebarTabExternalSectionLegacyvue_type_script_lang_ts_setup_true","sectionCallback","Function","component","SidebarTabExternalSectionLegacyvue_type_style_index_0_id_3e4e67d2_prod_scoped_true_lang_css_options","SidebarTabExternalSectionLegacyvue_type_style_index_0_id_3e4e67d2_prod_scoped_true_lang_css","SidebarTabExternalSectionLegacy","vue_material_design_icons_AccountCircleOutlinevue_type_script_lang_js","AccountCircleOutline","vue_material_design_icons_AccountGroupvue_type_script_lang_js","AccountGroup","vue_material_design_icons_CircleOutlinevue_type_script_lang_js","CircleOutline","vue_material_design_icons_Emailvue_type_script_lang_js","vue_material_design_icons_Eyevue_type_script_lang_js","Eye","vue_material_design_icons_ShareCirclevue_type_script_lang_js","ShareCircle","vue_material_design_icons_TrayArrowUpvue_type_script_lang_js","TrayArrowUp","SidebarTabExternal_SidebarTabExternalActionvue_type_script_lang_ts_setup_true","action","expose","save","actionElement","savingCallback","async","onSave","toRaw","SidebarTabExternalAction","_setup","SidebarTabExternal_SidebarTabExternalActionLegacyvue_type_script_lang_js","SidebarTabExternalActionLegacy","is","_g","handlers","text","client","getClient","GeneratePassword","verbose","api","generate","context","info","array","Uint8Array","ratio","passwordSet","self","crypto","getRandomValues","len","floor","charAt","SharesMixin","SharesRequests","sharing_dist","I","errors","saving","open","passwordProtectedState","updateQueue","PQueue","concurrency","reactiveState","replace","hasNote","set","dateTomorrow","lang","weekdaysShort","dayNamesShort","monthsShort","monthNamesShort","formatLocale","firstDayOfWeek","firstDay","weekdaysMin","monthFormat","isNewShare","isFolder","isPublicShare","Link","includes","isRemoteShare","isShareOwner","isExpiryDateEnforced","hasCustomPermissions","basePermissions","maxExpirationDateEnforced","isPasswordProtected","generatedPassword","$set","getNode","propfindPayload","getDefaultPropfind","stat","getRootPath","details","resultToNode","fetchNode","checkShare","expirationDate","isValid","formatDateToString","UTC","getFullYear","getMonth","toISOString","split","onExpirationChange","parsedDate","onDelete","shareId","queueUpdate","propertyNames","add","updatedShare","property","$delete","updateSuccessMessage","onSyncError","propertyEl","focusable","querySelector","debounceQueueUpdate","views_SharingDetailsTabvue_type_script_lang_js","NcAvatar","NcButton","NcCheckboxRadioSwitch","NcDateTimePickerNative","NcInputField","NcLoadingIcon","NcPasswordField","NcTextArea","CloseIcon","Close","CircleIcon","EditIcon","PencilOutline","LinkIcon","GroupIcon","ShareIcon","UserIcon","UploadIcon","ViewIcon","MenuDownIcon","MenuDown","MenuUpIcon","MenuUp","DotsHorizontalIcon","DotsHorizontal","Refresh","shareRequestValue","writeNoteToRecipientIsChecked","sharingPermission","revertSharingPermission","passwordError","advancedSectionAccordionExpanded","isFirstComponentLoad","test","creating","initialToken","loadingToken","initialPermissions","initialExpireDate","initialNote","initialLabel","initialHideDownload","initialSendPasswordByTalk","initialHasDownloadPermission","externalShareActions","_nc_files_sharing_sidebar_actions","ExternalShareActions","bundledPermissions","allPermissions","checked","updateAtomicPermissions","isEditChecked","canCreate","isCreateChecked","isDeleteChecked","isReshareChecked","showInGridView","getShareAttribute","setShareAttribute","canDownload","hasRead","isReadChecked","hasExpirationDate","isValidShareAttribute","defaultExpiryDate","isSetDownloadButtonVisible","isPasswordEnforced","isGroupShare","isUserShare","allowsFileDrop","hasFileDropPermissions","shareButtonText","resharingIsPossible","canSetEdit","sharePermissions","canSetCreate","canSetDelete","canSetReshare","canSetDownload","canRemoveReadPermission","hasUnsavedPassword","expirationTime","moment","diff","fromNow","isTalkEnabled","appswebroots","spreed","isPasswordProtectedByTalkAvailable","isPasswordProtectedByTalk","isEmailShareType","canTogglePasswordProtectedByTalkAvailable","canChangeHideDownload","shareAttributes","shareAttribute","customPermissionsList","translatedPermissions","ATOMIC_PERMISSIONS_READ","ATOMIC_PERMISSIONS_CREATE","ATOMIC_PERMISSIONS_UPDATE","ATOMIC_PERMISSIONS_SHARE","ATOMIC_PERMISSIONS_DELETE","permission","hasPermissions","initialPermissionSet","permissionsToCheck","index","toLocaleLowerCase","getLanguage","join","advancedControlExpandedValue","errorPasswordLabel","passwordHint","sortedExternalShareActions","order","externalLegacyShareActions","actions","advanced","watch","isChecked","beforeMount","initializePermissions","initializeAttributes","quickPermissions","fallback","generateNewToken","generateToken","cancel","expandCustomPermissions","toggleCustomPermissions","selectedPermission","isCustomPermissions","toDateString","handleShareType","handleDefaultPermissions","handleCustomPermissions","saveShare","permissionsAndAttributes","publicShareAttributes","sharePermissionsSet","incomingShare","addShare","prop","Promise","allSettled","externalLinkActions","$children","at","resolve","removeShare","onPasswordChange","getShareTypeIcon","EmailIcon","SharingDetailsTabvue_type_style_index_0_id_1e0a769c_prod_lang_scss_scoped_true_options","SharingDetailsTabvue_type_style_index_0_id_1e0a769c_prod_lang_scss_scoped_true","SharingDetailsTab_component","url","variant","alignment","autocomplete","min","max","input","_l","refInFor","readonly","preventDefault","apply","arguments","SharingDetailsTab","components_SharingEntryInheritedvue_type_script_lang_js","NcActionLink","NcActionText","viaFileTargetUrl","viaFolderName","basename","SharingEntryInheritedvue_type_style_index_0_id_731a9650_prod_lang_scss_scoped_true_options","SharingEntryInheritedvue_type_style_index_0_id_731a9650_prod_lang_scss_scoped_true","SharingEntryInherited_component","initiator","href","folder","SharingEntryInherited","views_SharingInheritedvue_type_script_lang_js","loaded","showInheritedShares","showInheritedSharesIcon","mainTitle","subTitle","toggleTooltip","fullPath","resetState","toggleInheritedShares","fetchInheritedShares","Notification","showTemporary","findIndex","SharingInheritedvue_type_style_index_0_id_cedf3238_prod_lang_scss_scoped_true_options","SharingInheritedvue_type_style_index_0_id_cedf3238_prod_lang_scss_scoped_true","SharingInherited_component","stopPropagation","SharingInherited","vue_material_design_icons_CalendarBlankOutlinevue_type_script_lang_js","CalendarBlankOutline","vue_material_design_icons_CheckBoldvue_type_script_lang_js","CheckBold","vue_material_design_icons_Exclamationvue_type_script_lang_js","Exclamation","vue_material_design_icons_LockOutlinevue_type_script_lang_js","LockOutline","vue_material_design_icons_Plusvue_type_script_lang_js","Plus","vue_material_design_icons_Qrcodevue_type_script_lang_js","Qrcode","vue_material_design_icons_Tunevue_type_script_lang_js","Tune","vue_material_design_icons_ClockOutlinevue_type_script_lang_js","ClockOutline","components_ShareExpiryTimevue_type_script_lang_js","NcPopover","NcDateTime","ClockIcon","expiryTime","getTime","timeFormat","dateStyle","timeStyle","ShareExpiryTimevue_type_style_index_0_id_c9199db0_prod_scoped_true_lang_scss_options","ShareExpiryTimevue_type_style_index_0_id_c9199db0_prod_scoped_true_lang_scss","ShareExpiryTime","toLocaleString","timestamp","vue_material_design_icons_EyeOutlinevue_type_script_lang_js","EyeOutline","vue_material_design_icons_TriangleSmallDownvue_type_script_lang_js","SharingEntryQuickShareSelectvue_type_script_lang_js","DropdownIcon","selectedOption","ariaLabel","canViewText","canEditText","fileDropText","customPermissionsText","preSelectedOption","IconEyeOutline","IconPencil","supportsFileDrop","IconFileUpload","IconTune","dropDownPermissionValue","created","subscribe","unmounted","unsubscribe","selectOption","optionLabel","quickShareActions","menuButton","components_SharingEntryQuickShareSelectvue_type_script_lang_js","SharingEntryQuickShareSelectvue_type_style_index_0_id_5ae7b89a_prod_lang_scss_scoped_true_options","SharingEntryQuickShareSelectvue_type_style_index_0_id_5ae7b89a_prod_lang_scss_scoped_true","SharingEntryQuickShareSelect","SharingEntryLinkvue_type_script_lang_js","NcActionCheckbox","NcActionCheckbox_Cbg5yktN","N","NcActionInput","NcActionSeparator","NcDialog","NcIconSvgWrapper","VueQrcode","vue_qrcode_default","IconCalendarBlank","IconQr","ErrorIcon","LockIcon","PlusIcon","mdiCheck","mdi","Tfj","mdiContentCopy","shareCreationComplete","defaultExpirationDateEnabled","pending","_nc_files_sharing_sidebar_inline_actions","showQRCode","minPasswordLength","isPasswordPolicyEnabled","policies","sharing","minLength","l10nOptions","escape","pendingDataIsMissing","pendingPassword","pendingEnforcedPassword","pendingDefaultExpirationDate","pendingEnforcedExpirationDate","isPendingShare","isNaN","sharePolicyHasEnforcedProperties","enforcedPropertiesMissing","isPasswordMissing","isExpireDateMissing","shareLink","actionsTooltip","copyLinkLabel","shareRequiresReview","shareReviewComplete","onNewLinkShare","shareDefaults","pushNewLinkShare","e","update","newShare","match","copyButton","prompt","onPasswordDisable","onExpirationDateToggleUpdate","expirationDateChanged","event","target","onCancel","components_SharingEntryLinkvue_type_script_lang_js","SharingEntryLinkvue_type_style_index_0_id_7a5c0ee5_prod_lang_scss_scoped_true_options","SharingEntryLinkvue_type_style_index_0_id_7a5c0ee5_prod_lang_scss_scoped_true","SharingEntryLink_component","class","close","uncheck","minlength","submit","change","exec","svg","iconSvg","views_SharingLinkListvue_type_script_lang_js","SharingEntryLink","canLinkShare","hasLinkShares","hasShares","l10n_dist","awaitForShare","$nextTick","SharingLinkList_component","SharingLinkList","components_SharingEntryvue_type_script_lang_js","showAsInternal","tooltip","hasStatus","isArray","SharingEntryvue_type_style_index_0_id_fa3f3612_prod_lang_scss_scoped_true_options","SharingEntryvue_type_style_index_0_id_fa3f3612_prod_lang_scss_scoped_true","views_SharingListvue_type_script_lang_js","SharingEntry","SharingList","productName","theme","SharingTabvue_type_script_lang_js","InfoIcon","InformationOutline","NcCollectionList","NcCollectionList_q7zkDwqG","deleteEvent","expirationInterval","sharedWithMe","externalShares","legacySections","ShareTabSections","getSections","sections","_nc_files_sharing_sidebar_sections","projectsEnabled","showSharingDetailsView","shareDetailsData","returnFocusElement","internalSharesHelpText","externalSharesHelpText","additionalSharesHelpText","hasExternalSections","sortedExternalSections","isSharedWithMe","isLinkSharingAllowed","capabilities","internalShareInputPlaceholder","externalShareInputPlaceholder","immediate","newValue","oldValue","getShares","fetchShares","reshares","fetchSharedWithMe","shared_with_me","all","processSharedWithMe","processShares","clearInterval","updateExpirationSubtitle","unix","relativetime","orderBy","findShareListByShare","group","circle","conversation","shareWithTitle","setInterval","shareOwnerId","shareOwner","unshift","removeShareFromList","shareList","listComponent","linkShareList","toggleShareDetailsView","eventData","from","document","activeElement","classList","className","startsWith","menuId","closest","views_SharingTabvue_type_script_lang_js","SharingTabvue_type_style_index_0_id_cd6ad9ee_prod_scoped_true_lang_scss_options","SharingTabvue_type_style_index_0_id_cd6ad9ee_prod_scoped_true_lang_scss","SharingTab","emptyContentWithSections","directives","rawName","FileInfo","rawFileInfo","dirname","mtime","etag","hasPreview","isEncrypted","isFavourited","favorite","mime","mountType","Files","isDirectory","views_FilesSidebarTabvue_type_script_setup_true_lang_ts","active","view","FilesSidebarTab","defaultDavProperties","defaultDavNamespaces","nc","oc","getDavProperties","_chunks_folder_29HuacU_mjs__WEBPACK_IMPORTED_MODULE_4__","s","davProperties","getDavNameSpaces","davNamespaces","keys","ns","getRecentSearch","lastModified","_nextcloud_auth__WEBPACK_IMPORTED_MODULE_0__","HW","_nextcloud_sharing_public__WEBPACK_IMPORTED_MODULE_2__","f","G","defaultRootPath","defaultRemoteURL","_nextcloud_router__WEBPACK_IMPORTED_MODULE_1__","dC","getRemoteURL","remoteURL","headers","webdav__WEBPACK_IMPORTED_MODULE_3__","UU","setHeaders","requesttoken","zo","Gu","patch","headers2","method","fetch","getFavoriteNodes","davRoot","getDirectoryContents","signal","includeSelf","filename","filesRoot","userId","permString","P","NONE","READ","WRITE","CREATE","UPDATE","DELETE","SHARE","parsePermissions","lastmod","crtime","creationdate","nodeData","source","displayname","getcontentlength","c","FAILED","root"],"sourceRoot":""} \ No newline at end of file diff --git a/dist/499-499.js.map.license b/dist/499-499.js.map.license deleted file mode 120000 index b523d634ba03c..0000000000000 --- a/dist/499-499.js.map.license +++ /dev/null @@ -1 +0,0 @@ -499-499.js.license \ No newline at end of file diff --git a/dist/6863-6863.js b/dist/6863-6863.js new file mode 100644 index 0000000000000..787201271bf63 --- /dev/null +++ b/dist/6863-6863.js @@ -0,0 +1,2 @@ +"use strict";(globalThis.webpackChunknextcloud_ui_legacy||=[]).push([[6863],{28069(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".share-expiry-time[data-v-c9199db0]{display:inline-flex;align-items:center;justify-content:center}.share-expiry-time .hint-icon[data-v-c9199db0]{padding:0;margin:0;width:24px;height:24px}.hint-heading[data-v-c9199db0]{text-align:center;font-size:1rem;margin-top:8px;padding-bottom:8px;margin-bottom:0;border-bottom:1px solid var(--color-border)}.hint-body[data-v-c9199db0]{padding:var(--border-radius-element);max-width:300px}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/ShareExpiryTime.vue"],names:[],mappings:"AACA,oCACI,mBAAA,CACA,kBAAA,CACA,sBAAA,CAEA,+CACI,SAAA,CACA,QAAA,CACA,UAAA,CACA,WAAA,CAIR,+BACI,iBAAA,CACA,cAAA,CACA,cAAA,CACA,kBAAA,CACA,eAAA,CACA,2CAAA,CAGJ,4BACI,oCAAA,CACA,eAAA",sourcesContent:["\n.share-expiry-time {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .hint-icon {\n padding: 0;\n margin: 0;\n width: 24px;\n height: 24px;\n }\n}\n\n.hint-heading {\n text-align: center;\n font-size: 1rem;\n margin-top: 8px;\n padding-bottom: 8px;\n margin-bottom: 0;\n border-bottom: 1px solid var(--color-border);\n}\n\n.hint-body {\n padding: var(--border-radius-element);\n max-width: 300px;\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},40749(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-fa3f3612]{display:flex;align-items:center;height:44px}.sharing-entry__summary[data-v-fa3f3612]{padding:8px;padding-inline-start:10px;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;flex:1 0;min-width:0}.sharing-entry__summary__desc[data-v-fa3f3612]{display:inline-block;padding-bottom:0;line-height:1.2em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sharing-entry__summary__desc p[data-v-fa3f3612],.sharing-entry__summary__desc small[data-v-fa3f3612]{color:var(--color-text-maxcontrast)}.sharing-entry__summary__desc-unique[data-v-fa3f3612]{color:var(--color-text-maxcontrast)}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntry.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,WAAA,CACA,yCACC,WAAA,CACA,yBAAA,CACA,YAAA,CACA,qBAAA,CACA,sBAAA,CACA,sBAAA,CACA,QAAA,CACA,WAAA,CAEA,+CACC,oBAAA,CACA,gBAAA,CACA,iBAAA,CACA,kBAAA,CACA,eAAA,CACA,sBAAA,CAEA,sGAEC,mCAAA,CAGD,sDACC,mCAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\theight: 44px;\n\t&__summary {\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tjustify-content: center;\n\t\talign-items: flex-start;\n\t\tflex: 1 0;\n\t\tmin-width: 0;\n\n\t\t&__desc {\n\t\t\tdisplay: inline-block;\n\t\t\tpadding-bottom: 0;\n\t\t\tline-height: 1.2em;\n\t\t\twhite-space: nowrap;\n\t\t\toverflow: hidden;\n\t\t\ttext-overflow: ellipsis;\n\n\t\t\tp,\n\t\t\tsmall {\n\t\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t\t}\n\n\t\t\t&-unique {\n\t\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t\t}\n\t\t}\n\t}\n\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},29199(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-731a9650]{display:flex;align-items:center;height:44px}.sharing-entry__desc[data-v-731a9650]{display:flex;flex-direction:column;justify-content:space-between;padding:8px;padding-inline-start:10px;line-height:1.2em}.sharing-entry__desc p[data-v-731a9650]{color:var(--color-text-maxcontrast)}.sharing-entry__actions[data-v-731a9650]{margin-inline-start:auto}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryInherited.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,WAAA,CACA,sCACC,YAAA,CACA,qBAAA,CACA,6BAAA,CACA,WAAA,CACA,yBAAA,CACA,iBAAA,CACA,wCACC,mCAAA,CAGF,yCACC,wBAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\theight: 44px;\n\t&__desc {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tjustify-content: space-between;\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tline-height: 1.2em;\n\t\tp {\n\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t}\n\t}\n\t&__actions {\n\t\tmargin-inline-start: auto;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},76459(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry__internal .avatar-external[data-v-6c4cb23b]{width:32px;height:32px;line-height:32px;font-size:18px;background-color:var(--color-text-maxcontrast);border-radius:50%;flex-shrink:0}.sharing-entry__internal .icon-checkmark-color[data-v-6c4cb23b]{opacity:1;color:var(--color-border-success)}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryInternal.vue"],names:[],mappings:"AAEC,2DACC,UAAA,CACA,WAAA,CACA,gBAAA,CACA,cAAA,CACA,8CAAA,CACA,iBAAA,CACA,aAAA,CAED,gEACC,SAAA,CACA,iCAAA",sourcesContent:["\n.sharing-entry__internal {\n\t.avatar-external {\n\t\twidth: 32px;\n\t\theight: 32px;\n\t\tline-height: 32px;\n\t\tfont-size: 18px;\n\t\tbackground-color: var(--color-text-maxcontrast);\n\t\tborder-radius: 50%;\n\t\tflex-shrink: 0;\n\t}\n\t.icon-checkmark-color {\n\t\topacity: 1;\n\t\tcolor: var(--color-border-success);\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},91950(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-7a5c0ee5]{display:flex;align-items:center;min-height:44px}.sharing-entry__summary[data-v-7a5c0ee5]{padding:8px;padding-inline-start:10px;display:flex;justify-content:space-between;flex:1 0;min-width:0}.sharing-entry__desc[data-v-7a5c0ee5]{display:flex;flex-direction:column;line-height:1.2em}.sharing-entry__desc p[data-v-7a5c0ee5]{color:var(--color-text-maxcontrast)}.sharing-entry__desc__title[data-v-7a5c0ee5]{text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.sharing-entry__actions[data-v-7a5c0ee5]{display:flex;align-items:center;margin-inline-start:auto}.sharing-entry:not(.sharing-entry--share) .sharing-entry__actions .new-share-link[data-v-7a5c0ee5]{border-top:1px solid var(--color-border)}.sharing-entry[data-v-7a5c0ee5] .avatar-link-share{background-color:var(--color-primary-element)}.sharing-entry .sharing-entry__action--public-upload[data-v-7a5c0ee5]{border-bottom:1px solid var(--color-border)}.sharing-entry__loading[data-v-7a5c0ee5]{width:44px;height:44px;margin:0;padding:14px;margin-inline-start:auto}.sharing-entry .action-item~.action-item[data-v-7a5c0ee5],.sharing-entry .action-item~.sharing-entry__loading[data-v-7a5c0ee5]{margin-inline-start:0}.sharing-entry__copy-icon--success[data-v-7a5c0ee5]{color:var(--color-border-success)}.qr-code-dialog[data-v-7a5c0ee5]{display:flex;width:100%;justify-content:center}.qr-code-dialog__img[data-v-7a5c0ee5]{width:100%;height:auto}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryLink.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,eAAA,CAEA,yCACC,WAAA,CACA,yBAAA,CACA,YAAA,CACA,6BAAA,CACA,QAAA,CACA,WAAA,CAGA,sCACC,YAAA,CACA,qBAAA,CACA,iBAAA,CAEA,wCACC,mCAAA,CAGD,6CACC,sBAAA,CACA,eAAA,CACA,kBAAA,CAIF,yCACC,YAAA,CACA,kBAAA,CACA,wBAAA,CAID,mGACC,wCAAA,CAIF,mDACC,6CAAA,CAGD,sEACC,2CAAA,CAGD,yCACC,UAAA,CACA,WAAA,CACA,QAAA,CACA,YAAA,CACA,wBAAA,CAOA,+HAEC,qBAAA,CAIF,oDACC,iCAAA,CAKF,iCACC,YAAA,CACA,UAAA,CACA,sBAAA,CAEA,sCACC,UAAA,CACA,WAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\tmin-height: 44px;\n\n\t&__summary {\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tflex: 1 0;\n\t\tmin-width: 0;\n\t}\n\n\t\t&__desc {\n\t\t\tdisplay: flex;\n\t\t\tflex-direction: column;\n\t\t\tline-height: 1.2em;\n\n\t\t\tp {\n\t\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t\t}\n\n\t\t\t&__title {\n\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\toverflow: hidden;\n\t\t\t\twhite-space: nowrap;\n\t\t\t}\n\t\t}\n\n\t\t&__actions {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\tmargin-inline-start: auto;\n\t\t}\n\n\t&:not(.sharing-entry--share) &__actions {\n\t\t.new-share-link {\n\t\t\tborder-top: 1px solid var(--color-border);\n\t\t}\n\t}\n\n\t:deep(.avatar-link-share) {\n\t\tbackground-color: var(--color-primary-element);\n\t}\n\n\t.sharing-entry__action--public-upload {\n\t\tborder-bottom: 1px solid var(--color-border);\n\t}\n\n\t&__loading {\n\t\twidth: 44px;\n\t\theight: 44px;\n\t\tmargin: 0;\n\t\tpadding: 14px;\n\t\tmargin-inline-start: auto;\n\t}\n\n\t// put menus to the left\n\t// but only the first one\n\t.action-item {\n\n\t\t~.action-item,\n\t\t~.sharing-entry__loading {\n\t\t\tmargin-inline-start: 0;\n\t\t}\n\t}\n\n\t&__copy-icon--success {\n\t\tcolor: var(--color-border-success);\n\t}\n}\n\n// styling for the qr-code container\n.qr-code-dialog {\n\tdisplay: flex;\n\twidth: 100%;\n\tjustify-content: center;\n\n\t&__img {\n\t\twidth: 100%;\n\t\theight: auto;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},20569(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".share-select[data-v-839566a2]{display:block}.share-select[data-v-839566a2] .action-item__menutoggle{color:var(--color-primary-element) !important;font-size:12.5px !important;height:auto !important;min-height:auto !important}.share-select[data-v-839566a2] .action-item__menutoggle .button-vue__text{font-weight:normal !important}.share-select[data-v-839566a2] .action-item__menutoggle .button-vue__icon{height:24px !important;min-height:24px !important;width:24px !important;min-width:24px !important}.share-select[data-v-839566a2] .action-item__menutoggle .button-vue__wrapper{flex-direction:row-reverse !important}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue"],names:[],mappings:"AACA,+BACC,aAAA,CAIA,wDACC,6CAAA,CACA,2BAAA,CACA,sBAAA,CACA,0BAAA,CAEA,0EACC,6BAAA,CAGD,0EACC,sBAAA,CACA,0BAAA,CACA,qBAAA,CACA,yBAAA,CAGD,6EAEC,qCAAA",sourcesContent:["\n.share-select {\n\tdisplay: block;\n\n\t// TODO: NcActions should have a slot for custom trigger button like NcPopover\n\t// Overrider NcActionms button to make it small\n\t:deep(.action-item__menutoggle) {\n\t\tcolor: var(--color-primary-element) !important;\n\t\tfont-size: 12.5px !important;\n\t\theight: auto !important;\n\t\tmin-height: auto !important;\n\n\t\t.button-vue__text {\n\t\t\tfont-weight: normal !important;\n\t\t}\n\n\t\t.button-vue__icon {\n\t\t\theight: 24px !important;\n\t\t\tmin-height: 24px !important;\n\t\t\twidth: 24px !important;\n\t\t\tmin-width: 24px !important;\n\t\t}\n\n\t\t.button-vue__wrapper {\n\t\t\t// Emulate NcButton's alignment=center-reverse\n\t\t\tflex-direction: row-reverse !important;\n\t\t}\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},33176(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry[data-v-13d4a0bb]{display:flex;align-items:center;min-height:44px}.sharing-entry__desc[data-v-13d4a0bb]{padding:8px;padding-inline-start:10px;line-height:1.2em;position:relative;flex:1 1;min-width:0}.sharing-entry__desc p[data-v-13d4a0bb]{color:var(--color-text-maxcontrast)}.sharing-entry__title[data-v-13d4a0bb]{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:inherit}.sharing-entry__actions[data-v-13d4a0bb]{margin-inline-start:auto !important}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingEntrySimple.vue"],names:[],mappings:"AACA,gCACC,YAAA,CACA,kBAAA,CACA,eAAA,CACA,sCACC,WAAA,CACA,yBAAA,CACA,iBAAA,CACA,iBAAA,CACA,QAAA,CACA,WAAA,CACA,wCACC,mCAAA,CAGF,uCACC,kBAAA,CACA,sBAAA,CACA,eAAA,CACA,iBAAA,CAED,yCACC,mCAAA",sourcesContent:["\n.sharing-entry {\n\tdisplay: flex;\n\talign-items: center;\n\tmin-height: 44px;\n\t&__desc {\n\t\tpadding: 8px;\n\t\tpadding-inline-start: 10px;\n\t\tline-height: 1.2em;\n\t\tposition: relative;\n\t\tflex: 1 1;\n\t\tmin-width: 0;\n\t\tp {\n\t\t\tcolor: var(--color-text-maxcontrast);\n\t\t}\n\t}\n\t&__title {\n\t\twhite-space: nowrap;\n\t\ttext-overflow: ellipsis;\n\t\toverflow: hidden;\n\t\tmax-width: inherit;\n\t}\n\t&__actions {\n\t\tmargin-inline-start: auto !important;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},24992(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-search{display:flex;flex-direction:column;margin-bottom:4px}.sharing-search label[for=sharing-search-input]{margin-bottom:2px}.sharing-search__input{width:100%;margin:10px 0}.vs__dropdown-menu span[lookup] .avatardiv{background-image:var(--icon-search-white);background-repeat:no-repeat;background-position:center;background-color:var(--color-text-maxcontrast) !important}.vs__dropdown-menu span[lookup] .avatardiv .avatardiv__initials-wrapper{display:none}","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SharingInput.vue"],names:[],mappings:"AACA,gBACC,YAAA,CACA,qBAAA,CACA,iBAAA,CAEA,gDACC,iBAAA,CAGD,uBACC,UAAA,CACA,aAAA,CAOA,2CACC,yCAAA,CACA,2BAAA,CACA,0BAAA,CACA,yDAAA,CACA,wEACC,YAAA",sourcesContent:['\n.sharing-search {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-bottom: 4px;\n\n\tlabel[for="sharing-search-input"] {\n\t\tmargin-bottom: 2px;\n\t}\n\n\t&__input {\n\t\twidth: 100%;\n\t\tmargin: 10px 0;\n\t}\n}\n\n.vs__dropdown-menu {\n\t// properly style the lookup entry\n\tspan[lookup] {\n\t\t.avatardiv {\n\t\t\tbackground-image: var(--icon-search-white);\n\t\t\tbackground-repeat: no-repeat;\n\t\t\tbackground-position: center;\n\t\t\tbackground-color: var(--color-text-maxcontrast) !important;\n\t\t\t.avatardiv__initials-wrapper {\n\t\t\t\tdisplay: none;\n\t\t\t}\n\t\t}\n\t}\n}\n'],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},23716(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharingTabDetailsView[data-v-1e0a769c]{display:flex;flex-direction:column;width:100%;margin:0 auto;position:relative;height:100%;overflow:hidden}.sharingTabDetailsView__header[data-v-1e0a769c]{display:flex;align-items:center;box-sizing:border-box;margin:.2em}.sharingTabDetailsView__header span[data-v-1e0a769c]{display:flex;align-items:center}.sharingTabDetailsView__header span h1[data-v-1e0a769c]{font-size:15px;padding-inline-start:.3em}.sharingTabDetailsView__wrapper[data-v-1e0a769c]{position:relative;overflow:scroll;flex-shrink:1;padding:4px;padding-inline-end:12px}.sharingTabDetailsView__quick-permissions[data-v-1e0a769c]{display:flex;justify-content:center;width:100%;margin:0 auto;border-radius:0}.sharingTabDetailsView__quick-permissions div[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__quick-permissions div span span[data-v-1e0a769c]:nth-child(1){align-items:center;justify-content:center;padding:.1em}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c] label span{display:flex;flex-direction:column}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c] span.checkbox-content__text.checkbox-radio-switch__text{flex-wrap:wrap}.sharingTabDetailsView__quick-permissions div span[data-v-1e0a769c] span.checkbox-content__text.checkbox-radio-switch__text .subline{display:block;flex-basis:100%}.sharingTabDetailsView__advanced-control[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__advanced-control button[data-v-1e0a769c]{margin-top:.5em}.sharingTabDetailsView__advanced[data-v-1e0a769c]{width:100%;margin-bottom:.5em;text-align:start;padding-inline-start:0}.sharingTabDetailsView__advanced section textarea[data-v-1e0a769c],.sharingTabDetailsView__advanced section div.mx-datepicker[data-v-1e0a769c]{width:100%}.sharingTabDetailsView__advanced section textarea[data-v-1e0a769c]{height:80px;margin:0}.sharingTabDetailsView__advanced section span[data-v-1e0a769c] label{padding-inline-start:0 !important;background-color:initial !important;border:none !important}.sharingTabDetailsView__advanced section section.custom-permissions-group[data-v-1e0a769c]{padding-inline-start:1.5em}.sharingTabDetailsView__label[data-v-1e0a769c]{padding-block-end:6px}.sharingTabDetailsView__delete>button[data-v-1e0a769c]:first-child{color:#df0707}.sharingTabDetailsView__footer[data-v-1e0a769c]{width:100%;display:flex;position:sticky;bottom:0;flex-direction:column;justify-content:space-between;align-items:flex-start;background:linear-gradient(to bottom, rgba(255, 255, 255, 0), var(--color-main-background))}.sharingTabDetailsView__footer .button-group[data-v-1e0a769c]{display:flex;justify-content:space-between;width:100%;margin-top:16px}.sharingTabDetailsView__footer .button-group button[data-v-1e0a769c]{margin-inline-start:16px}.sharingTabDetailsView__footer .button-group button[data-v-1e0a769c]:first-child{margin-inline-start:0}","",{version:3,sources:["webpack://./apps/files_sharing/src/views/SharingDetailsTab.vue"],names:[],mappings:"AACA,wCACC,YAAA,CACA,qBAAA,CACA,UAAA,CACA,aAAA,CACA,iBAAA,CACA,WAAA,CACA,eAAA,CAEA,gDACC,YAAA,CACA,kBAAA,CACA,qBAAA,CACA,WAAA,CAEA,qDACC,YAAA,CACA,kBAAA,CAEA,wDACC,cAAA,CACA,yBAAA,CAMH,iDACC,iBAAA,CACA,eAAA,CACA,aAAA,CACA,WAAA,CACA,uBAAA,CAGD,2DACC,YAAA,CACA,sBAAA,CACA,UAAA,CACA,aAAA,CACA,eAAA,CAEA,+DACC,UAAA,CAEA,oEACC,UAAA,CAEA,sFACC,kBAAA,CACA,sBAAA,CACA,YAAA,CAGD,+EACC,YAAA,CACA,qBAAA,CAID,4HACC,cAAA,CAEA,qIACC,aAAA,CACA,eAAA,CAQL,0DACC,UAAA,CAEA,iEACC,eAAA,CAKF,kDACC,UAAA,CACA,kBAAA,CACA,gBAAA,CACA,sBAAA,CAIC,+IAEC,UAAA,CAGD,mEACC,WAAA,CACA,QAAA,CAYD,qEACC,iCAAA,CACA,mCAAA,CACA,sBAAA,CAGD,2FACC,0BAAA,CAKH,+CACC,qBAAA,CAIA,mEACC,aAAA,CAIF,gDACC,UAAA,CACA,YAAA,CACA,eAAA,CACA,QAAA,CACA,qBAAA,CACA,6BAAA,CACA,sBAAA,CACA,2FAAA,CAEA,8DACC,YAAA,CACA,6BAAA,CACA,UAAA,CACA,eAAA,CAEA,qEACC,wBAAA,CAEA,iFACC,qBAAA",sourcesContent:["\n.sharingTabDetailsView {\n\tdisplay: flex;\n\tflex-direction: column;\n\twidth: 100%;\n\tmargin: 0 auto;\n\tposition: relative;\n\theight: 100%;\n\toverflow: hidden;\n\n\t&__header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tbox-sizing: border-box;\n\t\tmargin: 0.2em;\n\n\t\tspan {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\n\t\t\th1 {\n\t\t\t\tfont-size: 15px;\n\t\t\t\tpadding-inline-start: 0.3em;\n\t\t\t}\n\n\t\t}\n\t}\n\n\t&__wrapper {\n\t\tposition: relative;\n\t\toverflow: scroll;\n\t\tflex-shrink: 1;\n\t\tpadding: 4px;\n\t\tpadding-inline-end: 12px;\n\t}\n\n\t&__quick-permissions {\n\t\tdisplay: flex;\n\t\tjustify-content: center;\n\t\twidth: 100%;\n\t\tmargin: 0 auto;\n\t\tborder-radius: 0;\n\n\t\tdiv {\n\t\t\twidth: 100%;\n\n\t\t\tspan {\n\t\t\t\twidth: 100%;\n\n\t\t\t\tspan:nth-child(1) {\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tpadding: 0.1em;\n\t\t\t\t}\n\n\t\t\t\t:deep(label span) {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t}\n\n\t\t\t\t/* Target component based style in NcCheckboxRadioSwitch slot content*/\n\t\t\t\t:deep(span.checkbox-content__text.checkbox-radio-switch__text) {\n\t\t\t\t\tflex-wrap: wrap;\n\n\t\t\t\t\t.subline {\n\t\t\t\t\t\tdisplay: block;\n\t\t\t\t\t\tflex-basis: 100%;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t}\n\t}\n\n\t&__advanced-control {\n\t\twidth: 100%;\n\n\t\tbutton {\n\t\t\tmargin-top: 0.5em;\n\t\t}\n\n\t}\n\n\t&__advanced {\n\t\twidth: 100%;\n\t\tmargin-bottom: 0.5em;\n\t\ttext-align: start;\n\t\tpadding-inline-start: 0;\n\n\t\tsection {\n\n\t\t\ttextarea,\n\t\t\tdiv.mx-datepicker {\n\t\t\t\twidth: 100%;\n\t\t\t}\n\n\t\t\ttextarea {\n\t\t\t\theight: 80px;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t/*\n\t\t\t The following style is applied out of the component's scope\n\t\t\t to remove padding from the label.checkbox-radio-switch__label,\n\t\t\t which is used to group radio checkbox items. The use of ::v-deep\n\t\t\t ensures that the padding is modified without being affected by\n\t\t\t the component's scoping.\n\t\t\t Without this achieving left alignment for the checkboxes would not\n\t\t\t be possible.\n\t\t\t*/\n\t\t\tspan :deep(label) {\n\t\t\t\tpadding-inline-start: 0 !important;\n\t\t\t\tbackground-color: initial !important;\n\t\t\t\tborder: none !important;\n\t\t\t}\n\n\t\t\tsection.custom-permissions-group {\n\t\t\t\tpadding-inline-start: 1.5em;\n\t\t\t}\n\t\t}\n\t}\n\n\t&__label {\n\t\tpadding-block-end: 6px;\n\t}\n\n\t&__delete {\n\t\t> button:first-child {\n\t\t\tcolor: rgb(223, 7, 7);\n\t\t}\n\t}\n\n\t&__footer {\n\t\twidth: 100%;\n\t\tdisplay: flex;\n\t\tposition: sticky;\n\t\tbottom: 0;\n\t\tflex-direction: column;\n\t\tjustify-content: space-between;\n\t\talign-items: flex-start;\n\t\tbackground: linear-gradient(to bottom, rgba(255, 255, 255, 0), var(--color-main-background));\n\n\t\t.button-group {\n\t\t\tdisplay: flex;\n\t\t\tjustify-content: space-between;\n\t\t\twidth: 100%;\n\t\t\tmargin-top: 16px;\n\n\t\t\tbutton {\n\t\t\t\tmargin-inline-start: 16px;\n\n\t\t\t\t&:first-child {\n\t\t\t\t\tmargin-inline-start: 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},19353(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".sharing-entry__inherited .avatar-shared[data-v-cedf3238]{width:32px;height:32px;line-height:32px;font-size:18px;background-color:var(--color-text-maxcontrast);border-radius:50%;flex-shrink:0}","",{version:3,sources:["webpack://./apps/files_sharing/src/views/SharingInherited.vue"],names:[],mappings:"AAEC,0DACC,UAAA,CACA,WAAA,CACA,gBAAA,CACA,cAAA,CACA,8CAAA,CACA,iBAAA,CACA,aAAA",sourcesContent:["\n.sharing-entry__inherited {\n\t.avatar-shared {\n\t\twidth: 32px;\n\t\theight: 32px;\n\t\tline-height: 32px;\n\t\tfont-size: 18px;\n\t\tbackground-color: var(--color-text-maxcontrast);\n\t\tborder-radius: 50%;\n\t\tflex-shrink: 0;\n\t}\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},41253(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,".emptyContentWithSections[data-v-cd6ad9ee]{margin:1rem auto}.sharingTab[data-v-cd6ad9ee]{position:relative;height:100%}.sharingTab__content[data-v-cd6ad9ee]{padding:0 6px}.sharingTab__content section[data-v-cd6ad9ee]{padding-bottom:16px}.sharingTab__content section .section-header[data-v-cd6ad9ee]{margin-top:2px;margin-bottom:2px;display:flex;align-items:center;padding-bottom:4px}.sharingTab__content section .section-header h4[data-v-cd6ad9ee]{margin:0;font-size:16px}.sharingTab__content section .section-header .visually-hidden[data-v-cd6ad9ee]{display:none}.sharingTab__content section .section-header .hint-icon[data-v-cd6ad9ee]{color:var(--color-primary-element)}.sharingTab__content>section[data-v-cd6ad9ee]:not(:last-child){border-bottom:2px solid var(--color-border)}.sharingTab__additionalContent[data-v-cd6ad9ee]{margin:var(--default-clickable-area) 0}.hint-body[data-v-cd6ad9ee]{max-width:300px;padding:var(--border-radius-element)}","",{version:3,sources:["webpack://./apps/files_sharing/src/views/SharingTab.vue"],names:[],mappings:"AACA,2CACC,gBAAA,CAGD,6BACC,iBAAA,CACA,WAAA,CAEA,sCACC,aAAA,CAEA,8CACC,mBAAA,CAEA,8DACC,cAAA,CACA,iBAAA,CACA,YAAA,CACA,kBAAA,CACA,kBAAA,CAEA,iEACC,QAAA,CACA,cAAA,CAGD,+EACC,YAAA,CAGD,yEACC,kCAAA,CAOH,+DACC,2CAAA,CAKF,gDACC,sCAAA,CAIF,4BACC,eAAA,CACA,oCAAA",sourcesContent:["\n.emptyContentWithSections {\n\tmargin: 1rem auto;\n}\n\n.sharingTab {\n\tposition: relative;\n\theight: 100%;\n\n\t&__content {\n\t\tpadding: 0 6px;\n\n\t\tsection {\n\t\t\tpadding-bottom: 16px;\n\n\t\t\t.section-header {\n\t\t\t\tmargin-top: 2px;\n\t\t\t\tmargin-bottom: 2px;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tpadding-bottom: 4px;\n\n\t\t\t\th4 {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tfont-size: 16px;\n\t\t\t\t}\n\n\t\t\t\t.visually-hidden {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t}\n\n\t\t\t\t.hint-icon {\n\t\t\t\t\tcolor: var(--color-primary-element);\n\t\t\t\t}\n\n\t\t\t}\n\n\t\t}\n\n\t\t& > section:not(:last-child) {\n\t\t\tborder-bottom: 2px solid var(--color-border);\n\t\t}\n\n\t}\n\n\t&__additionalContent {\n\t\tmargin: var(--default-clickable-area) 0;\n\t}\n}\n\n.hint-body {\n\tmax-width: 300px;\n\tpadding: var(--border-radius-element);\n}\n"],sourceRoot:""}]);const o=r;i.d(t,["A",0,o])},70544(e,t,i){var s=i(71354),a=i.n(s),n=i(76314),r=i.n(n)()(a());r.push([e.id,"\n.sharing-tab-external-section-legacy[data-v-3e4e67d2] {\n\twidth: 100%;\n}\n","",{version:3,sources:["webpack://./apps/files_sharing/src/components/SidebarTabExternal/SidebarTabExternalSectionLegacy.vue"],names:[],mappings:";AAkCA;CACA,WAAA;AACA",sourcesContent:['\x3c!--\n - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n - SPDX-License-Identifier: AGPL-3.0-or-later\n--\x3e\n\n\n\n\\n\\n\\n\"],\"sourceRoot\":\"\"}]);\n// Exports\nexport default ___CSS_LOADER_EXPORT___;\n","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return (_setup.fileInfo)?_c(_setup.SharingTab,{attrs:{\"file-info\":_setup.fileInfo}}):_vm._e()\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./ContentCopy.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./ContentCopy.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./ContentCopy.vue?vue&type=template&id=0e8bd3c4\"\nimport script from \"./ContentCopy.vue?vue&type=script&lang=js\"\nexport * from \"./ContentCopy.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon content-copy-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('li',{staticClass:\"sharing-entry\"},[_vm._t(\"avatar\"),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__desc\"},[_c('span',{staticClass:\"sharing-entry__title\"},[_vm._v(_vm._s(_vm.title))]),_vm._v(\" \"),(_vm.subtitle)?_c('p',[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.subtitle)+\"\\n\\t\\t\")]):_vm._e()]),_vm._v(\" \"),(_vm.$slots['default'])?_c('NcActions',{ref:\"actionsComponent\",staticClass:\"sharing-entry__actions\",attrs:{\"menu-align\":\"right\",\"aria-expanded\":_vm.ariaExpandedValue}},[_vm._t(\"default\")],2):_vm._e()],2)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=style&index=0&id=13d4a0bb&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntrySimple.vue?vue&type=style&index=0&id=13d4a0bb&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntrySimple.vue?vue&type=template&id=13d4a0bb&scoped=true\"\nimport script from \"./SharingEntrySimple.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntrySimple.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntrySimple.vue?vue&type=style&index=0&id=13d4a0bb&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"13d4a0bb\",\n null\n \n)\n\nexport default component.exports","/**\n * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getCapabilities } from '@nextcloud/capabilities';\nimport { generateUrl, getBaseUrl } from '@nextcloud/router';\n/**\n * @param fileid - The file ID to generate the direct file link for\n */\nexport function generateFileUrl(fileid) {\n const baseURL = getBaseUrl();\n const { globalscale } = getCapabilities();\n if (globalscale?.token) {\n return generateUrl('/gf/{token}/{fileid}', {\n token: globalscale.token,\n fileid,\n }, { baseURL });\n }\n return generateUrl('/f/{fileid}', {\n fileid,\n }, {\n baseURL,\n });\n}\n","\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=style&index=0&id=6c4cb23b&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInternal.vue?vue&type=style&index=0&id=6c4cb23b&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryInternal.vue?vue&type=template&id=6c4cb23b&scoped=true\"\nimport script from \"./SharingEntryInternal.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryInternal.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryInternal.vue?vue&type=style&index=0&id=6c4cb23b&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"6c4cb23b\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('ul',[_c('SharingEntrySimple',{ref:\"shareEntrySimple\",staticClass:\"sharing-entry__internal\",attrs:{\"title\":_vm.t('files_sharing', 'Internal link'),\"subtitle\":_vm.internalLinkSubtitle},scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('div',{staticClass:\"avatar-external icon-external-white\"})]},proxy:true}])},[_vm._v(\" \"),_c('NcActionButton',{attrs:{\"title\":_vm.copyLinkTooltip,\"aria-label\":_vm.copyLinkTooltip},on:{\"click\":_vm.copyLink},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [(_vm.copied && _vm.copySuccess)?_c('CheckIcon',{staticClass:\"icon-checkmark-color\",attrs:{\"size\":20}}):_c('ClipboardIcon',{attrs:{\"size\":20}})]},proxy:true}])})],1)],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"sharing-search\"},[_c('label',{staticClass:\"hidden-visually\",attrs:{\"for\":_vm.shareInputId}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.isExternal\n\t\t\t? _vm.t('files_sharing', 'Enter external recipients')\n\t\t\t: _vm.t('files_sharing', 'Search for internal recipients'))+\"\\n\\t\")]),_vm._v(\" \"),_c('NcSelect',{ref:\"select\",staticClass:\"sharing-search__input\",attrs:{\"input-id\":_vm.shareInputId,\"disabled\":!_vm.canReshare,\"loading\":_vm.loading,\"filterable\":false,\"placeholder\":_vm.inputPlaceholder,\"clear-search-on-blur\":() => false,\"user-select\":true,\"options\":_vm.options,\"label-outside\":true},on:{\"search\":_vm.asyncFind,\"option:selected\":_vm.onSelected},scopedSlots:_vm._u([{key:\"no-options\",fn:function({ search }){return [_vm._v(\"\\n\\t\\t\\t\"+_vm._s(search ? _vm.noResultText : _vm.placeholder)+\"\\n\\t\\t\")]}}]),model:{value:(_vm.value),callback:function ($$v) {_vm.value=$$v},expression:\"value\"}})],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","/**\n * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nexport const ATOMIC_PERMISSIONS = {\n\tNONE: 0,\n\tREAD: 1,\n\tUPDATE: 2,\n\tCREATE: 4,\n\tDELETE: 8,\n\tSHARE: 16,\n}\n\nconst BUNDLED_PERMISSIONS = {\n\tREAD_ONLY: ATOMIC_PERMISSIONS.READ,\n\tUPLOAD_AND_UPDATE: ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE,\n\tFILE_DROP: ATOMIC_PERMISSIONS.CREATE,\n\tALL: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE,\n\tALL_FILE: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.SHARE,\n}\n\n/**\n * Get bundled permissions based on config.\n *\n * @param {boolean} excludeShare - Whether to exclude SHARE permission from ALL and ALL_FILE bundles.\n * @return {object}\n */\nexport function getBundledPermissions(excludeShare = false) {\n\tif (excludeShare) {\n\t\treturn {\n\t\t\t...BUNDLED_PERMISSIONS,\n\t\t\tALL: BUNDLED_PERMISSIONS.ALL & ~ATOMIC_PERMISSIONS.SHARE,\n\t\t\tALL_FILE: BUNDLED_PERMISSIONS.ALL_FILE & ~ATOMIC_PERMISSIONS.SHARE,\n\t\t}\n\t}\n\treturn BUNDLED_PERMISSIONS\n}\n\n/**\n * Return whether a given permissions set contains some permissions.\n *\n * @param {number} initialPermissionSet - the permissions set.\n * @param {number} permissionsToCheck - the permissions to check.\n * @return {boolean}\n */\nexport function hasPermissions(initialPermissionSet, permissionsToCheck) {\n\treturn initialPermissionSet !== ATOMIC_PERMISSIONS.NONE && (initialPermissionSet & permissionsToCheck) === permissionsToCheck\n}\n\n/**\n * Return whether a given permissions set is valid.\n *\n * @param {number} permissionsSet - the permissions set.\n *\n * @return {boolean}\n */\nexport function permissionsSetIsValid(permissionsSet) {\n\t// Must have at least READ or CREATE permission.\n\tif (!hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.READ) && !hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.CREATE)) {\n\t\treturn false\n\t}\n\n\t// Must have READ permission if have UPDATE or DELETE.\n\tif (!hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.READ) && (\n\t\thasPermissions(permissionsSet, ATOMIC_PERMISSIONS.UPDATE) || hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.DELETE)\n\t)) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n/**\n * Add some permissions to an initial set of permissions.\n *\n * @param {number} initialPermissionSet - the initial permissions.\n * @param {number} permissionsToAdd - the permissions to add.\n *\n * @return {number}\n */\nexport function addPermissions(initialPermissionSet, permissionsToAdd) {\n\treturn initialPermissionSet | permissionsToAdd\n}\n\n/**\n * Remove some permissions from an initial set of permissions.\n *\n * @param {number} initialPermissionSet - the initial permissions.\n * @param {number} permissionsToSubtract - the permissions to remove.\n *\n * @return {number}\n */\nexport function subtractPermissions(initialPermissionSet, permissionsToSubtract) {\n\treturn initialPermissionSet & ~permissionsToSubtract\n}\n\n/**\n * Toggle some permissions from an initial set of permissions.\n *\n * @param {number} initialPermissionSet - the permissions set.\n * @param {number} permissionsToToggle - the permissions to toggle.\n *\n * @return {number}\n */\nexport function togglePermissions(initialPermissionSet, permissionsToToggle) {\n\tif (hasPermissions(initialPermissionSet, permissionsToToggle)) {\n\t\treturn subtractPermissions(initialPermissionSet, permissionsToToggle)\n\t} else {\n\t\treturn addPermissions(initialPermissionSet, permissionsToToggle)\n\t}\n}\n\n/**\n * Return whether some given permissions can be toggled from a permission set.\n *\n * @param {number} permissionSet - the initial permissions set.\n * @param {number} permissionsToToggle - the permissions to toggle.\n *\n * @return {boolean}\n */\nexport function canTogglePermissions(permissionSet, permissionsToToggle) {\n\treturn permissionsSetIsValid(togglePermissions(permissionSet, permissionsToToggle))\n}\n\n/**\n * The permission bundles the share editor offers, in the order they are matched.\n *\n * @type {string[]}\n */\nconst EDITOR_BUNDLES = ['READ_ONLY', 'ALL', 'ALL_FILE', 'FILE_DROP']\n\n/**\n * Find the permission bundle a share's permissions correspond to.\n *\n * Link and email shares carry the SHARE permission whenever federation on\n * public shares is enabled: the server adds it on top of whatever bundle was\n * picked, so it must be ignored when matching those shares against a bundle.\n *\n * @param {number} permissions - the share permissions.\n * @param {object} [options] - matching options.\n * @param {boolean} [options.isPublicShare] - whether the share is a link or email share.\n * @param {boolean} [options.excludeReshareFromEdit] - whether SHARE is excluded from the editing bundles.\n *\n * @return {string|null} the name of the matching bundle, or `null` for custom permissions.\n */\nexport function matchBundledPermissions(permissions, { isPublicShare = false, excludeReshareFromEdit = false } = {}) {\n\tconst bundles = getBundledPermissions(isPublicShare || excludeReshareFromEdit)\n\tconst comparablePermissions = isPublicShare\n\t\t? subtractPermissions(permissions, ATOMIC_PERMISSIONS.SHARE)\n\t\t: permissions\n\n\treturn EDITOR_BUNDLES.find((bundle) => bundles[bundle] === comparablePermissions) ?? null\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport logger from '../services/logger.ts';\nimport { isFileRequest } from '../services/SharingService.ts';\nexport default class Share {\n _share;\n /**\n * Create the share object\n *\n * @param ocsData ocs request response\n */\n constructor(ocsData) {\n if (ocsData.ocs && ocsData.ocs.data && ocsData.ocs.data[0]) {\n ocsData = ocsData.ocs.data[0];\n }\n // string to int\n if (typeof ocsData.id === 'string') {\n ocsData.id = Number.parseInt(ocsData.id);\n }\n // convert int into boolean\n ocsData.hide_download = !!ocsData.hide_download;\n ocsData.mail_send = !!ocsData.mail_send;\n if (ocsData.attributes && typeof ocsData.attributes === 'string') {\n try {\n ocsData.attributes = JSON.parse(ocsData.attributes);\n }\n catch {\n logger.warn('Could not parse share attributes returned by server', ocsData.attributes);\n }\n }\n ocsData.attributes = ocsData.attributes ?? [];\n // Pre-declared so Vue 2 makes newPassword reactive at observation time,\n // avoiding $set's property-addition path which races with async setters.\n ocsData.newPassword = ocsData.newPassword ?? undefined;\n // store state\n this._share = ocsData;\n }\n /**\n * Get the share state\n * ! used for reactivity purpose\n * Do not remove. It allow vuejs to\n * inject its watchers into the #share\n * state and make the whole class reactive\n *\n * @return the share raw state\n */\n get state() {\n return this._share;\n }\n /**\n * get the share id\n */\n get id() {\n return this._share.id;\n }\n /**\n * Get the share type\n */\n get type() {\n return this._share.share_type;\n }\n /**\n * Get the share permissions\n * See window.OC.PERMISSION_* variables\n */\n get permissions() {\n return this._share.permissions;\n }\n /**\n * Get the share attributes\n */\n get attributes() {\n return this._share.attributes || [];\n }\n /**\n * Set the share permissions\n * See window.OC.PERMISSION_* variables\n */\n set permissions(permissions) {\n this._share.permissions = permissions;\n }\n // SHARE OWNER --------------------------------------------------\n /**\n * Get the share owner uid\n */\n get owner() {\n return this._share.uid_owner;\n }\n /**\n * Get the share owner's display name\n */\n get ownerDisplayName() {\n return this._share.displayname_owner;\n }\n // SHARED WITH --------------------------------------------------\n /**\n * Get the share with entity uid\n */\n get shareWith() {\n return this._share.share_with;\n }\n /**\n * Get the share with entity display name\n * fallback to its uid if none\n */\n get shareWithDisplayName() {\n return this._share.share_with_displayname\n || this._share.share_with;\n }\n /**\n * Unique display name in case of multiple\n * duplicates results with the same name.\n */\n get shareWithDisplayNameUnique() {\n return this._share.share_with_displayname_unique\n || this._share.share_with;\n }\n /**\n * Get the share with entity link\n */\n get shareWithLink() {\n return this._share.share_with_link;\n }\n /**\n * Get the share with avatar if any\n */\n get shareWithAvatar() {\n return this._share.share_with_avatar;\n }\n // SHARED FILE OR FOLDER OWNER ----------------------------------\n /**\n * Get the shared item owner uid\n */\n get uidFileOwner() {\n return this._share.uid_file_owner;\n }\n /**\n * Get the shared item display name\n * fallback to its uid if none\n */\n get displaynameFileOwner() {\n return this._share.displayname_file_owner\n || this._share.uid_file_owner;\n }\n // TIME DATA ----------------------------------------------------\n /**\n * Get the share creation timestamp\n */\n get createdTime() {\n return this._share.stime;\n }\n /**\n * Get the expiration date\n *\n * @return date with YYYY-MM-DD format\n */\n get expireDate() {\n return this._share.expiration;\n }\n /**\n * Set the expiration date\n *\n * @param date the share expiration date with YYYY-MM-DD format\n */\n set expireDate(date) {\n this._share.expiration = date;\n }\n // EXTRA DATA ---------------------------------------------------\n /**\n * Get the public share token\n */\n get token() {\n return this._share.token;\n }\n /**\n * Set the public share token\n */\n set token(token) {\n this._share.token = token;\n }\n /**\n * Get the share note if any\n */\n get note() {\n return this._share.note;\n }\n /**\n * Set the share note if any\n */\n set note(note) {\n this._share.note = note;\n }\n /**\n * Get the share label if any\n * Should only exist on link shares\n */\n get label() {\n return this._share.label ?? '';\n }\n /**\n * Set the share label if any\n * Should only be set on link shares\n */\n set label(label) {\n this._share.label = label;\n }\n /**\n * Have a mail been sent\n */\n get mailSend() {\n return this._share.mail_send === true;\n }\n /**\n * Hide the download button on public page\n */\n get hideDownload() {\n return this._share.hide_download === true\n || this.attributes.find?.(({ scope, key, value }) => scope === 'permissions' && key === 'download' && !value) !== undefined;\n }\n /**\n * Hide the download button on public page\n */\n set hideDownload(state) {\n // disabling hide-download also enables the download permission\n // needed for regression in Nextcloud 31.0.0 until (incl.) 31.0.3\n if (!state) {\n const attribute = this.attributes.find(({ key, scope }) => key === 'download' && scope === 'permissions');\n if (attribute) {\n attribute.value = true;\n }\n }\n this._share.hide_download = state === true;\n }\n /**\n * Password protection of the share\n */\n get password() {\n return this._share.password;\n }\n /**\n * Password protection of the share\n */\n set password(password) {\n this._share.password = password;\n }\n /**\n * Unsaved password (set during share creation or editing).\n * Delegates to _share so reads/writes go through the reactive state.\n */\n get newPassword() {\n return this._share.newPassword;\n }\n set newPassword(value) {\n this._share.newPassword = value;\n }\n /**\n * Password expiration time\n *\n * @return date with YYYY-MM-DD format\n */\n get passwordExpirationTime() {\n return this._share.password_expiration_time;\n }\n /**\n * Password expiration time\n *\n * @param passwordExpirationTime date with YYYY-MM-DD format\n */\n set passwordExpirationTime(passwordExpirationTime) {\n this._share.password_expiration_time = passwordExpirationTime;\n }\n /**\n * Password protection by Talk of the share\n */\n get sendPasswordByTalk() {\n return this._share.send_password_by_talk;\n }\n /**\n * Password protection by Talk of the share\n *\n * @param sendPasswordByTalk whether to send the password by Talk or not\n */\n set sendPasswordByTalk(sendPasswordByTalk) {\n this._share.send_password_by_talk = sendPasswordByTalk;\n }\n // SHARED ITEM DATA ---------------------------------------------\n /**\n * Get the shared item absolute full path\n */\n get path() {\n return this._share.path;\n }\n /**\n * Return the item type: file or folder\n *\n * @return 'folder' | 'file'\n */\n get itemType() {\n return this._share.item_type;\n }\n /**\n * Get the shared item mimetype\n */\n get mimetype() {\n return this._share.mimetype;\n }\n /**\n * Get the shared item id\n */\n get fileSource() {\n return this._share.file_source;\n }\n /**\n * Get the target path on the receiving end\n * e.g the file /xxx/aaa will be shared in\n * the receiving root as /aaa, the fileTarget is /aaa\n */\n get fileTarget() {\n return this._share.file_target;\n }\n /**\n * Get the parent folder id if any\n */\n get fileParent() {\n return this._share.file_parent;\n }\n // PERMISSIONS Shortcuts\n /**\n * Does this share have READ permissions\n */\n get hasReadPermission() {\n return !!((this.permissions & window.OC.PERMISSION_READ));\n }\n /**\n * Does this share have CREATE permissions\n */\n get hasCreatePermission() {\n return !!((this.permissions & window.OC.PERMISSION_CREATE));\n }\n /**\n * Does this share have DELETE permissions\n */\n get hasDeletePermission() {\n return !!((this.permissions & window.OC.PERMISSION_DELETE));\n }\n /**\n * Does this share have UPDATE permissions\n */\n get hasUpdatePermission() {\n return !!((this.permissions & window.OC.PERMISSION_UPDATE));\n }\n /**\n * Does this share have SHARE permissions\n */\n get hasSharePermission() {\n return !!((this.permissions & window.OC.PERMISSION_SHARE));\n }\n /**\n * Does this share have download permissions\n */\n get hasDownloadPermission() {\n const hasDisabledDownload = (attribute) => {\n return attribute.scope === 'permissions' && attribute.key === 'download' && attribute.value === false;\n };\n return !this.attributes.some(hasDisabledDownload);\n }\n /**\n * Is this mail share a file request ?\n */\n get isFileRequest() {\n return isFileRequest(JSON.stringify(this.attributes));\n }\n set hasDownloadPermission(enabled) {\n this.setAttribute('permissions', 'download', !!enabled);\n }\n setAttribute(scope, key, value) {\n const attrUpdate = {\n scope,\n key,\n value,\n };\n // try and replace existing\n for (const i in this._share.attributes) {\n const attr = this._share.attributes[i];\n if (attr.scope === attrUpdate.scope && attr.key === attrUpdate.key) {\n this._share.attributes.splice(i, 1, attrUpdate);\n return;\n }\n }\n this._share.attributes.push(attrUpdate);\n }\n // PERMISSIONS Shortcuts for the CURRENT USER\n // ! the permissions above are the share settings,\n // ! meaning the permissions for the recipient\n /**\n * Can the current user EDIT this share ?\n */\n get canEdit() {\n return this._share.can_edit === true;\n }\n /**\n * Can the current user DELETE this share ?\n */\n get canDelete() {\n return this._share.can_delete === true;\n }\n /**\n * Top level accessible shared folder fileid for the current user\n */\n get viaFileid() {\n return this._share.via_fileid;\n }\n /**\n * Top level accessible shared folder path for the current user\n */\n get viaPath() {\n return this._share.via_path;\n }\n // TODO: SORT THOSE PROPERTIES\n get parent() {\n return this._share.parent;\n }\n get storageId() {\n return this._share.storage_id;\n }\n get storage() {\n return this._share.storage;\n }\n get itemSource() {\n return this._share.item_source;\n }\n get status() {\n return this._share.status;\n }\n /**\n * Is the share from a trusted server\n */\n get isTrustedServer() {\n return !!this._share.is_trusted_server;\n }\n}\n","/**\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n// TODO: Fix this instead of disabling ESLint!!!\n/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { getCurrentUser } from '@nextcloud/auth';\nimport axios from '@nextcloud/axios';\nimport { File, Folder, Permission } from '@nextcloud/files';\nimport { getRemoteURL, getRootPath } from '@nextcloud/files/dav';\nimport { generateOcsUrl } from '@nextcloud/router';\nimport logger from './logger.ts';\nconst headers = {\n 'Content-Type': 'application/json',\n};\n/**\n *\n * @param ocsEntry\n * @param unmounted whether the share is not mounted into the filesystem (pending or deleted)\n */\nasync function ocsEntryToNode(ocsEntry, unmounted = false) {\n try {\n // Federated share handling\n if (ocsEntry?.remote_id !== undefined) {\n if (!ocsEntry.mimetype) {\n const mime = (await import('mime')).default;\n // This won't catch files without an extension, but this is the best we can do\n ocsEntry.mimetype = mime.getType(ocsEntry.name);\n }\n const type = ocsEntry.type === 'dir' ? 'folder' : ocsEntry.type;\n ocsEntry.item_type = type || (ocsEntry.mimetype ? 'file' : 'folder');\n // different naming for remote shares\n ocsEntry.item_mtime = ocsEntry.mtime;\n ocsEntry.file_target = ocsEntry.file_target || ocsEntry.mountpoint;\n if (ocsEntry.file_target.includes('TemporaryMountPointName')) {\n ocsEntry.file_target = ocsEntry.name;\n }\n // If the share is not accepted yet we don't know which permissions it will have\n if (!ocsEntry.accepted) {\n // Need to set permissions to NONE for federated shares\n ocsEntry.item_permissions = Permission.NONE;\n ocsEntry.permissions = Permission.NONE;\n }\n ocsEntry.uid_owner = ocsEntry.owner;\n // TODO: have the real display name stored somewhere\n ocsEntry.displayname_owner = ocsEntry.owner;\n }\n // Pending and deleted shares are not mounted into the user's filesystem,\n // so no file operation can act on them until they are accepted or restored.\n if (unmounted) {\n ocsEntry.item_permissions = Permission.NONE;\n ocsEntry.permissions = Permission.NONE;\n }\n const isFolder = ocsEntry?.item_type === 'folder';\n const hasPreview = ocsEntry?.has_preview === true;\n const Node = isFolder ? Folder : File;\n // If this is an external share that is not yet accepted,\n // we don't have an id. We can fallback to the row id temporarily\n // local shares (this server) use `file_source`, but remote shares (federated) use `file_id`\n const fileid = ocsEntry.file_source || ocsEntry.file_id || ocsEntry.id;\n // Generate path and strip double slashes\n const path = ocsEntry.path || ocsEntry.file_target || ocsEntry.name;\n const source = `${getRemoteURL()}${getRootPath()}/${path.replace(/^\\/+/, '')}`;\n let mtime = ocsEntry.item_mtime ? new Date((ocsEntry.item_mtime) * 1000) : undefined;\n // Prefer share time if more recent than item mtime\n if (ocsEntry?.stime > (ocsEntry?.item_mtime || 0)) {\n mtime = new Date((ocsEntry.stime) * 1000);\n }\n let sharees;\n if ('share_with' in ocsEntry) {\n sharees = {\n sharee: {\n id: ocsEntry.share_with,\n 'display-name': ocsEntry.share_with_displayname || ocsEntry.share_with,\n type: ocsEntry.share_type,\n },\n };\n }\n return new Node({\n id: fileid,\n source,\n owner: ocsEntry?.uid_owner,\n mime: ocsEntry?.mimetype || 'application/octet-stream',\n mtime,\n size: ocsEntry?.item_size ?? undefined,\n permissions: ocsEntry?.item_permissions || ocsEntry?.permissions,\n root: getRootPath(),\n attributes: {\n ...ocsEntry,\n // 'id' is a forbidden property name\n 'share-id': ocsEntry.id,\n 'has-preview': hasPreview,\n 'hide-download': ocsEntry?.hide_download === 1,\n // Also check the sharingStatusAction.ts code\n 'owner-id': ocsEntry?.uid_owner,\n 'owner-display-name': ocsEntry?.displayname_owner,\n 'share-types': ocsEntry?.share_type,\n 'share-attributes': ocsEntry?.attributes || '[]',\n sharees,\n favorite: ocsEntry?.tags?.includes(window.OC.TAG_FAVORITE) ? 1 : 0,\n },\n });\n }\n catch (error) {\n logger.error('Error while parsing OCS entry', { error });\n return null;\n }\n}\n/**\n *\n * @param shareWithMe\n */\nfunction getShares(shareWithMe = false) {\n const url = generateOcsUrl('apps/files_sharing/api/v1/shares');\n return axios.get(url, {\n headers,\n params: {\n shared_with_me: shareWithMe,\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getSharedWithYou() {\n return getShares(true);\n}\n/**\n *\n */\nfunction getSharedWithOthers() {\n return getShares();\n}\n/**\n *\n */\nfunction getRemoteShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getPendingShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/shares/pending');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getRemotePendingShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares/pending');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n *\n */\nfunction getDeletedShares() {\n const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares');\n return axios.get(url, {\n headers,\n params: {\n include_tags: true,\n },\n });\n}\n/**\n * Check if a file request is enabled\n *\n * @param attributes the share attributes json-encoded array\n */\nexport function isFileRequest(attributes = '[]') {\n const isFileRequest = (attribute) => {\n return attribute.scope === 'fileRequest' && attribute.key === 'enabled' && attribute.value === true;\n };\n try {\n const attributesArray = JSON.parse(attributes);\n return attributesArray.some(isFileRequest);\n }\n catch (error) {\n logger.error('Error while parsing share attributes', { error });\n return false;\n }\n}\n/**\n * Group an array of objects (here Nodes) by a key\n * and return an array of arrays of them.\n *\n * @param nodes Nodes to group\n * @param key The attribute to group by\n */\nfunction groupBy(nodes, key) {\n return Object.values(nodes.reduce(function (acc, curr) {\n (acc[curr[key]] = acc[curr[key]] || []).push(curr);\n return acc;\n }, {}));\n}\n/**\n *\n * @param sharedWithYou\n * @param sharedWithOthers\n * @param pendingShares\n * @param deletedshares\n * @param filterTypes\n */\nexport async function getContents(sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, filterTypes = []) {\n const requests = [];\n if (sharedWithYou) {\n requests.push({ promise: getSharedWithYou(), unmounted: false }, { promise: getRemoteShares(), unmounted: false });\n }\n if (sharedWithOthers) {\n requests.push({ promise: getSharedWithOthers(), unmounted: false });\n }\n if (pendingShares) {\n requests.push({ promise: getPendingShares(), unmounted: true }, { promise: getRemotePendingShares(), unmounted: true });\n }\n if (deletedshares) {\n requests.push({ promise: getDeletedShares(), unmounted: true });\n }\n const responses = await Promise.all(requests.map(({ promise }) => promise));\n const data = responses.flatMap((response, index) => response.data.ocs.data\n .map((entry) => ({ entry, unmounted: requests[index].unmounted })));\n let contents = (await Promise.all(data.map(({ entry, unmounted }) => ocsEntryToNode(entry, unmounted))))\n .filter((node) => node !== null);\n if (filterTypes.length > 0) {\n contents = contents.filter((node) => filterTypes.includes(node.attributes?.share_type));\n }\n // Merge duplicate shares and group their attributes\n // Also check the sharingStatusAction.ts code\n contents = groupBy(contents, 'source').map((nodes) => {\n const node = nodes[0];\n node.attributes['share-types'] = nodes.map((node) => node.attributes['share-types']);\n return node;\n });\n return {\n folder: new Folder({\n id: 0,\n source: `${getRemoteURL()}${getRootPath()}`,\n owner: getCurrentUser()?.uid || null,\n root: getRootPath(),\n }),\n contents,\n };\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getCapabilities } from '@nextcloud/capabilities';\nimport { loadState } from '@nextcloud/initial-state';\nexport default class Config {\n _capabilities;\n constructor() {\n this._capabilities = getCapabilities();\n }\n /**\n * Get default share permissions, if any\n */\n get defaultPermissions() {\n return this._capabilities.files_sharing?.default_permissions;\n }\n /**\n * Should SHARE permission be excluded from \"Allow editing\" bundled permissions\n */\n get excludeReshareFromEdit() {\n return this._capabilities.files_sharing?.exclude_reshare_from_edit === true;\n }\n /**\n * Is public upload allowed on link shares ?\n * This covers File request and Full upload/edit option.\n */\n get isPublicUploadEnabled() {\n return this._capabilities.files_sharing?.public?.upload === true;\n }\n /**\n * Get the federated sharing documentation link\n */\n get federatedShareDocLink() {\n return window.OC.appConfig.core.federatedCloudShareDoc;\n }\n /**\n * Get the default link share expiration date\n */\n get defaultExpirationDate() {\n if (this.isDefaultExpireDateEnabled && this.defaultExpireDate !== null) {\n return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate));\n }\n return null;\n }\n /**\n * Get the default internal expiration date\n */\n get defaultInternalExpirationDate() {\n if (this.isDefaultInternalExpireDateEnabled && this.defaultInternalExpireDate !== null) {\n return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate));\n }\n return null;\n }\n /**\n * Get the default remote expiration date\n */\n get defaultRemoteExpirationDateString() {\n if (this.isDefaultRemoteExpireDateEnabled && this.defaultRemoteExpireDate !== null) {\n return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate));\n }\n return null;\n }\n /**\n * Are link shares password-enforced ?\n */\n get enforcePasswordForPublicLink() {\n return window.OC.appConfig.core.enforcePasswordForPublicLink === true;\n }\n /**\n * Is password asked by default on link shares ?\n */\n get enableLinkPasswordByDefault() {\n return window.OC.appConfig.core.enableLinkPasswordByDefault === true;\n }\n /**\n * Is link shares expiration enforced ?\n */\n get isDefaultExpireDateEnforced() {\n return window.OC.appConfig.core.defaultExpireDateEnforced === true;\n }\n /**\n * Is there a default expiration date for new link shares ?\n */\n get isDefaultExpireDateEnabled() {\n return window.OC.appConfig.core.defaultExpireDateEnabled === true;\n }\n /**\n * Is internal shares expiration enforced ?\n */\n get isDefaultInternalExpireDateEnforced() {\n return window.OC.appConfig.core.defaultInternalExpireDateEnforced === true;\n }\n /**\n * Is there a default expiration date for new internal shares ?\n */\n get isDefaultInternalExpireDateEnabled() {\n return window.OC.appConfig.core.defaultInternalExpireDateEnabled === true;\n }\n /**\n * Is remote shares expiration enforced ?\n */\n get isDefaultRemoteExpireDateEnforced() {\n return window.OC.appConfig.core.defaultRemoteExpireDateEnforced === true;\n }\n /**\n * Is there a default expiration date for new remote shares ?\n */\n get isDefaultRemoteExpireDateEnabled() {\n return window.OC.appConfig.core.defaultRemoteExpireDateEnabled === true;\n }\n /**\n * Are users on this server allowed to send shares to other servers ?\n */\n get isRemoteShareAllowed() {\n return window.OC.appConfig.core.remoteShareAllowed === true;\n }\n /**\n * Is federation enabled ?\n */\n get isFederationEnabled() {\n return this._capabilities?.files_sharing?.federation?.outgoing === true;\n }\n /**\n * Is public sharing enabled ?\n */\n get isPublicShareAllowed() {\n return this._capabilities?.files_sharing?.public?.enabled === true;\n }\n /**\n * Is sharing my mail (link share) enabled ?\n */\n get isMailShareAllowed() {\n return this._capabilities?.files_sharing?.sharebymail?.enabled === true\n && this.isPublicShareAllowed === true;\n }\n /**\n * Get the default days to link shares expiration\n */\n get defaultExpireDate() {\n return window.OC.appConfig.core.defaultExpireDate;\n }\n /**\n * Get the default days to internal shares expiration\n */\n get defaultInternalExpireDate() {\n return window.OC.appConfig.core.defaultInternalExpireDate;\n }\n /**\n * Get the default days to remote shares expiration\n */\n get defaultRemoteExpireDate() {\n return window.OC.appConfig.core.defaultRemoteExpireDate;\n }\n /**\n * Is resharing allowed ?\n */\n get isResharingAllowed() {\n return window.OC.appConfig.core.resharingAllowed === true;\n }\n /**\n * Is password enforced for mail shares ?\n */\n get isPasswordForMailSharesRequired() {\n return this._capabilities.files_sharing?.sharebymail?.password?.enforced === true;\n }\n /**\n * Always show the email or userid unique sharee label if enabled by the admin\n */\n get shouldAlwaysShowUnique() {\n return this._capabilities.files_sharing?.sharee?.always_show_unique === true;\n }\n /**\n * Is sharing with groups allowed ?\n */\n get allowGroupSharing() {\n return window.OC.appConfig.core.allowGroupSharing === true;\n }\n /**\n * Get the maximum results of a share search\n */\n get maxAutocompleteResults() {\n return parseInt(window.OC.config['sharing.maxAutocompleteResults'], 10) || 25;\n }\n /**\n * Get the minimal string length\n * to initiate a share search\n */\n get minSearchStringLength() {\n return parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0;\n }\n /**\n * Get the password policy configuration\n */\n get passwordPolicy() {\n return this._capabilities?.password_policy || {};\n }\n /**\n * Returns true if custom tokens are allowed\n */\n get allowCustomTokens() {\n return this._capabilities?.files_sharing?.public?.custom_tokens;\n }\n /**\n * Show federated shares as internal shares\n *\n * @return\n */\n get showFederatedSharesAsInternal() {\n return loadState('files_sharing', 'showFederatedSharesAsInternal', false);\n }\n /**\n * Show federated shares to trusted servers as internal shares\n *\n * @return\n */\n get showFederatedSharesToTrustedServersAsInternal() {\n return loadState('files_sharing', 'showFederatedSharesToTrustedServersAsInternal', false);\n }\n /**\n * Show the external share ui\n */\n get showExternalSharing() {\n return loadState('files_sharing', 'showExternalSharing', true);\n }\n}\n","/**\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { ATOMIC_PERMISSIONS } from '../lib/SharePermissionsToolBox.js'\nimport Share from '../models/Share.ts'\nimport Config from '../services/ConfigService.ts'\nimport logger from '../services/logger.ts'\n\nexport default {\n\tmethods: {\n\t\tasync openSharingDetails(shareRequestObject) {\n\t\t\tlet share\n\t\t\t// handle externalResults from OCA.Sharing.ShareSearch\n\t\t\t// TODO : Better name/interface for handler required\n\t\t\t// For example `externalAppCreateShareHook` with proper documentation\n\t\t\tif (shareRequestObject.handler) {\n\t\t\t\tconst handlerInput = {}\n\t\t\t\tif (this.suggestions) {\n\t\t\t\t\thandlerInput.suggestions = this.suggestions\n\t\t\t\t\thandlerInput.fileInfo = this.fileInfo\n\t\t\t\t\thandlerInput.query = this.query\n\t\t\t\t}\n\t\t\t\tconst externalShareRequestObject = await shareRequestObject.handler(handlerInput)\n\t\t\t\tshare = this.mapShareRequestToShareObject(externalShareRequestObject)\n\t\t\t} else {\n\t\t\t\tshare = this.mapShareRequestToShareObject(shareRequestObject)\n\t\t\t}\n\n\t\t\tif (this.fileInfo.type !== 'dir') {\n\t\t\t\tconst originalPermissions = share.permissions\n\t\t\t\tconst strippedPermissions = originalPermissions\n\t\t\t\t\t& ~ATOMIC_PERMISSIONS.CREATE\n\t\t\t\t\t& ~ATOMIC_PERMISSIONS.DELETE\n\n\t\t\t\tif (originalPermissions !== strippedPermissions) {\n\t\t\t\t\tlogger.debug('Removed create/delete permissions from file share (only valid for folders)')\n\t\t\t\t\tshare.permissions = strippedPermissions\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst shareDetails = {\n\t\t\t\tfileInfo: this.fileInfo,\n\t\t\t\tshare,\n\t\t\t}\n\n\t\t\tthis.$emit('open-sharing-details', shareDetails)\n\t\t},\n\t\topenShareDetailsForCustomSettings(share) {\n\t\t\tshare.setCustomPermissions = true\n\t\t\tthis.openSharingDetails(share)\n\t\t},\n\t\tmapShareRequestToShareObject(shareRequestObject) {\n\t\t\tif (shareRequestObject.id) {\n\t\t\t\treturn shareRequestObject\n\t\t\t}\n\n\t\t\tconst share = {\n\t\t\t\tattributes: [\n\t\t\t\t\t{\n\t\t\t\t\t\tvalue: true,\n\t\t\t\t\t\tkey: 'download',\n\t\t\t\t\t\tscope: 'permissions',\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\thideDownload: false,\n\t\t\t\tshare_type: shareRequestObject.shareType,\n\t\t\t\tshare_with: shareRequestObject.shareWith,\n\t\t\t\tis_no_user: shareRequestObject.isNoUser,\n\t\t\t\tuser: shareRequestObject.shareWith,\n\t\t\t\tshare_with_displayname: shareRequestObject.displayName,\n\t\t\t\tsubtitle: shareRequestObject.subtitle,\n\t\t\t\tpermissions: shareRequestObject.permissions ?? new Config().defaultPermissions,\n\t\t\t\texpiration: '',\n\t\t\t}\n\n\t\t\treturn new Share(share)\n\t\t},\n\t},\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport axios, { isAxiosError } from '@nextcloud/axios'\nimport { showError } from '@nextcloud/dialogs'\nimport { emit } from '@nextcloud/event-bus'\nimport { generateOcsUrl } from '@nextcloud/router'\nimport Share from '../models/Share.ts'\nimport logger from '../services/logger.ts'\n\nconst shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')\n\nexport default {\n\tmethods: {\n\t\t/**\n\t\t * Create a new share\n\t\t *\n\t\t * @param {object} data destructuring object\n\t\t * @param {string} data.path path to the file/folder which should be shared\n\t\t * @param {number} data.shareType 0 = user; 1 = group; 3 = public link; 6 = federated cloud share\n\t\t * @param {string} data.shareWith user/group id with which the file should be shared (optional for shareType > 1)\n\t\t * @param {boolean} [data.publicUpload] allow public upload to a public shared folder\n\t\t * @param {string} [data.password] password to protect public link Share with\n\t\t * @param {number} [data.permissions] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1)\n\t\t * @param {boolean} [data.sendPasswordByTalk] send the password via a talk conversation\n\t\t * @param {string} [data.expireDate] expire the share automatically after\n\t\t * @param {string} [data.label] custom label\n\t\t * @param {string} [data.attributes] Share attributes encoded as json\n\t\t * @param {string} data.note custom note to recipient\n\t\t * @return {Share} the new share\n\t\t * @throws {Error}\n\t\t */\n\t\tasync createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes }) {\n\t\t\ttry {\n\t\t\t\tconst request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes })\n\t\t\t\tif (!request?.data?.ocs) {\n\t\t\t\t\tthrow request\n\t\t\t\t}\n\t\t\t\tconst share = new Share(request.data.ocs.data)\n\t\t\t\temit('files_sharing:share:created', { share })\n\t\t\t\treturn share\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMessage = getErrorMessage(error) ?? t('files_sharing', 'Error creating the share')\n\t\t\t\tshowError(errorMessage)\n\t\t\t\tthrow new Error(errorMessage, { cause: error })\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Delete a share\n\t\t *\n\t\t * @param {number} id share id\n\t\t * @throws {Error}\n\t\t */\n\t\tasync deleteShare(id) {\n\t\t\ttry {\n\t\t\t\tconst request = await axios.delete(shareUrl + `/${id}`)\n\t\t\t\tif (!request?.data?.ocs) {\n\t\t\t\t\tthrow request\n\t\t\t\t}\n\t\t\t\temit('files_sharing:share:deleted', { id })\n\t\t\t\treturn true\n\t\t\t} catch (error) {\n\t\t\t\tconst errorMessage = getErrorMessage(error) ?? t('files_sharing', 'Error deleting the share')\n\t\t\t\tshowError(errorMessage)\n\t\t\t\tthrow new Error(errorMessage, { cause: error })\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Update a share\n\t\t *\n\t\t * @param {number} id share id\n\t\t * @param {object} properties key-value object of the properties to update\n\t\t */\n\t\tasync updateShare(id, properties) {\n\t\t\ttry {\n\t\t\t\tconst request = await axios.put(shareUrl + `/${id}`, properties)\n\t\t\t\temit('files_sharing:share:updated', { id })\n\t\t\t\tif (!request?.data?.ocs) {\n\t\t\t\t\tthrow request\n\t\t\t\t} else {\n\t\t\t\t\treturn request.data.ocs.data\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error('Error while updating share', { error })\n\t\t\t\tconst errorMessage = getErrorMessage(error) ?? t('files_sharing', 'Error updating the share')\n\t\t\t\t// the error will be shown in apps/files_sharing/src/mixins/SharesMixin.js\n\t\t\t\tthrow new Error(errorMessage, { cause: error })\n\t\t\t}\n\t\t},\n\t},\n}\n\n/**\n * Handle an error response from the server and show a notification with the error message if possible\n *\n * @param {unknown} error - The received error\n * @return {string|undefined} the error message if it could be extracted from the response, otherwise undefined\n */\nfunction getErrorMessage(error) {\n\tif (isAxiosError(error) && error.response.data?.ocs) {\n\t\t/** @type {import('@nextcloud/typings/ocs').OCSResponse} */\n\t\tconst response = error.response.data\n\t\tif (response.ocs.meta?.message) {\n\t\t\treturn response.ocs.meta.message\n\t\t}\n\t}\n}\n","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=style&index=0&id=0b151499&prod&lang=scss\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInput.vue?vue&type=style&index=0&id=0b151499&prod&lang=scss\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingInput.vue?vue&type=template&id=0b151499\"\nimport script from \"./SharingInput.vue?vue&type=script&lang=js\"\nexport * from \"./SharingInput.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingInput.vue?vue&type=style&index=0&id=0b151499&prod&lang=scss\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c(_vm.section.element,{ref:\"sectionElement\",tag:\"component\",domProps:{\"node\":_vm.node}})\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import { render, staticRenderFns } from \"./SidebarTabExternalSection.vue?vue&type=template&id=9785f99e\"\nimport script from \"./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"\nexport * from \"./SidebarTabExternalSection.vue?vue&type=script&lang=ts&setup=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c('div',{staticClass:\"sharing-tab-external-section-legacy\"},[_c(_setup.component,{tag:\"component\",attrs:{\"file-info\":_vm.fileInfo}})],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n import API from \"!../../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../../node_modules/css-loader/dist/cjs.js!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=style&index=0&id=3e4e67d2&prod&scoped=true&lang=css\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../../node_modules/css-loader/dist/cjs.js!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalSectionLegacy.vue?vue&type=style&index=0&id=3e4e67d2&prod&scoped=true&lang=css\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SidebarTabExternalSectionLegacy.vue?vue&type=template&id=3e4e67d2&scoped=true\"\nimport script from \"./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"\nexport * from \"./SidebarTabExternalSectionLegacy.vue?vue&type=script&lang=ts&setup=true\"\nimport style0 from \"./SidebarTabExternalSectionLegacy.vue?vue&type=style&index=0&id=3e4e67d2&prod&scoped=true&lang=css\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"3e4e67d2\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"sharingTabDetailsView\"},[_c('div',{staticClass:\"sharingTabDetailsView__header\"},[_c('span',[(_vm.isUserShare)?_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"is-no-user\":_vm.share.shareType !== _vm.ShareType.User,\"user\":_vm.share.shareWith,\"display-name\":_vm.share.shareWithDisplayName,\"menu-position\":\"left\",\"url\":_vm.share.shareWithAvatar}}):_vm._e(),_vm._v(\" \"),_c(_vm.getShareTypeIcon(_vm.share.type),{tag:\"component\",attrs:{\"size\":32}})],1),_vm._v(\" \"),_c('span',[_c('h1',[_vm._v(_vm._s(_vm.title))])])]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__wrapper\"},[_c('div',{ref:\"quickPermissions\",staticClass:\"sharingTabDetailsView__quick-permissions\"},[_c('div',[_c('NcCheckboxRadioSwitch',{attrs:{\"button-variant\":true,\"data-cy-files-sharing-share-permissions-bundle\":\"read-only\",\"value\":_vm.bundledPermissions.READ_ONLY.toString(),\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.toggleCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('ViewIcon',{attrs:{\"size\":20}})]},proxy:true}]),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'View only'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"button-variant\":true,\"data-cy-files-sharing-share-permissions-bundle\":\"upload-edit\",\"value\":_vm.allPermissions,\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.toggleCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('EditIcon',{attrs:{\"size\":20}})]},proxy:true}]),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[(_vm.allowsFileDrop)?[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Allow upload and editing'))+\"\\n\\t\\t\\t\\t\\t\")]:[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Allow editing'))+\"\\n\\t\\t\\t\\t\\t\")]],2),_vm._v(\" \"),(_vm.allowsFileDrop)?_c('NcCheckboxRadioSwitch',{attrs:{\"data-cy-files-sharing-share-permissions-bundle\":\"file-drop\",\"button-variant\":true,\"value\":_vm.bundledPermissions.FILE_DROP.toString(),\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.toggleCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('UploadIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,1083194048),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'File request'))+\"\\n\\t\\t\\t\\t\\t\"),_c('small',{staticClass:\"subline\"},[_vm._v(_vm._s(_vm.t('files_sharing', 'Upload only')))])]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"button-variant\":true,\"data-cy-files-sharing-share-permissions-bundle\":\"custom\",\"value\":\"custom\",\"name\":\"sharing_permission_radio\",\"type\":\"radio\",\"button-variant-grouped\":\"vertical\"},on:{\"update:modelValue\":_vm.expandCustomPermissions},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('DotsHorizontalIcon',{attrs:{\"size\":20}})]},proxy:true}]),model:{value:(_vm.sharingPermission),callback:function ($$v) {_vm.sharingPermission=$$v},expression:\"sharingPermission\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Custom permissions'))+\"\\n\\t\\t\\t\\t\\t\"),_c('small',{staticClass:\"subline\"},[_vm._v(_vm._s(_vm.customPermissionsList))])])],1)]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__advanced-control\"},[_c('NcButton',{attrs:{\"id\":\"advancedSectionAccordionAdvancedControl\",\"variant\":\"tertiary\",\"alignment\":\"end-reverse\",\"aria-controls\":\"advancedSectionAccordionAdvanced\",\"aria-expanded\":_vm.advancedControlExpandedValue},on:{\"click\":function($event){_vm.advancedSectionAccordionExpanded = !_vm.advancedSectionAccordionExpanded}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [(!_vm.advancedSectionAccordionExpanded)?_c('MenuDownIcon'):_c('MenuUpIcon')]},proxy:true}])},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Advanced settings'))+\"\\n\\t\\t\\t\\t\")])],1),_vm._v(\" \"),(_vm.advancedSectionAccordionExpanded)?_c('div',{staticClass:\"sharingTabDetailsView__advanced\",attrs:{\"id\":\"advancedSectionAccordionAdvanced\",\"aria-labelledby\":\"advancedSectionAccordionAdvancedControl\",\"role\":\"region\"}},[_c('section',[(_vm.isPublicShare)?_c('NcInputField',{staticClass:\"sharingTabDetailsView__label\",attrs:{\"autocomplete\":\"off\",\"label\":_vm.t('files_sharing', 'Share label')},model:{value:(_vm.share.label),callback:function ($$v) {_vm.$set(_vm.share, \"label\", $$v)},expression:\"share.label\"}}):_vm._e(),_vm._v(\" \"),(_vm.config.allowCustomTokens && _vm.isPublicShare && !_vm.isNewShare)?_c('NcInputField',{attrs:{\"autocomplete\":\"off\",\"label\":_vm.t('files_sharing', 'Share link token'),\"helper-text\":_vm.t('files_sharing', 'Set the public share link token to something easy to remember or generate a new token. It is not recommended to use a guessable token for shares which contain sensitive information.'),\"show-trailing-button\":\"\",\"trailing-button-label\":_vm.loadingToken ? _vm.t('files_sharing', 'Generating…') : _vm.t('files_sharing', 'Generate new token')},on:{\"trailing-button-click\":_vm.generateNewToken},scopedSlots:_vm._u([{key:\"trailing-button-icon\",fn:function(){return [(_vm.loadingToken)?_c('NcLoadingIcon'):_c('Refresh',{attrs:{\"size\":20}})]},proxy:true}],null,false,4228062821),model:{value:(_vm.share.token),callback:function ($$v) {_vm.$set(_vm.share, \"token\", $$v)},expression:\"share.token\"}}):_vm._e(),_vm._v(\" \"),(_vm.isPublicShare)?[_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":_vm.isPasswordEnforced},model:{value:(_vm.isPasswordProtected),callback:function ($$v) {_vm.isPasswordProtected=$$v},expression:\"isPasswordProtected\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Set password'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.isPasswordProtected)?_c('NcPasswordField',{attrs:{\"autocomplete\":\"new-password\",\"model-value\":_vm.share.newPassword ?? '',\"error\":_vm.passwordError,\"helper-text\":_vm.errorPasswordLabel || _vm.passwordHint,\"required\":_vm.isPasswordEnforced && _vm.isNewShare,\"label\":_vm.t('files_sharing', 'Password')},on:{\"update:value\":_vm.onPasswordChange}}):_vm._e(),_vm._v(\" \"),(_vm.isEmailShareType && _vm.passwordExpirationTime)?_c('span',{attrs:{\"icon\":\"icon-info\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Password expires {passwordExpirationTime}', { passwordExpirationTime: _vm.passwordExpirationTime }))+\"\\n\\t\\t\\t\\t\\t\")]):(_vm.isEmailShareType && _vm.passwordExpirationTime !== null)?_c('span',{attrs:{\"icon\":\"icon-error\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Password expired'))+\"\\n\\t\\t\\t\\t\\t\")]):_vm._e()]:_vm._e(),_vm._v(\" \"),(_vm.canTogglePasswordProtectedByTalkAvailable)?_c('NcCheckboxRadioSwitch',{model:{value:(_vm.isPasswordProtectedByTalk),callback:function ($$v) {_vm.isPasswordProtectedByTalk=$$v},expression:\"isPasswordProtectedByTalk\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Video verification'))+\"\\n\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":_vm.isExpiryDateEnforced},model:{value:(_vm.hasExpirationDate),callback:function ($$v) {_vm.hasExpirationDate=$$v},expression:\"hasExpirationDate\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.isExpiryDateEnforced\n\t\t\t\t\t\t? _vm.t('files_sharing', 'Expiration date (enforced)')\n\t\t\t\t\t\t: _vm.t('files_sharing', 'Set expiration date'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.hasExpirationDate)?_c('NcDateTimePickerNative',{attrs:{\"id\":\"share-date-picker\",\"model-value\":new Date(_vm.share.expireDate ?? _vm.dateTomorrow),\"min\":_vm.dateTomorrow,\"max\":_vm.maxExpirationDateEnforced,\"hide-label\":\"\",\"label\":_vm.t('files_sharing', 'Expiration date'),\"placeholder\":_vm.t('files_sharing', 'Expiration date'),\"type\":\"date\"},on:{\"input\":_vm.onExpirationChange}}):_vm._e(),_vm._v(\" \"),(_vm.isPublicShare)?_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":_vm.canChangeHideDownload},model:{value:(_vm.share.hideDownload),callback:function ($$v) {_vm.$set(_vm.share, \"hideDownload\", $$v)},expression:\"share.hideDownload\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Hide download'))+\"\\n\\t\\t\\t\\t\")]):_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetDownload,\"data-cy-files-sharing-share-permissions-checkbox\":\"download\"},model:{value:(_vm.canDownload),callback:function ($$v) {_vm.canDownload=$$v},expression:\"canDownload\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Allow download and sync'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{model:{value:(_vm.writeNoteToRecipientIsChecked),callback:function ($$v) {_vm.writeNoteToRecipientIsChecked=$$v},expression:\"writeNoteToRecipientIsChecked\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Note to recipient'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.writeNoteToRecipientIsChecked)?[_c('NcTextArea',{attrs:{\"label\":_vm.t('files_sharing', 'Note to recipient'),\"placeholder\":_vm.t('files_sharing', 'Enter a note for the share recipient')},model:{value:(_vm.share.note),callback:function ($$v) {_vm.$set(_vm.share, \"note\", $$v)},expression:\"share.note\"}})]:_vm._e(),_vm._v(\" \"),(_vm.isPublicShare && _vm.isFolder)?_c('NcCheckboxRadioSwitch',{model:{value:(_vm.showInGridView),callback:function ($$v) {_vm.showInGridView=$$v},expression:\"showInGridView\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Show files in grid view'))+\"\\n\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_vm._l((_vm.sortedExternalShareActions),function(action){return _c('SidebarTabExternalAction',{key:action.id,ref:\"externalShareActions\",refInFor:true,attrs:{\"action\":action,\"node\":_vm.fileInfo.node /* TODO: Fix once we have proper Node API */,\"share\":_vm.share}})}),_vm._v(\" \"),_vm._l((_vm.externalLegacyShareActions),function(action){return _c('SidebarTabExternalActionLegacy',{key:action.id,ref:\"externalLinkActions\",refInFor:true,attrs:{\"id\":action.id,\"action\":action,\"file-info\":_vm.fileInfo,\"share\":_vm.share}})}),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{model:{value:(_vm.setCustomPermissions),callback:function ($$v) {_vm.setCustomPermissions=$$v},expression:\"setCustomPermissions\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Custom permissions'))+\"\\n\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.setCustomPermissions)?_c('section',{staticClass:\"custom-permissions-group\"},[_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canRemoveReadPermission,\"data-cy-files-sharing-share-permissions-checkbox\":\"read\"},model:{value:(_vm.hasRead),callback:function ($$v) {_vm.hasRead=$$v},expression:\"hasRead\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Read'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.isFolder)?_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetCreate,\"data-cy-files-sharing-share-permissions-checkbox\":\"create\"},model:{value:(_vm.canCreate),callback:function ($$v) {_vm.canCreate=$$v},expression:\"canCreate\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Create'))+\"\\n\\t\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetEdit,\"data-cy-files-sharing-share-permissions-checkbox\":\"update\"},model:{value:(_vm.canEdit),callback:function ($$v) {_vm.canEdit=$$v},expression:\"canEdit\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Edit'))+\"\\n\\t\\t\\t\\t\\t\")]),_vm._v(\" \"),(_vm.resharingIsPossible)?_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetReshare,\"data-cy-files-sharing-share-permissions-checkbox\":\"share\"},model:{value:(_vm.canReshare),callback:function ($$v) {_vm.canReshare=$$v},expression:\"canReshare\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Share'))+\"\\n\\t\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),_c('NcCheckboxRadioSwitch',{attrs:{\"disabled\":!_vm.canSetDelete,\"data-cy-files-sharing-share-permissions-checkbox\":\"delete\"},model:{value:(_vm.canDelete),callback:function ($$v) {_vm.canDelete=$$v},expression:\"canDelete\"}},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Delete'))+\"\\n\\t\\t\\t\\t\\t\")])],1):_vm._e()],2)]):_vm._e()]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__footer\"},[_c('div',{staticClass:\"button-group\"},[_c('NcButton',{attrs:{\"data-cy-files-sharing-share-editor-action\":\"cancel\"},on:{\"click\":_vm.cancel}},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Cancel'))+\"\\n\\t\\t\\t\")]),_vm._v(\" \"),_c('div',{staticClass:\"sharingTabDetailsView__delete\"},[(!_vm.isNewShare)?_c('NcButton',{attrs:{\"aria-label\":_vm.t('files_sharing', 'Delete share'),\"disabled\":false,\"readonly\":false,\"variant\":\"tertiary\"},on:{\"click\":function($event){$event.preventDefault();return _vm.removeShare.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CloseIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2428343285)},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Delete share'))+\"\\n\\t\\t\\t\\t\")]):_vm._e()],1),_vm._v(\" \"),_c('NcButton',{attrs:{\"variant\":\"primary\",\"data-cy-files-sharing-share-editor-action\":\"save\",\"disabled\":_vm.creating},on:{\"click\":_vm.saveShare},scopedSlots:_vm._u([(_vm.creating)?{key:\"icon\",fn:function(){return [_c('NcLoadingIcon')]},proxy:true}:null],null,true)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.shareButtonText)+\"\\n\\t\\t\\t\\t\")])],1)])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountCircleOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountCircleOutline.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./AccountCircleOutline.vue?vue&type=template&id=5b2fe1de\"\nimport script from \"./AccountCircleOutline.vue?vue&type=script&lang=js\"\nexport * from \"./AccountCircleOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon account-circle-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M7.07,18.28C7.5,17.38 10.12,16.5 12,16.5C13.88,16.5 16.5,17.38 16.93,18.28C15.57,19.36 13.86,20 12,20C10.14,20 8.43,19.36 7.07,18.28M18.36,16.83C16.93,15.09 13.46,14.5 12,14.5C10.54,14.5 7.07,15.09 5.64,16.83C4.62,15.5 4,13.82 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,13.82 19.38,15.5 18.36,16.83M12,6C10.06,6 8.5,7.56 8.5,9.5C8.5,11.44 10.06,13 12,13C13.94,13 15.5,11.44 15.5,9.5C15.5,7.56 13.94,6 12,6M12,11A1.5,1.5 0 0,1 10.5,9.5A1.5,1.5 0 0,1 12,8A1.5,1.5 0 0,1 13.5,9.5A1.5,1.5 0 0,1 12,11Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountGroup.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./AccountGroup.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./AccountGroup.vue?vue&type=template&id=fa2b1464\"\nimport script from \"./AccountGroup.vue?vue&type=script&lang=js\"\nexport * from \"./AccountGroup.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon account-group-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./CircleOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./CircleOutline.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./CircleOutline.vue?vue&type=template&id=c013567c\"\nimport script from \"./CircleOutline.vue?vue&type=script&lang=js\"\nexport * from \"./CircleOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon circle-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Email.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Email.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./Email.vue?vue&type=template&id=7dd7f6aa\"\nimport script from \"./Email.vue?vue&type=script&lang=js\"\nexport * from \"./Email.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon email-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C22,4.89 21.1,4 20,4Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Eye.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Eye.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Eye.vue?vue&type=template&id=4ae2345c\"\nimport script from \"./Eye.vue?vue&type=script&lang=js\"\nexport * from \"./Eye.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon eye-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./ShareCircle.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./ShareCircle.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./ShareCircle.vue?vue&type=template&id=0e958886\"\nimport script from \"./ShareCircle.vue?vue&type=script&lang=js\"\nexport * from \"./ShareCircle.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon share-circle-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M14 16V13C10.39 13 7.81 14.43 6 17C6.72 13.33 8.94 9.73 14 9V6L19 11L14 16Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./TrayArrowUp.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./TrayArrowUp.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./TrayArrowUp.vue?vue&type=template&id=ae55bf4e\"\nimport script from \"./TrayArrowUp.vue?vue&type=script&lang=js\"\nexport * from \"./TrayArrowUp.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon tray-arrow-up-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M2 12H4V17H20V12H22V17C22 18.11 21.11 19 20 19H4C2.9 19 2 18.11 2 17V12M12 2L6.46 7.46L7.88 8.88L11 5.75V15H13V5.75L16.13 8.88L17.55 7.45L12 2Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"","var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c(_vm.action.element,{key:_vm.action.id,ref:\"actionElement\",tag:\"component\",domProps:{\"share\":_vm.share,\"node\":_vm.node,\"onSave\":_setup.onSave}})\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import { render, staticRenderFns } from \"./SidebarTabExternalAction.vue?vue&type=template&id=5ea2e6c7\"\nimport script from \"./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"\nexport * from \"./SidebarTabExternalAction.vue?vue&type=script&lang=ts&setup=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../../node_modules/babel-loader/lib/index.js!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"","\n\n\n\n\n","import { render, staticRenderFns } from \"./SidebarTabExternalActionLegacy.vue?vue&type=template&id=50e2cb04\"\nimport script from \"./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"\nexport * from \"./SidebarTabExternalActionLegacy.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c(_vm.data.is,_vm._g(_vm._b({tag:\"component\"},'component',_vm.data,false),_vm.action.handlers),[_vm._v(\"\\n\\t\"+_vm._s(_vm.data.text)+\"\\n\")])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","/*!\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav';\nexport const client = getClient();\n/**\n * Fetches a node from the given path\n *\n * @param path - The path to fetch the node from\n */\nexport async function fetchNode(path) {\n const propfindPayload = getDefaultPropfind();\n const result = await client.stat(`${getRootPath()}${path}`, {\n details: true,\n data: propfindPayload,\n });\n return resultToNode(result.data);\n}\n","/**\n * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport axios from '@nextcloud/axios';\nimport { showError, showSuccess } from '@nextcloud/dialogs';\nimport { t } from '@nextcloud/l10n';\nimport Config from '../services/ConfigService.ts';\nimport logger from '../services/logger.ts';\nconst config = new Config();\n// note: some chars removed on purpose to make them human friendly when read out\nconst passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789';\n/**\n * Generate a valid policy password or request a valid password if password_policy is enabled\n *\n * @param verbose If enabled the the status is shown to the user via toast\n */\nexport default async function (verbose = false) {\n // password policy is enabled, let's request a pass\n if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {\n try {\n const request = await axios.get(config.passwordPolicy.api.generate, {\n params: { context: 'sharing' },\n });\n if (request.data.ocs.data.password) {\n if (verbose) {\n showSuccess(t('files_sharing', 'Password created successfully'));\n }\n return request.data.ocs.data.password;\n }\n }\n catch (error) {\n logger.info('Error generating password from password_policy', { error });\n if (verbose) {\n showError(t('files_sharing', 'Error generating password from password policy'));\n }\n }\n }\n const array = new Uint8Array(10);\n const ratio = passwordSet.length / 255;\n getRandomValues(array);\n let password = '';\n for (let i = 0; i < array.length; i++) {\n password += passwordSet.charAt(array[i] * ratio);\n }\n return password;\n}\n/**\n * Fills the given array with cryptographically secure random values.\n * If the crypto API is not available, it falls back to less secure Math.random().\n * Crypto API is available in modern browsers on secure contexts (HTTPS).\n *\n * @param array - The array to fill with random values.\n */\nfunction getRandomValues(array) {\n if (self?.crypto?.getRandomValues) {\n self.crypto.getRandomValues(array);\n return;\n }\n let len = array.length;\n while (len--) {\n array[len] = Math.floor(Math.random() * 256);\n }\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { getCurrentUser } from '@nextcloud/auth'\nimport { showError, showSuccess } from '@nextcloud/dialogs'\nimport { emit } from '@nextcloud/event-bus'\nimport { ShareType } from '@nextcloud/sharing'\nimport debounce from 'debounce'\nimport PQueue from 'p-queue'\nimport { fetchNode } from '../../../files/src/services/WebdavClient.ts'\nimport { matchBundledPermissions } from '../lib/SharePermissionsToolBox.js'\nimport Share from '../models/Share.ts'\nimport Config from '../services/ConfigService.ts'\nimport logger from '../services/logger.ts'\nimport GeneratePassword from '../utils/GeneratePassword.ts'\nimport SharesRequests from './ShareRequests.js'\n\nexport default {\n\tmixins: [SharesRequests],\n\n\tprops: {\n\t\tfileInfo: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => { },\n\t\t\trequired: true,\n\t\t},\n\t\tshare: {\n\t\t\ttype: Share,\n\t\t\tdefault: null,\n\t\t},\n\t\tisUnique: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\tconfig: new Config(),\n\t\t\tnode: null,\n\t\t\tShareType,\n\n\t\t\t// errors helpers\n\t\t\terrors: {},\n\n\t\t\t// component status toggles\n\t\t\tloading: false,\n\t\t\tsaving: false,\n\t\t\topen: false,\n\n\t\t\t/** @type {boolean | undefined} */\n\t\t\tpasswordProtectedState: undefined,\n\n\t\t\t// concurrency management queue\n\t\t\t// we want one queue per share\n\t\t\tupdateQueue: new PQueue({ concurrency: 1 }),\n\n\t\t\t/**\n\t\t\t * ! This allow vue to make the Share class state reactive\n\t\t\t * ! do not remove it ot you'll lose all reactivity here\n\t\t\t */\n\t\t\treactiveState: this.share?.state,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\tpath() {\n\t\t\treturn (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')\n\t\t},\n\t\t/**\n\t\t * Does the current share have a note\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\thasNote: {\n\t\t\tget() {\n\t\t\t\treturn this.share.note !== ''\n\t\t\t},\n\t\t\tset(enabled) {\n\t\t\t\tthis.share.note = enabled\n\t\t\t\t\t? null // enabled but user did not changed the content yet\n\t\t\t\t\t: '' // empty = no note = disabled\n\t\t\t},\n\t\t},\n\n\t\tdateTomorrow() {\n\t\t\treturn new Date(new Date().setDate(new Date().getDate() + 1))\n\t\t},\n\n\t\t// Datepicker language\n\t\tlang() {\n\t\t\tconst weekdaysShort = window.dayNamesShort\n\t\t\t\t? window.dayNamesShort // provided by Nextcloud\n\t\t\t\t: ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.']\n\t\t\tconst monthsShort = window.monthNamesShort\n\t\t\t\t? window.monthNamesShort // provided by Nextcloud\n\t\t\t\t: ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']\n\t\t\tconst firstDayOfWeek = window.firstDay ? window.firstDay : 0\n\n\t\t\treturn {\n\t\t\t\tformatLocale: {\n\t\t\t\t\tfirstDayOfWeek,\n\t\t\t\t\tmonthsShort,\n\t\t\t\t\tweekdaysMin: weekdaysShort,\n\t\t\t\t\tweekdaysShort,\n\t\t\t\t},\n\t\t\t\tmonthFormat: 'MMM',\n\t\t\t}\n\t\t},\n\t\tisNewShare() {\n\t\t\treturn !this.share.id\n\t\t},\n\t\tisFolder() {\n\t\t\treturn this.fileInfo.type === 'dir'\n\t\t},\n\t\tisPublicShare() {\n\t\t\tconst shareType = this.share.shareType ?? this.share.type\n\t\t\treturn [ShareType.Link, ShareType.Email].includes(shareType)\n\t\t},\n\t\tisRemoteShare() {\n\t\t\treturn this.share.type === ShareType.RemoteGroup || this.share.type === ShareType.Remote\n\t\t},\n\t\tisShareOwner() {\n\t\t\treturn this.share && this.share.owner === getCurrentUser().uid\n\t\t},\n\t\tisExpiryDateEnforced() {\n\t\t\tif (this.isPublicShare) {\n\t\t\t\treturn this.config.isDefaultExpireDateEnforced\n\t\t\t}\n\t\t\tif (this.isRemoteShare) {\n\t\t\t\treturn this.config.isDefaultRemoteExpireDateEnforced\n\t\t\t}\n\t\t\treturn this.config.isDefaultInternalExpireDateEnforced\n\t\t},\n\t\tpermissionsBundle() {\n\t\t\treturn matchBundledPermissions(this.share.permissions, {\n\t\t\t\tisPublicShare: this.isPublicShare,\n\t\t\t\texcludeReshareFromEdit: this.config.excludeReshareFromEdit,\n\t\t\t})\n\t\t},\n\t\thasCustomPermissions() {\n\t\t\treturn this.permissionsBundle === null\n\t\t},\n\t\tmaxExpirationDateEnforced() {\n\t\t\tif (this.isExpiryDateEnforced) {\n\t\t\t\tif (this.isPublicShare) {\n\t\t\t\t\treturn this.config.defaultExpirationDate\n\t\t\t\t}\n\t\t\t\tif (this.isRemoteShare) {\n\t\t\t\t\treturn this.config.defaultRemoteExpirationDateString\n\t\t\t\t}\n\t\t\t\t// If it get's here then it must be an internal share\n\t\t\t\treturn this.config.defaultInternalExpirationDate\n\t\t\t}\n\t\t\treturn null\n\t\t},\n\t\t/**\n\t\t * Is the current share password protected ?\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tisPasswordProtected: {\n\t\t\tget() {\n\t\t\t\tif (this.config.enforcePasswordForPublicLink) {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\tif (this.passwordProtectedState !== undefined) {\n\t\t\t\t\treturn this.passwordProtectedState\n\t\t\t\t}\n\t\t\t\treturn typeof this.share.newPassword === 'string'\n\t\t\t\t\t|| typeof this.share.password === 'string'\n\t\t\t},\n\t\t\tasync set(enabled) {\n\t\t\t\tif (enabled) {\n\t\t\t\t\tthis.passwordProtectedState = true\n\t\t\t\t\tconst generatedPassword = await GeneratePassword(true)\n\t\t\t\t\tif (!this.share.newPassword) {\n\t\t\t\t\t\tthis.$set(this.share, 'newPassword', generatedPassword)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tthis.passwordProtectedState = false\n\t\t\t\t\tthis.$set(this.share, 'newPassword', '')\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Fetch WebDAV node\n\t\t *\n\t\t * @return {Node}\n\t\t */\n\t\tasync getNode() {\n\t\t\tconst node = { path: this.path }\n\t\t\ttry {\n\t\t\t\tthis.node = await fetchNode(node.path)\n\t\t\t\tlogger.info('Fetched node:', { node: this.node })\n\t\t\t} catch (error) {\n\t\t\t\tlogger.error('Error:', error)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Check if a share is valid before\n\t\t * firing the request\n\t\t *\n\t\t * @param {Share} share the share to check\n\t\t * @return {boolean}\n\t\t */\n\t\tcheckShare(share) {\n\t\t\tif (share.password) {\n\t\t\t\tif (typeof share.password !== 'string' || share.password.trim() === '') {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (share.newPassword) {\n\t\t\t\tif (typeof share.newPassword !== 'string') {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (share.expirationDate) {\n\t\t\t\tconst date = share.expirationDate\n\t\t\t\tif (!date.isValid()) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true\n\t\t},\n\n\t\t/**\n\t\t * @param {Date} date the date to format\n\t\t * @return {string} date a date with YYYY-MM-DD format\n\t\t */\n\t\tformatDateToString(date) {\n\t\t\t// Force utc time. Drop time information to be timezone-less\n\t\t\tconst utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))\n\t\t\t// Format to YYYY-MM-DD\n\t\t\treturn utcDate.toISOString().split('T')[0]\n\t\t},\n\n\t\t/**\n\t\t * Save given value to expireDate and trigger queueUpdate\n\t\t *\n\t\t * @param {Date} date\n\t\t */\n\t\tonExpirationChange(date) {\n\t\t\tif (!date) {\n\t\t\t\tthis.share.expireDate = null\n\t\t\t\tthis.$set(this.share, 'expireDate', null)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst parsedDate = (date instanceof Date) ? date : new Date(date)\n\t\t\tthis.share.expireDate = this.formatDateToString(parsedDate)\n\t\t},\n\n\t\t/**\n\t\t * Delete share button handler\n\t\t */\n\t\tasync onDelete() {\n\t\t\ttry {\n\t\t\t\tthis.loading = true\n\t\t\t\tthis.open = false\n\t\t\t\tawait this.deleteShare(this.share.id)\n\t\t\t\tlogger.debug('Share deleted', { shareId: this.share.id })\n\t\t\t\tconst path = this.share.path.replace(/^\\//, '')\n\t\t\t\tconst message = this.share.itemType === 'file'\n\t\t\t\t\t? t('files_sharing', 'File \"{path}\" has been unshared', { path })\n\t\t\t\t\t: t('files_sharing', 'Folder \"{path}\" has been unshared', { path })\n\t\t\t\tshowSuccess(message)\n\t\t\t\tthis.$emit('remove:share', this.share)\n\t\t\t\tawait this.getNode()\n\t\t\t\temit('files:node:updated', this.node)\n\t\t\t} catch {\n\t\t\t\t// re-open menu if error\n\t\t\t\tthis.open = true\n\t\t\t} finally {\n\t\t\t\tthis.loading = false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Send an update of the share to the queue\n\t\t *\n\t\t * @param {Array} propertyNames the properties to sync\n\t\t */\n\t\tqueueUpdate(...propertyNames) {\n\t\t\tif (propertyNames.length === 0) {\n\t\t\t\t// Nothing to update\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (this.share.id) {\n\t\t\t\tconst properties = {}\n\t\t\t\t// force value to string because that is what our\n\t\t\t\t// share api controller accepts\n\t\t\t\tfor (const name of propertyNames) {\n\t\t\t\t\tif (name === 'password') {\n\t\t\t\t\t\tif (this.share.newPassword !== undefined) {\n\t\t\t\t\t\t\tproperties[name] = this.share.newPassword\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this.share[name] === null || this.share[name] === undefined) {\n\t\t\t\t\t\tproperties[name] = ''\n\t\t\t\t\t} else if ((typeof this.share[name]) === 'object') {\n\t\t\t\t\t\tproperties[name] = JSON.stringify(this.share[name])\n\t\t\t\t\t} else {\n\t\t\t\t\t\tproperties[name] = this.share[name].toString()\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn this.updateQueue.add(async () => {\n\t\t\t\t\tthis.saving = true\n\t\t\t\t\tthis.errors = {}\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst updatedShare = await this.updateShare(this.share.id, properties)\n\n\t\t\t\t\t\tif (propertyNames.includes('password')) {\n\t\t\t\t\t\t\t// reset password state after sync\n\t\t\t\t\t\t\tthis.share.password = this.share.newPassword || undefined\n\t\t\t\t\t\t\tthis.$set(this.share, 'newPassword', undefined)\n\n\t\t\t\t\t\t\t// updates password expiration time after sync\n\t\t\t\t\t\t\tthis.share.passwordExpirationTime = updatedShare.password_expiration_time\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// clear any previous errors\n\t\t\t\t\t\tfor (const property of propertyNames) {\n\t\t\t\t\t\t\tthis.$delete(this.errors, property)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tshowSuccess(this.updateSuccessMessage(propertyNames))\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tlogger.error('Could not update share', { error, share: this.share, propertyNames })\n\n\t\t\t\t\t\tconst { message } = error\n\t\t\t\t\t\tif (message && message !== '') {\n\t\t\t\t\t\t\tfor (const property of propertyNames) {\n\t\t\t\t\t\t\t\tthis.onSyncError(property, message)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tshowError(message)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// We do not have information what happened, but we should still inform the user\n\t\t\t\t\t\t\tshowError(t('files_sharing', 'Could not update share'))\n\t\t\t\t\t\t}\n\t\t\t\t\t} finally {\n\t\t\t\t\t\tthis.saving = false\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// This share does not exists on the server yet\n\t\t\tlogger.debug('Updated local share', { share: this.share })\n\t\t},\n\n\t\t/**\n\t\t * @param {string[]} names Properties changed\n\t\t */\n\t\tupdateSuccessMessage(names) {\n\t\t\tif (names.length !== 1) {\n\t\t\t\treturn t('files_sharing', 'Share saved')\n\t\t\t}\n\n\t\t\tswitch (names[0]) {\n\t\t\t\tcase 'expireDate':\n\t\t\t\t\treturn t('files_sharing', 'Share expiry date saved')\n\t\t\t\tcase 'hideDownload':\n\t\t\t\t\treturn t('files_sharing', 'Share hide-download state saved')\n\t\t\t\tcase 'label':\n\t\t\t\t\treturn t('files_sharing', 'Share label saved')\n\t\t\t\tcase 'note':\n\t\t\t\t\treturn t('files_sharing', 'Share note for recipient saved')\n\t\t\t\tcase 'password':\n\t\t\t\t\treturn t('files_sharing', 'Share password saved')\n\t\t\t\tcase 'permissions':\n\t\t\t\t\treturn t('files_sharing', 'Share permissions saved')\n\t\t\t\tdefault:\n\t\t\t\t\treturn t('files_sharing', 'Share saved')\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Manage sync errors\n\t\t *\n\t\t * @param {string} property the errored property, e.g. 'password'\n\t\t * @param {string} message the error message\n\t\t */\n\t\tonSyncError(property, message) {\n\t\t\tif (property === 'password' && this.share.newPassword !== undefined) {\n\t\t\t\tif (this.share.newPassword === this.share.password) {\n\t\t\t\t\tthis.share.password = ''\n\t\t\t\t}\n\t\t\t\tthis.$set(this.share, 'newPassword', undefined)\n\t\t\t}\n\n\t\t\t// re-open menu if closed\n\t\t\tthis.open = true\n\t\t\tswitch (property) {\n\t\t\t\tcase 'password':\n\t\t\t\tcase 'pending':\n\t\t\t\tcase 'expireDate':\n\t\t\t\tcase 'label':\n\t\t\t\tcase 'note': {\n\t\t\t\t// show error\n\t\t\t\t\tthis.$set(this.errors, property, message)\n\n\t\t\t\t\tlet propertyEl = this.$refs[property]\n\t\t\t\t\tif (propertyEl) {\n\t\t\t\t\t\tif (propertyEl.$el) {\n\t\t\t\t\t\t\tpropertyEl = propertyEl.$el\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// focus if there is a focusable action element\n\t\t\t\t\t\tconst focusable = propertyEl.querySelector('.focusable')\n\t\t\t\t\t\tif (focusable) {\n\t\t\t\t\t\t\tfocusable.focus()\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tcase 'sendPasswordByTalk': {\n\t\t\t\t// show error\n\t\t\t\t\tthis.$set(this.errors, property, message)\n\n\t\t\t\t\t// Restore previous state\n\t\t\t\t\tthis.share.sendPasswordByTalk = !this.share.sendPasswordByTalk\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t/**\n\t\t * Debounce queueUpdate to avoid requests spamming\n\t\t * more importantly for text data\n\t\t *\n\t\t * @param {string} property the property to sync\n\t\t */\n\t\tdebounceQueueUpdate: debounce(function(property) {\n\t\t\tthis.queueUpdate(property)\n\t\t}, 500),\n\t},\n}\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n","/*!\n * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: GPL-3.0-or-later\n */\nimport isSvg from 'is-svg';\n/**\n * Register a new sidebar action\n *\n * @param action - The action to register\n */\nexport function registerSidebarAction(action) {\n if (!action.id) {\n throw new Error('Sidebar actions must have an id');\n }\n if (!action.element || !action.element.startsWith('oca_') || !window.customElements.get(action.element)) {\n throw new Error('Sidebar actions must provide a registered custom web component identifier');\n }\n if (typeof action.order !== 'number') {\n throw new Error('Sidebar actions must have the order property');\n }\n if (typeof action.enabled !== 'function') {\n throw new Error('Sidebar actions must implement the \"enabled\" method');\n }\n window._nc_files_sharing_sidebar_actions ??= new Map();\n if (window._nc_files_sharing_sidebar_actions.has(action.id)) {\n throw new Error(`Sidebar action with id \"${action.id}\" is already registered`);\n }\n window._nc_files_sharing_sidebar_actions.set(action.id, action);\n}\n/**\n * Register a new sidebar action\n *\n * @param action - The action to register\n */\nexport function registerSidebarInlineAction(action) {\n if (!action.id) {\n throw new Error('Sidebar actions must have an id');\n }\n if (typeof action.order !== 'number') {\n throw new Error('Sidebar actions must have the \"order\" property');\n }\n if (typeof action.iconSvg !== 'string' || !isSvg(action.iconSvg)) {\n throw new Error('Sidebar actions must have the \"iconSvg\" property');\n }\n if (typeof action.label !== 'function') {\n throw new Error('Sidebar actions must implement the \"label\" method');\n }\n if (typeof action.exec !== 'function') {\n throw new Error('Sidebar actions must implement the \"exec\" method');\n }\n if (typeof action.enabled !== 'function') {\n throw new Error('Sidebar actions must implement the \"enabled\" method');\n }\n window._nc_files_sharing_sidebar_inline_actions ??= new Map();\n if (window._nc_files_sharing_sidebar_inline_actions.has(action.id)) {\n throw new Error(`Sidebar action with id \"${action.id}\" is already registered`);\n }\n window._nc_files_sharing_sidebar_inline_actions.set(action.id, action);\n}\n/**\n * Get all registered sidebar actions\n */\nexport function getSidebarActions() {\n return [...(window._nc_files_sharing_sidebar_actions?.values() ?? [])];\n}\n/**\n * Get all registered sidebar inline actions\n */\nexport function getSidebarInlineActions() {\n return [...(window._nc_files_sharing_sidebar_inline_actions?.values() ?? [])];\n}\n","/**\n * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport axios from '@nextcloud/axios';\nimport { generateOcsUrl } from '@nextcloud/router';\n/**\n *\n */\nexport async function generateToken() {\n const { data } = await axios.get(generateOcsUrl('/apps/files_sharing/api/v1/token'));\n return data.ocs.data.token;\n}\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=style&index=0&id=1e0a769c&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingDetailsTab.vue?vue&type=style&index=0&id=1e0a769c&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingDetailsTab.vue?vue&type=template&id=1e0a769c&scoped=true\"\nimport script from \"./SharingDetailsTab.vue?vue&type=script&lang=js\"\nexport * from \"./SharingDetailsTab.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingDetailsTab.vue?vue&type=style&index=0&id=1e0a769c&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"1e0a769c\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('ul',{attrs:{\"id\":\"sharing-inherited-shares\"}},[_c('SharingEntrySimple',{staticClass:\"sharing-entry__inherited\",attrs:{\"title\":_vm.mainTitle,\"subtitle\":_vm.subTitle,\"aria-expanded\":_vm.showInheritedShares},scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('div',{staticClass:\"avatar-shared icon-more-white\"})]},proxy:true}])},[_vm._v(\" \"),_c('NcActionButton',{attrs:{\"icon\":_vm.showInheritedSharesIcon,\"aria-label\":_vm.toggleTooltip,\"title\":_vm.toggleTooltip},on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.toggleInheritedShares.apply(null, arguments)}}})],1),_vm._v(\" \"),_vm._l((_vm.shares),function(share){return _c('SharingEntryInherited',{key:share.id,attrs:{\"file-info\":_vm.fileInfo,\"share\":share},on:{\"remove:share\":_vm.removeShare}})})],2)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=style&index=0&id=731a9650&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryInherited.vue?vue&type=style&index=0&id=731a9650&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryInherited.vue?vue&type=template&id=731a9650&scoped=true\"\nimport script from \"./SharingEntryInherited.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryInherited.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryInherited.vue?vue&type=style&index=0&id=731a9650&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"731a9650\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('SharingEntrySimple',{key:_vm.share.id,staticClass:\"sharing-entry__inherited\",attrs:{\"title\":_vm.share.shareWithDisplayName},scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"user\":_vm.share.shareWith,\"display-name\":_vm.share.shareWithDisplayName}})]},proxy:true}])},[_vm._v(\" \"),_c('NcActionText',{attrs:{\"icon\":\"icon-user\"}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Added by {initiator}', { initiator: _vm.share.ownerDisplayName }))+\"\\n\\t\")]),_vm._v(\" \"),(_vm.share.viaPath && _vm.share.viaFileid)?_c('NcActionLink',{attrs:{\"icon\":\"icon-folder\",\"href\":_vm.viaFileTargetUrl}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Via “{folder}”', { folder: _vm.viaFolderName }))+\"\\n\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.share.canDelete)?_c('NcActionButton',{attrs:{\"icon\":\"icon-close\"},on:{\"click\":function($event){$event.preventDefault();return _vm.onDelete.apply(null, arguments)}}},[_vm._v(\"\\n\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Unshare'))+\"\\n\\t\")]):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=style&index=0&id=cedf3238&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingInherited.vue?vue&type=style&index=0&id=cedf3238&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingInherited.vue?vue&type=template&id=cedf3238&scoped=true\"\nimport script from \"./SharingInherited.vue?vue&type=script&lang=js\"\nexport * from \"./SharingInherited.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingInherited.vue?vue&type=style&index=0&id=cedf3238&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"cedf3238\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return (_vm.canLinkShare)?_c('ul',{staticClass:\"sharing-link-list\",attrs:{\"aria-label\":_vm.t('files_sharing', 'Link shares')}},[(_vm.hasShares)?_vm._l((_vm.shares),function(share,index){return _c('SharingEntryLink',{key:share.id,attrs:{\"index\":_vm.shares.length > 1 ? index + 1 : null,\"can-reshare\":_vm.canReshare,\"share\":_vm.shares[index],\"file-info\":_vm.fileInfo},on:{\"update:share\":[function($event){return _vm.$set(_vm.shares, index, $event)},function($event){return _vm.awaitForShare(...arguments)}],\"add:share\":function($event){return _vm.addShare(...arguments)},\"remove:share\":_vm.removeShare,\"open-sharing-details\":function($event){return _vm.openSharingDetails(share)}}})}):_vm._e(),_vm._v(\" \"),(!_vm.hasLinkShares && _vm.canReshare)?_c('SharingEntryLink',{attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo},on:{\"add:share\":_vm.addShare}}):_vm._e()],2):_vm._e()\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./CalendarBlankOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./CalendarBlankOutline.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./CalendarBlankOutline.vue?vue&type=template&id=784b59e6\"\nimport script from \"./CalendarBlankOutline.vue?vue&type=script&lang=js\"\nexport * from \"./CalendarBlankOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon calendar-blank-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M19 3H18V1H16V3H8V1H6V3H5C3.89 3 3 3.9 3 5V19C3 20.11 3.9 21 5 21H19C20.11 21 21 20.11 21 19V5C21 3.9 20.11 3 19 3M19 19H5V9H19V19M19 7H5V5H19V7Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./CheckBold.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./CheckBold.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./CheckBold.vue?vue&type=template&id=5603f41f\"\nimport script from \"./CheckBold.vue?vue&type=script&lang=js\"\nexport * from \"./CheckBold.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon check-bold-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Exclamation.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Exclamation.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Exclamation.vue?vue&type=template&id=03239926\"\nimport script from \"./Exclamation.vue?vue&type=script&lang=js\"\nexport * from \"./Exclamation.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon exclamation-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M 11,4L 13,4L 13,15L 11,15L 11,4 Z M 13,18L 13,20L 11,20L 11,18L 13,18 Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./LockOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./LockOutline.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./LockOutline.vue?vue&type=template&id=54353a96\"\nimport script from \"./LockOutline.vue?vue&type=script&lang=js\"\nexport * from \"./LockOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon lock-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,17C10.89,17 10,16.1 10,15C10,13.89 10.89,13 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17M18,20V10H6V20H18M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10C4,8.89 4.89,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Plus.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Plus.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Plus.vue?vue&type=template&id=055261ec\"\nimport script from \"./Plus.vue?vue&type=script&lang=js\"\nexport * from \"./Plus.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon plus-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Qrcode.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Qrcode.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Qrcode.vue?vue&type=template&id=aba87788\"\nimport script from \"./Qrcode.vue?vue&type=script&lang=js\"\nexport * from \"./Qrcode.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon qrcode-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M3,11H5V13H3V11M11,5H13V9H11V5M9,11H13V15H11V13H9V11M15,11H17V13H19V11H21V13H19V15H21V19H19V21H17V19H13V21H11V17H15V15H17V13H15V11M19,19V15H17V19H19M15,3H21V9H15V3M17,5V7H19V5H17M3,3H9V9H3V3M5,5V7H7V5H5M3,15H9V21H3V15M5,17V19H7V17H5Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./Tune.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./Tune.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./Tune.vue?vue&type=template&id=18d04e6a\"\nimport script from \"./Tune.vue?vue&type=script&lang=js\"\nexport * from \"./Tune.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon tune-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M3,17V19H9V17H3M3,5V7H13V5H3M13,21V19H21V17H13V15H11V21H13M7,9V11H3V13H7V15H9V9H7M21,13V11H11V13H21M15,9H17V7H21V5H17V3H15V9Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"share-expiry-time\"},[_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [(_vm.expiryTime)?_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary\",\"aria-label\":_vm.t('files_sharing', 'Share expiration: {date}', { date: new Date(_vm.expiryTime).toLocaleString() })},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('ClockIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,3754271979)}):_vm._e()]},proxy:true}])},[_vm._v(\" \"),_c('h3',{staticClass:\"hint-heading\"},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Share Expiration'))+\"\\n\\t\\t\")]),_vm._v(\" \"),(_vm.expiryTime)?_c('p',{staticClass:\"hint-body\"},[_c('NcDateTime',{attrs:{\"timestamp\":_vm.expiryTime,\"format\":_vm.timeFormat,\"relative-time\":false}}),_vm._v(\" (\"),_c('NcDateTime',{attrs:{\"timestamp\":_vm.expiryTime}}),_vm._v(\")\\n\\t\\t\")],1):_vm._e()])],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./ClockOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./ClockOutline.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./ClockOutline.vue?vue&type=template&id=1a84e403\"\nimport script from \"./ClockOutline.vue?vue&type=script&lang=js\"\nexport * from \"./ClockOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon clock-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=style&index=0&id=c9199db0&prod&scoped=true&lang=scss\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ShareExpiryTime.vue?vue&type=style&index=0&id=c9199db0&prod&scoped=true&lang=scss\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./ShareExpiryTime.vue?vue&type=template&id=c9199db0&scoped=true\"\nimport script from \"./ShareExpiryTime.vue?vue&type=script&lang=js\"\nexport * from \"./ShareExpiryTime.vue?vue&type=script&lang=js\"\nimport style0 from \"./ShareExpiryTime.vue?vue&type=style&index=0&id=c9199db0&prod&scoped=true&lang=scss\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"c9199db0\",\n null\n \n)\n\nexport default component.exports","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./EyeOutline.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./EyeOutline.vue?vue&type=script&lang=js\"","\n\n","import { render, staticRenderFns } from \"./EyeOutline.vue?vue&type=template&id=e26de6f6\"\nimport script from \"./EyeOutline.vue?vue&type=script&lang=js\"\nexport * from \"./EyeOutline.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon eye-outline-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9M12,4.5C17,4.5 21.27,7.61 23,12C21.27,16.39 17,19.5 12,19.5C7,19.5 2.73,16.39 1,12C2.73,7.61 7,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C15.76,17.5 19.17,15.36 20.82,12C19.17,8.64 15.76,6.5 12,6.5C8.24,6.5 4.83,8.64 3.18,12Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./TriangleSmallDown.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./TriangleSmallDown.vue?vue&type=script&lang=js\"","\n\n","\n\n\n\n\n\n","import { render, staticRenderFns } from \"./TriangleSmallDown.vue?vue&type=template&id=1eed3dd9\"\nimport script from \"./TriangleSmallDown.vue?vue&type=script&lang=js\"\nexport * from \"./TriangleSmallDown.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon triangle-small-down-icon\",attrs:{\"aria-hidden\":_vm.title ? null : 'true',\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M8 9H16L12 16\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=style&index=0&id=839566a2&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryQuickShareSelect.vue?vue&type=style&index=0&id=839566a2&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryQuickShareSelect.vue?vue&type=template&id=839566a2&scoped=true\"\nimport script from \"./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryQuickShareSelect.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryQuickShareSelect.vue?vue&type=style&index=0&id=839566a2&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"839566a2\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('NcActions',{ref:\"quickShareActions\",staticClass:\"share-select\",attrs:{\"menu-name\":_vm.selectedOption,\"aria-label\":_vm.ariaLabel,\"variant\":\"tertiary-no-background\",\"disabled\":!_vm.share.canEdit,\"force-name\":\"\"},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('DropdownIcon',{attrs:{\"size\":15}})]},proxy:true}])},[_vm._v(\" \"),_vm._l((_vm.options),function(option){return _c('NcActionButton',{key:option.label,attrs:{\"type\":\"radio\",\"model-value\":option.label === _vm.selectedOption,\"close-after-click\":\"\"},on:{\"click\":function($event){return _vm.selectOption(option.label)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c(option.icon,{tag:\"component\"})]},proxy:true}],null,true)},[_vm._v(\"\\n\\t\\t\"+_vm._s(option.label)+\"\\n\\t\")])})],2)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=style&index=0&id=7a5c0ee5&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntryLink.vue?vue&type=style&index=0&id=7a5c0ee5&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntryLink.vue?vue&type=template&id=7a5c0ee5&scoped=true\"\nimport script from \"./SharingEntryLink.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntryLink.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntryLink.vue?vue&type=style&index=0&id=7a5c0ee5&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"7a5c0ee5\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('li',{staticClass:\"sharing-entry sharing-entry__link\",class:{ 'sharing-entry--share': _vm.share }},[_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"is-no-user\":true,\"icon-class\":_vm.isEmailShareType ? 'avatar-link-share icon-mail-white' : 'avatar-link-share icon-public-white'}}),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__summary\"},[_c('div',{staticClass:\"sharing-entry__desc\"},[_c('span',{staticClass:\"sharing-entry__title\",attrs:{\"title\":_vm.title}},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.title)+\"\\n\\t\\t\\t\")]),_vm._v(\" \"),(_vm.subtitle)?_c('p',[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.subtitle)+\"\\n\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.share && _vm.share.permissions !== undefined)?_c('SharingEntryQuickShareSelect',{attrs:{\"share\":_vm.share,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":function($event){return _vm.openShareDetailsForCustomSettings(_vm.share)}}}):_vm._e()],1),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__actions\"},[(_vm.share && _vm.share.expireDate)?_c('ShareExpiryTime',{attrs:{\"share\":_vm.share}}):_vm._e(),_vm._v(\" \"),_c('div',[(_vm.share && (!_vm.isEmailShareType || _vm.isFileRequest) && _vm.share.token)?_c('NcActions',{ref:\"copyButton\",staticClass:\"sharing-entry__copy\"},[_c('NcActionButton',{attrs:{\"aria-label\":_vm.copyLinkLabel,\"title\":_vm.copySuccess ? _vm.t('files_sharing', 'Successfully copied public link') : undefined,\"href\":_vm.shareLink},on:{\"click\":function($event){$event.preventDefault();return _vm.copyLink.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('NcIconSvgWrapper',{staticClass:\"sharing-entry__copy-icon\",class:{ 'sharing-entry__copy-icon--success': _vm.copySuccess },attrs:{\"path\":_vm.copySuccess ? _vm.mdiCheck : _vm.mdiContentCopy}})]},proxy:true}],null,false,1728815133)})],1):_vm._e()],1)],1)]),_vm._v(\" \"),(!_vm.pending && _vm.pendingDataIsMissing)?_c('NcActions',{staticClass:\"sharing-entry__actions\",attrs:{\"aria-label\":_vm.actionsTooltip,\"menu-align\":\"right\",\"open\":_vm.open},on:{\"update:open\":function($event){_vm.open=$event},\"close\":_vm.onCancel}},[(_vm.errors.pending)?_c('NcActionText',{staticClass:\"error\",scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('ErrorIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,1966124155)},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.errors.pending)+\"\\n\\t\\t\")]):_c('NcActionText',{attrs:{\"icon\":\"icon-info\"}},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Please enter the following required information before creating the share'))+\"\\n\\t\\t\")]),_vm._v(\" \"),(_vm.pendingPassword)?_c('NcActionCheckbox',{staticClass:\"share-link-password-checkbox\",attrs:{\"disabled\":_vm.config.enforcePasswordForPublicLink || _vm.saving},on:{\"uncheck\":_vm.onPasswordDisable},model:{value:(_vm.isPasswordProtected),callback:function ($$v) {_vm.isPasswordProtected=$$v},expression:\"isPasswordProtected\"}},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.config.enforcePasswordForPublicLink ? _vm.t('files_sharing', 'Password protection (enforced)') : _vm.t('files_sharing', 'Password protection'))+\"\\n\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.pendingEnforcedPassword || _vm.isPasswordProtected)?_c('NcActionInput',{staticClass:\"share-link-password\",attrs:{\"label\":_vm.t('files_sharing', 'Enter a password'),\"disabled\":_vm.saving,\"required\":_vm.config.enableLinkPasswordByDefault || _vm.config.enforcePasswordForPublicLink,\"minlength\":_vm.minPasswordLength,\"autocomplete\":\"new-password\"},on:{\"submit\":function($event){return _vm.onNewLinkShare(true)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('LockIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2056568168),model:{value:(_vm.share.newPassword),callback:function ($$v) {_vm.$set(_vm.share, \"newPassword\", $$v)},expression:\"share.newPassword\"}}):_vm._e(),_vm._v(\" \"),(_vm.pendingDefaultExpirationDate)?_c('NcActionCheckbox',{staticClass:\"share-link-expiration-date-checkbox\",attrs:{\"disabled\":_vm.pendingEnforcedExpirationDate || _vm.saving},on:{\"update:model-value\":_vm.onExpirationDateToggleUpdate},model:{value:(_vm.defaultExpirationDateEnabled),callback:function ($$v) {_vm.defaultExpirationDateEnabled=$$v},expression:\"defaultExpirationDateEnabled\"}},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.config.isDefaultExpireDateEnforced ? _vm.t('files_sharing', 'Enable link expiration (enforced)') : _vm.t('files_sharing', 'Enable link expiration'))+\"\\n\\t\\t\")]):_vm._e(),_vm._v(\" \"),((_vm.pendingDefaultExpirationDate || _vm.pendingEnforcedExpirationDate) && _vm.defaultExpirationDateEnabled)?_c('NcActionInput',{staticClass:\"share-link-expire-date\",attrs:{\"data-cy-files-sharing-expiration-date-input\":\"\",\"label\":_vm.pendingEnforcedExpirationDate ? _vm.t('files_sharing', 'Enter expiration date (enforced)') : _vm.t('files_sharing', 'Enter expiration date'),\"disabled\":_vm.saving,\"is-native-picker\":true,\"hide-label\":true,\"model-value\":new Date(_vm.share.expireDate),\"type\":\"date\",\"min\":_vm.dateTomorrow,\"max\":_vm.maxExpirationDateEnforced},on:{\"update:model-value\":_vm.onExpirationChange,\"change\":_vm.expirationDateChanged},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('IconCalendarBlank',{attrs:{\"size\":20}})]},proxy:true}],null,false,3418578971)}):_vm._e(),_vm._v(\" \"),_c('NcActionButton',{attrs:{\"disabled\":_vm.pendingEnforcedPassword && !_vm.share.newPassword},on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onNewLinkShare(true)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CheckIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2630571749)},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Create share'))+\"\\n\\t\\t\")]),_vm._v(\" \"),_c('NcActionButton',{on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onCancel.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CloseIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2428343285)},[_vm._v(\"\\n\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Cancel'))+\"\\n\\t\\t\")])],1):(!_vm.loading)?_c('NcActions',{staticClass:\"sharing-entry__actions\",attrs:{\"aria-label\":_vm.actionsTooltip,\"menu-align\":\"right\",\"open\":_vm.open},on:{\"update:open\":function($event){_vm.open=$event}}},[(_vm.share)?[(_vm.share.canEdit && _vm.canReshare)?[_c('NcActionButton',{attrs:{\"disabled\":_vm.saving,\"close-after-click\":true},on:{\"click\":function($event){$event.preventDefault();return _vm.openSharingDetails.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('Tune',{attrs:{\"size\":20}})]},proxy:true}],null,false,1300586850)},[_vm._v(\"\\n\\t\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Customize link'))+\"\\n\\t\\t\\t\\t\")])]:_vm._e(),_vm._v(\" \"),_c('NcActionButton',{attrs:{\"close-after-click\":true},on:{\"click\":function($event){$event.preventDefault();_vm.showQRCode = true}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('IconQr',{attrs:{\"size\":20}})]},proxy:true}],null,false,1082198240)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Generate QR code'))+\"\\n\\t\\t\\t\")]),_vm._v(\" \"),_c('NcActionSeparator'),_vm._v(\" \"),_vm._l((_vm.sortedExternalShareActions),function(action){return _c('NcActionButton',{key:action.id,on:{\"click\":function($event){return action.exec(_vm.share, _vm.fileInfo.node)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('NcIconSvgWrapper',{attrs:{\"svg\":action.iconSvg}})]},proxy:true}],null,true)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(action.label(_vm.share, _vm.fileInfo.node))+\"\\n\\t\\t\\t\")])}),_vm._v(\" \"),_vm._l((_vm.externalLegacyShareActions),function(action){return _c('SidebarTabExternalActionLegacy',{key:action.id,attrs:{\"id\":action.id,\"action\":action,\"file-info\":_vm.fileInfo,\"share\":_vm.share}})}),_vm._v(\" \"),(!_vm.isEmailShareType && _vm.canReshare)?_c('NcActionButton',{staticClass:\"new-share-link\",on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onNewLinkShare.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('PlusIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2953566425)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Add another link'))+\"\\n\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.share.canDelete)?_c('NcActionButton',{attrs:{\"disabled\":_vm.saving},on:{\"click\":function($event){$event.preventDefault();return _vm.onDelete.apply(null, arguments)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('CloseIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,2428343285)},[_vm._v(\"\\n\\t\\t\\t\\t\"+_vm._s(_vm.t('files_sharing', 'Unshare'))+\"\\n\\t\\t\\t\")]):_vm._e()]:(_vm.canReshare)?_c('NcActionButton',{staticClass:\"new-share-link\",attrs:{\"title\":_vm.t('files_sharing', 'Create a new share link'),\"aria-label\":_vm.t('files_sharing', 'Create a new share link'),\"icon\":_vm.loading ? 'icon-loading-small' : 'icon-add'},on:{\"click\":function($event){$event.preventDefault();$event.stopPropagation();return _vm.onNewLinkShare.apply(null, arguments)}}}):_vm._e()],2):_c('NcLoadingIcon',{staticClass:\"sharing-entry__loading\"}),_vm._v(\" \"),(_vm.showQRCode)?_c('NcDialog',{attrs:{\"size\":\"normal\",\"open\":_vm.showQRCode,\"name\":_vm.title,\"close-on-click-outside\":true},on:{\"update:open\":function($event){_vm.showQRCode=$event},\"close\":function($event){_vm.showQRCode = false}}},[_c('div',{staticClass:\"qr-code-dialog\"},[_c('VueQrcode',{staticClass:\"qr-code-dialog__img\",attrs:{\"tag\":\"img\",\"value\":_vm.shareLink}})],1)]):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingLinkList.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingLinkList.vue?vue&type=script&lang=js\"","\n\n\n\n\n","import { render, staticRenderFns } from \"./SharingLinkList.vue?vue&type=template&id=708b3104\"\nimport script from \"./SharingLinkList.vue?vue&type=script&lang=js\"\nexport * from \"./SharingLinkList.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=script&lang=js\"","\n\n\n\n\n\n\n","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=style&index=0&id=fa3f3612&prod&lang=scss&scoped=true\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingEntry.vue?vue&type=style&index=0&id=fa3f3612&prod&lang=scss&scoped=true\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingEntry.vue?vue&type=template&id=fa3f3612&scoped=true\"\nimport script from \"./SharingEntry.vue?vue&type=script&lang=js\"\nexport * from \"./SharingEntry.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingEntry.vue?vue&type=style&index=0&id=fa3f3612&prod&lang=scss&scoped=true\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"fa3f3612\",\n null\n \n)\n\nexport default component.exports","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingList.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingList.vue?vue&type=script&lang=js\"","\n\n\n\n\n","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('li',{staticClass:\"sharing-entry\"},[_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"is-no-user\":_vm.share.type !== _vm.ShareType.User,\"user\":_vm.share.shareWith,\"display-name\":_vm.share.shareWithDisplayName,\"menu-position\":\"left\",\"url\":_vm.share.shareWithAvatar}}),_vm._v(\" \"),_c('div',{staticClass:\"sharing-entry__summary\"},[_c(_vm.share.shareWithLink ? 'a' : 'div',{tag:\"component\",staticClass:\"sharing-entry__summary__desc\",attrs:{\"title\":_vm.tooltip,\"aria-label\":_vm.tooltip,\"href\":_vm.share.shareWithLink}},[_c('span',[_vm._v(_vm._s(_vm.title)+\"\\n\\t\\t\\t\\t\"),(!_vm.isUnique)?_c('span',{staticClass:\"sharing-entry__summary__desc-unique\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t(\"+_vm._s(_vm.share.shareWithDisplayNameUnique)+\")\\n\\t\\t\\t\\t\")]):_vm._e(),_vm._v(\" \"),(_vm.hasStatus && _vm.share.status.message)?_c('small',[_vm._v(\"(\"+_vm._s(_vm.share.status.message)+\")\")]):_vm._e()])]),_vm._v(\" \"),_c('SharingEntryQuickShareSelect',{attrs:{\"share\":_vm.share,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":function($event){return _vm.openShareDetailsForCustomSettings(_vm.share)}}})],1),_vm._v(\" \"),(_vm.share && _vm.share.expireDate)?_c('ShareExpiryTime',{attrs:{\"share\":_vm.share}}):_vm._e(),_vm._v(\" \"),(_vm.share.canEdit)?_c('NcButton',{staticClass:\"sharing-entry__action\",attrs:{\"data-cy-files-sharing-share-actions\":\"\",\"aria-label\":_vm.t('files_sharing', 'Open Sharing Details'),\"variant\":\"tertiary\"},on:{\"click\":function($event){return _vm.openSharingDetails(_vm.share)}},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('DotsHorizontalIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,1700783217)}):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import { render, staticRenderFns } from \"./SharingList.vue?vue&type=template&id=7e1141c6\"\nimport script from \"./SharingList.vue?vue&type=script&lang=js\"\nexport * from \"./SharingList.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('ul',{staticClass:\"sharing-sharee-list\",attrs:{\"aria-label\":_vm.t('files_sharing', 'Shares')}},_vm._l((_vm.shares),function(share){return _c('SharingEntry',{key:share.id,attrs:{\"file-info\":_vm.fileInfo,\"share\":share,\"is-unique\":_vm.isUnique(share)},on:{\"open-sharing-details\":function($event){return _vm.openSharingDetails(share)}}})}),1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n\n\n","/*!\n * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: GPL-3.0-or-later\n */\n/**\n * Register a new sidebar section inside the files sharing sidebar tab.\n *\n * @param section - The section to register\n */\nexport function registerSidebarSection(section) {\n if (!section.id) {\n throw new Error('Sidebar sections must have an id');\n }\n if (!section.element || !section.element.startsWith('oca_') || !window.customElements.get(section.element)) {\n throw new Error('Sidebar sections must provide a registered custom web component identifier');\n }\n if (typeof section.order !== 'number') {\n throw new Error('Sidebar sections must have the order property');\n }\n if (typeof section.enabled !== 'function') {\n throw new Error('Sidebar sections must implement the enabled method');\n }\n window._nc_files_sharing_sidebar_sections ??= new Map();\n if (window._nc_files_sharing_sidebar_sections.has(section.id)) {\n throw new Error(`Sidebar section with id \"${section.id}\" is already registered`);\n }\n window._nc_files_sharing_sidebar_sections.set(section.id, section);\n}\n/**\n * Get all registered sidebar sections for the files sharing sidebar tab.\n */\nexport function getSidebarSections() {\n return [...(window._nc_files_sharing_sidebar_sections?.values() ?? [])];\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { ShareType } from '@nextcloud/sharing'\n\n/**\n *\n * @param share\n */\nfunction shareWithTitle(share) {\n\tif (share.type === ShareType.Group) {\n\t\treturn t(\n\t\t\t'files_sharing',\n\t\t\t'Shared with you and the group {group} by {owner}',\n\t\t\t{\n\t\t\t\tgroup: share.shareWithDisplayName,\n\t\t\t\towner: share.ownerDisplayName,\n\t\t\t},\n\t\t\tundefined,\n\t\t\t{ escape: false },\n\t\t)\n\t} else if (share.type === ShareType.Team) {\n\t\treturn t(\n\t\t\t'files_sharing',\n\t\t\t'Shared with you and {circle} by {owner}',\n\t\t\t{\n\t\t\t\tcircle: share.shareWithDisplayName,\n\t\t\t\towner: share.ownerDisplayName,\n\t\t\t},\n\t\t\tundefined,\n\t\t\t{ escape: false },\n\t\t)\n\t} else if (share.type === ShareType.Room) {\n\t\tif (share.shareWithDisplayName) {\n\t\t\treturn t(\n\t\t\t\t'files_sharing',\n\t\t\t\t'Shared with you and the conversation {conversation} by {owner}',\n\t\t\t\t{\n\t\t\t\t\tconversation: share.shareWithDisplayName,\n\t\t\t\t\towner: share.ownerDisplayName,\n\t\t\t\t},\n\t\t\t\tundefined,\n\t\t\t\t{ escape: false },\n\t\t\t)\n\t\t} else {\n\t\t\treturn t(\n\t\t\t\t'files_sharing',\n\t\t\t\t'Shared with you in a conversation by {owner}',\n\t\t\t\t{\n\t\t\t\t\towner: share.ownerDisplayName,\n\t\t\t\t},\n\t\t\t\tundefined,\n\t\t\t\t{ escape: false },\n\t\t\t)\n\t\t}\n\t} else {\n\t\treturn t(\n\t\t\t'files_sharing',\n\t\t\t'Shared with you by {owner}',\n\t\t\t{ owner: share.ownerDisplayName },\n\t\t\tundefined,\n\t\t\t{ escape: false },\n\t\t)\n\t}\n}\n\nexport { shareWithTitle }\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=script&lang=js\"","\n import API from \"!../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\";\n import domAPI from \"!../../../../node_modules/style-loader/dist/runtime/styleDomAPI.js\";\n import insertFn from \"!../../../../node_modules/style-loader/dist/runtime/insertBySelector.js\";\n import setAttributes from \"!../../../../node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js\";\n import insertStyleElement from \"!../../../../node_modules/style-loader/dist/runtime/insertStyleElement.js\";\n import styleTagTransformFn from \"!../../../../node_modules/style-loader/dist/runtime/styleTagTransform.js\";\n import content, * as namedExport from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=style&index=0&id=cd6ad9ee&prod&scoped=true&lang=scss\";\n \n \n\nvar options = {};\n\noptions.styleTagTransform = styleTagTransformFn;\noptions.setAttributes = setAttributes;\noptions.insert = insertFn.bind(null, \"head\");\noptions.domAPI = domAPI;\noptions.insertStyleElement = insertStyleElement;\n\nvar update = API(content, options);\n\n\n\nexport * from \"!!../../../../node_modules/css-loader/dist/cjs.js!../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../node_modules/sass-loader/dist/cjs/index.js!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./SharingTab.vue?vue&type=style&index=0&id=cd6ad9ee&prod&scoped=true&lang=scss\";\n export default content && content.locals ? content.locals : undefined;\n","import { render, staticRenderFns } from \"./SharingTab.vue?vue&type=template&id=cd6ad9ee&scoped=true\"\nimport script from \"./SharingTab.vue?vue&type=script&lang=js\"\nexport * from \"./SharingTab.vue?vue&type=script&lang=js\"\nimport style0 from \"./SharingTab.vue?vue&type=style&index=0&id=cd6ad9ee&prod&scoped=true&lang=scss\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n \"cd6ad9ee\",\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('div',{staticClass:\"sharingTab\",class:{ 'icon-loading': _vm.loading }},[(_vm.error)?_c('div',{staticClass:\"emptycontent\",class:{ emptyContentWithSections: _vm.hasExternalSections }},[_c('div',{staticClass:\"icon icon-error\"}),_vm._v(\" \"),_c('h2',[_vm._v(_vm._s(_vm.error))])]):_vm._e(),_vm._v(\" \"),_c('div',{directives:[{name:\"show\",rawName:\"v-show\",value:(!_vm.showSharingDetailsView),expression:\"!showSharingDetailsView\"}],staticClass:\"sharingTab__content\"},[(_vm.isSharedWithMe)?_c('ul',[_c('SharingEntrySimple',_vm._b({staticClass:\"sharing-entry__reshare\",scopedSlots:_vm._u([{key:\"avatar\",fn:function(){return [_c('NcAvatar',{staticClass:\"sharing-entry__avatar\",attrs:{\"user\":_vm.sharedWithMe.user,\"display-name\":_vm.sharedWithMe.displayName}})]},proxy:true}],null,false,3197855346)},'SharingEntrySimple',_vm.sharedWithMe,false))],1):_vm._e(),_vm._v(\" \"),_c('section',[_c('div',{staticClass:\"section-header\"},[_c('h4',[_vm._v(_vm._s(_vm.t('files_sharing', 'Internal shares')))]),_vm._v(\" \"),_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary-no-background\",\"aria-label\":_vm.t('files_sharing', 'Internal shares explanation')},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('InfoIcon',{attrs:{\"size\":20}})]},proxy:true}])})]},proxy:true}])},[_vm._v(\" \"),_c('p',{staticClass:\"hint-body\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.internalSharesHelpText)+\"\\n\\t\\t\\t\\t\\t\")])])],1),_vm._v(\" \"),(!_vm.loading)?_c('SharingInput',{attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo,\"link-shares\":_vm.linkShares,\"reshare\":_vm.reshare,\"shares\":_vm.shares,\"placeholder\":_vm.internalShareInputPlaceholder},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(!_vm.loading)?_c('SharingList',{ref:\"shareList\",attrs:{\"shares\":_vm.shares,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(_vm.canReshare && !_vm.loading)?_c('SharingInherited',{attrs:{\"file-info\":_vm.fileInfo}}):_vm._e(),_vm._v(\" \"),_c('SharingEntryInternal',{attrs:{\"file-info\":_vm.fileInfo}})],1),_vm._v(\" \"),(_vm.config.showExternalSharing)?_c('section',[_c('div',{staticClass:\"section-header\"},[_c('h4',[_vm._v(_vm._s(_vm.t('files_sharing', 'External shares')))]),_vm._v(\" \"),_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary-no-background\",\"aria-label\":_vm.t('files_sharing', 'External shares explanation')},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('InfoIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,915383693)})]},proxy:true}],null,false,4045083138)},[_vm._v(\" \"),_c('p',{staticClass:\"hint-body\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.externalSharesHelpText)+\"\\n\\t\\t\\t\\t\\t\")])])],1),_vm._v(\" \"),(!_vm.loading)?_c('SharingInput',{attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo,\"link-shares\":_vm.linkShares,\"is-external\":true,\"placeholder\":_vm.externalShareInputPlaceholder,\"reshare\":_vm.reshare,\"shares\":_vm.shares},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(!_vm.loading)?_c('SharingList',{attrs:{\"shares\":_vm.externalShares,\"file-info\":_vm.fileInfo},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e(),_vm._v(\" \"),(!_vm.loading && _vm.isLinkSharingAllowed)?_c('SharingLinkList',{ref:\"linkShareList\",attrs:{\"can-reshare\":_vm.canReshare,\"file-info\":_vm.fileInfo,\"shares\":_vm.linkShares},on:{\"open-sharing-details\":_vm.toggleShareDetailsView}}):_vm._e()],1):_vm._e(),_vm._v(\" \"),(_vm.hasExternalSections && !_vm.showSharingDetailsView)?_c('section',[_c('div',{staticClass:\"section-header\"},[_c('h4',[_vm._v(_vm._s(_vm.t('files_sharing', 'Additional shares')))]),_vm._v(\" \"),_c('NcPopover',{attrs:{\"popup-role\":\"dialog\"},scopedSlots:_vm._u([{key:\"trigger\",fn:function(){return [_c('NcButton',{staticClass:\"hint-icon\",attrs:{\"variant\":\"tertiary-no-background\",\"aria-label\":_vm.t('files_sharing', 'Additional shares explanation')},scopedSlots:_vm._u([{key:\"icon\",fn:function(){return [_c('InfoIcon',{attrs:{\"size\":20}})]},proxy:true}],null,false,915383693)})]},proxy:true}],null,false,880248230)},[_vm._v(\" \"),_c('p',{staticClass:\"hint-body\"},[_vm._v(\"\\n\\t\\t\\t\\t\\t\\t\"+_vm._s(_vm.additionalSharesHelpText)+\"\\n\\t\\t\\t\\t\\t\")])])],1),_vm._v(\" \"),_vm._l((_vm.sortedExternalSections),function(section){return _c('SidebarTabExternalSection',{key:section.id,staticClass:\"sharingTab__additionalContent\",attrs:{\"section\":section,\"node\":_vm.fileInfo.node /* TODO: Fix once we have proper Node API */}})}),_vm._v(\" \"),_vm._l((_vm.legacySections),function(section,index){return _c('SidebarTabExternalSectionLegacy',{key:index,staticClass:\"sharingTab__additionalContent\",attrs:{\"file-info\":_vm.fileInfo,\"section-callback\":section}})}),_vm._v(\" \"),(_vm.projectsEnabled)?_c('div',{directives:[{name:\"show\",rawName:\"v-show\",value:(!_vm.showSharingDetailsView && _vm.fileInfo),expression:\"!showSharingDetailsView && fileInfo\"}],staticClass:\"sharingTab__additionalContent\"},[_c('NcCollectionList',{attrs:{\"id\":`${_vm.fileInfo.id}`,\"type\":\"file\",\"name\":_vm.fileInfo.name}})],1):_vm._e()],2):_vm._e()]),_vm._v(\" \"),(_vm.showSharingDetailsView)?_c('SharingDetailsTab',{attrs:{\"file-info\":_vm.shareDetailsData.fileInfo,\"share\":_vm.shareDetailsData.share},on:{\"close-sharing-details\":_vm.toggleShareDetailsView,\"add:share\":_vm.addShare,\"remove:share\":_vm.removeShare}}):_vm._e()],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","/*!\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n/**\n * Convert Node to legacy file info\n *\n * @param node - The Node to convert\n */\nexport default function (node) {\n const rawFileInfo = {\n id: node.fileid,\n path: node.dirname,\n name: node.basename,\n mtime: node.mtime?.getTime(),\n etag: node.attributes.etag,\n size: node.size,\n hasPreview: node.attributes.hasPreview,\n isEncrypted: node.attributes.isEncrypted === 1,\n isFavourited: node.attributes.favorite === 1,\n mimetype: node.mime,\n permissions: node.permissions,\n mountType: node.attributes['mount-type'],\n sharePermissions: node.attributes['share-permissions'],\n shareAttributes: JSON.parse(node.attributes['share-attributes'] || '[]'),\n type: node.type === 'file' ? 'file' : 'dir',\n attributes: node.attributes,\n };\n const fileInfo = new OC.Files.FileInfo(rawFileInfo);\n // TODO remove when no more legacy backbone is used\n fileInfo.get = (key) => fileInfo[key];\n fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory';\n fileInfo.canEdit = () => Boolean(fileInfo.permissions & OC.PERMISSION_UPDATE);\n fileInfo.node = node;\n return fileInfo;\n}\n","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"","import { render, staticRenderFns } from \"./FilesSidebarTab.vue?vue&type=template&id=8a2257be\"\nimport script from \"./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"\nexport * from \"./FilesSidebarTab.vue?vue&type=script&setup=true&lang=ts\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","import { getCurrentUser, onRequestTokenUpdate, getRequestToken } from \"@nextcloud/auth\";\nimport { generateRemoteUrl } from \"@nextcloud/router\";\nimport { isPublicShare, getSharingToken } from \"@nextcloud/sharing/public\";\nimport { createClient, getPatcher } from \"webdav\";\nimport { P as Permission, s as scopedGlobals, l as logger, c as NodeStatus, a as File, b as Folder } from \"./chunks/folder-29HuacU_.mjs\";\nimport \"@nextcloud/paths\";\n/*!\n * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nfunction parsePermissions(permString = \"\") {\n let permissions = Permission.NONE;\n if (!permString) {\n return permissions;\n }\n if (permString.includes(\"G\")) {\n permissions |= Permission.READ;\n }\n if (permString.includes(\"W\")) {\n permissions |= Permission.WRITE;\n }\n if (permString.includes(\"CK\")) {\n permissions |= Permission.CREATE;\n }\n if (permString.includes(\"NV\")) {\n permissions |= Permission.UPDATE;\n }\n if (permString.includes(\"D\")) {\n permissions |= Permission.DELETE;\n }\n if (permString.includes(\"R\")) {\n permissions |= Permission.SHARE;\n }\n return permissions;\n}\nconst defaultDavProperties = [\n \"d:getcontentlength\",\n \"d:getcontenttype\",\n \"d:getetag\",\n \"d:getlastmodified\",\n \"d:creationdate\",\n \"d:displayname\",\n \"d:quota-available-bytes\",\n \"d:resourcetype\",\n \"nc:has-preview\",\n \"nc:is-encrypted\",\n \"nc:mount-type\",\n \"oc:comments-unread\",\n \"oc:favorite\",\n \"oc:fileid\",\n \"oc:owner-display-name\",\n \"oc:owner-id\",\n \"oc:permissions\",\n \"oc:size\"\n];\nconst defaultDavNamespaces = {\n d: \"DAV:\",\n nc: \"http://nextcloud.org/ns\",\n oc: \"http://owncloud.org/ns\",\n ocs: \"http://open-collaboration-services.org/ns\"\n};\nfunction registerDavProperty(prop, namespace = { nc: \"http://nextcloud.org/ns\" }) {\n scopedGlobals.davNamespaces ??= { ...defaultDavNamespaces };\n scopedGlobals.davProperties ??= [...defaultDavProperties];\n const namespaces = { ...scopedGlobals.davNamespaces, ...namespace };\n if (scopedGlobals.davProperties.find((search) => search === prop)) {\n logger.warn(`${prop} already registered`, { prop });\n return false;\n }\n if (prop.startsWith(\"<\") || prop.split(\":\").length !== 2) {\n logger.error(`${prop} is not valid. See example: 'oc:fileid'`, { prop });\n return false;\n }\n const ns = prop.split(\":\")[0];\n if (!namespaces[ns]) {\n logger.error(`${prop} namespace unknown`, { prop, namespaces });\n return false;\n }\n scopedGlobals.davProperties.push(prop);\n scopedGlobals.davNamespaces = namespaces;\n return true;\n}\nfunction getDavProperties() {\n scopedGlobals.davProperties ??= [...defaultDavProperties];\n return scopedGlobals.davProperties.map((prop) => `<${prop} />`).join(\" \");\n}\nfunction getDavNameSpaces() {\n scopedGlobals.davNamespaces ??= { ...defaultDavNamespaces };\n return Object.keys(scopedGlobals.davNamespaces).map((ns) => `xmlns:${ns}=\"${scopedGlobals.davNamespaces?.[ns]}\"`).join(\" \");\n}\nfunction getDefaultPropfind() {\n return `\n\t\t\n\t\t\t\n\t\t\t\t${getDavProperties()}\n\t\t\t\n\t\t`;\n}\nfunction getFavoritesReport() {\n return `\n\t\t\n\t\t\t\n\t\t\t\t${getDavProperties()}\n\t\t\t\n\t\t\t\n\t\t\t\t1\n\t\t\t\n\t\t`;\n}\nfunction getRecentSearch(lastModified) {\n return `\n\n\t\n\t\t\n\t\t\t\n\t\t\t\t${getDavProperties()}\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t/files/${getCurrentUser()?.uid}/\n\t\t\t\tinfinity\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\thttpd/unix-directory\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t0\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t${lastModified}\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t100\n\t\t\t0\n\t\t\n\t\n`;\n}\nfunction getRootPath() {\n if (isPublicShare()) {\n return `/files/${getSharingToken()}`;\n }\n return `/files/${getCurrentUser()?.uid}`;\n}\nconst defaultRootPath = getRootPath();\nfunction getRemoteURL() {\n const url = generateRemoteUrl(\"dav\");\n if (isPublicShare()) {\n return url.replace(\"remote.php\", \"public.php\");\n }\n return url;\n}\nconst defaultRemoteURL = getRemoteURL();\nfunction getClient(remoteURL = defaultRemoteURL, headers = {}) {\n const client = createClient(remoteURL, { headers });\n function setHeaders(token) {\n client.setHeaders({\n ...headers,\n // Add this so the server knows it is an request from the browser\n \"X-Requested-With\": \"XMLHttpRequest\",\n // Inject user auth\n requesttoken: token ?? \"\"\n });\n }\n onRequestTokenUpdate(setHeaders);\n setHeaders(getRequestToken());\n const patcher = getPatcher();\n patcher.patch(\"fetch\", (url, options) => {\n const headers2 = options.headers;\n if (headers2?.method) {\n options.method = headers2.method;\n delete headers2.method;\n }\n return fetch(url, options);\n });\n return client;\n}\nasync function getFavoriteNodes(options = {}) {\n const client = options.client ?? getClient();\n const path = options.path ?? \"/\";\n const davRoot = options.davRoot ?? defaultRootPath;\n const contentsResponse = await client.getDirectoryContents(`${davRoot}${path}`, {\n signal: options.signal,\n details: true,\n data: getFavoritesReport(),\n headers: {\n // see getClient for patched webdav client\n method: \"REPORT\"\n },\n includeSelf: true\n });\n return contentsResponse.data.filter((node) => node.filename !== path).map((result) => resultToNode(result, davRoot));\n}\nfunction resultToNode(node, filesRoot = defaultRootPath, remoteURL = defaultRemoteURL) {\n let userId = getCurrentUser()?.uid;\n if (isPublicShare()) {\n userId = userId ?? \"anonymous\";\n } else if (!userId) {\n throw new Error(\"No user id found\");\n }\n const props = node.props;\n const permissions = parsePermissions(props?.permissions);\n const owner = String(props?.[\"owner-id\"] || userId);\n const id = props.fileid || 0;\n const mtime = new Date(Date.parse(node.lastmod));\n const crtime = new Date(Date.parse(props.creationdate));\n const nodeData = {\n id,\n source: `${remoteURL}${node.filename}`,\n mtime: !isNaN(mtime.getTime()) && mtime.getTime() !== 0 ? mtime : void 0,\n crtime: !isNaN(crtime.getTime()) && crtime.getTime() !== 0 ? crtime : void 0,\n mime: node.mime || \"application/octet-stream\",\n // Manually cast to work around for https://github.com/perry-mitchell/webdav-client/pull/380\n displayname: props.displayname !== void 0 ? String(props.displayname) : void 0,\n size: props?.size || Number.parseInt(props.getcontentlength || \"0\"),\n // The fileid is set to -1 for failed requests\n status: id < 0 ? NodeStatus.FAILED : void 0,\n permissions,\n owner,\n root: filesRoot,\n attributes: {\n ...node,\n ...props,\n hasPreview: props?.[\"has-preview\"]\n }\n };\n delete nodeData.attributes?.props;\n return node.type === \"file\" ? new File(nodeData) : new Folder(nodeData);\n}\nexport {\n defaultDavNamespaces,\n defaultDavProperties,\n defaultRemoteURL,\n defaultRootPath,\n getClient,\n getDavNameSpaces,\n getDavProperties,\n getDefaultPropfind,\n getFavoriteNodes,\n getFavoritesReport,\n getRecentSearch,\n getRemoteURL,\n getRootPath,\n parsePermissions,\n registerDavProperty,\n resultToNode\n};\n//# sourceMappingURL=dav.mjs.map\n"],"names":["___CSS_LOADER_EXPORT___","_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default","_node_modules_css_loader_dist_runtime_sourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default","push","module","id","version","sources","names","mappings","sourcesContent","sourceRoot","__WEBPACK_DEFAULT_EXPORT__","vue_material_design_icons_ContentCopyvue_type_script_lang_js","name","emits","props","title","type","String","fillColor","default","size","Number","ContentCopy","componentNormalizer","A","_vm","this","_c","_self","_b","staticClass","attrs","role","on","click","$event","$emit","$attrs","fill","width","height","viewBox","d","_v","_s","_e","components_SharingEntrySimplevue_type_script_lang_js","components","NcActions","required","subtitle","isUnique","Boolean","ariaExpanded","computed","ariaExpandedValue","options","styleTagTransform","styleTagTransform_default","setAttributes","setAttributesWithoutAttributes_default","insert","insertBySelector_default","bind","domAPI","styleDomAPI_default","insertStyleElement","insertStyleElement_default","injectStylesIntoStyleTag_default","SharingEntrySimplevue_type_style_index_0_id_13d4a0bb_prod_lang_scss_scoped_true","locals","SharingEntrySimple","_t","$slots","ref","generateFileUrl","fileid","baseURL","getBaseUrl","globalscale","getCapabilities","token","generateUrl","components_SharingEntryInternalvue_type_script_lang_js","NcActionButton","CheckIcon","Check","ClipboardIcon","fileInfo","Object","data","copied","copySuccess","internalLink","copyLinkTooltip","t","internalLinkSubtitle","methods","copyLink","navigator","clipboard","writeText","showSuccess","$refs","shareEntrySimple","actionsComponent","$el","focus","error","logger","setTimeout","SharingEntryInternalvue_type_style_index_0_id_6c4cb23b_prod_lang_scss_scoped_true_options","SharingEntryInternalvue_type_style_index_0_id_6c4cb23b_prod_lang_scss_scoped_true","SharingEntryInternal","scopedSlots","_u","key","fn","proxy","ATOMIC_PERMISSIONS","BUNDLED_PERMISSIONS","READ_ONLY","UPLOAD_AND_UPDATE","FILE_DROP","ALL","ALL_FILE","getBundledPermissions","excludeShare","EDITOR_BUNDLES","Share","constructor","ocsData","_defineProperty","ocs","parseInt","hide_download","mail_send","attributes","JSON","parse","warn","newPassword","undefined","_share","state","share_type","permissions","owner","uid_owner","ownerDisplayName","displayname_owner","shareWith","share_with","shareWithDisplayName","share_with_displayname","shareWithDisplayNameUnique","share_with_displayname_unique","shareWithLink","share_with_link","shareWithAvatar","share_with_avatar","uidFileOwner","uid_file_owner","displaynameFileOwner","displayname_file_owner","createdTime","stime","expireDate","expiration","date","note","label","mailSend","hideDownload","find","scope","value","attribute","password","passwordExpirationTime","password_expiration_time","sendPasswordByTalk","send_password_by_talk","path","itemType","item_type","mimetype","fileSource","file_source","fileTarget","file_target","fileParent","file_parent","hasReadPermission","window","OC","PERMISSION_READ","hasCreatePermission","PERMISSION_CREATE","hasDeletePermission","PERMISSION_DELETE","hasUpdatePermission","PERMISSION_UPDATE","hasSharePermission","PERMISSION_SHARE","hasDownloadPermission","some","isFileRequest","stringify","enabled","setAttribute","attrUpdate","i","attr","splice","canEdit","can_edit","canDelete","can_delete","viaFileid","via_fileid","viaPath","via_path","parent","storageId","storage_id","storage","itemSource","item_source","status","isTrustedServer","is_trusted_server","Config","_capabilities","defaultPermissions","files_sharing","default_permissions","excludeReshareFromEdit","exclude_reshare_from_edit","isPublicUploadEnabled","public","upload","federatedShareDocLink","appConfig","core","federatedCloudShareDoc","defaultExpirationDate","isDefaultExpireDateEnabled","defaultExpireDate","Date","setDate","getDate","defaultInternalExpirationDate","isDefaultInternalExpireDateEnabled","defaultInternalExpireDate","defaultRemoteExpirationDateString","isDefaultRemoteExpireDateEnabled","defaultRemoteExpireDate","enforcePasswordForPublicLink","enableLinkPasswordByDefault","isDefaultExpireDateEnforced","defaultExpireDateEnforced","defaultExpireDateEnabled","isDefaultInternalExpireDateEnforced","defaultInternalExpireDateEnforced","defaultInternalExpireDateEnabled","isDefaultRemoteExpireDateEnforced","defaultRemoteExpireDateEnforced","defaultRemoteExpireDateEnabled","isRemoteShareAllowed","remoteShareAllowed","isFederationEnabled","federation","outgoing","isPublicShareAllowed","isMailShareAllowed","sharebymail","isResharingAllowed","resharingAllowed","isPasswordForMailSharesRequired","enforced","shouldAlwaysShowUnique","sharee","always_show_unique","allowGroupSharing","maxAutocompleteResults","config","minSearchStringLength","passwordPolicy","password_policy","allowCustomTokens","custom_tokens","showFederatedSharesAsInternal","loadState","showFederatedSharesToTrustedServersAsInternal","showExternalSharing","ShareDetails","openSharingDetails","shareRequestObject","share","handler","handlerInput","suggestions","query","externalShareRequestObject","mapShareRequestToShareObject","originalPermissions","strippedPermissions","debug","shareDetails","openShareDetailsForCustomSettings","setCustomPermissions","shareType","is_no_user","isNoUser","user","displayName","shareUrl","generateOcsUrl","ShareRequests","createShare","publicUpload","request","axios","post","emit","errorMessage","getErrorMessage","showError","Error","cause","deleteShare","delete","updateShare","properties","put","isAxiosError","response","meta","message","components_SharingInputvue_type_script_lang_js","NcSelect","mixins","shares","Array","linkShares","reshare","canReshare","isExternal","placeholder","setup","shareInputId","Math","random","toString","slice","loading","recommendations","ShareSearch","OCA","Sharing","externalResults","results","inputPlaceholder","allowRemoteSharing","isValidQuery","trim","length","noResultText","mounted","getRecommendations","onSelected","option","asyncFind","debounceGetSuggestions","getSuggestions","search","lookup","query_lookup_default","remoteTypes","ShareType","Remote","RemoteGroup","showFederatedAsInternal","shouldAddRemoteTypes","Email","User","Group","Team","Room","Guest","Deck","ScienceMesh","get","params","format","perPage","exact","rawExactSuggestions","values","flat","rawSuggestions","exactSuggestions","filterOutExistingShares","filter","result","filterByTrustedServer","map","formatForMultiselect","sort","a","b","lookupEntry","lookupEnabled","condition","allSuggestions","concat","nameCounts","reduce","item","desc","debounce","args","rawRecommendations","arr","elem","getCurrentUser","uid","indexOf","sharesObj","obj","shareTypeToIcon","icon","iconTitle","Sciencemesh","subname","extra","email","server","shareWithDescription","uuid","SharingInputvue_type_style_index_0_id_0b151499_prod_lang_scss_options","SharingInputvue_type_style_index_0_id_0b151499_prod_lang_scss","SharingInput","for","disabled","filterable","clear-search-on-blur","model","callback","$$v","expression","SidebarTabExternal_SidebarTabExternalSectionvue_type_script_lang_ts_setup_true","_defineComponent","__name","node","section","__props","sectionElement","watchEffect","__sfc","SidebarTabExternalSection","_setupProxy","element","tag","domProps","SidebarTabExternal_SidebarTabExternalSectionLegacyvue_type_script_lang_ts_setup_true","sectionCallback","Function","component","SidebarTabExternalSectionLegacyvue_type_style_index_0_id_3e4e67d2_prod_scoped_true_lang_css_options","SidebarTabExternalSectionLegacyvue_type_style_index_0_id_3e4e67d2_prod_scoped_true_lang_css","SidebarTabExternalSectionLegacy","vue_material_design_icons_AccountCircleOutlinevue_type_script_lang_js","AccountCircleOutline","vue_material_design_icons_AccountGroupvue_type_script_lang_js","AccountGroup","vue_material_design_icons_CircleOutlinevue_type_script_lang_js","CircleOutline","vue_material_design_icons_Emailvue_type_script_lang_js","vue_material_design_icons_Eyevue_type_script_lang_js","Eye","vue_material_design_icons_ShareCirclevue_type_script_lang_js","ShareCircle","vue_material_design_icons_TrayArrowUpvue_type_script_lang_js","TrayArrowUp","SidebarTabExternal_SidebarTabExternalActionvue_type_script_lang_ts_setup_true","action","expose","save","actionElement","savingCallback","async","onSave","toRaw","SidebarTabExternalAction","_setup","SidebarTabExternal_SidebarTabExternalActionLegacyvue_type_script_lang_js","SidebarTabExternalActionLegacy","is","_g","handlers","text","client","getClient","GeneratePassword","verbose","api","generate","context","info","array","Uint8Array","ratio","passwordSet","self","crypto","getRandomValues","len","floor","charAt","SharesMixin","SharesRequests","sharing_dist","I","errors","saving","open","passwordProtectedState","updateQueue","PQueue","concurrency","reactiveState","replace","hasNote","set","dateTomorrow","lang","weekdaysShort","dayNamesShort","monthsShort","monthNamesShort","formatLocale","firstDayOfWeek","firstDay","weekdaysMin","monthFormat","isNewShare","isFolder","isPublicShare","Link","includes","isRemoteShare","isShareOwner","isExpiryDateEnforced","permissionsBundle","bundles","comparablePermissions","bundle","matchBundledPermissions","hasCustomPermissions","maxExpirationDateEnforced","isPasswordProtected","generatedPassword","$set","getNode","propfindPayload","getDefaultPropfind","stat","getRootPath","details","resultToNode","fetchNode","checkShare","expirationDate","isValid","formatDateToString","UTC","getFullYear","getMonth","toISOString","split","onExpirationChange","parsedDate","onDelete","shareId","queueUpdate","propertyNames","add","updatedShare","property","$delete","updateSuccessMessage","onSyncError","propertyEl","focusable","querySelector","debounceQueueUpdate","views_SharingDetailsTabvue_type_script_lang_js","NcAvatar","NcButton","NcCheckboxRadioSwitch","NcDateTimePickerNative","NcInputField","NcLoadingIcon","NcPasswordField","NcTextArea","CloseIcon","Close","CircleIcon","EditIcon","PencilOutline","LinkIcon","GroupIcon","ShareIcon","UserIcon","UploadIcon","ViewIcon","MenuDownIcon","MenuDown","MenuUpIcon","MenuUp","DotsHorizontalIcon","DotsHorizontal","Refresh","shareRequestValue","writeNoteToRecipientIsChecked","sharingPermission","revertSharingPermission","passwordError","advancedSectionAccordionExpanded","isFirstComponentLoad","test","creating","initialToken","loadingToken","initialPermissions","initialExpireDate","initialNote","initialLabel","initialHideDownload","initialSendPasswordByTalk","initialHasDownloadPermission","externalShareActions","_nc_files_sharing_sidebar_actions","ExternalShareActions","bundledPermissions","allPermissions","checked","updateAtomicPermissions","isEditChecked","canCreate","isCreateChecked","isDeleteChecked","isReshareChecked","showInGridView","getShareAttribute","setShareAttribute","canDownload","hasRead","isReadChecked","hasExpirationDate","isValidShareAttribute","defaultExpiryDate","isSetDownloadButtonVisible","isPasswordEnforced","isGroupShare","isUserShare","allowsFileDrop","hasFileDropPermissions","shareButtonText","resharingIsPossible","canSetEdit","sharePermissions","canSetCreate","canSetDelete","canSetReshare","canSetDownload","canRemoveReadPermission","hasUnsavedPassword","expirationTime","moment","diff","fromNow","isTalkEnabled","appswebroots","spreed","isPasswordProtectedByTalkAvailable","isPasswordProtectedByTalk","isEmailShareType","canTogglePasswordProtectedByTalkAvailable","canChangeHideDownload","shareAttributes","shareAttribute","customPermissionsList","translatedPermissions","ATOMIC_PERMISSIONS_READ","ATOMIC_PERMISSIONS_CREATE","ATOMIC_PERMISSIONS_UPDATE","ATOMIC_PERMISSIONS_SHARE","ATOMIC_PERMISSIONS_DELETE","permission","hasPermissions","initialPermissionSet","permissionsToCheck","index","toLocaleLowerCase","getLanguage","join","advancedControlExpandedValue","errorPasswordLabel","passwordHint","sortedExternalShareActions","order","externalLegacyShareActions","actions","advanced","watch","isChecked","beforeMount","initializePermissions","initializeAttributes","quickPermissions","fallback","generateNewToken","generateToken","cancel","expandCustomPermissions","toggleCustomPermissions","selectedPermission","isCustomPermissions","toDateString","handleShareType","handleDefaultPermissions","basePermissions","handleCustomPermissions","saveShare","permissionsAndAttributes","publicShareAttributes","sharePermissionsSet","incomingShare","addShare","prop","Promise","allSettled","externalLinkActions","$children","at","resolve","removeShare","onPasswordChange","getShareTypeIcon","EmailIcon","SharingDetailsTabvue_type_style_index_0_id_1e0a769c_prod_lang_scss_scoped_true_options","SharingDetailsTabvue_type_style_index_0_id_1e0a769c_prod_lang_scss_scoped_true","SharingDetailsTab_component","url","variant","alignment","autocomplete","min","max","input","_l","refInFor","readonly","preventDefault","apply","arguments","SharingDetailsTab","components_SharingEntryInheritedvue_type_script_lang_js","NcActionLink","NcActionText","viaFileTargetUrl","viaFolderName","basename","SharingEntryInheritedvue_type_style_index_0_id_731a9650_prod_lang_scss_scoped_true_options","SharingEntryInheritedvue_type_style_index_0_id_731a9650_prod_lang_scss_scoped_true","SharingEntryInherited_component","initiator","href","folder","SharingEntryInherited","views_SharingInheritedvue_type_script_lang_js","loaded","showInheritedShares","showInheritedSharesIcon","mainTitle","subTitle","toggleTooltip","fullPath","resetState","toggleInheritedShares","fetchInheritedShares","Notification","showTemporary","findIndex","SharingInheritedvue_type_style_index_0_id_cedf3238_prod_lang_scss_scoped_true_options","SharingInheritedvue_type_style_index_0_id_cedf3238_prod_lang_scss_scoped_true","SharingInherited_component","stopPropagation","SharingInherited","vue_material_design_icons_CalendarBlankOutlinevue_type_script_lang_js","CalendarBlankOutline","vue_material_design_icons_CheckBoldvue_type_script_lang_js","CheckBold","vue_material_design_icons_Exclamationvue_type_script_lang_js","Exclamation","vue_material_design_icons_LockOutlinevue_type_script_lang_js","LockOutline","vue_material_design_icons_Plusvue_type_script_lang_js","Plus","vue_material_design_icons_Qrcodevue_type_script_lang_js","Qrcode","vue_material_design_icons_Tunevue_type_script_lang_js","Tune","vue_material_design_icons_ClockOutlinevue_type_script_lang_js","ClockOutline","components_ShareExpiryTimevue_type_script_lang_js","NcPopover","NcDateTime","ClockIcon","expiryTime","getTime","timeFormat","dateStyle","timeStyle","ShareExpiryTimevue_type_style_index_0_id_c9199db0_prod_scoped_true_lang_scss_options","ShareExpiryTimevue_type_style_index_0_id_c9199db0_prod_scoped_true_lang_scss","ShareExpiryTime","toLocaleString","timestamp","vue_material_design_icons_EyeOutlinevue_type_script_lang_js","EyeOutline","vue_material_design_icons_TriangleSmallDownvue_type_script_lang_js","SharingEntryQuickShareSelectvue_type_script_lang_js","DropdownIcon","selectedOption","ariaLabel","canViewText","canEditText","fileDropText","customPermissionsText","preSelectedOption","IconEyeOutline","IconPencil","supportsFileDrop","IconFileUpload","IconTune","dropDownPermissionValue","created","subscribe","unmounted","unsubscribe","selectOption","optionLabel","quickShareActions","menuButton","components_SharingEntryQuickShareSelectvue_type_script_lang_js","SharingEntryQuickShareSelectvue_type_style_index_0_id_839566a2_prod_lang_scss_scoped_true_options","SharingEntryQuickShareSelectvue_type_style_index_0_id_839566a2_prod_lang_scss_scoped_true","SharingEntryQuickShareSelect","SharingEntryLinkvue_type_script_lang_js","NcActionCheckbox","NcActionCheckbox_Cbg5yktN","N","NcActionInput","NcActionSeparator","NcDialog","NcIconSvgWrapper","VueQrcode","vue_qrcode_default","IconCalendarBlank","IconQr","ErrorIcon","LockIcon","PlusIcon","mdiCheck","mdi","Tfj","mdiContentCopy","shareCreationComplete","defaultExpirationDateEnabled","pending","_nc_files_sharing_sidebar_inline_actions","showQRCode","minPasswordLength","isPasswordPolicyEnabled","policies","sharing","minLength","l10nOptions","escape","pendingDataIsMissing","pendingPassword","pendingEnforcedPassword","pendingDefaultExpirationDate","pendingEnforcedExpirationDate","isPendingShare","isNaN","sharePolicyHasEnforcedProperties","enforcedPropertiesMissing","isPasswordMissing","isExpireDateMissing","shareLink","actionsTooltip","copyLinkLabel","shareRequiresReview","shareReviewComplete","onNewLinkShare","shareDefaults","pushNewLinkShare","e","update","newShare","match","copyButton","prompt","onPasswordDisable","onExpirationDateToggleUpdate","expirationDateChanged","event","target","onCancel","components_SharingEntryLinkvue_type_script_lang_js","SharingEntryLinkvue_type_style_index_0_id_7a5c0ee5_prod_lang_scss_scoped_true_options","SharingEntryLinkvue_type_style_index_0_id_7a5c0ee5_prod_lang_scss_scoped_true","SharingEntryLink_component","class","close","uncheck","minlength","submit","change","exec","svg","iconSvg","views_SharingLinkListvue_type_script_lang_js","SharingEntryLink","canLinkShare","hasLinkShares","hasShares","l10n_dist","awaitForShare","$nextTick","SharingLinkList_component","SharingLinkList","components_SharingEntryvue_type_script_lang_js","showAsInternal","tooltip","hasStatus","isArray","SharingEntryvue_type_style_index_0_id_fa3f3612_prod_lang_scss_scoped_true_options","SharingEntryvue_type_style_index_0_id_fa3f3612_prod_lang_scss_scoped_true","views_SharingListvue_type_script_lang_js","SharingEntry","SharingList","productName","theme","SharingTabvue_type_script_lang_js","InfoIcon","InformationOutline","NcCollectionList","NcCollectionList_q7zkDwqG","deleteEvent","expirationInterval","sharedWithMe","externalShares","legacySections","ShareTabSections","getSections","sections","_nc_files_sharing_sidebar_sections","projectsEnabled","showSharingDetailsView","shareDetailsData","returnFocusElement","internalSharesHelpText","externalSharesHelpText","additionalSharesHelpText","hasExternalSections","sortedExternalSections","isSharedWithMe","isLinkSharingAllowed","capabilities","internalShareInputPlaceholder","externalShareInputPlaceholder","immediate","newValue","oldValue","getShares","fetchShares","reshares","fetchSharedWithMe","shared_with_me","all","processSharedWithMe","processShares","clearInterval","updateExpirationSubtitle","unix","relativetime","orderBy","findShareListByShare","group","circle","conversation","shareWithTitle","setInterval","shareOwnerId","shareOwner","unshift","removeShareFromList","shareList","listComponent","linkShareList","toggleShareDetailsView","eventData","from","document","activeElement","classList","className","startsWith","menuId","closest","views_SharingTabvue_type_script_lang_js","SharingTabvue_type_style_index_0_id_cd6ad9ee_prod_scoped_true_lang_scss_options","SharingTabvue_type_style_index_0_id_cd6ad9ee_prod_scoped_true_lang_scss","SharingTab","emptyContentWithSections","directives","rawName","FileInfo","rawFileInfo","dirname","mtime","etag","hasPreview","isEncrypted","isFavourited","favorite","mime","mountType","Files","isDirectory","views_FilesSidebarTabvue_type_script_setup_true_lang_ts","active","view","FilesSidebarTab","defaultDavProperties","defaultDavNamespaces","nc","oc","getDavProperties","_chunks_folder_29HuacU_mjs__WEBPACK_IMPORTED_MODULE_4__","s","davProperties","getDavNameSpaces","davNamespaces","keys","ns","getRecentSearch","lastModified","_nextcloud_auth__WEBPACK_IMPORTED_MODULE_0__","HW","_nextcloud_sharing_public__WEBPACK_IMPORTED_MODULE_2__","f","G","defaultRootPath","defaultRemoteURL","_nextcloud_router__WEBPACK_IMPORTED_MODULE_1__","dC","getRemoteURL","remoteURL","headers","webdav__WEBPACK_IMPORTED_MODULE_3__","UU","setHeaders","requesttoken","zo","Gu","patch","headers2","method","fetch","getFavoriteNodes","davRoot","getDirectoryContents","signal","includeSelf","filename","filesRoot","userId","permString","P","NONE","READ","WRITE","CREATE","UPDATE","DELETE","SHARE","parsePermissions","lastmod","crtime","creationdate","nodeData","source","displayname","getcontentlength","c","FAILED","root"],"sourceRoot":""} \ No newline at end of file diff --git a/dist/6863-6863.js.map.license b/dist/6863-6863.js.map.license new file mode 120000 index 0000000000000..b26e3a94c4987 --- /dev/null +++ b/dist/6863-6863.js.map.license @@ -0,0 +1 @@ +6863-6863.js.license \ No newline at end of file diff --git a/dist/files_sharing-files_sharing_tab.js b/dist/files_sharing-files_sharing_tab.js index 33ad3ec8121cb..a80209f63ad0c 100644 --- a/dist/files_sharing-files_sharing_tab.js +++ b/dist/files_sharing-files_sharing_tab.js @@ -1,2 +1,2 @@ -(()=>{var e={28237(e,t,r){"use strict";var i=r(21777),n=r(35810),o=r(53334),a=r(26422),s=r(85471),c=r(48564);r.nc=(0,i.aV)(),window.OCA.Sharing??={},Object.assign(window.OCA.Sharing,{ShareSearch:new class{constructor(){var e,t,r;e=this,r=void 0,(t=function(e){var t=function(e){if("object"!=typeof e||!e)return e;var t=e[Symbol.toPrimitive];if(void 0!==t){var r=t.call(e,"string");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e);return"symbol"==typeof t?t:t+""}(t="_state"))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,this._state={},this._state.results=[],c.A.debug("OCA.Sharing.ShareSearch initialized")}get state(){return this._state}addNewResult(e){return""!==e.displayName.trim()&&"function"==typeof e.handler?(this._state.results.push(e),!0):(c.A.error("Invalid search result provided",{result:e}),!1)}}}),Object.assign(window.OCA.Sharing,{ExternalShareActions:new class{constructor(){var e,t,r;e=this,r=void 0,(t=function(e){var t=function(e){if("object"!=typeof e||!e)return e;var t=e[Symbol.toPrimitive];if(void 0!==t){var r=t.call(e,"string");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e);return"symbol"==typeof t?t:t+""}(t="_state"))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,this._state={},this._state.actions=[],c.A.debug("OCA.Sharing.ExternalShareActions initialized")}get state(){return this._state}registerAction(e){return c.A.warn("OCA.Sharing.ExternalShareActions is deprecated, use `registerSidebarAction` from `@nextcloud/sharing` instead"),"object"==typeof e&&"string"==typeof e.id&&"function"==typeof e.data&&Array.isArray(e.shareType)&&"object"==typeof e.handlers&&Object.values(e.handlers).every(e=>"function"==typeof e)?this._state.actions.findIndex(t=>t.id===e.id)>-1?(c.A.error(`An action with the same id ${e.id} already exists`,e),!1):(this._state.actions.push(e),!0):(c.A.error("Invalid action provided",e),!1)}}}),Object.assign(window.OCA.Sharing,{ShareTabSections:new class{constructor(){var e,t,r;e=this,r=void 0,(t=function(e){var t=function(e){if("object"!=typeof e||!e)return e;var t=e[Symbol.toPrimitive];if(void 0!==t){var r=t.call(e,"string");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e);return"symbol"==typeof t?t:t+""}(t="_sections"))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,this._sections=[]}registerSection(e){this._sections.push(e)}getSections(){return this._sections}}}),s.Ay.prototype.t=o.t,s.Ay.prototype.n=o.n;const l="files_sharing-sidebar-tab";(0,n.dC)().registerTab({id:"sharing",displayName:(0,o.t)("files_sharing","Sharing"),iconSvgInline:'',order:10,tagName:l,async onInit(){const{default:e}=await Promise.all([r.e(4208),r.e(499)]).then(()=>r(20499)),t=(0,a.A)(s.Ay,e);Object.defineProperty(t.prototype,"attachShadow",{value(){return this}}),Object.defineProperty(t.prototype,"shadowRoot",{get(){return this}}),window.customElements.define(l,t)}})},48564(e,t,r){"use strict";const i=(0,r(35947).YK)().setApp("files_sharing").detectUser().build();r.d(t,["A",0,i])},63779(){},77199(){}};const t={};function r(i){const n=t[i];if(void 0!==n)return n.exports;const o=t[i]={id:i,loaded:!1,exports:{}};return e[i].call(o.exports,o,o.exports,r),o.loaded=!0,o.exports}r.m=e,(()=>{const e=[];r.O=(t,i,n,o)=>{if(i){o||=0;for(var a=e.length;a>0&&e[a-1][2]>o;a--)e[a]=e[a-1];return void(e[a]=[i,n,o])}let s=1/0;for(a=0;a=o)||!Object.keys(r.O).every(e=>r.O[e](i[c]))?(l=!1,o{const t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.cw=e=>{var t;return()=>{if(e){var r=e;e=0,t={exports:{}},r.call(t.exports,t,t.exports)}return t.exports}},r.d=(e,t)=>{if(Array.isArray(t))for(var i=0;iPromise.all(Object.keys(r.f).reduce((t,i)=>(r.f[i](e,t),t),[])),r.u=e=>e+"-"+e+".js?v="+{499:"609046cea1f426b58907",857:"7df76d83ddc257b2632e",3252:"b635936cdb66279b7564",4227:"e39457fc20956e106b65",4941:"c7ec5d046d44a27a6e9c",6798:"93837c551f35879aa50f",7471:"e1dc641ea8726e1b57ce",7859:"bcc5897e2eeff615aca7",8374:"f99a263fa8080ba95736",8689:"e6e40b6e60af67b75832",8826:"4fc5b956849ac69d256b"}[e],r.o=(e,t)=>Object.hasOwn(e,t),(()=>{const e={},t="nextcloud-ui-legacy:";r.l=(i,n,o,a)=>{if(e[i])return void e[i].push(n);let s,c;if(void 0!==o){const e=document.getElementsByTagName("script");for(var l=0;l{s.onerror=s.onload=null,clearTimeout(u);const n=e[i];if(delete e[i],s.parentNode?.removeChild(s),n?.forEach(e=>e(r)),t)return t(r)},u=setTimeout(d.bind(null,void 0,{type:"timeout",target:s}),12e4);s.onerror=d.bind(null,s.onerror),s.onload=d.bind(null,s.onload),c&&document.head.appendChild(s)}})(),r.r=e=>{Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),r.j=4958,r.dn=e=>{var t=Object.getOwnPropertyDescriptor(e,"name");(!t||!t.writable&&t.configurable)&&Object.defineProperty(e,"name",{value:"default",configurable:!0})},(()=>{let e;globalThis.importScripts&&(e=globalThis.location+"");const t=globalThis.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^https?:/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:|[?#].*$/g,"").replace(/\/[^/]+$/,"/"),r.p=e})(),(()=>{r.b="undefined"!=typeof document&&document.baseURI||self.location.href;const e={4958:0};r.f.j=(t,i)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)i.push(n[2]);else{const o=new Promise((r,i)=>n=e[t]=[r,i]);i.push(n[2]=o);const a=new Error,s=i=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=i&&("load"===i.type?"missing":i.type),r=i&&i.target&&i.target.src;a.message="Loading chunk "+t+" failed.\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=i,n[1](a)}};r.l(r.p+r.u(t),s,"chunk-"+t,t)}},r.O.j=t=>0===e[t];const t=(t,i)=>{let[n,o,a]=i;var s,c,l=0;if(n.some(t=>0!==e[t])){for(s in o)r.o(o,s)&&(r.m[s]=o[s]);if(a)var d=a(r)}for(t&&t(i);lr(28237));i=r.O(i)})(); -//# sourceMappingURL=files_sharing-files_sharing_tab.js.map?v=d9bb86f7dc575ac9e848 \ No newline at end of file +(()=>{var e={28237(e,t,r){"use strict";var i=r(21777),n=r(35810),o=r(53334),a=r(26422),s=r(85471),c=r(48564);r.nc=(0,i.aV)(),window.OCA.Sharing??={},Object.assign(window.OCA.Sharing,{ShareSearch:new class{constructor(){var e,t,r;e=this,r=void 0,(t=function(e){var t=function(e){if("object"!=typeof e||!e)return e;var t=e[Symbol.toPrimitive];if(void 0!==t){var r=t.call(e,"string");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e);return"symbol"==typeof t?t:t+""}(t="_state"))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,this._state={},this._state.results=[],c.A.debug("OCA.Sharing.ShareSearch initialized")}get state(){return this._state}addNewResult(e){return""!==e.displayName.trim()&&"function"==typeof e.handler?(this._state.results.push(e),!0):(c.A.error("Invalid search result provided",{result:e}),!1)}}}),Object.assign(window.OCA.Sharing,{ExternalShareActions:new class{constructor(){var e,t,r;e=this,r=void 0,(t=function(e){var t=function(e){if("object"!=typeof e||!e)return e;var t=e[Symbol.toPrimitive];if(void 0!==t){var r=t.call(e,"string");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e);return"symbol"==typeof t?t:t+""}(t="_state"))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,this._state={},this._state.actions=[],c.A.debug("OCA.Sharing.ExternalShareActions initialized")}get state(){return this._state}registerAction(e){return c.A.warn("OCA.Sharing.ExternalShareActions is deprecated, use `registerSidebarAction` from `@nextcloud/sharing` instead"),"object"==typeof e&&"string"==typeof e.id&&"function"==typeof e.data&&Array.isArray(e.shareType)&&"object"==typeof e.handlers&&Object.values(e.handlers).every(e=>"function"==typeof e)?this._state.actions.findIndex(t=>t.id===e.id)>-1?(c.A.error(`An action with the same id ${e.id} already exists`,e),!1):(this._state.actions.push(e),!0):(c.A.error("Invalid action provided",e),!1)}}}),Object.assign(window.OCA.Sharing,{ShareTabSections:new class{constructor(){var e,t,r;e=this,r=void 0,(t=function(e){var t=function(e){if("object"!=typeof e||!e)return e;var t=e[Symbol.toPrimitive];if(void 0!==t){var r=t.call(e,"string");if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e);return"symbol"==typeof t?t:t+""}(t="_sections"))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,this._sections=[]}registerSection(e){this._sections.push(e)}getSections(){return this._sections}}}),s.Ay.prototype.t=o.t,s.Ay.prototype.n=o.n;const l="files_sharing-sidebar-tab";(0,n.dC)().registerTab({id:"sharing",displayName:(0,o.t)("files_sharing","Sharing"),iconSvgInline:'',order:10,tagName:l,async onInit(){const{default:e}=await Promise.all([r.e(4208),r.e(6863)]).then(()=>r(6863)),t=(0,a.A)(s.Ay,e);Object.defineProperty(t.prototype,"attachShadow",{value(){return this}}),Object.defineProperty(t.prototype,"shadowRoot",{get(){return this}}),window.customElements.define(l,t)}})},48564(e,t,r){"use strict";const i=(0,r(35947).YK)().setApp("files_sharing").detectUser().build();r.d(t,["A",0,i])},63779(){},77199(){}};const t={};function r(i){const n=t[i];if(void 0!==n)return n.exports;const o=t[i]={id:i,loaded:!1,exports:{}};return e[i].call(o.exports,o,o.exports,r),o.loaded=!0,o.exports}r.m=e,(()=>{const e=[];r.O=(t,i,n,o)=>{if(i){o||=0;for(var a=e.length;a>0&&e[a-1][2]>o;a--)e[a]=e[a-1];return void(e[a]=[i,n,o])}let s=1/0;for(a=0;a=o)||!Object.keys(r.O).every(e=>r.O[e](i[c]))?(l=!1,o{const t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.cw=e=>{var t;return()=>{if(e){var r=e;e=0,t={exports:{}},r.call(t.exports,t,t.exports)}return t.exports}},r.d=(e,t)=>{if(Array.isArray(t))for(var i=0;iPromise.all(Object.keys(r.f).reduce((t,i)=>(r.f[i](e,t),t),[])),r.u=e=>e+"-"+e+".js?v="+{857:"7df76d83ddc257b2632e",3252:"b635936cdb66279b7564",4227:"e39457fc20956e106b65",4941:"c7ec5d046d44a27a6e9c",6798:"93837c551f35879aa50f",6863:"7d96314e2c5c981d5c8d",7471:"e1dc641ea8726e1b57ce",7859:"bcc5897e2eeff615aca7",8374:"f99a263fa8080ba95736",8689:"e6e40b6e60af67b75832",8826:"4fc5b956849ac69d256b"}[e],r.o=(e,t)=>Object.hasOwn(e,t),(()=>{const e={},t="nextcloud-ui-legacy:";r.l=(i,n,o,a)=>{if(e[i])return void e[i].push(n);let s,c;if(void 0!==o){const e=document.getElementsByTagName("script");for(var l=0;l{s.onerror=s.onload=null,clearTimeout(u);const n=e[i];if(delete e[i],s.parentNode?.removeChild(s),n?.forEach(e=>e(r)),t)return t(r)},u=setTimeout(d.bind(null,void 0,{type:"timeout",target:s}),12e4);s.onerror=d.bind(null,s.onerror),s.onload=d.bind(null,s.onload),c&&document.head.appendChild(s)}})(),r.r=e=>{Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),r.j=4958,r.dn=e=>{var t=Object.getOwnPropertyDescriptor(e,"name");(!t||!t.writable&&t.configurable)&&Object.defineProperty(e,"name",{value:"default",configurable:!0})},(()=>{let e;globalThis.importScripts&&(e=globalThis.location+"");const t=globalThis.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^https?:/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:|[?#].*$/g,"").replace(/\/[^/]+$/,"/"),r.p=e})(),(()=>{r.b="undefined"!=typeof document&&document.baseURI||self.location.href;const e={4958:0};r.f.j=(t,i)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)i.push(n[2]);else{const o=new Promise((r,i)=>n=e[t]=[r,i]);i.push(n[2]=o);const a=new Error,s=i=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=i&&("load"===i.type?"missing":i.type),r=i&&i.target&&i.target.src;a.message="Loading chunk "+t+" failed.\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=i,n[1](a)}};r.l(r.p+r.u(t),s,"chunk-"+t,t)}},r.O.j=t=>0===e[t];const t=(t,i)=>{let[n,o,a]=i;var s,c,l=0;if(n.some(t=>0!==e[t])){for(s in o)r.o(o,s)&&(r.m[s]=o[s]);if(a)var d=a(r)}for(t&&t(i);lr(28237));i=r.O(i)})(); +//# sourceMappingURL=files_sharing-files_sharing_tab.js.map?v=b76b199eff517347cb93 \ No newline at end of file diff --git a/dist/files_sharing-files_sharing_tab.js.map b/dist/files_sharing-files_sharing_tab.js.map index ebb8273af59bf..7597e6ad0dfb2 100644 --- a/dist/files_sharing-files_sharing_tab.js.map +++ b/dist/files_sharing-files_sharing_tab.js.map @@ -1 +1 @@ -{"version":3,"file":"files_sharing-files_sharing_tab.js?v=428e9b9fe487f4f944ca","mappings":"6GAaAA,EAAAA,IAAoBC,EAAAA,EAAAA,MAEpBC,OAAOC,IAAIC,UAAY,CAAC,EACxBC,OAAOC,OAAOJ,OAAOC,IAAIC,QAAS,CAAEG,YAAa,ICTlC,MAGdC,WAAAA,eAAcC,YAAA,0YAEbA,KAAKC,OAAS,CAAC,EAGfD,KAAKC,OAAOC,QAAU,GACtBC,EAAAA,EAAOC,MAAM,sCACd,CASA,SAAIC,GACH,OAAOL,KAAKC,MACb,CAgBAK,YAAAA,CAAaC,GACZ,MAAkC,KAA9BA,EAAOC,YAAYC,QACO,mBAAnBF,EAAOG,SACjBV,KAAKC,OAAOC,QAAQS,KAAKJ,IAClB,IAERJ,EAAAA,EAAOS,MAAM,iCAAkC,CAAEL,YAC1C,EACR,KDnCDX,OAAOC,OAAOJ,OAAOC,IAAIC,QAAS,CAAEkB,qBAAsB,IEV3C,MAGdd,WAAAA,eAAcC,YAAA,0YAEbA,KAAKC,OAAS,CAAC,EAGfD,KAAKC,OAAOa,QAAU,GACtBX,EAAAA,EAAOC,MAAM,+CACd,CASA,SAAIC,GACH,OAAOL,KAAKC,MACb,CAkBAc,cAAAA,CAAeC,GAId,OAHAb,EAAAA,EAAOc,KAAK,iHAGU,iBAAXD,GACc,iBAAdA,EAAOE,IACS,mBAAhBF,EAAOG,MACbC,MAAMC,QAAQL,EAAOM,YACK,iBAApBN,EAAOO,UACb3B,OAAO4B,OAAOR,EAAOO,UAAUE,MAAOf,GAA+B,mBAAZA,GAMzCV,KAAKC,OAAOa,QAAQY,UAAWC,GAAUA,EAAMT,KAAOF,EAAOE,KAAO,GAExFf,EAAAA,EAAOS,MAAM,8BAA8BI,EAAOE,oBAAqBF,IAChE,IAGRhB,KAAKC,OAAOa,QAAQH,KAAKK,IAClB,IAZNb,EAAAA,EAAOS,MAAM,0BAA2BI,IACjC,EAYT,KFnDDpB,OAAOC,OAAOJ,OAAOC,IAAIC,QAAS,CAAEiC,iBAAkB,IGLvC,MAGd7B,WAAAA,eAAcC,YAAA,6YACbA,KAAK6B,UAAY,EAClB,CAKAC,eAAAA,CAAgBC,GACf/B,KAAK6B,UAAUlB,KAAKoB,EACrB,CAEAC,WAAAA,GACC,OAAOhC,KAAK6B,SACb,KHVDI,EAAAA,GAAIC,UAAUC,EAAIA,EAAAA,EAClBF,EAAAA,GAAIC,UAAUE,EAAIA,EAAAA,EAClB,MAAMC,EAAU,6BAChBC,EAAAA,EAAAA,MAAaC,YAAY,CACrBrB,GAAI,UACJV,aAAa2B,EAAAA,EAAAA,GAAE,gBAAiB,WAChCK,ijBACAC,MAAO,GACPJ,UACA,YAAMK,GACF,MAAQC,QAASC,SAA0BC,QAAAC,IAAA,CAAAC,EAAAC,EAAA,MAAAD,EAAAC,EAAA,OAAAC,KAAA,IAAAF,EAAA,QACrCG,GAAeC,EAAAA,EAAAA,GAAKlB,EAAAA,GAAKW,GAE/BhD,OAAOwD,eAAeF,EAAahB,UAAW,eAAgB,CAC1DmB,KAAAA,GAAU,OAAOrD,IAAM,IAE3BJ,OAAOwD,eAAeF,EAAahB,UAAW,aAAc,CACxDoB,GAAAA,GAAQ,OAAOtD,IAAM,IAEzBP,OAAO8D,eAAeC,OAAOnB,EAASa,EAC1C,+BIlCJ,MAAAO,GAAeC,WAAAA,MACVC,OAAO,iBACPC,aACAC,+CCPL,MAAAC,EAAA,GAGA,SAAAf,EAAAgB,GAEA,MAAAC,EAAAF,EAAAC,GACA,QAAAE,IAAAD,EACA,OAAAA,EAAAE,QAGA,MAAAC,EAAAL,EAAAC,GAAA,CACA7C,GAAA6C,EACAK,QAAA,EACAF,QAAA,IAUA,OANAG,EAAAN,GAAAO,KAAAH,EAAAD,QAAAC,EAAAA,EAAAD,QAAAnB,GAGAoB,EAAAC,QAAA,EAGAD,EAAAD,OACA,CAGAnB,EAAAwB,EAAAF,QC5BA,MAAAG,EAAA,GACAzB,EAAA0B,EAAA,CAAAlE,EAAAmE,EAAAC,EAAAC,KACA,GAAAF,EAAA,CACAE,IAAA,EACA,QAAAC,EAAAL,EAAAM,OAA+BD,EAAA,GAAAL,EAAAK,EAAA,MAAAD,EAAwCC,IAAAL,EAAAK,GAAAL,EAAAK,EAAA,GAEvE,YADAL,EAAAK,GAAA,CAAAH,EAAAC,EAAAC,GAEA,CACA,IAAAG,EAAAC,IACA,IAAAH,EAAA,EAAiBA,EAAAL,EAAAM,OAAqBD,IAAA,CACtC,IAAAH,EAAAC,EAAAC,GAAAJ,EAAAK,GACAI,GAAA,EACA,QAAAC,EAAA,EAAkBA,EAAAR,EAAAI,OAAqBI,IACvC,EAAAN,KAAAG,GAAAH,KAAAhF,OAAAuF,KAAApC,EAAA0B,GAAAhD,MAAA2D,GAAArC,EAAA0B,EAAAW,GAAAV,EAAAQ,MAGAD,GAAA,EACAL,EAAAG,IAAAA,EAAAH,IAHAF,EAAAW,OAAAH,IAAA,GAMA,GAAAD,EAAA,CACAT,EAAAa,OAAAR,IAAA,GACA,MAAAS,EAAAX,SACAV,IAAAqB,IAAA/E,EAAA+E,EACA,CACA,CACA,OAAA/E,OCzBAwC,EAAAX,EAAA+B,IACA,MAAAoB,EAAApB,GAAAA,EAAAqB,WACA,IAAArB,EAAA,QACA,MAEA,OADApB,EAAA0C,EAAAF,EAAA,CAAiCG,EAAAH,IACjCA,GCHAxC,EAAA4C,GAAAC,IACA,IAAAC,EACA,WACA,GAAAD,EAAA,CACA,IAAAjB,EAAAiB,EACAA,EAAA,EACAC,EAAA,CAAW3B,QAAA,IACXS,EAAAL,KAAAuB,EAAA3B,QAAA2B,EAAAA,EAAA3B,QACA,CACA,OAAA2B,EAAA3B,UCXAnB,EAAA0C,EAAA,CAAAvB,EAAA4B,KACA,GAAA1E,MAAAC,QAAAyE,GAEA,IADA,IAAAjB,EAAA,EACAA,EAAAiB,EAAAhB,QAAA,CACA,IAAAM,EAAAU,EAAAjB,KACAkB,EAAAD,EAAAjB,KACAmB,EAAA,IAAAD,EAAA,CAAsCE,YAAA,EAAA5C,MAAAyC,EAAAjB,MAA2C,CAAIoB,YAAA,EAAA3C,IAAAyC,GACrFhD,EAAAmD,EAAAhC,EAAAkB,IAAAxF,OAAAwD,eAAAc,EAAAkB,EAAAY,EACA,MAEA,QAAAZ,KAAAU,EACA/C,EAAAmD,EAAAJ,EAAAV,KAAArC,EAAAmD,EAAAhC,EAAAkB,IACAxF,OAAAwD,eAAAc,EAAAkB,EAAA,CAA0Ca,YAAA,EAAA3C,IAAAwC,EAAAV,MCb1CrC,EAAAoD,EAAA,GAGApD,EAAAC,EAAAoD,GACAvD,QAAAC,IAAAlD,OAAAuF,KAAApC,EAAAoD,GAAAE,OAAA,CAAAC,EAAAlB,KACArC,EAAAoD,EAAAf,GAAAgB,EAAAE,GACAA,GACE,KCNFvD,EAAAwD,EAAAH,GAAAA,EAAA,IAAAA,EAAA,UAA4E,mTAAwUA,GCDpZrD,EAAAmD,EAAA,CAAAM,EAAAC,IAAA7G,OAAA8G,OAAAF,EAAAC,SCAA,MAAAE,EAAA,GACAC,EAAA,uBAEA7D,EAAA8D,EAAA,CAAAC,EAAAC,EAAA3B,EAAAgB,KACA,GAAAO,EAAAG,GAAmD,YAA5BH,EAAAG,GAAAnG,KAAAoG,GACvB,IAAAC,EAAAC,EACA,QAAAhD,IAAAmB,EAAA,CACA,MAAA8B,EAAAC,SAAAC,qBAAA,UACA,QAAAvC,EAAA,EAAiBA,EAAAqC,EAAApC,OAAoBD,IAAA,CACrC,MAAAwC,EAAAH,EAAArC,GACA,GAAAwC,EAAAC,aAAA,QAAAR,GAAAO,EAAAC,aAAA,iBAAAV,EAAAxB,EAAA,CAAmG4B,EAAAK,EAAY,MAC/G,CACA,CACAL,IACAC,GAAA,EACAD,EAAAG,SAAAI,cAAA,UAEAP,EAAAQ,QAAA,QACAzE,EAAA0E,IACAT,EAAAU,aAAA,QAAA3E,EAAA0E,IAEAT,EAAAU,aAAA,eAAAd,EAAAxB,GAEA4B,EAAAW,IAAAb,GAEAH,EAAAG,GAAA,CAAAC,GACA,MAAAa,EAAA,CAAAC,EAAAC,KAEAd,EAAAe,QAAAf,EAAAgB,OAAA,KACAC,aAAAC,GACA,MAAAC,EAAAxB,EAAAG,GAIA,UAHAH,EAAAG,GACAE,EAAAoB,YAAAC,YAAArB,GACAmB,GAAAG,QAAA3D,GAAAA,EAAAmD,IACAD,EAAA,OAAAA,EAAAC,IAEAI,EAAAK,WAAAX,EAAAY,KAAA,UAAAvE,EAAA,CAAqEwE,KAAA,UAAAC,OAAA1B,IAAiC,MACtGA,EAAAe,QAAAH,EAAAY,KAAA,KAAAxB,EAAAe,SACAf,EAAAgB,OAAAJ,EAAAY,KAAA,KAAAxB,EAAAgB,QACAf,GAAAE,SAAAwB,KAAAC,YAAA5B,QCtCAjE,EAAAuC,EAAApB,IACAtE,OAAAwD,eAAAc,EAAA2E,OAAAC,YAAA,CAAsDzF,MAAA,WACtDzD,OAAAwD,eAAAc,EAAA,cAAgDb,OAAA,KCHhDN,EAAAgG,IAAA5E,IACAA,EAAA6E,MAAA,GACA7E,EAAA8E,WAAA9E,EAAA8E,SAAA,IACA9E,GCHApB,EAAAmC,EAAA,KCGAnC,EAAAmG,GAAAC,IACA,IAAAnD,EAAApG,OAAAwJ,yBAAAD,EAAA,UACAnD,IAAAA,EAAAqD,UAAArD,EAAAsD,eAAA1J,OAAAwD,eAAA+F,EAAA,QAA0G9F,MAAA,UAAAiG,cAAA,WCL1G,IAAAC,EACAC,WAAAC,gBAAAF,EAAAC,WAAAE,SAAA,IACA,MAAAvC,EAAAqC,WAAArC,SACA,IAAAoC,GAAApC,IACA,WAAAA,EAAAwC,eAAAtH,QAAAuH,gBACAL,EAAApC,EAAAwC,cAAAhC,MACA4B,GAAA,CACA,MAAArC,EAAAC,EAAAC,qBAAA,UACA,GAAAF,EAAApC,OAAA,CACA,IAAAD,EAAAqC,EAAApC,OAAA,EACA,KAAAD,GAAA,KAAA0E,IAAA,WAAAM,KAAAN,KAAAA,EAAArC,EAAArC,KAAA8C,GACA,CACA,CAIA,IAAA4B,EAAA,UAAAO,MAAA,yDACAP,EAAAA,EAAAQ,QAAA,sBAAAA,QAAA,gBACAhH,EAAAiH,EAAAT,YClBAxG,EAAAkH,EAAA,oBAAA9C,UAAAA,SAAA+C,SAAAC,KAAAT,SAAAU,KAKA,MAAAC,EAAA,CACA,QAGAtH,EAAAoD,EAAAjB,EAAA,CAAAkB,EAAAE,KAEA,IAAAgE,EAAAvH,EAAAmD,EAAAmE,EAAAjE,GAAAiE,EAAAjE,QAAAnC,EACA,OAAAqG,EAGA,GAAAA,EACAhE,EAAA3F,KAAA2J,EAAA,QAEA,CAEA,MAAAC,EAAA,IAAA1H,QAAA,CAAA2H,EAAAC,IAAAH,EAAAD,EAAAjE,GAAA,CAAAoE,EAAAC,IACAnE,EAAA3F,KAAA2J,EAAA,GAAAC,GAGA,MAAA3J,EAAA,IAAAkJ,MACAY,EAAA5C,IACA,GAAA/E,EAAAmD,EAAAmE,EAAAjE,KACAkE,EAAAD,EAAAjE,GACA,IAAAkE,IAAAD,EAAAjE,QAAAnC,GACAqG,GAAA,CACA,MAAAK,EAAA7C,IAAA,SAAAA,EAAAW,KAAA,UAAAX,EAAAW,MACAmC,EAAA9C,GAAAA,EAAAY,QAAAZ,EAAAY,OAAAf,IACA/G,EAAAiK,QAAA,iBAAAzE,EAAA,cAAAuE,EAAA,KAAAC,EAAA,IACAhK,EAAAkK,KAAA,iBACAlK,EAAA6H,KAAAkC,EACA/J,EAAAmK,QAAAH,EACAhK,EAAAkH,MAAAA,EACAwC,EAAA,GAAA1J,EACA,GAGAmC,EAAA8D,EAAA9D,EAAAiH,EAAAjH,EAAAwD,EAAAH,GAAAsE,EAAA,SAAAtE,EAAAA,EACA,GAaArD,EAAA0B,EAAAS,EAAAkB,GAAA,IAAAiE,EAAAjE,GAGA,MAAA4E,EAAA,CAAAC,EAAA9J,KACA,IAAAuD,EAAAwG,EAAAC,GAAAhK,EAGA,IAAA4C,EAAAqC,EAAAvB,EAAA,EACA,GAAAH,EAAA0G,KAAAlK,GAAA,IAAAmJ,EAAAnJ,IAAA,CACA,IAAA6C,KAAAmH,EACAnI,EAAAmD,EAAAgF,EAAAnH,KACAhB,EAAAwB,EAAAR,GAAAmH,EAAAnH,IAGA,GAAAoH,EAAA,IAAA5K,EAAA4K,EAAApI,EACA,CAEA,IADAkI,GAAAA,EAAA9J,GACM0D,EAAAH,EAAAI,OAAqBD,IAC3BuB,EAAA1B,EAAAG,GACA9B,EAAAmD,EAAAmE,EAAAjE,IAAAiE,EAAAjE,IACAiE,EAAAjE,GAAA,KAEAiE,EAAAjE,GAAA,EAEA,OAAArD,EAAA0B,EAAAlE,IAGA8K,EAAA7B,WAAA,qCACA6B,EAAA/C,QAAA0C,EAAAxC,KAAA,SACA6C,EAAA1K,KAAAqK,EAAAxC,KAAA,KAAA6C,EAAA1K,KAAA6H,KAAA6C,QCpFAtI,EAAA0E,QAAAxD,ECGA,IAAAqH,EAAAvI,EAAA0B,OAAAR,EAAA,WAAAlB,EAAA,QACAuI,EAAAvI,EAAA0B,EAAA6G","sources":["webpack:///nextcloud/apps/files_sharing/src/files-sidebar.ts","webpack:///nextcloud/apps/files_sharing/src/services/ShareSearch.js","webpack:///nextcloud/apps/files_sharing/src/services/ExternalShareActions.js","webpack:///nextcloud/apps/files_sharing/src/services/TabSections.js","webpack:///nextcloud/apps/files_sharing/src/services/logger.ts","webpack:///nextcloud/webpack/bootstrap","webpack:///nextcloud/webpack/runtime/chunk loaded","webpack:///nextcloud/webpack/runtime/compat get default export","webpack:///nextcloud/webpack/runtime/concatenation wrap","webpack:///nextcloud/webpack/runtime/define property getters","webpack:///nextcloud/webpack/runtime/ensure chunk","webpack:///nextcloud/webpack/runtime/get javascript chunk filename","webpack:///nextcloud/webpack/runtime/hasOwnProperty shorthand","webpack:///nextcloud/webpack/runtime/load script","webpack:///nextcloud/webpack/runtime/make namespace object","webpack:///nextcloud/webpack/runtime/node module decorator","webpack:///nextcloud/webpack/runtime/runtimeId","webpack:///nextcloud/webpack/runtime/set anonymous default export name","webpack:///nextcloud/webpack/runtime/publicPath","webpack:///nextcloud/webpack/runtime/jsonp chunk loading","webpack:///nextcloud/webpack/runtime/nonce","webpack:///nextcloud/webpack/startup"],"sourcesContent":["/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport ShareVariant from '@mdi/svg/svg/share-variant.svg?raw';\nimport { getCSPNonce } from '@nextcloud/auth';\nimport { getSidebar } from '@nextcloud/files';\nimport { n, t } from '@nextcloud/l10n';\nimport wrap from '@vue/web-component-wrapper';\nimport Vue from 'vue';\nimport ExternalShareActions from './services/ExternalShareActions.js';\nimport ShareSearch from './services/ShareSearch.js';\nimport TabSections from './services/TabSections.js';\n__webpack_nonce__ = getCSPNonce();\n// Init Sharing Tab Service\nwindow.OCA.Sharing ??= {};\nObject.assign(window.OCA.Sharing, { ShareSearch: new ShareSearch() });\nObject.assign(window.OCA.Sharing, { ExternalShareActions: new ExternalShareActions() });\nObject.assign(window.OCA.Sharing, { ShareTabSections: new TabSections() });\nVue.prototype.t = t;\nVue.prototype.n = n;\nconst tagName = 'files_sharing-sidebar-tab';\ngetSidebar().registerTab({\n id: 'sharing',\n displayName: t('files_sharing', 'Sharing'),\n iconSvgInline: ShareVariant,\n order: 10,\n tagName,\n async onInit() {\n const { default: FilesSidebarTab } = await import('./views/FilesSidebarTab.vue');\n const webComponent = wrap(Vue, FilesSidebarTab);\n // In Vue 2, wrap doesn't support diseabling shadow. Disable with a hack\n Object.defineProperty(webComponent.prototype, 'attachShadow', {\n value() { return this; },\n });\n Object.defineProperty(webComponent.prototype, 'shadowRoot', {\n get() { return this; },\n });\n window.customElements.define(tagName, webComponent);\n },\n});\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport logger from './logger.ts'\n\nexport default class ShareSearch {\n\t_state\n\n\tconstructor() {\n\t\t// init empty state\n\t\tthis._state = {}\n\n\t\t// init default values\n\t\tthis._state.results = []\n\t\tlogger.debug('OCA.Sharing.ShareSearch initialized')\n\t}\n\n\t/**\n\t * Get the state\n\t *\n\t * @readonly\n\t * @memberof ShareSearch\n\t * @return {object} the data state\n\t */\n\tget state() {\n\t\treturn this._state\n\t}\n\n\t/**\n\t * Register a new result\n\t * Mostly used by the guests app.\n\t * We should consider deprecation and add results via php ?\n\t *\n\t * @param {object} result entry to append\n\t * @param {string} [result.user] entry user\n\t * @param {string} result.displayName entry first line\n\t * @param {string} [result.desc] entry second line\n\t * @param {string} [result.icon] entry icon\n\t * @param {Function} result.handler function to run on entry selection\n\t * @param {Function} [result.condition] condition to add entry or not\n\t * @return {boolean}\n\t */\n\taddNewResult(result) {\n\t\tif (result.displayName.trim() !== ''\n\t\t\t&& typeof result.handler === 'function') {\n\t\t\tthis._state.results.push(result)\n\t\t\treturn true\n\t\t}\n\t\tlogger.error('Invalid search result provided', { result })\n\t\treturn false\n\t}\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport logger from './logger.ts'\n\nexport default class ExternalShareActions {\n\t_state\n\n\tconstructor() {\n\t\t// init empty state\n\t\tthis._state = {}\n\n\t\t// init default values\n\t\tthis._state.actions = []\n\t\tlogger.debug('OCA.Sharing.ExternalShareActions initialized')\n\t}\n\n\t/**\n\t * Get the state\n\t *\n\t * @readonly\n\t * @memberof ExternalLinkActions\n\t * @return {object} the data state\n\t */\n\tget state() {\n\t\treturn this._state\n\t}\n\n\t/**\n\t * @typedef ExternalShareActionData\n\t * @property {import('vue').Component} is Vue component to render, for advanced actions the `async onSave` method of the component will be called when saved\n\t */\n\n\t/**\n\t * Register a new option/entry for the a given share type\n\t *\n\t * @param {object} action new action component to register\n\t * @param {string} action.id unique action id\n\t * @param {(data: any) => ExternalShareActionData & Record} action.data data to bind the component to\n\t * @param {Array} action.shareType list of \\@nextcloud/sharing.Types.SHARE_XXX to be mounted on\n\t * @param {boolean} action.advanced `true` if the action entry should be rendered within advanced settings\n\t * @param {object} action.handlers list of listeners\n\t * @return {boolean}\n\t */\n\tregisterAction(action) {\n\t\tlogger.warn('OCA.Sharing.ExternalShareActions is deprecated, use `registerSidebarAction` from `@nextcloud/sharing` instead')\n\n\t\t// Validate action\n\t\tif (typeof action !== 'object'\n\t\t\t|| typeof action.id !== 'string'\n\t\t\t|| typeof action.data !== 'function' // () => {disabled: true}\n\t\t\t|| !Array.isArray(action.shareType) // [\\@nextcloud/sharing.Types.Link, ...]\n\t\t\t|| typeof action.handlers !== 'object' // {click: () => {}, ...}\n\t\t\t|| !Object.values(action.handlers).every((handler) => typeof handler === 'function')) {\n\t\t\tlogger.error('Invalid action provided', action)\n\t\t\treturn false\n\t\t}\n\n\t\t// Check duplicates\n\t\tconst hasDuplicate = this._state.actions.findIndex((check) => check.id === action.id) > -1\n\t\tif (hasDuplicate) {\n\t\t\tlogger.error(`An action with the same id ${action.id} already exists`, action)\n\t\t\treturn false\n\t\t}\n\n\t\tthis._state.actions.push(action)\n\t\treturn true\n\t}\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\n/**\n * Callback to render a section in the sharing tab.\n *\n * @callback registerSectionCallback\n * @param {undefined} el - Deprecated and will always be undefined (formerly the root element)\n * @param {object} fileInfo - File info object\n */\n\nexport default class TabSections {\n\t_sections\n\n\tconstructor() {\n\t\tthis._sections = []\n\t}\n\n\t/**\n\t * @param {registerSectionCallback} section To be called to mount the section to the sharing sidebar\n\t */\n\tregisterSection(section) {\n\t\tthis._sections.push(section)\n\t}\n\n\tgetSections() {\n\t\treturn this._sections\n\t}\n}\n","/**\n * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getLoggerBuilder } from '@nextcloud/logger';\nexport default getLoggerBuilder()\n .setApp('files_sharing')\n .detectUser()\n .build();\n","// The module cache\nconst __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tconst cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tconst module = __webpack_module_cache__[moduleId] = {\n\t\tid: moduleId,\n\t\tloaded: false,\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Flag the module as loaded\n\tmodule.loaded = true;\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n// expose the modules object (__webpack_modules__)\n__webpack_require__.m = __webpack_modules__;\n\n","const deferred = [];\n__webpack_require__.O = (result, chunkIds, fn, priority) => {\n\tif(chunkIds) {\n\t\tpriority ||= 0;\n\t\tfor(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];\n\t\tdeferred[i] = [chunkIds, fn, priority];\n\t\treturn;\n\t}\n\tlet notFulfilled = Infinity;\n\tfor (var i = 0; i < deferred.length; i++) {\n\t\tlet [chunkIds, fn, priority] = deferred[i];\n\t\tlet fulfilled = true;\n\t\tfor (var j = 0; j < chunkIds.length; j++) {\n\t\t\tif (((priority & 1) === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every((key) => (__webpack_require__.O[key](chunkIds[j])))) {\n\t\t\t\tchunkIds.splice(j--, 1);\n\t\t\t} else {\n\t\t\t\tfulfilled = false;\n\t\t\t\tif(priority < notFulfilled) notFulfilled = priority;\n\t\t\t}\n\t\t}\n\t\tif(fulfilled) {\n\t\t\tdeferred.splice(i--, 1)\n\t\t\tconst r = fn();\n\t\t\tif (r !== undefined) result = r;\n\t\t}\n\t}\n\treturn result;\n};","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// wrap a concatenated module body as a lazy, memoized accessor; mod is\n// set before the body runs so re-entrant calls (require cycles) observe\n// the partial exports like Node.js\n__webpack_require__.cw = (body) => {\n\tvar mod;\n\treturn () => {\n\t\tif (body) {\n\t\t\tvar fn = body;\n\t\t\tbody = 0;\n\t\t\tmod = { exports: {} };\n\t\t\tfn.call(mod.exports, mod, mod.exports);\n\t\t}\n\t\treturn mod.exports;\n\t};\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tvar descriptor = binding === 0 ? { enumerable: true, value: definition[i++] } : { enumerable: true, get: binding };\n\t\t\tif(!__webpack_require__.o(exports, key)) Object.defineProperty(exports, key, descriptor);\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.f = {};\n// This file contains only the entry chunk.\n// The chunk loading function for additional chunks\n__webpack_require__.e = (chunkId) => {\n\treturn Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {\n\t\t__webpack_require__.f[key](chunkId, promises);\n\t\treturn promises;\n\t}, []));\n};","// This function allow to reference async chunks\n__webpack_require__.u = (chunkId) => (chunkId + \"-\" + chunkId + \".js?v=\" + {\"499\":\"1c3b1c62dc693a4555dc\",\"857\":\"c78894d5df34d854f7aa\",\"3252\":\"5cdefe70d09ea015d504\",\"4227\":\"44c552cb6722d4c29085\",\"4941\":\"cbb590bc81c3552fe3fc\",\"6798\":\"38b417c535f358052c1a\",\"7471\":\"34493087eb2469ae6e25\",\"7859\":\"8b3b7c211b6d4a439761\",\"8374\":\"4f24f83d985c39aa0044\",\"8689\":\"5bbad32eaeecdbe5bbc4\",\"8826\":\"992dcbc4edbba49089e8\"}[chunkId] + \"\");","__webpack_require__.o = (obj, prop) => (Object.hasOwn(obj, prop));","const inProgress = {};\nconst dataWebpackPrefix = \"nextcloud-ui-legacy:\";\n// loadScript function to load a script via script tag\n__webpack_require__.l = (url, done, key, chunkId) => {\n\tif(inProgress[url]) { inProgress[url].push(done); return; }\n\tlet script, needAttach;\n\tif(key !== undefined) {\n\t\tconst scripts = document.getElementsByTagName(\"script\");\n\t\tfor(var i = 0; i < scripts.length; i++) {\n\t\t\tconst s = scripts[i];\n\t\t\tif(s.getAttribute(\"src\") == url || s.getAttribute(\"data-webpack\") == dataWebpackPrefix + key) { script = s; break; }\n\t\t}\n\t}\n\tif(!script) {\n\t\tneedAttach = true;\n\t\tscript = document.createElement('script');\n\n\t\tscript.charset = 'utf-8';\n\t\tif (__webpack_require__.nc) {\n\t\t\tscript.setAttribute(\"nonce\", __webpack_require__.nc);\n\t\t}\n\t\tscript.setAttribute(\"data-webpack\", dataWebpackPrefix + key);\n\n\t\tscript.src = url;\n\t}\n\tinProgress[url] = [done];\n\tconst onScriptComplete = (prev, event) => {\n\t\t// avoid mem leaks in IE.\n\t\tscript.onerror = script.onload = null;\n\t\tclearTimeout(timeout);\n\t\tconst doneFns = inProgress[url];\n\t\tdelete inProgress[url];\n\t\tscript.parentNode?.removeChild(script);\n\t\tdoneFns?.forEach((fn) => (fn(event)));\n\t\tif(prev) return prev(event);\n\t}\n\tconst timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);\n\tscript.onerror = onScriptComplete.bind(null, script.onerror);\n\tscript.onload = onScriptComplete.bind(null, script.onload);\n\tneedAttach && document.head.appendChild(script);\n};","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","__webpack_require__.nmd = (module) => {\n\tmodule.paths = [];\n\tif (!module.children) module.children = [];\n\treturn module;\n};","__webpack_require__.j = 4958;","// set .name for anonymous default exports per ES spec\n// skipped when the property is non-configurable (pre-ES2015 engines),\n// where Object.defineProperty would throw\n__webpack_require__.dn = (x) => {\n\tvar descriptor = Object.getOwnPropertyDescriptor(x, \"name\");\n\tif (!descriptor || (!descriptor.writable && descriptor.configurable)) Object.defineProperty(x, \"name\", { value: \"default\", configurable: true });\n};","let scriptUrl;\nif (globalThis.importScripts) scriptUrl = globalThis.location + \"\";\nconst document = globalThis.document;\nif (!scriptUrl && document) {\n\tif (document.currentScript?.tagName.toUpperCase() === 'SCRIPT')\n\t\tscriptUrl = document.currentScript.src;\n\tif (!scriptUrl) {\n\t\tconst scripts = document.getElementsByTagName(\"script\");\n\t\tif(scripts.length) {\n\t\t\tlet i = scripts.length - 1;\n\t\t\twhile (i > -1 && (!scriptUrl || !/^https?:/.test(scriptUrl))) scriptUrl = scripts[i--].src;\n\t\t}\n\t}\n}\n// When supporting browsers where an automatic publicPath is not supported you must specify an output.publicPath manually via configuration\n// or pass an empty string (\"\") and set the __webpack_public_path__ variable from your code to use your own logic.\nif (!scriptUrl) throw new Error(\"Automatic publicPath is not supported in this browser\");\nscriptUrl = scriptUrl.replace(/^blob:|[?#].*$/g, \"\").replace(/\\/[^/]+$/, \"/\");\n__webpack_require__.p = scriptUrl;","__webpack_require__.b = (typeof document !== 'undefined' && document.baseURI) || self.location.href;\n\n// object to store loaded and loading chunks\n// undefined = chunk not loaded, null = chunk preloaded/prefetched\n// [resolve, reject, Promise] = chunk loading, 0 = chunk loaded\nconst installedChunks = {\n\t4958: 0\n};\n\n__webpack_require__.f.j = (chunkId, promises) => {\n\t\t// JSONP chunk loading for javascript\n\t\tlet installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;\n\t\tif(installedChunkData !== 0) { // 0 means \"already installed\".\n\n\t\t\t// a Promise means \"currently loading\".\n\t\t\tif(installedChunkData) {\n\t\t\t\tpromises.push(installedChunkData[2]);\n\t\t\t} else {\n\t\t\t\tif(true) { // all chunks have JS\n\t\t\t\t\t// setup Promise in chunk cache\n\t\t\t\t\tconst promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));\n\t\t\t\t\tpromises.push(installedChunkData[2] = promise);\n\n\t\t\t\t\t// create error before stack unwound to get useful stacktrace later\n\t\t\t\t\tconst error = new Error();\n\t\t\t\t\tconst loadingEnded = (event) => {\n\t\t\t\t\t\tif(__webpack_require__.o(installedChunks, chunkId)) {\n\t\t\t\t\t\t\tinstalledChunkData = installedChunks[chunkId];\n\t\t\t\t\t\t\tif(installedChunkData !== 0) installedChunks[chunkId] = undefined;\n\t\t\t\t\t\t\tif(installedChunkData) {\n\t\t\t\t\t\t\t\tconst errorType = event && (event.type === 'load' ? 'missing' : event.type);\n\t\t\t\t\t\t\t\tconst realSrc = event && event.target && event.target.src;\n\t\t\t\t\t\t\t\terror.message = 'Loading chunk ' + chunkId + ' failed.\\n(' + errorType + ': ' + realSrc + ')';\n\t\t\t\t\t\t\t\terror.name = 'ChunkLoadError';\n\t\t\t\t\t\t\t\terror.type = errorType;\n\t\t\t\t\t\t\t\terror.request = realSrc;\n\t\t\t\t\t\t\t\terror.event = event;\n\t\t\t\t\t\t\t\tinstalledChunkData[1](error);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\t\t\t\t\t__webpack_require__.l(__webpack_require__.p + __webpack_require__.u(chunkId), loadingEnded, \"chunk-\" + chunkId, chunkId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n};\n\n// no prefetching\n\n// no preloaded\n\n// no HMR\n\n// no HMR manifest\n\n__webpack_require__.O.j = (chunkId) => (installedChunks[chunkId] === 0);\n\n// install a JSONP callback for chunk loading\nconst webpackJsonpCallback = (parentChunkLoadingFunction, data) => {\n\tlet [chunkIds, moreModules, runtime] = data;\n\t// add \"moreModules\" to the modules object,\n\t// then flag all \"chunkIds\" as loaded and fire callback\n\tvar moduleId, chunkId, i = 0;\n\tif(chunkIds.some((id) => (installedChunks[id] !== 0))) {\n\t\tfor(moduleId in moreModules) {\n\t\t\tif(__webpack_require__.o(moreModules, moduleId)) {\n\t\t\t\t__webpack_require__.m[moduleId] = moreModules[moduleId];\n\t\t\t}\n\t\t}\n\t\tif(runtime) var result = runtime(__webpack_require__);\n\t}\n\tif(parentChunkLoadingFunction) parentChunkLoadingFunction(data);\n\tfor(;i < chunkIds.length; i++) {\n\t\tchunkId = chunkIds[i];\n\t\tif(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {\n\t\t\tinstalledChunks[chunkId][0]();\n\t\t}\n\t\tinstalledChunks[chunkId] = 0;\n\t}\n\treturn __webpack_require__.O(result);\n}\n\nconst chunkLoadingGlobal = globalThis[\"webpackChunknextcloud_ui_legacy\"] ||= [];\nchunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));\nchunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));","__webpack_require__.nc = undefined;","// startup\n// Load entry module and return exports\n// This entry module depends on other loaded chunks and execution need to be delayed\nlet __webpack_exports__ = __webpack_require__.O(undefined, [4208], () => (__webpack_require__(28237)))\n__webpack_exports__ = __webpack_require__.O(__webpack_exports__);\n"],"names":["__webpack_nonce__","getCSPNonce","window","OCA","Sharing","Object","assign","ShareSearch","constructor","this","_state","results","logger","debug","state","addNewResult","result","displayName","trim","handler","push","error","ExternalShareActions","actions","registerAction","action","warn","id","data","Array","isArray","shareType","handlers","values","every","findIndex","check","ShareTabSections","_sections","registerSection","section","getSections","Vue","prototype","t","n","tagName","getSidebar","registerTab","iconSvgInline","order","onInit","default","FilesSidebarTab","Promise","all","__webpack_require__","e","then","webComponent","wrap","defineProperty","value","get","customElements","define","__WEBPACK_DEFAULT_EXPORT__","getLoggerBuilder","setApp","detectUser","build","__webpack_module_cache__","moduleId","cachedModule","undefined","exports","module","loaded","__webpack_modules__","call","m","deferred","O","chunkIds","fn","priority","i","length","notFulfilled","Infinity","fulfilled","j","keys","key","splice","r","getter","__esModule","d","a","cw","body","mod","definition","binding","descriptor","enumerable","o","f","chunkId","reduce","promises","u","obj","prop","hasOwn","inProgress","dataWebpackPrefix","l","url","done","script","needAttach","scripts","document","getElementsByTagName","s","getAttribute","createElement","charset","nc","setAttribute","src","onScriptComplete","prev","event","onerror","onload","clearTimeout","timeout","doneFns","parentNode","removeChild","forEach","setTimeout","bind","type","target","head","appendChild","Symbol","toStringTag","nmd","paths","children","dn","x","getOwnPropertyDescriptor","writable","configurable","scriptUrl","globalThis","importScripts","location","currentScript","toUpperCase","test","Error","replace","p","b","baseURI","self","href","installedChunks","installedChunkData","promise","resolve","reject","loadingEnded","errorType","realSrc","message","name","request","webpackJsonpCallback","parentChunkLoadingFunction","moreModules","runtime","some","chunkLoadingGlobal","__webpack_exports__"],"sourceRoot":""} \ No newline at end of file +{"version":3,"file":"files_sharing-files_sharing_tab.js?v=46bc2a73510a3e1cde4b","mappings":"6GAaAA,EAAAA,IAAoBC,EAAAA,EAAAA,MAEpBC,OAAOC,IAAIC,UAAY,CAAC,EACxBC,OAAOC,OAAOJ,OAAOC,IAAIC,QAAS,CAAEG,YAAa,ICTlC,MAGdC,WAAAA,eAAcC,YAAA,0YAEbA,KAAKC,OAAS,CAAC,EAGfD,KAAKC,OAAOC,QAAU,GACtBC,EAAAA,EAAOC,MAAM,sCACd,CASA,SAAIC,GACH,OAAOL,KAAKC,MACb,CAgBAK,YAAAA,CAAaC,GACZ,MAAkC,KAA9BA,EAAOC,YAAYC,QACO,mBAAnBF,EAAOG,SACjBV,KAAKC,OAAOC,QAAQS,KAAKJ,IAClB,IAERJ,EAAAA,EAAOS,MAAM,iCAAkC,CAAEL,YAC1C,EACR,KDnCDX,OAAOC,OAAOJ,OAAOC,IAAIC,QAAS,CAAEkB,qBAAsB,IEV3C,MAGdd,WAAAA,eAAcC,YAAA,0YAEbA,KAAKC,OAAS,CAAC,EAGfD,KAAKC,OAAOa,QAAU,GACtBX,EAAAA,EAAOC,MAAM,+CACd,CASA,SAAIC,GACH,OAAOL,KAAKC,MACb,CAkBAc,cAAAA,CAAeC,GAId,OAHAb,EAAAA,EAAOc,KAAK,iHAGU,iBAAXD,GACc,iBAAdA,EAAOE,IACS,mBAAhBF,EAAOG,MACbC,MAAMC,QAAQL,EAAOM,YACK,iBAApBN,EAAOO,UACb3B,OAAO4B,OAAOR,EAAOO,UAAUE,MAAOf,GAA+B,mBAAZA,GAMzCV,KAAKC,OAAOa,QAAQY,UAAWC,GAAUA,EAAMT,KAAOF,EAAOE,KAAO,GAExFf,EAAAA,EAAOS,MAAM,8BAA8BI,EAAOE,oBAAqBF,IAChE,IAGRhB,KAAKC,OAAOa,QAAQH,KAAKK,IAClB,IAZNb,EAAAA,EAAOS,MAAM,0BAA2BI,IACjC,EAYT,KFnDDpB,OAAOC,OAAOJ,OAAOC,IAAIC,QAAS,CAAEiC,iBAAkB,IGLvC,MAGd7B,WAAAA,eAAcC,YAAA,6YACbA,KAAK6B,UAAY,EAClB,CAKAC,eAAAA,CAAgBC,GACf/B,KAAK6B,UAAUlB,KAAKoB,EACrB,CAEAC,WAAAA,GACC,OAAOhC,KAAK6B,SACb,KHVDI,EAAAA,GAAIC,UAAUC,EAAIA,EAAAA,EAClBF,EAAAA,GAAIC,UAAUE,EAAIA,EAAAA,EAClB,MAAMC,EAAU,6BAChBC,EAAAA,EAAAA,MAAaC,YAAY,CACrBrB,GAAI,UACJV,aAAa2B,EAAAA,EAAAA,GAAE,gBAAiB,WAChCK,ijBACAC,MAAO,GACPJ,UACA,YAAMK,GACF,MAAQC,QAASC,SAA0BC,QAAAC,IAAA,CAAAC,EAAAC,EAAA,MAAAD,EAAAC,EAAA,QAAAC,KAAA,IAAAF,EAAA,OACrCG,GAAeC,EAAAA,EAAAA,GAAKlB,EAAAA,GAAKW,GAE/BhD,OAAOwD,eAAeF,EAAahB,UAAW,eAAgB,CAC1DmB,KAAAA,GAAU,OAAOrD,IAAM,IAE3BJ,OAAOwD,eAAeF,EAAahB,UAAW,aAAc,CACxDoB,GAAAA,GAAQ,OAAOtD,IAAM,IAEzBP,OAAO8D,eAAeC,OAAOnB,EAASa,EAC1C,+BIlCJ,MAAAO,GAAeC,WAAAA,MACVC,OAAO,iBACPC,aACAC,+CCPL,MAAAC,EAAA,GAGA,SAAAf,EAAAgB,GAEA,MAAAC,EAAAF,EAAAC,GACA,QAAAE,IAAAD,EACA,OAAAA,EAAAE,QAGA,MAAAC,EAAAL,EAAAC,GAAA,CACA7C,GAAA6C,EACAK,QAAA,EACAF,QAAA,IAUA,OANAG,EAAAN,GAAAO,KAAAH,EAAAD,QAAAC,EAAAA,EAAAD,QAAAnB,GAGAoB,EAAAC,QAAA,EAGAD,EAAAD,OACA,CAGAnB,EAAAwB,EAAAF,QC5BA,MAAAG,EAAA,GACAzB,EAAA0B,EAAA,CAAAlE,EAAAmE,EAAAC,EAAAC,KACA,GAAAF,EAAA,CACAE,IAAA,EACA,QAAAC,EAAAL,EAAAM,OAA+BD,EAAA,GAAAL,EAAAK,EAAA,MAAAD,EAAwCC,IAAAL,EAAAK,GAAAL,EAAAK,EAAA,GAEvE,YADAL,EAAAK,GAAA,CAAAH,EAAAC,EAAAC,GAEA,CACA,IAAAG,EAAAC,IACA,IAAAH,EAAA,EAAiBA,EAAAL,EAAAM,OAAqBD,IAAA,CACtC,IAAAH,EAAAC,EAAAC,GAAAJ,EAAAK,GACAI,GAAA,EACA,QAAAC,EAAA,EAAkBA,EAAAR,EAAAI,OAAqBI,IACvC,EAAAN,KAAAG,GAAAH,KAAAhF,OAAAuF,KAAApC,EAAA0B,GAAAhD,MAAA2D,GAAArC,EAAA0B,EAAAW,GAAAV,EAAAQ,MAGAD,GAAA,EACAL,EAAAG,IAAAA,EAAAH,IAHAF,EAAAW,OAAAH,IAAA,GAMA,GAAAD,EAAA,CACAT,EAAAa,OAAAR,IAAA,GACA,MAAAS,EAAAX,SACAV,IAAAqB,IAAA/E,EAAA+E,EACA,CACA,CACA,OAAA/E,OCzBAwC,EAAAX,EAAA+B,IACA,MAAAoB,EAAApB,GAAAA,EAAAqB,WACA,IAAArB,EAAA,QACA,MAEA,OADApB,EAAA0C,EAAAF,EAAA,CAAiCG,EAAAH,IACjCA,GCHAxC,EAAA4C,GAAAC,IACA,IAAAC,EACA,WACA,GAAAD,EAAA,CACA,IAAAjB,EAAAiB,EACAA,EAAA,EACAC,EAAA,CAAW3B,QAAA,IACXS,EAAAL,KAAAuB,EAAA3B,QAAA2B,EAAAA,EAAA3B,QACA,CACA,OAAA2B,EAAA3B,UCXAnB,EAAA0C,EAAA,CAAAvB,EAAA4B,KACA,GAAA1E,MAAAC,QAAAyE,GAEA,IADA,IAAAjB,EAAA,EACAA,EAAAiB,EAAAhB,QAAA,CACA,IAAAM,EAAAU,EAAAjB,KACAkB,EAAAD,EAAAjB,KACAmB,EAAA,IAAAD,EAAA,CAAsCE,YAAA,EAAA5C,MAAAyC,EAAAjB,MAA2C,CAAIoB,YAAA,EAAA3C,IAAAyC,GACrFhD,EAAAmD,EAAAhC,EAAAkB,IAAAxF,OAAAwD,eAAAc,EAAAkB,EAAAY,EACA,MAEA,QAAAZ,KAAAU,EACA/C,EAAAmD,EAAAJ,EAAAV,KAAArC,EAAAmD,EAAAhC,EAAAkB,IACAxF,OAAAwD,eAAAc,EAAAkB,EAAA,CAA0Ca,YAAA,EAAA3C,IAAAwC,EAAAV,MCb1CrC,EAAAoD,EAAA,GAGApD,EAAAC,EAAAoD,GACAvD,QAAAC,IAAAlD,OAAAuF,KAAApC,EAAAoD,GAAAE,OAAA,CAAAC,EAAAlB,KACArC,EAAAoD,EAAAf,GAAAgB,EAAAE,GACAA,GACE,KCNFvD,EAAAwD,EAAAH,GAAAA,EAAA,IAAAA,EAAA,UAA4E,oTAAyUA,GCDrZrD,EAAAmD,EAAA,CAAAM,EAAAC,IAAA7G,OAAA8G,OAAAF,EAAAC,SCAA,MAAAE,EAAA,GACAC,EAAA,uBAEA7D,EAAA8D,EAAA,CAAAC,EAAAC,EAAA3B,EAAAgB,KACA,GAAAO,EAAAG,GAAmD,YAA5BH,EAAAG,GAAAnG,KAAAoG,GACvB,IAAAC,EAAAC,EACA,QAAAhD,IAAAmB,EAAA,CACA,MAAA8B,EAAAC,SAAAC,qBAAA,UACA,QAAAvC,EAAA,EAAiBA,EAAAqC,EAAApC,OAAoBD,IAAA,CACrC,MAAAwC,EAAAH,EAAArC,GACA,GAAAwC,EAAAC,aAAA,QAAAR,GAAAO,EAAAC,aAAA,iBAAAV,EAAAxB,EAAA,CAAmG4B,EAAAK,EAAY,MAC/G,CACA,CACAL,IACAC,GAAA,EACAD,EAAAG,SAAAI,cAAA,UAEAP,EAAAQ,QAAA,QACAzE,EAAA0E,IACAT,EAAAU,aAAA,QAAA3E,EAAA0E,IAEAT,EAAAU,aAAA,eAAAd,EAAAxB,GAEA4B,EAAAW,IAAAb,GAEAH,EAAAG,GAAA,CAAAC,GACA,MAAAa,EAAA,CAAAC,EAAAC,KAEAd,EAAAe,QAAAf,EAAAgB,OAAA,KACAC,aAAAC,GACA,MAAAC,EAAAxB,EAAAG,GAIA,UAHAH,EAAAG,GACAE,EAAAoB,YAAAC,YAAArB,GACAmB,GAAAG,QAAA3D,GAAAA,EAAAmD,IACAD,EAAA,OAAAA,EAAAC,IAEAI,EAAAK,WAAAX,EAAAY,KAAA,UAAAvE,EAAA,CAAqEwE,KAAA,UAAAC,OAAA1B,IAAiC,MACtGA,EAAAe,QAAAH,EAAAY,KAAA,KAAAxB,EAAAe,SACAf,EAAAgB,OAAAJ,EAAAY,KAAA,KAAAxB,EAAAgB,QACAf,GAAAE,SAAAwB,KAAAC,YAAA5B,QCtCAjE,EAAAuC,EAAApB,IACAtE,OAAAwD,eAAAc,EAAA2E,OAAAC,YAAA,CAAsDzF,MAAA,WACtDzD,OAAAwD,eAAAc,EAAA,cAAgDb,OAAA,KCHhDN,EAAAgG,IAAA5E,IACAA,EAAA6E,MAAA,GACA7E,EAAA8E,WAAA9E,EAAA8E,SAAA,IACA9E,GCHApB,EAAAmC,EAAA,KCGAnC,EAAAmG,GAAAC,IACA,IAAAnD,EAAApG,OAAAwJ,yBAAAD,EAAA,UACAnD,IAAAA,EAAAqD,UAAArD,EAAAsD,eAAA1J,OAAAwD,eAAA+F,EAAA,QAA0G9F,MAAA,UAAAiG,cAAA,WCL1G,IAAAC,EACAC,WAAAC,gBAAAF,EAAAC,WAAAE,SAAA,IACA,MAAAvC,EAAAqC,WAAArC,SACA,IAAAoC,GAAApC,IACA,WAAAA,EAAAwC,eAAAtH,QAAAuH,gBACAL,EAAApC,EAAAwC,cAAAhC,MACA4B,GAAA,CACA,MAAArC,EAAAC,EAAAC,qBAAA,UACA,GAAAF,EAAApC,OAAA,CACA,IAAAD,EAAAqC,EAAApC,OAAA,EACA,KAAAD,GAAA,KAAA0E,IAAA,WAAAM,KAAAN,KAAAA,EAAArC,EAAArC,KAAA8C,GACA,CACA,CAIA,IAAA4B,EAAA,UAAAO,MAAA,yDACAP,EAAAA,EAAAQ,QAAA,sBAAAA,QAAA,gBACAhH,EAAAiH,EAAAT,YClBAxG,EAAAkH,EAAA,oBAAA9C,UAAAA,SAAA+C,SAAAC,KAAAT,SAAAU,KAKA,MAAAC,EAAA,CACA,QAGAtH,EAAAoD,EAAAjB,EAAA,CAAAkB,EAAAE,KAEA,IAAAgE,EAAAvH,EAAAmD,EAAAmE,EAAAjE,GAAAiE,EAAAjE,QAAAnC,EACA,OAAAqG,EAGA,GAAAA,EACAhE,EAAA3F,KAAA2J,EAAA,QAEA,CAEA,MAAAC,EAAA,IAAA1H,QAAA,CAAA2H,EAAAC,IAAAH,EAAAD,EAAAjE,GAAA,CAAAoE,EAAAC,IACAnE,EAAA3F,KAAA2J,EAAA,GAAAC,GAGA,MAAA3J,EAAA,IAAAkJ,MACAY,EAAA5C,IACA,GAAA/E,EAAAmD,EAAAmE,EAAAjE,KACAkE,EAAAD,EAAAjE,GACA,IAAAkE,IAAAD,EAAAjE,QAAAnC,GACAqG,GAAA,CACA,MAAAK,EAAA7C,IAAA,SAAAA,EAAAW,KAAA,UAAAX,EAAAW,MACAmC,EAAA9C,GAAAA,EAAAY,QAAAZ,EAAAY,OAAAf,IACA/G,EAAAiK,QAAA,iBAAAzE,EAAA,cAAAuE,EAAA,KAAAC,EAAA,IACAhK,EAAAkK,KAAA,iBACAlK,EAAA6H,KAAAkC,EACA/J,EAAAmK,QAAAH,EACAhK,EAAAkH,MAAAA,EACAwC,EAAA,GAAA1J,EACA,GAGAmC,EAAA8D,EAAA9D,EAAAiH,EAAAjH,EAAAwD,EAAAH,GAAAsE,EAAA,SAAAtE,EAAAA,EACA,GAaArD,EAAA0B,EAAAS,EAAAkB,GAAA,IAAAiE,EAAAjE,GAGA,MAAA4E,EAAA,CAAAC,EAAA9J,KACA,IAAAuD,EAAAwG,EAAAC,GAAAhK,EAGA,IAAA4C,EAAAqC,EAAAvB,EAAA,EACA,GAAAH,EAAA0G,KAAAlK,GAAA,IAAAmJ,EAAAnJ,IAAA,CACA,IAAA6C,KAAAmH,EACAnI,EAAAmD,EAAAgF,EAAAnH,KACAhB,EAAAwB,EAAAR,GAAAmH,EAAAnH,IAGA,GAAAoH,EAAA,IAAA5K,EAAA4K,EAAApI,EACA,CAEA,IADAkI,GAAAA,EAAA9J,GACM0D,EAAAH,EAAAI,OAAqBD,IAC3BuB,EAAA1B,EAAAG,GACA9B,EAAAmD,EAAAmE,EAAAjE,IAAAiE,EAAAjE,IACAiE,EAAAjE,GAAA,KAEAiE,EAAAjE,GAAA,EAEA,OAAArD,EAAA0B,EAAAlE,IAGA8K,EAAA7B,WAAA,qCACA6B,EAAA/C,QAAA0C,EAAAxC,KAAA,SACA6C,EAAA1K,KAAAqK,EAAAxC,KAAA,KAAA6C,EAAA1K,KAAA6H,KAAA6C,QCpFAtI,EAAA0E,QAAAxD,ECGA,IAAAqH,EAAAvI,EAAA0B,OAAAR,EAAA,WAAAlB,EAAA,QACAuI,EAAAvI,EAAA0B,EAAA6G","sources":["webpack:///nextcloud/apps/files_sharing/src/files-sidebar.ts","webpack:///nextcloud/apps/files_sharing/src/services/ShareSearch.js","webpack:///nextcloud/apps/files_sharing/src/services/ExternalShareActions.js","webpack:///nextcloud/apps/files_sharing/src/services/TabSections.js","webpack:///nextcloud/apps/files_sharing/src/services/logger.ts","webpack:///nextcloud/webpack/bootstrap","webpack:///nextcloud/webpack/runtime/chunk loaded","webpack:///nextcloud/webpack/runtime/compat get default export","webpack:///nextcloud/webpack/runtime/concatenation wrap","webpack:///nextcloud/webpack/runtime/define property getters","webpack:///nextcloud/webpack/runtime/ensure chunk","webpack:///nextcloud/webpack/runtime/get javascript chunk filename","webpack:///nextcloud/webpack/runtime/hasOwnProperty shorthand","webpack:///nextcloud/webpack/runtime/load script","webpack:///nextcloud/webpack/runtime/make namespace object","webpack:///nextcloud/webpack/runtime/node module decorator","webpack:///nextcloud/webpack/runtime/runtimeId","webpack:///nextcloud/webpack/runtime/set anonymous default export name","webpack:///nextcloud/webpack/runtime/publicPath","webpack:///nextcloud/webpack/runtime/jsonp chunk loading","webpack:///nextcloud/webpack/runtime/nonce","webpack:///nextcloud/webpack/startup"],"sourcesContent":["/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport ShareVariant from '@mdi/svg/svg/share-variant.svg?raw';\nimport { getCSPNonce } from '@nextcloud/auth';\nimport { getSidebar } from '@nextcloud/files';\nimport { n, t } from '@nextcloud/l10n';\nimport wrap from '@vue/web-component-wrapper';\nimport Vue from 'vue';\nimport ExternalShareActions from './services/ExternalShareActions.js';\nimport ShareSearch from './services/ShareSearch.js';\nimport TabSections from './services/TabSections.js';\n__webpack_nonce__ = getCSPNonce();\n// Init Sharing Tab Service\nwindow.OCA.Sharing ??= {};\nObject.assign(window.OCA.Sharing, { ShareSearch: new ShareSearch() });\nObject.assign(window.OCA.Sharing, { ExternalShareActions: new ExternalShareActions() });\nObject.assign(window.OCA.Sharing, { ShareTabSections: new TabSections() });\nVue.prototype.t = t;\nVue.prototype.n = n;\nconst tagName = 'files_sharing-sidebar-tab';\ngetSidebar().registerTab({\n id: 'sharing',\n displayName: t('files_sharing', 'Sharing'),\n iconSvgInline: ShareVariant,\n order: 10,\n tagName,\n async onInit() {\n const { default: FilesSidebarTab } = await import('./views/FilesSidebarTab.vue');\n const webComponent = wrap(Vue, FilesSidebarTab);\n // In Vue 2, wrap doesn't support diseabling shadow. Disable with a hack\n Object.defineProperty(webComponent.prototype, 'attachShadow', {\n value() { return this; },\n });\n Object.defineProperty(webComponent.prototype, 'shadowRoot', {\n get() { return this; },\n });\n window.customElements.define(tagName, webComponent);\n },\n});\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport logger from './logger.ts'\n\nexport default class ShareSearch {\n\t_state\n\n\tconstructor() {\n\t\t// init empty state\n\t\tthis._state = {}\n\n\t\t// init default values\n\t\tthis._state.results = []\n\t\tlogger.debug('OCA.Sharing.ShareSearch initialized')\n\t}\n\n\t/**\n\t * Get the state\n\t *\n\t * @readonly\n\t * @memberof ShareSearch\n\t * @return {object} the data state\n\t */\n\tget state() {\n\t\treturn this._state\n\t}\n\n\t/**\n\t * Register a new result\n\t * Mostly used by the guests app.\n\t * We should consider deprecation and add results via php ?\n\t *\n\t * @param {object} result entry to append\n\t * @param {string} [result.user] entry user\n\t * @param {string} result.displayName entry first line\n\t * @param {string} [result.desc] entry second line\n\t * @param {string} [result.icon] entry icon\n\t * @param {Function} result.handler function to run on entry selection\n\t * @param {Function} [result.condition] condition to add entry or not\n\t * @return {boolean}\n\t */\n\taddNewResult(result) {\n\t\tif (result.displayName.trim() !== ''\n\t\t\t&& typeof result.handler === 'function') {\n\t\t\tthis._state.results.push(result)\n\t\t\treturn true\n\t\t}\n\t\tlogger.error('Invalid search result provided', { result })\n\t\treturn false\n\t}\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport logger from './logger.ts'\n\nexport default class ExternalShareActions {\n\t_state\n\n\tconstructor() {\n\t\t// init empty state\n\t\tthis._state = {}\n\n\t\t// init default values\n\t\tthis._state.actions = []\n\t\tlogger.debug('OCA.Sharing.ExternalShareActions initialized')\n\t}\n\n\t/**\n\t * Get the state\n\t *\n\t * @readonly\n\t * @memberof ExternalLinkActions\n\t * @return {object} the data state\n\t */\n\tget state() {\n\t\treturn this._state\n\t}\n\n\t/**\n\t * @typedef ExternalShareActionData\n\t * @property {import('vue').Component} is Vue component to render, for advanced actions the `async onSave` method of the component will be called when saved\n\t */\n\n\t/**\n\t * Register a new option/entry for the a given share type\n\t *\n\t * @param {object} action new action component to register\n\t * @param {string} action.id unique action id\n\t * @param {(data: any) => ExternalShareActionData & Record} action.data data to bind the component to\n\t * @param {Array} action.shareType list of \\@nextcloud/sharing.Types.SHARE_XXX to be mounted on\n\t * @param {boolean} action.advanced `true` if the action entry should be rendered within advanced settings\n\t * @param {object} action.handlers list of listeners\n\t * @return {boolean}\n\t */\n\tregisterAction(action) {\n\t\tlogger.warn('OCA.Sharing.ExternalShareActions is deprecated, use `registerSidebarAction` from `@nextcloud/sharing` instead')\n\n\t\t// Validate action\n\t\tif (typeof action !== 'object'\n\t\t\t|| typeof action.id !== 'string'\n\t\t\t|| typeof action.data !== 'function' // () => {disabled: true}\n\t\t\t|| !Array.isArray(action.shareType) // [\\@nextcloud/sharing.Types.Link, ...]\n\t\t\t|| typeof action.handlers !== 'object' // {click: () => {}, ...}\n\t\t\t|| !Object.values(action.handlers).every((handler) => typeof handler === 'function')) {\n\t\t\tlogger.error('Invalid action provided', action)\n\t\t\treturn false\n\t\t}\n\n\t\t// Check duplicates\n\t\tconst hasDuplicate = this._state.actions.findIndex((check) => check.id === action.id) > -1\n\t\tif (hasDuplicate) {\n\t\t\tlogger.error(`An action with the same id ${action.id} already exists`, action)\n\t\t\treturn false\n\t\t}\n\n\t\tthis._state.actions.push(action)\n\t\treturn true\n\t}\n}\n","/**\n * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\n/**\n * Callback to render a section in the sharing tab.\n *\n * @callback registerSectionCallback\n * @param {undefined} el - Deprecated and will always be undefined (formerly the root element)\n * @param {object} fileInfo - File info object\n */\n\nexport default class TabSections {\n\t_sections\n\n\tconstructor() {\n\t\tthis._sections = []\n\t}\n\n\t/**\n\t * @param {registerSectionCallback} section To be called to mount the section to the sharing sidebar\n\t */\n\tregisterSection(section) {\n\t\tthis._sections.push(section)\n\t}\n\n\tgetSections() {\n\t\treturn this._sections\n\t}\n}\n","/**\n * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\nimport { getLoggerBuilder } from '@nextcloud/logger';\nexport default getLoggerBuilder()\n .setApp('files_sharing')\n .detectUser()\n .build();\n","// The module cache\nconst __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tconst cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tconst module = __webpack_module_cache__[moduleId] = {\n\t\tid: moduleId,\n\t\tloaded: false,\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Flag the module as loaded\n\tmodule.loaded = true;\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n// expose the modules object (__webpack_modules__)\n__webpack_require__.m = __webpack_modules__;\n\n","const deferred = [];\n__webpack_require__.O = (result, chunkIds, fn, priority) => {\n\tif(chunkIds) {\n\t\tpriority ||= 0;\n\t\tfor(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];\n\t\tdeferred[i] = [chunkIds, fn, priority];\n\t\treturn;\n\t}\n\tlet notFulfilled = Infinity;\n\tfor (var i = 0; i < deferred.length; i++) {\n\t\tlet [chunkIds, fn, priority] = deferred[i];\n\t\tlet fulfilled = true;\n\t\tfor (var j = 0; j < chunkIds.length; j++) {\n\t\t\tif (((priority & 1) === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every((key) => (__webpack_require__.O[key](chunkIds[j])))) {\n\t\t\t\tchunkIds.splice(j--, 1);\n\t\t\t} else {\n\t\t\t\tfulfilled = false;\n\t\t\t\tif(priority < notFulfilled) notFulfilled = priority;\n\t\t\t}\n\t\t}\n\t\tif(fulfilled) {\n\t\t\tdeferred.splice(i--, 1)\n\t\t\tconst r = fn();\n\t\t\tif (r !== undefined) result = r;\n\t\t}\n\t}\n\treturn result;\n};","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tconst getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// wrap a concatenated module body as a lazy, memoized accessor; mod is\n// set before the body runs so re-entrant calls (require cycles) observe\n// the partial exports like Node.js\n__webpack_require__.cw = (body) => {\n\tvar mod;\n\treturn () => {\n\t\tif (body) {\n\t\t\tvar fn = body;\n\t\t\tbody = 0;\n\t\t\tmod = { exports: {} };\n\t\t\tfn.call(mod.exports, mod, mod.exports);\n\t\t}\n\t\treturn mod.exports;\n\t};\n};","// define getter/value functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tif(Array.isArray(definition)) {\n\t\tvar i = 0;\n\t\twhile(i < definition.length) {\n\t\t\tvar key = definition[i++];\n\t\t\tvar binding = definition[i++];\n\t\t\tvar descriptor = binding === 0 ? { enumerable: true, value: definition[i++] } : { enumerable: true, get: binding };\n\t\t\tif(!__webpack_require__.o(exports, key)) Object.defineProperty(exports, key, descriptor);\n\t\t}\n\t} else {\n\t\tfor(var key in definition) {\n\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t\t}\n\t\t}\n\t}\n};","__webpack_require__.f = {};\n// This file contains only the entry chunk.\n// The chunk loading function for additional chunks\n__webpack_require__.e = (chunkId) => {\n\treturn Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {\n\t\t__webpack_require__.f[key](chunkId, promises);\n\t\treturn promises;\n\t}, []));\n};","// This function allow to reference async chunks\n__webpack_require__.u = (chunkId) => (chunkId + \"-\" + chunkId + \".js?v=\" + {\"857\":\"c78894d5df34d854f7aa\",\"3252\":\"5cdefe70d09ea015d504\",\"4227\":\"44c552cb6722d4c29085\",\"4941\":\"cbb590bc81c3552fe3fc\",\"6798\":\"38b417c535f358052c1a\",\"6863\":\"d5c1d7105ea63887403c\",\"7471\":\"34493087eb2469ae6e25\",\"7859\":\"8b3b7c211b6d4a439761\",\"8374\":\"4f24f83d985c39aa0044\",\"8689\":\"5bbad32eaeecdbe5bbc4\",\"8826\":\"992dcbc4edbba49089e8\"}[chunkId] + \"\");","__webpack_require__.o = (obj, prop) => (Object.hasOwn(obj, prop));","const inProgress = {};\nconst dataWebpackPrefix = \"nextcloud-ui-legacy:\";\n// loadScript function to load a script via script tag\n__webpack_require__.l = (url, done, key, chunkId) => {\n\tif(inProgress[url]) { inProgress[url].push(done); return; }\n\tlet script, needAttach;\n\tif(key !== undefined) {\n\t\tconst scripts = document.getElementsByTagName(\"script\");\n\t\tfor(var i = 0; i < scripts.length; i++) {\n\t\t\tconst s = scripts[i];\n\t\t\tif(s.getAttribute(\"src\") == url || s.getAttribute(\"data-webpack\") == dataWebpackPrefix + key) { script = s; break; }\n\t\t}\n\t}\n\tif(!script) {\n\t\tneedAttach = true;\n\t\tscript = document.createElement('script');\n\n\t\tscript.charset = 'utf-8';\n\t\tif (__webpack_require__.nc) {\n\t\t\tscript.setAttribute(\"nonce\", __webpack_require__.nc);\n\t\t}\n\t\tscript.setAttribute(\"data-webpack\", dataWebpackPrefix + key);\n\n\t\tscript.src = url;\n\t}\n\tinProgress[url] = [done];\n\tconst onScriptComplete = (prev, event) => {\n\t\t// avoid mem leaks in IE.\n\t\tscript.onerror = script.onload = null;\n\t\tclearTimeout(timeout);\n\t\tconst doneFns = inProgress[url];\n\t\tdelete inProgress[url];\n\t\tscript.parentNode?.removeChild(script);\n\t\tdoneFns?.forEach((fn) => (fn(event)));\n\t\tif(prev) return prev(event);\n\t}\n\tconst timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);\n\tscript.onerror = onScriptComplete.bind(null, script.onerror);\n\tscript.onload = onScriptComplete.bind(null, script.onload);\n\tneedAttach && document.head.appendChild(script);\n};","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","__webpack_require__.nmd = (module) => {\n\tmodule.paths = [];\n\tif (!module.children) module.children = [];\n\treturn module;\n};","__webpack_require__.j = 4958;","// set .name for anonymous default exports per ES spec\n// skipped when the property is non-configurable (pre-ES2015 engines),\n// where Object.defineProperty would throw\n__webpack_require__.dn = (x) => {\n\tvar descriptor = Object.getOwnPropertyDescriptor(x, \"name\");\n\tif (!descriptor || (!descriptor.writable && descriptor.configurable)) Object.defineProperty(x, \"name\", { value: \"default\", configurable: true });\n};","let scriptUrl;\nif (globalThis.importScripts) scriptUrl = globalThis.location + \"\";\nconst document = globalThis.document;\nif (!scriptUrl && document) {\n\tif (document.currentScript?.tagName.toUpperCase() === 'SCRIPT')\n\t\tscriptUrl = document.currentScript.src;\n\tif (!scriptUrl) {\n\t\tconst scripts = document.getElementsByTagName(\"script\");\n\t\tif(scripts.length) {\n\t\t\tlet i = scripts.length - 1;\n\t\t\twhile (i > -1 && (!scriptUrl || !/^https?:/.test(scriptUrl))) scriptUrl = scripts[i--].src;\n\t\t}\n\t}\n}\n// When supporting browsers where an automatic publicPath is not supported you must specify an output.publicPath manually via configuration\n// or pass an empty string (\"\") and set the __webpack_public_path__ variable from your code to use your own logic.\nif (!scriptUrl) throw new Error(\"Automatic publicPath is not supported in this browser\");\nscriptUrl = scriptUrl.replace(/^blob:|[?#].*$/g, \"\").replace(/\\/[^/]+$/, \"/\");\n__webpack_require__.p = scriptUrl;","__webpack_require__.b = (typeof document !== 'undefined' && document.baseURI) || self.location.href;\n\n// object to store loaded and loading chunks\n// undefined = chunk not loaded, null = chunk preloaded/prefetched\n// [resolve, reject, Promise] = chunk loading, 0 = chunk loaded\nconst installedChunks = {\n\t4958: 0\n};\n\n__webpack_require__.f.j = (chunkId, promises) => {\n\t\t// JSONP chunk loading for javascript\n\t\tlet installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;\n\t\tif(installedChunkData !== 0) { // 0 means \"already installed\".\n\n\t\t\t// a Promise means \"currently loading\".\n\t\t\tif(installedChunkData) {\n\t\t\t\tpromises.push(installedChunkData[2]);\n\t\t\t} else {\n\t\t\t\tif(true) { // all chunks have JS\n\t\t\t\t\t// setup Promise in chunk cache\n\t\t\t\t\tconst promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));\n\t\t\t\t\tpromises.push(installedChunkData[2] = promise);\n\n\t\t\t\t\t// create error before stack unwound to get useful stacktrace later\n\t\t\t\t\tconst error = new Error();\n\t\t\t\t\tconst loadingEnded = (event) => {\n\t\t\t\t\t\tif(__webpack_require__.o(installedChunks, chunkId)) {\n\t\t\t\t\t\t\tinstalledChunkData = installedChunks[chunkId];\n\t\t\t\t\t\t\tif(installedChunkData !== 0) installedChunks[chunkId] = undefined;\n\t\t\t\t\t\t\tif(installedChunkData) {\n\t\t\t\t\t\t\t\tconst errorType = event && (event.type === 'load' ? 'missing' : event.type);\n\t\t\t\t\t\t\t\tconst realSrc = event && event.target && event.target.src;\n\t\t\t\t\t\t\t\terror.message = 'Loading chunk ' + chunkId + ' failed.\\n(' + errorType + ': ' + realSrc + ')';\n\t\t\t\t\t\t\t\terror.name = 'ChunkLoadError';\n\t\t\t\t\t\t\t\terror.type = errorType;\n\t\t\t\t\t\t\t\terror.request = realSrc;\n\t\t\t\t\t\t\t\terror.event = event;\n\t\t\t\t\t\t\t\tinstalledChunkData[1](error);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\t\t\t\t\t__webpack_require__.l(__webpack_require__.p + __webpack_require__.u(chunkId), loadingEnded, \"chunk-\" + chunkId, chunkId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n};\n\n// no prefetching\n\n// no preloaded\n\n// no HMR\n\n// no HMR manifest\n\n__webpack_require__.O.j = (chunkId) => (installedChunks[chunkId] === 0);\n\n// install a JSONP callback for chunk loading\nconst webpackJsonpCallback = (parentChunkLoadingFunction, data) => {\n\tlet [chunkIds, moreModules, runtime] = data;\n\t// add \"moreModules\" to the modules object,\n\t// then flag all \"chunkIds\" as loaded and fire callback\n\tvar moduleId, chunkId, i = 0;\n\tif(chunkIds.some((id) => (installedChunks[id] !== 0))) {\n\t\tfor(moduleId in moreModules) {\n\t\t\tif(__webpack_require__.o(moreModules, moduleId)) {\n\t\t\t\t__webpack_require__.m[moduleId] = moreModules[moduleId];\n\t\t\t}\n\t\t}\n\t\tif(runtime) var result = runtime(__webpack_require__);\n\t}\n\tif(parentChunkLoadingFunction) parentChunkLoadingFunction(data);\n\tfor(;i < chunkIds.length; i++) {\n\t\tchunkId = chunkIds[i];\n\t\tif(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {\n\t\t\tinstalledChunks[chunkId][0]();\n\t\t}\n\t\tinstalledChunks[chunkId] = 0;\n\t}\n\treturn __webpack_require__.O(result);\n}\n\nconst chunkLoadingGlobal = globalThis[\"webpackChunknextcloud_ui_legacy\"] ||= [];\nchunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));\nchunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));","__webpack_require__.nc = undefined;","// startup\n// Load entry module and return exports\n// This entry module depends on other loaded chunks and execution need to be delayed\nlet __webpack_exports__ = __webpack_require__.O(undefined, [4208], () => (__webpack_require__(28237)))\n__webpack_exports__ = __webpack_require__.O(__webpack_exports__);\n"],"names":["__webpack_nonce__","getCSPNonce","window","OCA","Sharing","Object","assign","ShareSearch","constructor","this","_state","results","logger","debug","state","addNewResult","result","displayName","trim","handler","push","error","ExternalShareActions","actions","registerAction","action","warn","id","data","Array","isArray","shareType","handlers","values","every","findIndex","check","ShareTabSections","_sections","registerSection","section","getSections","Vue","prototype","t","n","tagName","getSidebar","registerTab","iconSvgInline","order","onInit","default","FilesSidebarTab","Promise","all","__webpack_require__","e","then","webComponent","wrap","defineProperty","value","get","customElements","define","__WEBPACK_DEFAULT_EXPORT__","getLoggerBuilder","setApp","detectUser","build","__webpack_module_cache__","moduleId","cachedModule","undefined","exports","module","loaded","__webpack_modules__","call","m","deferred","O","chunkIds","fn","priority","i","length","notFulfilled","Infinity","fulfilled","j","keys","key","splice","r","getter","__esModule","d","a","cw","body","mod","definition","binding","descriptor","enumerable","o","f","chunkId","reduce","promises","u","obj","prop","hasOwn","inProgress","dataWebpackPrefix","l","url","done","script","needAttach","scripts","document","getElementsByTagName","s","getAttribute","createElement","charset","nc","setAttribute","src","onScriptComplete","prev","event","onerror","onload","clearTimeout","timeout","doneFns","parentNode","removeChild","forEach","setTimeout","bind","type","target","head","appendChild","Symbol","toStringTag","nmd","paths","children","dn","x","getOwnPropertyDescriptor","writable","configurable","scriptUrl","globalThis","importScripts","location","currentScript","toUpperCase","test","Error","replace","p","b","baseURI","self","href","installedChunks","installedChunkData","promise","resolve","reject","loadingEnded","errorType","realSrc","message","name","request","webpackJsonpCallback","parentChunkLoadingFunction","moreModules","runtime","some","chunkLoadingGlobal","__webpack_exports__"],"sourceRoot":""} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index ab6b15b605377..ee55d73071f4a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,6 @@ import { includeIgnoreFile } from '@eslint/compat' import { recommended } from '@nextcloud/eslint-config' -import CypressEslint from 'eslint-plugin-cypress' import noOnlyTests from 'eslint-plugin-no-only-tests' import { defineConfig } from 'eslint/config' import * as globals from 'globals' @@ -55,28 +54,10 @@ export default defineConfig([ }, }, - // Cypress setup - { - ...CypressEslint.configs.recommended, - files: ['cypress/**', '**/*.cy.*'], - }, - { - name: 'server/cypress', - files: ['cypress/**', '**/*.cy.*'], - rules: { - 'no-console': 'off', - 'jsdoc/require-jsdoc': 'off', - 'jsdoc/require-param-type': 'off', - 'jsdoc/require-param-description': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-expressions': 'off', - }, - }, - // Forbid commiting .only in test files (skipping tests is very unexpected) { name: 'server/no-only-in-tests', - files: ['cypress/**', 'apps/**/*.spec.*', 'core/**/*.spec.*'], + files: ['tests/playwright/**', 'apps/**/*.spec.*', 'core/**/*.spec.*'], plugins: { 'no-only-tests': noOnlyTests, }, diff --git a/package-lock.json b/package-lock.json index c9aebdf28e953..b025a9261e023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,12 +39,12 @@ }, "devDependencies": { "@nextcloud/browserslist-config": "^3.1.2", - "@nextcloud/e2e-test-server": "^0.5.0", + "@nextcloud/e2e-test-server": "^0.6.0", "@nextcloud/eslint-config": "^9.0.0-rc.9", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/typings": "^1.10.0", "@nextcloud/vite-config": "^2.5.4", - "@testing-library/cypress": "^10.1.3", + "@playwright/test": "^1.63.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/vue": "^8.1.0", "@types/dockerode": "^4.0.1", @@ -52,15 +52,9 @@ "@vue/test-utils": "^2.5.0", "@vue/tsconfig": "^0.9.1", "@zip.js/zip.js": "^2.11.2", + "axe-core": "^4.13.0", "concurrently": "^9.2.4", - "cypress": "^15.21.1", - "cypress-axe": "^1.7.0", - "cypress-if": "^1.17.1", - "cypress-split": "^1.25.0", - "cypress-vite": "^1.10.2", - "cypress-wait-until": "^3.0.2", "eslint": "^10.10.0", - "eslint-plugin-cypress": "^6.4.4", "eslint-plugin-no-only-tests": "^3.4.0", "is-svg": "^6.1.0", "jsdom": "^27.4.0", @@ -83,45 +77,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@actions/core": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-2.0.3.tgz", - "integrity": "sha512-Od9Thc3T1mQJYddvVPM4QGiLUewdh+3txmDYHHxoNdkqysR1MbCT+rFOtNUxYAz+7+6RIsqipVahY2GJqGPyxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/exec": "^2.0.0", - "@actions/http-client": "^3.0.2" - } - }, - "node_modules/@actions/exec": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-2.0.0.tgz", - "integrity": "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/io": "^2.0.0" - } - }, - "node_modules/@actions/http-client": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz", - "integrity": "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tunnel": "^0.0.6", - "undici": "^6.23.0" - } - }, - "node_modules/@actions/io": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@actions/io/-/io-2.0.0.tgz", - "integrity": "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -199,162 +154,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -373,32 +172,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", @@ -414,22 +187,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -439,42 +196,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", @@ -726,70 +447,6 @@ "node": ">=10" } }, - "node_modules/@cypress/request": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-4.0.1.tgz", - "integrity": "sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~4.0.4", - "http-signature": "~1.4.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "performance-now": "^2.1.0", - "qs": "^6.15.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "^5.0.0", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">= 14.17.0" - } - }, - "node_modules/@cypress/xvfb": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", - "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.1.0", - "lodash.once": "^4.1.1" - } - }, - "node_modules/@cypress/xvfb/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@dependents/detective-less": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.1.tgz", - "integrity": "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "gonzales-pe": "^4.3.0", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@es-joy/jsdoccomment": { "version": "0.86.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", @@ -2047,16 +1704,16 @@ } }, "node_modules/@nextcloud/e2e-test-server": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@nextcloud/e2e-test-server/-/e2e-test-server-0.5.0.tgz", - "integrity": "sha512-N1naJXOUYDyLy1JmjZtCmTqKGnGHs+CREytXzQVV7riEL171tXHw6GaB2Vi4kHWF5SMlGMnfv7r0yR0qlnUW4w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@nextcloud/e2e-test-server/-/e2e-test-server-0.6.0.tgz", + "integrity": "sha512-C/ulfZTsM6JcwRubBqS0J2vQBy6jv40nRQ5aYCbZa3JMgGMJaGJEnzzUKiQHXLN0dCpUZZ5EjXVLZw0srwc95A==", "dev": true, "license": "AGPL-3.0-or-later", "dependencies": { "@nextcloud/paths": "^3.1.0", "dockerode": "^5.0.0", "fast-xml-parser": "^5.2.2", - "tar-stream": "^3.2.0", + "tar-stream": "^3.2.1", "wait-on": "^9.0.1" }, "engines": { @@ -2905,6 +2562,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3573,44 +3246,6 @@ "eslint": "^9.0.0 || ^10.0.0" } }, - "node_modules/@testing-library/cypress": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.1.3.tgz", - "integrity": "sha512-rVCH92TmU8idROHqCdTSp/bosIIUezihSwFfR/J2GZD0EAwyzRuYNseh54eziKJWjJ64BCXPT3X3jLO7v4yenQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.14.6", - "@testing-library/dom": "^10.1.0" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "cypress": ">=12" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -3862,13 +3497,6 @@ "@types/node": "*" } }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", - "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/sizzle": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", @@ -3909,13 +3537,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/toastify-js": { "version": "1.12.4", "resolved": "https://registry.npmjs.org/@types/toastify-js/-/toastify-js-1.12.4.tgz", @@ -4885,19 +4506,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4974,22 +4582,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5016,34 +4608,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/app-module-path": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz", - "integrity": "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/arch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", - "integrity": "sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/are-docs-informative": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", @@ -5054,13 +4618,6 @@ "node": ">=14" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5105,16 +4662,6 @@ "node": ">=0.10.0" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -5158,16 +4705,6 @@ "util": "^0.12.5" } }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5194,16 +4731,6 @@ "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/ast-module-types": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ast-module-types/-/ast-module-types-6.0.1.tgz", - "integrity": "sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", @@ -5266,16 +4793,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5292,30 +4809,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", - "dev": true, - "license": "MIT" - }, "node_modules/axe-core": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", - "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, "license": "MPL-2.0", - "peer": true, "engines": { "node": ">=4" } @@ -5358,9 +4857,9 @@ } }, "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -5389,9 +4888,9 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", - "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -5404,9 +4903,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", - "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5417,7 +4916,7 @@ "fast-fifo": "^1.3.2" }, "engines": { - "bare": ">=1.16.0" + "bare": ">=1.28.0" }, "peerDependencies": { "bare-buffer": "*" @@ -5429,16 +4928,16 @@ } }, "node_modules/bare-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", - "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", "dev": true, "license": "Apache-2.0" }, "node_modules/bare-stream": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", - "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5464,9 +4963,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", - "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5558,20 +5057,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/blob-util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", - "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true, - "license": "MIT" - }, "node_modules/blurhash": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", @@ -5883,16 +5368,6 @@ "qified": "^0.10.1" } }, - "node_modules/cachedir": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", - "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5980,13 +5455,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -6108,65 +5576,6 @@ "dev": true, "license": "ISC" }, - "node_modules/chrome-remote-interface": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz", - "integrity": "sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "2.11.x", - "ws": "^7.2.0" - }, - "bin": { - "chrome-remote-interface": "bin/client.js" - } - }, - "node_modules/chrome-remote-interface/node_modules/commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/chrome-remote-interface/node_modules/ws": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", - "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", @@ -6182,101 +5591,6 @@ "node": ">= 0.10" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-table3": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", - "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "colors": "1.4.0" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -6393,24 +5707,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -6433,16 +5729,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/comment-parser": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", @@ -6460,16 +5746,6 @@ "dev": true, "license": "MIT" }, - "node_modules/common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/compare-versions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", @@ -6532,19 +5808,6 @@ "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", "dev": true }, - "node_modules/console.table": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/console.table/-/console.table-0.10.0.tgz", - "integrity": "sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "easy-table": "1.1.0" - }, - "engines": { - "node": "> 0.10" - } - }, "node_modules/constants-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", @@ -6862,185 +6125,39 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/cypress": { - "version": "15.21.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.21.1.tgz", - "integrity": "sha512-ogHpHMj0XNlZA5MGzjg8SWHf0eMw9lTwVQRKjpcT1GzNTxNUjwnHA8YCG6nOJ8ThU+XZaUxJgpMYZnJ//8aDaw==", - "dev": true, - "hasInstallScript": true, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", - "dependencies": { - "@cypress/request": "^4.0.0", - "@cypress/xvfb": "^1.2.4", - "@types/sinonjs__fake-timers": "8.1.1", - "@types/sizzle": "^2.3.2", - "@types/tmp": "^0.2.3", - "arch": "^3.0.0", - "blob-util": "^2.0.2", - "bluebird": "^3.7.2", - "buffer": "^5.7.1", - "cachedir": "^2.4.0", - "chalk": "^4.1.0", - "chrome-remote-interface": "0.33.3", - "ci-info": "^4.1.0", - "cli-table3": "0.6.1", - "commander": "^6.2.1", - "common-tags": "^1.8.0", - "dayjs": "^1.10.4", - "debug": "^4.3.4", - "eventemitter2": "6.4.7", - "execa": "4.1.0", - "executable": "^4.1.1", - "fs-extra": "^9.1.0", - "hasha": "5.2.2", - "is-installed-globally": "~0.4.0", - "listr2": "^9.0.5", - "lodash": "^4.17.23", - "log-symbols": "^4.0.0", - "minimist": "^1.2.8", - "ospath": "^1.2.2", - "pretty-bytes": "^5.6.0", - "process": "^0.11.10", - "proxy-from-env": "1.0.0", - "request-progress": "^3.0.0", - "supports-color": "^8.1.1", - "systeminformation": "^5.31.1", - "tmp": "~0.2.4", - "tree-kill": "1.2.2", - "untildify": "^4.0.0", - "yauzl": "^3.3.1" - }, - "bin": { - "cypress": "bin/cypress" - }, "engines": { - "node": "^20.1.0 || ^22.0.0 || >=24.0.0" + "node": ">= 12" } }, - "node_modules/cypress-axe": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/cypress-axe/-/cypress-axe-1.7.0.tgz", - "integrity": "sha512-zzJpvAAjauEB3GZl0KYXb8i3w6MztWAt2WM3czYTFyNVC30alDmqCm9E7GwZ4bgkldZJlmHakaVEyu73R5St4w==", + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" }, - "peerDependencies": { - "axe-core": "^3 || ^4", - "cypress": "^10 || ^11 || ^12 || ^13 || ^14 || ^15" + "engines": { + "node": ">=20" } }, - "node_modules/cypress-if": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/cypress-if/-/cypress-if-1.17.1.tgz", - "integrity": "sha512-u3fJK3+ebsTw3c9kftTXA/oPSUHj8gRM67v/lkGR9Ogne7HyBGlRv/qCrO6bQ/p1RyKhgh+Eri6fCcTleSqkNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3" - } - }, - "node_modules/cypress-split": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/cypress-split/-/cypress-split-1.25.0.tgz", - "integrity": "sha512-/K8qQz3yeSc5GyHFyqJC6ipz3S47kwtFu7WfuZ+uSAjtfNlCxTBlPV5CTTn0v39zjWEVqrfpu2arZuzZegu/9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/core": "2.0.3", - "arg": "^5.0.2", - "console.table": "^0.10.0", - "debug": "^4.3.4", - "fast-shuffle": "^6.1.0", - "find-cypress-specs": "1.54.12", - "globby": "^11.1.0", - "humanize-duration": "^3.28.0" - }, - "bin": { - "cypress-split-merge": "bin/merge.js", - "cypress-split-preview": "bin/preview.js" - } - }, - "node_modules/cypress-vite": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/cypress-vite/-/cypress-vite-1.10.2.tgz", - "integrity": "sha512-tmCH7riwzprnl5M21ZXfU4jzBY7XBFHLBOAZA7B+SXSYOHmMbNPFrt+Y45EzlW8DVQCTVWs/dH76zx3WXvCZAA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "chokidar": "^2 || ^3 || ^4 || ^5", - "debug": "^2 || ^3 || ^4", - "vite": "^2.9 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/cypress-wait-until": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/cypress-wait-until/-/cypress-wait-until-3.0.2.tgz", - "integrity": "sha512-iemies796dD5CgjG5kV0MnpEmKSH+s7O83ZoJLVzuVbZmm4lheMsZqAVT73hlMx4QlkwhxbyUzhOBUOZwoOe0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/cypress/node_modules/proxy-from-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", - "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", - "dev": true, - "license": "MIT" - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "dev": true, - "license": "MIT" - }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -7137,31 +6254,6 @@ "dev": true, "license": "MIT" }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -7207,35 +6299,6 @@ "node": ">=0.4.0" } }, - "node_modules/dependency-tree": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.4.0.tgz", - "integrity": "sha512-r4wZ1pfv8eQrnoWbIGdrJTVmlb0dkXdwBjKsotKO4gmfqrOsAMG+0+cfA5EZ3NO8umc85twXOl1eO27E5pjTzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0", - "filing-cabinet": "^5.2.0", - "precinct": "^12.2.0", - "typescript": "^5.9.3" - }, - "bin": { - "dependency-tree": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/dependency-tree/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -7295,147 +6358,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/detective-amd": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.1.tgz", - "integrity": "sha512-TtyZ3OhwUoEEIhTFoc1C9IyJIud3y+xYkSRjmvCt65+ycQuc3VcBrPRTMWoO/AnuCyOB8T5gky+xf7Igxtjd3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "escodegen": "^2.1.0", - "get-amd-module-type": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "bin": { - "detective-amd": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-cjs": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.1.0.tgz", - "integrity": "sha512-Qt3S4IddVNDb+71lm+jmt5NznIsgcKlibTnrw9Zr91rT9vRwKp+73+ImqLTNrQj4YuOxnzrC7GwIAVwF7136XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-es6": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.1.tgz", - "integrity": "sha512-XusTPuewnSUdoxRSx8OOI6xIA/uld/wMQwYsouvFN2LAg7HgP06NF1lHRV3x6BZxyL2Kkoih4ewcq8hcbGtwew==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-postcss": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/detective-postcss/-/detective-postcss-7.0.1.tgz", - "integrity": "sha512-bEOVpHU9picRZux5XnwGsmCN4+8oZo7vSW0O0/Enq/TO5R2pIAP2279NsszpJR7ocnQt4WXU0+nnh/0JuK4KHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-url": "^1.2.4", - "postcss-values-parser": "^6.0.2" - }, - "engines": { - "node": "^14.0.0 || >=16.0.0" - }, - "peerDependencies": { - "postcss": "^8.4.47" - } - }, - "node_modules/detective-sass": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/detective-sass/-/detective-sass-6.0.1.tgz", - "integrity": "sha512-jSGPO8QDy7K7pztUmGC6aiHkexBQT4GIH+mBAL9ZyBmnUIOFbkfZnO8wPRRJFP/QP83irObgsZHCoDHZ173tRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "gonzales-pe": "^4.3.0", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-scss": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-scss/-/detective-scss-5.0.1.tgz", - "integrity": "sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg==", - "dev": true, - "license": "MIT", - "dependencies": { - "gonzales-pe": "^4.3.0", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-stylus": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-stylus/-/detective-stylus-5.0.1.tgz", - "integrity": "sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-typescript": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.0.0.tgz", - "integrity": "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "^8.23.0", - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "typescript": "^5.4.4" - } - }, - "node_modules/detective-vue2": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.2.0.tgz", - "integrity": "sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@dependents/detective-less": "^5.0.1", - "@vue/compiler-sfc": "^3.5.13", - "detective-es6": "^5.0.1", - "detective-sass": "^6.0.1", - "detective-scss": "^5.0.1", - "detective-stylus": "^5.0.1", - "detective-typescript": "^14.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "typescript": "^5.4.4" - } - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -7478,19 +6400,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/docker-modem": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", @@ -7645,27 +6554,6 @@ "node": ">= 0.4" } }, - "node_modules/easy-table": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.1.0.tgz", - "integrity": "sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "wcwidth": ">=1.0.1" - } - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/editorconfig": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", @@ -7755,20 +6643,6 @@ "once": "^1.4.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -7791,19 +6665,6 @@ "node": ">=6" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -7958,28 +6819,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "10.10.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", @@ -8068,25 +6907,6 @@ "eslint": "*" } }, - "node_modules/eslint-plugin-cypress": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-6.4.4.tgz", - "integrity": "sha512-ez6i14V0xYrq0DMKQmKPeWpi9XP8RI8GK6YT657yOnVlg72PUyR2psgVmC3jXjpTAXVdyB5VDoQPN/ytaw9+Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "globals": "^17.8.0" - }, - "peerDependencies": { - "@typescript-eslint/parser": ">=8", - "eslint": ">=9" - }, - "peerDependenciesMeta": { - "@typescript-eslint/parser": { - "optional": true - } - } - }, "node_modules/eslint-plugin-jsdoc": { "version": "62.9.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", @@ -8314,20 +7134,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -8401,13 +7207,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter2": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", - "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", - "dev": true, - "license": "MIT" - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -8445,43 +7244,6 @@ "safe-buffer": "^5.1.1" } }, - "node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.2.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -8504,16 +7266,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8559,16 +7311,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-shuffle": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/fast-shuffle/-/fast-shuffle-6.1.1.tgz", - "integrity": "sha512-HPxFJxEi18KPmVQuK5Hi5l4KSl3u50jtaxseRrPqrxewqfvU+sTPTaUpP33Hj+NdJoLuJP5ipx3ybTr+fa6dEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pcg": "1.1.0" - } - }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -8720,42 +7462,6 @@ "flat-cache": "^6.1.23" } }, - "node_modules/filing-cabinet": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/filing-cabinet/-/filing-cabinet-5.2.0.tgz", - "integrity": "sha512-eNrCJGdYQY0tV+ACNesQ7vb2aMxD76NM7THayMn0Z5XBt1Tonr4vbVN+FbhHfekKGQG9O5UaciDDR7+dw8P9ZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-module-path": "^2.2.0", - "commander": "^12.1.0", - "enhanced-resolve": "^5.20.0", - "module-definition": "^6.0.1", - "module-lookup-amd": "^9.1.1", - "resolve": "^1.22.11", - "resolve-dependency-path": "^4.0.1", - "sass-lookup": "^6.1.0", - "stylus-lookup": "^6.1.0", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.3" - }, - "bin": { - "filing-cabinet": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/filing-cabinet/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8769,53 +7475,6 @@ "node": ">=8" } }, - "node_modules/find-cypress-specs": { - "version": "1.54.12", - "resolved": "https://registry.npmjs.org/find-cypress-specs/-/find-cypress-specs-1.54.12.tgz", - "integrity": "sha512-eTJi4qctSClNV5ZNveCq07D2cKdyreRXO6fPBx2zfknV+03WzJmxoOruNK7Ok41FvJNzf33wQyVygbO0XCvasQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/core": "^2.0.2", - "arg": "^5.0.1", - "console.table": "^0.10.0", - "debug": "^4.3.3", - "find-test-names": "1.29.19", - "minimatch": "^10.2.4", - "pluralize": "^8.0.0", - "require-and-forget": "^1.0.1", - "shelljs": "^0.10.0", - "spec-change": "^1.11.21", - "tinyglobby": "^0.2.15", - "tsx": "^4.19.3" - }, - "bin": { - "find-cypress-specs": "bin/find.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/find-test-names": { - "version": "1.29.19", - "resolved": "https://registry.npmjs.org/find-test-names/-/find-test-names-1.29.19.tgz", - "integrity": "sha512-fSO2GXgOU6dH+FdffmRXYN/kLdnd8zkBGIZrKsmAdfLSFUUDLpDFF7+F/h+wjmjDWQmMgD8hPfJZR+igiEUQHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.2", - "@babel/plugin-syntax-jsx": "^7.27.1", - "acorn-walk": "^8.2.0", - "debug": "^4.3.3", - "simple-bin-help": "^1.8.0", - "tinyglobby": "^0.2.13" - }, - "bin": { - "find-test-names": "bin/find-test-names.js", - "print-tests": "bin/print-tests.js", - "update-test-count": "bin/update-test-count.js" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8925,16 +7584,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -8970,22 +7619,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -9029,31 +7662,6 @@ "node": ">= 0.4" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-amd-module-type": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", - "integrity": "sha512-MtjsmYiCXcYDDrGqtNbeIYdAl85n+5mSv2r3FbzER/YV3ZILw4HNNIw34HuV5pyl0jzs6GFYU1VHVEefhgcNHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -9098,66 +7706,20 @@ "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-own-enumerable-property-symbols": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", - "dev": true, - "license": "ISC" - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "devOptional": true, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" + "engines": { + "node": ">= 0.4" } }, "node_modules/git-hooks-list": { @@ -9201,22 +7763,6 @@ "node": ">= 6" } }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -9278,27 +7824,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globjoin": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", @@ -9306,22 +7831,6 @@ "dev": true, "license": "MIT" }, - "node_modules/gonzales-pe": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", - "integrity": "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "gonzales": "bin/gonzales.js" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9439,23 +7948,6 @@ "minimalistic-assert": "^1.0.1" } }, - "node_modules/hasha": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-stream": "^2.0.0", - "type-fest": "^0.8.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -9710,21 +8202,6 @@ "node": ">= 14" } }, - "node_modules/http-signature": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", - "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.18.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", @@ -9746,26 +8223,6 @@ "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/humanize-duration": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.1.tgz", - "integrity": "sha512-hwzSCymnRdFx9YdRkQQ0OYequXiVAV6ZGQA2uzocwB0F4309Ke6pO8dg0P8LHhRQJyVjGteRTAA/zNfEcpXn8A==", - "dev": true, - "license": "Unlicense", - "funding": { - "url": "https://github.com/sponsors/EvanHahn" - } - }, "node_modules/ical.js": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz", @@ -9881,16 +8338,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -10148,23 +8595,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -10229,26 +8659,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -10298,16 +8708,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", - "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", @@ -10337,19 +8737,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -10416,46 +8803,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-url-superb": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-4.0.0.tgz", - "integrity": "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -10522,13 +8869,6 @@ "node": ">=10" } }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true, - "license": "MIT" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -10666,13 +9006,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true, - "license": "MIT" - }, "node_modules/jsdoc-type-pratt-parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", @@ -10799,13 +9132,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true, - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -10820,13 +9146,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -10852,22 +9171,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -10897,157 +9200,44 @@ "peer": true }, "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/layerr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", - "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", - "license": "MIT" - }, - "node_modules/lazy-ass": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-2.0.3.tgz", - "integrity": "sha512-/O3/DoQmI1XAhklDvF1dAjFf/epE8u3lzOZegQfLZ8G7Ud5bTRSZiFOpukHCu6jODrCA4gtIdwUCC7htxcDACA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "> 0.8" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/linkifyjs": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", - "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", - "license": "MIT" - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true, "license": "MIT" }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", + "license": "MIT" }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">= 0.8.0" } }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } + "license": "MIT" + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" }, "node_modules/local-pkg": { "version": "1.1.2", @@ -11096,13 +9286,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -11110,161 +9293,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -11297,17 +9325,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -11647,13 +9664,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -12175,29 +10185,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -12300,51 +10287,6 @@ "pathe": "^2.0.1" } }, - "node_modules/module-definition": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz", - "integrity": "sha512-FeVc50FTfVVQnolk/WQT8MX+2WVcDnTGiq6Wo+/+lJ2ET1bRVi3HG3YlJUfqagNMc/kUlFSoR96AJkxGpKz13g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "bin": { - "module-definition": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/module-lookup-amd": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/module-lookup-amd/-/module-lookup-amd-9.1.1.tgz", - "integrity": "sha512-JzXhQvud8K3yT9l24XTDMXMQ4/LD9a9oXBcbLP0ubdvBpVrGFsybm5+2PDIl0negUYP1l88fCgjQzoMMg247+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0", - "requirejs": "^2.3.8", - "requirejs-config-file": "^4.0.0" - }, - "bin": { - "lookup-amd": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/module-lookup-amd/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -12572,19 +10514,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-source-walk": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-7.0.1.tgz", - "integrity": "sha512-3VW/8JpPqPvnJvseXowjZcirPisssnBuDikk6JIZ8jQzF7KJQX52iPFX4RYYxLycYH7IbMRSPUOga/esVjy5Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.26.7" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/node-stdlib-browser": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz", @@ -12657,19 +10586,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -12768,24 +10684,8 @@ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "wrappy": "1" } }, "node_modules/optionator": { @@ -12813,13 +10713,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ospath": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", - "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", - "dev": true, - "license": "MIT" - }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -13100,16 +10993,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -13134,44 +11017,12 @@ "node": ">= 0.10" } }, - "node_modules/pcg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pcg/-/pcg-1.1.0.tgz", - "integrity": "sha512-S+bYs8CV6l2lj01PRN4g9EiHDktcXJKD9FdE/FqpdXSuy1zImsRq8A8T5UK6gkXdI9O5YFdAgH40uPoR8Bk8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "long": "5.2.3", - "ramda": "0.29.1" - } - }, - "node_modules/pcg/node_modules/long": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", - "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "license": "MIT" }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13190,16 +11041,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pinia": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", @@ -13254,14 +11095,33 @@ "pathe": "^2.0.3" } }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, "engines": { - "node": ">=4" + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" } }, "node_modules/possible-typed-array-names": { @@ -13410,64 +11270,6 @@ "dev": true, "license": "MIT" }, - "node_modules/postcss-values-parser": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-values-parser/-/postcss-values-parser-6.0.2.tgz", - "integrity": "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "color-name": "^1.1.4", - "is-url-superb": "^4.0.0", - "quote-unquote": "^1.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "postcss": "^8.2.9" - } - }, - "node_modules/precinct": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz", - "integrity": "sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@dependents/detective-less": "^5.0.1", - "commander": "^12.1.0", - "detective-amd": "^6.0.1", - "detective-cjs": "^6.0.1", - "detective-es6": "^5.0.1", - "detective-postcss": "^7.0.1", - "detective-sass": "^6.0.1", - "detective-scss": "^5.0.1", - "detective-stylus": "^5.0.1", - "detective-typescript": "^14.0.0", - "detective-vue2": "^2.2.0", - "module-definition": "^6.0.1", - "node-source-walk": "^7.0.1", - "postcss": "^8.5.1", - "typescript": "^5.7.3" - }, - "bin": { - "precinct": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/precinct/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13478,19 +11280,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -13717,24 +11506,6 @@ ], "license": "MIT" }, - "node_modules/quote-unquote": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/quote-unquote/-/quote-unquote-1.0.0.tgz", - "integrity": "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ramda": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz", - "integrity": "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -13935,54 +11706,6 @@ "unist-util-visit": "^5.0.0" } }, - "node_modules/request-progress": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "throttleit": "^1.0.0" - } - }, - "node_modules/require-and-forget": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/require-and-forget/-/require-and-forget-1.0.1.tgz", - "integrity": "sha512-Sea861D/seGo3cptxc857a34Df0oEijXit8Q3IDodiwZMzVmyXrRI9EgQQa3hjkhoEjNzCBvv0t/0fMgebmWLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4.3.4" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/require-and-forget/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/require-and-forget/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -14003,34 +11726,6 @@ "node": ">=0.10.0" } }, - "node_modules/requirejs": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.8.tgz", - "integrity": "sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw==", - "dev": true, - "license": "MIT", - "bin": { - "r_js": "bin/r.js", - "r.js": "bin/r.js" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/requirejs-config-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/requirejs-config-file/-/requirejs-config-file-4.0.0.tgz", - "integrity": "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esprima": "^4.0.0", - "stringify-object": "^3.2.1" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -14071,16 +11766,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-dependency-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/resolve-dependency-path/-/resolve-dependency-path-4.0.1.tgz", - "integrity": "sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -14091,62 +11776,6 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rettime": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", @@ -14468,33 +12097,6 @@ "@parcel/watcher": "^2.4.1" } }, - "node_modules/sass-lookup": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.1.tgz", - "integrity": "sha512-12dvZdQYTeKZ1ypjuiijZYuMZ1m0F+4+BkRX5yJi2WA9W3DBUrcdCt7bVuKlagHl11n8eYtalWDle+m98Ol2DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0", - "enhanced-resolve": "^5.20.0" - }, - "bin": { - "sass-lookup": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/sass-lookup/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -14633,69 +12235,8 @@ "engines": { "node": ">= 0.4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/shelljs": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.10.0.tgz", - "integrity": "sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "execa": "^5.1.1", - "fast-glob": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/shelljs/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/shelljs/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/shelljs/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/side-channel": { @@ -14781,79 +12322,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/simple-bin-help": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/simple-bin-help/-/simple-bin-help-1.8.0.tgz", - "integrity": "sha512-0LxHn+P1lF5r2WwVB/za3hLRIsYoLaNq1CXqjbrs3ZvLuvlWnRKrUjEWzV7umZL7hpQ7xULiQMV+0iXdRa5iFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/sort-object-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.1.0.tgz", @@ -15026,24 +12494,6 @@ "node": ">=0.10.0" } }, - "node_modules/spec-change": { - "version": "1.11.21", - "resolved": "https://registry.npmjs.org/spec-change/-/spec-change-1.11.21.tgz", - "integrity": "sha512-87FZBOBjTyXF/R8juve+lHp+Q+9UlcwCF4rEM995jPGrJBYAMl19saWc9oRvB36VJqLI66D1FXsXTmetFJtQ9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "arg": "^5.0.2", - "debug": "^4.3.4", - "deep-equal": "^2.2.3", - "dependency-tree": "^11.4.0", - "lazy-ass": "^2.0.3", - "tinyglobby": "^0.2.0" - }, - "bin": { - "spec-change": "bin/spec-change.js" - } - }, "node_modules/split-ca": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", @@ -15088,32 +12538,6 @@ "nan": "^2.23.0" } }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -15177,9 +12601,9 @@ } }, "node_modules/streamx": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", - "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", "dev": true, "license": "MIT", "dependencies": { @@ -15251,21 +12675,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/stringify-object": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", - "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "get-own-enumerable-property-symbols": "^3.0.0", - "is-obj": "^1.0.1", - "is-regexp": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -15279,26 +12688,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -15882,32 +13271,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/stylus-lookup": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.1.0.tgz", - "integrity": "sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0" - }, - "bin": { - "stylus-lookup": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/stylus-lookup/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/superjson": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz", @@ -16005,33 +13368,6 @@ "dev": true, "license": "MIT" }, - "node_modules/systeminformation": { - "version": "5.33.1", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz", - "integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==", - "dev": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32", - "freebsd", - "openbsd", - "netbsd", - "sunos", - "android" - ], - "bin": { - "systeminformation": "lib/cli.js" - }, - "engines": { - "node": ">=10.0.0" - }, - "funding": { - "type": "Buy me a coffee", - "url": "https://www.buymeacoffee.com/systeminfo" - } - }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -16110,20 +13446,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tar-fs": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", @@ -16155,9 +13477,9 @@ } }, "node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16187,16 +13509,6 @@ "b4a": "^1.6.4" } }, - "node_modules/throttleit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", - "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -16253,36 +13565,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -16334,19 +13616,6 @@ "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==", "license": "MIT" }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/tr46": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", @@ -16417,21 +13686,6 @@ "node": ">=18" } }, - "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -16439,26 +13693,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", @@ -16466,29 +13700,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -16509,16 +13720,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -16584,16 +13785,6 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, - "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -16778,16 +13969,6 @@ "url": "https://github.com/sponsors/kettanaito" } }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -16903,28 +14084,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT" - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -17767,17 +14926,6 @@ "node": ">=20.0.0" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -18105,14 +15253,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -18157,19 +15297,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.2.tgz", - "integrity": "sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index bd6a74807d6e5..68d25ad66d2bf 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,14 @@ "scripts": { "build": "build/demi.sh build", "postbuild": "build/npm-post-build.sh", - "cypress": "cypress run --e2e", - "cypress:gui": "cypress open", - "cypress:version": "cypress version", "dev": "build/demi.sh dev", "postinstall": "build/demi.sh ci", - "lint": "eslint --suppressions-location build/eslint-baseline.json --no-error-on-unmatched-pattern ./cypress", + "lint": "eslint --suppressions-location build/eslint-baseline.json --no-error-on-unmatched-pattern ./tests/playwright", "postlint": "build/demi.sh lint", "lint:fix": "build/demi.sh lint:fix", + "playwright": "playwright test --project=default --project=admin-settings", + "playwright:install": "playwright install chromium", + "playwright:setup": "playwright test --project=setup", "sass": "sass --style compressed --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)", "sass:icons": "node build/icons.mjs", "sass:watch": "sass --watch --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)", @@ -68,12 +68,12 @@ }, "devDependencies": { "@nextcloud/browserslist-config": "^3.1.2", - "@nextcloud/e2e-test-server": "^0.5.0", + "@nextcloud/e2e-test-server": "^0.6.0", "@nextcloud/eslint-config": "^9.0.0-rc.9", "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/typings": "^1.10.0", "@nextcloud/vite-config": "^2.5.4", - "@testing-library/cypress": "^10.1.3", + "@playwright/test": "^1.63.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/vue": "^8.1.0", "@types/dockerode": "^4.0.1", @@ -81,15 +81,9 @@ "@vue/test-utils": "^2.5.0", "@vue/tsconfig": "^0.9.1", "@zip.js/zip.js": "^2.11.2", + "axe-core": "^4.13.0", "concurrently": "^9.2.4", - "cypress": "^15.21.1", - "cypress-axe": "^1.7.0", - "cypress-if": "^1.17.1", - "cypress-split": "^1.25.0", - "cypress-vite": "^1.10.2", - "cypress-wait-until": "^3.0.2", "eslint": "^10.10.0", - "eslint-plugin-cypress": "^6.4.4", "eslint-plugin-no-only-tests": "^3.4.0", "is-svg": "^6.1.0", "jsdom": "^27.4.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000000..3e34aa3eb0a34 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/// + +import { defineConfig, devices } from '@playwright/test' + +type DeviceDescriptor = typeof devices[string] +const BROWSWER_CONFIG_CHROME: DeviceDescriptor & { channel: string } = { + ...devices['Desktop Chrome'], + channel: process.env.CI + ? 'chrome' // on CI use the chrome browser provided by the GitHub Actions runner + : 'chromium', // locally use the default playwright chromium browser +} + +export default defineConfig({ + testDir: './tests/playwright/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + timeout: process.env.CI ? 45_000 : undefined, // on CI allow 1.5x the default timeout to compensate for shared server resources + reporter: process.env.CI ? [['blob'], ['dot'], ['github']] : 'html', + use: { + baseURL: 'http://localhost:8042/index.php/', + trace: 'on-first-retry', + }, + projects: [ + { + // Installation-wizard tests. They un-install and re-install the shared + // server, so they must never run alongside other tests + name: 'setup', + fullyParallel: false, + workers: 1, + grep: /@setup/, + use: { + ...BROWSWER_CONFIG_CHROME, + }, + }, + + { + name: 'admin-settings', + fullyParallel: false, + workers: 1, // only one admin setting test can run at a time due to shared state + testMatch: '**/admin-settings*.spec.ts', + use: { + ...BROWSWER_CONFIG_CHROME, + }, + }, + + { + name: 'default', + testMatch: /\/(?!admin-settings)[^/]*\.spec\.ts$/, + grepInvert: /@setup/, + use: { + ...BROWSWER_CONFIG_CHROME, + }, + }, + ], + + webServer: { + command: 'node tests/playwright/start-nextcloud-server.js', + env: { + NEXTCLOUD_PORT: '8042', + }, + stderr: 'pipe', + stdout: 'pipe', + gracefulShutdown: { + signal: 'SIGTERM', + timeout: 10_000, + }, + reuseExistingServer: !process.env.CI, + timeout: 300_000, + wait: { + stdout: /Nextcloud container ready to run Playwright tests/, + }, + }, +}) diff --git a/cypress/fixtures/image.jpg b/tests/data/images/image.jpg similarity index 100% rename from cypress/fixtures/image.jpg rename to tests/data/images/image.jpg diff --git a/tests/playwright/README.md b/tests/playwright/README.md new file mode 100644 index 0000000000000..355e33b0f5b4f --- /dev/null +++ b/tests/playwright/README.md @@ -0,0 +1,165 @@ + + +# Playwright end-to-end tests + +Playwright tests for the Nextcloud server core and bundled apps. +The test runner starts a Nextcloud instance inside Docker automatically — no manual setup is needed. + +## Running the tests + +```bash +# Install the browser binary once +npm run playwright:install + +# Run all tests (starts the server automatically) +npm run playwright + +# Run a single spec file +npx playwright test tests/playwright/e2e/files/files-sidebar.spec.ts + +# Run with the interactive UI (recommended for local development) +npx playwright test --ui +``` + +The dev server is reused between runs locally (`reuseExistingServer: true`) so subsequent runs start faster. + +## Viewing test traces + +Traces are captured on the first retry of a failing test (`trace: 'on-first-retry'` in `playwright.config.ts`). After a run that produced trace files, open the Playwright trace viewer: + +```bash +# Open the HTML report — includes a "Traces" link for each failing test +npx playwright show-report + +# Open a specific trace archive directly +npx playwright show-trace test-results//trace.zip +``` + +The trace viewer shows a timeline of every action, a DOM snapshot at each step, network requests, and console output. Use it to pinpoint exactly where a test diverged from expected behavior. + +For an even faster loop while writing tests, run in **headed mode** so you can watch the browser live: + +```bash +npx playwright test --headed --project=chrome tests/playwright/e2e/files/files-sidebar.spec.ts +``` + +### Viewing test traces from CI + +When a test failed on the CI it is also possible to review the full test run locally. +For this download the "HTML report" archive from the CI summary of the Playwright tests. +Then extract it and use `playwright show-trace` as described above. + +## Directory layout + +``` +tests/playwright/ +├── e2e/ # Test specs, one directory per feature area +│ ├── dav/ +│ ├── files/ +│ ├── systemtags/ +│ └── theming/ +└── support/ + ├── fixtures/ # Playwright fixture extensions (auth, page objects) + ├── matchers.ts # Custom expect matchers + ├── sections/ # Page Object Model classes + └── utils/ # Shared helpers (DAV, theming, …) +``` + +## Adding a new test + +We use Page Object Models to abstract the Nextcloud UI and make tests reusable to easier create new tests and ease maintenance. +You can find more general information here: +- [General Playwright documentation](https://playwright.dev/docs/writing-tests) +- [Page Object Models](https://playwright.dev/docs/pom) + +### 1. Pick or create a fixture + +Fixtures in `support/fixtures/` extend Playwright's `test` with auth and page objects. Use an existing one when the test area is already covered: + +| Fixture file | When to use | +|---|---| +| `files-page.ts` | Tests that need a random user with `filesListPage` and `filesSidebar` | +| `random-user-session.ts` | Any test needing a fresh random user, no page objects | +| `admin-session.ts` | Admin-only tests | +| `admin-theming-page.ts` | Theming admin settings | +| `admin-appstore-page.ts` | Appstore admin settings | + +If no existing fixture fits, extend the closest one: + +```typescript +import { test as baseTest } from './random-user-session.ts' +import { MyPage } from '../sections/MyPage.ts' + +export const test = baseTest.extend<{ myPage: MyPage }>({ + myPage: async ({ page }, use) => { + await use(new MyPage(page)) + }, +}) +export { expect } from '../matchers.ts' +``` + +### 2. Write the spec + +Create `e2e//my-feature.spec.ts`. Import `test` and `expect` from the fixture: + +```typescript +import { test, expect } from '../../support/fixtures/files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: my feature', () => { + test.beforeEach(async ({ user, page, filesListPage }) => { + await uploadContent(page.request, user, Buffer.from('hello'), 'text/plain', '/hello.txt') + await filesListPage.open() + }) + + test('does something', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('hello.txt')).toBeVisible() + }) +}) +``` + +**Always set up `waitForResponse` before the action that triggers the request**, otherwise there is a race condition: + +```typescript +const saved = page.waitForResponse(r => r.url().includes('/endpoint')) +await page.getByRole('button', { name: 'Save' }).click() +await saved +``` + +### 3. Page Object Models + +Page objects live in `support/sections/`. Each class wraps a `Page` or a scoped `Locator` and exposes named locators and action methods. This keeps selectors out of the specs and makes them easy to update when the UI changes. + +A minimal page object: + +```typescript +import type { Locator, Page } from '@playwright/test' + +export class MyFeaturePage { + constructor(private readonly page: Page) {} + + // Locators — return Locator, never await + container(): Locator { + return this.page.locator('[data-cy-my-feature]') + } + + submitButton(): Locator { + return this.container().getByRole('button', { name: 'Submit' }) + } + + // Actions — async, orchestrate one user interaction + async open(): Promise { + await this.page.goto('apps/myapp') + await this.container().waitFor({ state: 'visible' }) + } +} +``` + +Guidelines: +- **Locator methods** are synchronous and return `Locator`. Only actions are `async`. +- Scope child locators to `this.container()` so they stay inside the component boundary. +- Prefer accessible selectors (`getByRole`, `getByLabel`) over CSS classes. Fall back to `data-cy-*` attributes for elements that have no stable accessible name. +- Add the page object to the appropriate fixture so tests receive it as a parameter — do not instantiate page objects inside specs. diff --git a/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts b/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts new file mode 100644 index 0000000000000..d87f7d8486fec --- /dev/null +++ b/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts @@ -0,0 +1,195 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-appstore-page.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +test.describe('Settings: App management', () => { + test.afterAll(async () => { + // 'Limit app usage to group' deselects the admin group without unchecking + // the group-limit checkbox, leaving Dashboard with an empty allow-list and + // hiding it from non-admin accounts. Re-enabling rewrites the app's + // `enabled` flag back to `yes`, which restores the `/` redirect to the + // dashboard for subsequent specs. + await runOcc(['app:enable', 'dashboard'], { failOnError: false }) + }) + + test.beforeEach(async ({ appstorePage }) => { + // Disable QA testing app if already enabled + await runOcc(['app:disable', 'testing'], { failOnError: false }) + // Enable update notification app if disabled + await runOcc(['app:enable', 'updatenotification'], { failOnError: false }) + + // Open the installed apps page + await appstorePage.openInstalledApps() + + // Wait for the apps table to load + await appstorePage.appsTable().waitFor({ state: 'visible', timeout: 10000 }) + }) + + test('Can enable an installed app', async ({ page, appstorePage }) => { + // Intercept the enable app request + const enableRequest = page.waitForResponse((response) => response.url().includes('/settings/apps/enable')) + + // Find and click the enable button for the QA testing app + await expect(appstorePage.appsTable()).toBeVisible() + const qaTestingRow = appstorePage.appRow('QA testing') + await expect(qaTestingRow).toBeVisible({ timeout: 10000 }) + + await appstorePage.enableButton('QA testing').click({ force: true }) + + // Handle password confirmation if needed + await handlePasswordConfirmation(page, 'admin') + + // Wait for the API request + await enableRequest + + // Wait until we see the disable button for the app + await expect(appstorePage.appsTable()).toBeVisible() + await expect(appstorePage.appRow('QA testing')).toBeVisible() + await expect(appstorePage.disableButton('QA testing')).toBeVisible() + + // Change to enabled apps view + await appstorePage.openEnabledApps() + + // Verify the app appears in the enabled list + await expect(appstorePage.appRow('QA testing')).toBeVisible() + }) + + test('Can disable an installed app', async ({ page, appstorePage }) => { + // Intercept the disable app request + const disableRequest = page.waitForResponse((response) => response.url().includes('/settings/apps/disable')) + + // Find and click the disable button for the Update notification app + await expect(appstorePage.appsTable()).toBeVisible() + const updateRow = appstorePage.appRow('Update notification') + await expect(updateRow).toBeVisible({ timeout: 10000 }) + + await appstorePage.disableButton('Update notification').click({ force: true }) + + // Handle password confirmation if needed + await handlePasswordConfirmation(page, 'admin') + + // Wait for the API request + await disableRequest + + // Wait until we see the enable button for the app + await expect(appstorePage.appsTable()).toBeVisible() + await expect(appstorePage.appRow('Update notification')).toBeVisible() + await expect(appstorePage.enableButton('Update notification')).toBeVisible() + + // Change to disabled apps view + await appstorePage.openDisabledApps() + + // Verify the app appears in the disabled list + await expect(appstorePage.appRow('Update notification')).toBeVisible() + }) + + test('Browse enabled apps', async ({ appstorePage }) => { + // Open the "Active apps" section + await appstorePage.openEnabledApps() + + // Verify that there are only enabled apps (all have "Disable" button, no "Enable" button) + await expect(appstorePage.appsTable()).toBeVisible() + + // Get all rows and verify each has a disable button and no enable button + const rows = appstorePage.appsTable().locator('tr') + const rowCount = await rows.count() + + for (let i = 1; i < rowCount; i++) { // Skip header row + const row = rows.nth(i) + const enableButton = row.getByRole('button', { name: 'Enable' }) + + // Enabled apps should not have an "Enable" button + await expect(enableButton).not.toBeVisible() + } + }) + + test('Browse disabled apps', async ({ appstorePage }) => { + // Open the "Disabled apps" section + await appstorePage.openDisabledApps() + + // Verify that there are only disabled apps (all have "Enable" button, no "Disable" button) + await expect(appstorePage.appsTable()).toBeVisible() + + // Get all rows and verify each has an enable button and no disable button + const rows = appstorePage.appsTable().locator('tr') + const rowCount = await rows.count() + + for (let i = 1; i < rowCount; i++) { // Skip header row + const row = rows.nth(i) + const disableButton = row.getByRole('button', { name: 'Disable' }) + + // Disabled apps should not have a "Disable" button + await expect(disableButton).not.toBeVisible() + } + }) + + test('Browse app bundles', async ({ appstorePage }) => { + // Open the "App bundles" section + await appstorePage.openBundles() + + // Verify we see the app bundles + await expect(appstorePage.bundleHeader('Enterprise bundle')).toBeVisible() + await expect(appstorePage.bundleHeader('Education bundle')).toBeVisible() + + // The "Enterprise bundle" is not installed yet + await expect( + appstorePage.bundleHeader('Enterprise bundle').getByRole('button', { name: 'Download and enable all' }), + ).toBeVisible() + }) + + test('View app details', async ({ appstorePage }) => { + // Click on the "QA testing" app + await appstorePage.appLink('QA testing').click({ force: true }) + + // Verify the app details sidebar is shown + const sidebar = appstorePage.appSidebar() + await expect(sidebar).toBeVisible() + await expect(appstorePage.appSidebarHeader()).toContainText('QA testing') + + // Verify the sidebar contains expected elements + await expect(appstorePage.viewInStoreLink()).toBeVisible() + await expect(appstorePage.appSidebarEnableButton()).toBeVisible() + await expect(appstorePage.removeButton()).toBeVisible() + + // Verify version information is displayed + await expect(appstorePage.versionText()).toBeVisible() + }) + + test('Limit app usage to group', async ({ appstorePage, page }) => { + // Open the "Active apps" section + await appstorePage.openEnabledApps() + + // Select the dashboard app + await appstorePage.appLink('Dashboard').scrollIntoViewIfNeeded() + await appstorePage.appLink('Dashboard').click() + await expect(appstorePage.appSidebar()).toBeVisible() + + // Enable the group limitation + await appstorePage.limitToGroupsLabel('dashboard').click() + await expect(appstorePage.limitToGroupsCheckbox('dashboard')).toBeChecked() + + // Select the admin group + await appstorePage.groupSearchInput().fill('admin') + await appstorePage.groupOption('admin').click() + + // Handle password confirmation + await handlePasswordConfirmation(page, 'admin') + + // Verify the group is now selected + await expect(appstorePage.deselectGroupButton('admin')).toBeVisible() + + // Now remove the group limitation again + await appstorePage.deselectGroupButton('admin').click() + + // Handle password confirmation + await handlePasswordConfirmation(page, 'admin') + + await expect(appstorePage.deselectGroupButton('admin')).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/core/404-error.spec.ts b/tests/playwright/e2e/core/404-error.spec.ts new file mode 100644 index 0000000000000..18a5b4a22294c --- /dev/null +++ b/tests/playwright/e2e/core/404-error.spec.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +test.describe('404 error page', () => { + test('renders 404 page with a link back to login', async ({ page }) => { + // No authentication — the 404 page is shown to unauthenticated visitors. + await page.goto('/doesnotexist') + + await expect(page.getByRole('heading', { name: /Page not found/ })).toBeVisible() + + const backLink = page.getByRole('link', { name: /Back to Nextcloud/ }) + await expect(backLink).toBeVisible() + await backLink.click() + + await expect(page).toHaveURL(/\/login$/) + }) +}) diff --git a/tests/playwright/e2e/core/header-access-levels.spec.ts b/tests/playwright/e2e/core/header-access-levels.spec.ts new file mode 100644 index 0000000000000..44bfee8543cbb --- /dev/null +++ b/tests/playwright/e2e/core/header-access-levels.spec.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test as adminTest } from '../../support/fixtures/admin-session.ts' +import { test as userTest } from '../../support/fixtures/random-user-session.ts' +import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts' + +// Regular user tests — the page fixture is logged in as a fresh random user. +userTest.describe('Header: Settings menu – regular user', () => { + userTest('can see the basic items', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + + // A standard installation presents exactly 6 items for regular users. + await expect(accountMenu.entries()).toHaveCount(6) + await expect(accountMenu.entry('View profile')).toBeVisible() + await expect(accountMenu.entry('Set status')).toBeVisible() + await expect(accountMenu.entry('Appearance and accessibility')).toBeVisible() + // Regular users see "Settings" (personal settings shortcut), not the + // separate "Personal settings" / "Administration settings" split. + await expect(accountMenu.entry('Settings')).toBeVisible() + await expect(accountMenu.entry('Help')).toBeVisible() + await expect(accountMenu.entry('Log out')).toBeVisible() + }) + + userTest('cannot see admin-level items', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + + await expect(accountMenu.entry('Users')).toHaveCount(0) + await expect(accountMenu.entry('Administration settings')).toHaveCount(0) + }) +}) + +// Admin tests — the page fixture is logged in as the built-in admin user. +adminTest.describe('Header: Settings menu – admin user', () => { + adminTest('can see the admin-level items', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + + // A standard installation presents exactly 9 items for the admin. + await expect(accountMenu.entries()).toHaveCount(9) + await expect(accountMenu.entry('View profile')).toBeVisible() + await expect(accountMenu.entry('Set status')).toBeVisible() + await expect(accountMenu.entry('Appearance and accessibility')).toBeVisible() + // Admins see the explicit split between personal and admin sections. + await expect(accountMenu.entry('Personal settings')).toBeVisible() + await expect(accountMenu.entry('Administration settings')).toBeVisible() + await expect(accountMenu.entry('Apps')).toBeVisible() + await expect(accountMenu.entry('Accounts')).toBeVisible() + await expect(accountMenu.entry('Help')).toBeVisible() + await expect(accountMenu.entry('Log out')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/core/header-contacts-menu.spec.ts b/tests/playwright/e2e/core/header-contacts-menu.spec.ts new file mode 100644 index 0000000000000..2ecfeeed3f6d9 --- /dev/null +++ b/tests/playwright/e2e/core/header-contacts-menu.spec.ts @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { expect } from '@playwright/test' +import { test as adminTest } from '../../support/fixtures/admin-session.ts' +import { ContactsMenuPage } from '../../support/sections/ContactsMenuPage.ts' + +type ContactsFixtures = { contactUser: User } + +// Extend the admin session with a fresh random user available as `contactUser`. +// The user enumeration config is also reset to the permissive default here so +// that tests that modify it cannot bleed across runs. +const test = adminTest.extend({ + contactUser: async ({}, use) => { + await runOcc(['config:app:delete', 'core', 'shareapi_restrict_user_enumeration_to_group']) + const user = await createRandomUser() + await use(user) + await runOcc(['user:delete', user.userId]) + }, +}) + +// The restriction test toggles a global OCC config. Serial mode prevents +// parallel tests from racing on that setting. +test.describe.configure({ mode: 'serial' }) + +test.describe('Header: Contacts menu', () => { + test('other users are seen in the contacts menu', async ({ page, contactUser }) => { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + // The logged-in admin must not appear in their own contacts list. + await expect(contactsMenu.contact('admin')).toHaveCount(0) + }) + + test('just-added users are seen in the contacts menu', async ({ page, contactUser }) => { + // Create a second user directly in the test body; clean up with try/finally. + const extraUser = await createRandomUser() + try { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + await expect(contactsMenu.contact(extraUser.userId)).toBeVisible() + await expect(contactsMenu.contact('admin')).toHaveCount(0) + } finally { + await runOcc(['user:delete', extraUser.userId]) + } + }) + + test('search filters the contact list', async ({ page, contactUser }) => { + const otherUser = await createRandomUser() + try { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + // Both users visible before searching. + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + await expect(contactsMenu.contact(otherUser.userId)).toBeVisible() + + // Searching for otherUser hides contactUser. + await contactsMenu.search(otherUser.userId) + await expect(contactsMenu.contact(otherUser.userId)).toBeVisible() + await expect(contactsMenu.contact(contactUser.userId)).toHaveCount(0) + await expect(contactsMenu.contact('admin')).toHaveCount(0) + } finally { + await runOcc(['user:delete', otherUser.userId]) + } + }) + + test('searching for an unknown user shows no results', async ({ page, contactUser }) => { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + + await contactsMenu.search('surely-unknown-user') + + // NcEmptyContent renders the "name" prop as a heading. + await expect(page.getByText('No contacts found', { exact: true })).toBeVisible() + await expect(contactsMenu.contact(contactUser.userId)).toHaveCount(0) + await expect(contactsMenu.contact('admin')).toHaveCount(0) + }) + + test('users from other groups are not seen when user enumeration is restricted to the same group', async ({ page, contactUser }) => { + // Enable restriction first, then open the menu. + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_restrict_user_enumeration_to_group']) + try { + await page.goto('/') + const contactsMenu = new ContactsMenuPage(page) + await contactsMenu.open() + + // contactUser is in no group shared with admin → hidden. + await expect(contactsMenu.contact(contactUser.userId)).toHaveCount(0) + await expect(contactsMenu.contact('admin')).toHaveCount(0) + + // Close, lift the restriction, reopen — the contact should reappear. + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_restrict_user_enumeration_to_group']) + await contactsMenu.close() + + await page.reload() + await contactsMenu.open() + + await expect(contactsMenu.contact(contactUser.userId)).toBeVisible() + await expect(contactsMenu.contact('admin')).toHaveCount(0) + } finally { + await runOcc(['config:app:delete', 'core', 'shareapi_restrict_user_enumeration_to_group']) + } + }) +}) diff --git a/tests/playwright/e2e/core/setup.spec.ts b/tests/playwright/e2e/core/setup.spec.ts new file mode 100644 index 0000000000000..96b034eeda134 --- /dev/null +++ b/tests/playwright/e2e/core/setup.spec.ts @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' +import type { DatabaseConnection } from '../../support/sections/SetupPage.ts' + +import { runExec } from '@nextcloud/e2e-test-server/docker' +import { test as base, expect } from '@playwright/test' +import { SetupPage } from '../../support/sections/SetupPage.ts' + +/** + * Installation-wizard tests. They repeatedly UN-INSTALL the shared server (so it + * can be set up again from scratch) and the non-SQLite cases need reachable + * database containers, so they run only in the dedicated setup job — + * isolated in the `setup` Playwright project. + * They are tagged `@setup` for selective runs. + */ +const test = base.extend<{ setupPage: SetupPage }>({ + setupPage: async ({ page }, use) => { + await use(new SetupPage(page)) + }, +}) + +/** How to handle the recommended-apps screen at the end of the wizard. */ +type RecommendedAppsMode = 'skip' | 'install-success' | 'install-failure' + +// The recommended-apps view fetches the listing from the app list route of the +// settings app, so the mock has to carry that shape. +const APPSTORE_APPS = { + apps: [ + { id: 'calendar', name: 'Calendar', isCompatible: true, canInstall: true }, + { id: 'contacts', name: 'Contacts', isCompatible: true, canInstall: true }, + ], +} + +const ENABLE_SUCCESS = { data: { update_required: false } } +const ENABLE_FAILURE = { data: { message: 'Forced failure' } } + +const MYSQL: DatabaseConnection = { user: 'root', password: 'rootpassword', name: 'nextcloud', host: 'mysql:3306' } +const MARIADB: DatabaseConnection = { user: 'root', password: 'rootpassword', name: 'nextcloud', host: 'mariadb:3306' } +const POSTGRES: DatabaseConnection = { user: 'root', password: 'rootpassword', name: 'nextcloud', host: 'postgres:5432' } +const ORACLE: DatabaseConnection = { user: 'system', password: 'oracle', name: 'FREE', host: 'oracle:1521' } + +/** A unique administration account name (also used as the password). */ +function randomAdmin(): string { + return `admin-${crypto.randomUUID().slice(0, 10)}` +} + +/** + * Stub the appstore listing (always) and, for the install modes, the bulk + * enable request — so the flow is exercised without hitting the real app store. + * Registered before the wizard submits, so the routes are live once the + * recommended-apps view mounts after the post-install redirect. + */ +async function mockAppstore(page: Page, mode: RecommendedAppsMode): Promise { + await page.route(/\/settings\/apps\/list(\?.*)?$/, (route) => route.fulfill({ json: APPSTORE_APPS })) + + if (mode !== 'skip') { + await page.route(/\/settings\/apps\/enable$/, (route) => route.fulfill(mode === 'install-success' + ? { status: 200, json: ENABLE_SUCCESS } + : { status: 500, json: ENABLE_FAILURE })) + } +} + +/** + * Drive the admin creation + submit, assert the recommended-apps screen, then + * either skip to the files app or install the recommended apps and assert the + * resulting redirect or inline error. + */ +async function completeSetup(page: Page, setupPage: SetupPage, mode: RecommendedAppsMode): Promise { + const admin = randomAdmin() + await setupPage.install(admin, admin) + + await expect(setupPage.recommendedAppsHeading()).toBeVisible() + await expect(setupPage.skipButton()).toBeVisible() + await expect(setupPage.installRecommendedButton()).toBeVisible() + + if (mode === 'skip') { + await setupPage.skipButton().click() + await page.goto('apps/files/') + await expect(page.locator('[data-cy-files-content]')).toBeVisible() + return + } + + await setupPage.installRecommendedApps(admin) + + if (mode === 'install-success') { + // The frontend redirects to the default page once every app is enabled. + await expect(page).not.toHaveURL(/\/core\/apps\/recommended/) + return + } + + // On failure it stays on the recommended-apps page and surfaces the per-app error. + await expect(page).toHaveURL(/\/core\/apps\/recommended/) + await expect(setupPage.recommendedApps()).toContainText('App download or installation failed') +} + +test.describe('Nextcloud installation wizard', { tag: '@setup' }, () => { + test.beforeEach(async () => { + // Reset the instance to an uninstalled state so the wizard is served again + await runExec(['rm', '-f', 'config/config.php'], { failOnError: false }) + await runExec(['rm', '-f', 'data/owncloud.db'], { failOnError: false }) + }) + + test.describe('SQLite', { tag: '@db_sqlite' }, () => { + test('installs with SQLite', async ({ page, setupPage }) => { + test.slow() + await mockAppstore(page, 'skip') + await setupPage.open() + + await expect(setupPage.adminLoginField()).toBeVisible() + await expect(setupPage.adminPasswordField()).toBeVisible() + await expect(setupPage.dataFolderField()).toHaveValue('/var/www/html/data') + + await setupPage.selectDatabase('SQLite') + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with SQLite and installs recommended apps (success)', async ({ page, setupPage }) => { + await mockAppstore(page, 'install-success') + await setupPage.open() + + await setupPage.selectDatabase('SQLite') + await completeSetup(page, setupPage, 'install-success') + }) + + test('installs with SQLite and reports failed recommended apps', async ({ page, setupPage }) => { + await mockAppstore(page, 'install-failure') + await setupPage.open() + + await setupPage.selectDatabase('SQLite') + await completeSetup(page, setupPage, 'install-failure') + }) + }) + + test('installs with MySQL', { tag: '@db_mysql' }, async ({ page, setupPage }) => { + test.slow() + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('MySQL/MariaDB') + await setupPage.fillDatabaseConnection(MYSQL) + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with MariaDB', { tag: '@db_mariadb' }, async ({ page, setupPage }) => { + test.slow() + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('MySQL/MariaDB') + await setupPage.fillDatabaseConnection(MARIADB) + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with PostgreSQL', { tag: '@db_postgres' }, async ({ page, setupPage }) => { + test.slow() + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('PostgreSQL') + await setupPage.fillDatabaseConnection(POSTGRES) + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with Oracle', { tag: '@db_oracle' }, async ({ page, setupPage }) => { + test.slow() + test.setTimeout(200_000) // Oracle is slow to start up, so give it more time + // Oracle is only offered when the server allows all databases + await runExec(['cp', 'tests/databases-all-config.php', 'config/config.php']) + + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('Oracle') + await setupPage.fillDatabaseConnection(ORACLE) + await completeSetup(page, setupPage, 'skip') + }) +}) diff --git a/tests/playwright/e2e/dashboard/widget-performance.spec.ts b/tests/playwright/e2e/dashboard/widget-performance.spec.ts new file mode 100644 index 0000000000000..f2a2886e85bde --- /dev/null +++ b/tests/playwright/e2e/dashboard/widget-performance.spec.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/random-user-session.ts' + +/** The endpoint the dashboard uses to load the data of its widgets. */ +const WIDGET_ITEMS_API = /\/dashboard\/api\/v2\/widget-items\?widgets/ + +/** + * Regression test of https://github.com/nextcloud/server/issues/48403: the + * dashboard must only fetch data for the widgets it actually shows. + */ +test('dashboard: only loads the data of enabled widgets', async ({ page, user }) => { + // A layout with a single widget — so exactly one data request is expected + await runOcc(['user:setting', '--', user.userId, 'dashboard', 'layout', 'files-favorites']) + + const requests: string[] = [] + page.on('request', (request) => { + if (WIDGET_ITEMS_API.test(request.url())) { + requests.push(request.url()) + } + }) + + const loaded = page.waitForResponse((r) => WIDGET_ITEMS_API.test(r.url())) + await page.goto('apps/dashboard') + await expect(page.getByRole('heading', { name: /(Good (morning|afternoon|evening)|Hello)/ })).toBeVisible() + await loaded + + // Give any further (unwanted) widget request time to be fired … + await page.waitForTimeout(2000) + // … and confirm the favorites widget was the only one that loaded data + expect(requests).toHaveLength(1) +}) diff --git a/tests/playwright/e2e/dav/availability.spec.ts b/tests/playwright/e2e/dav/availability.spec.ts new file mode 100644 index 0000000000000..82bbf221604ed --- /dev/null +++ b/tests/playwright/e2e/dav/availability.spec.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/e2e-test-server' +import { addUser, runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/random-user-session.ts' + +test.describe('Calendar: Availability', () => { + test('User can see the availability section in settings', async ({ page }) => { + await page.goto('settings/user') + + // The settings sidebar lists an "Availability" navigation link + await page.getByRole('link', { name: /Availability/i }).first().click() + + await expect(page).toHaveURL(/settings\/user\/availability$/) + await expect(page.getByRole('heading', { name: /Availability/i, level: 2 })).toBeVisible() + }) + + test('Users can set their availability status', async ({ page }) => { + await page.goto('settings/user/availability') + + // CalendarAvailability renders listitems without an accessible name; filter by text content + const fridayItem = page.locator('#availability').getByRole('listitem').filter({ hasText: 'Friday' }) + await expect(fridayItem).toBeVisible() + await expect(fridayItem).toContainText('No working hours set') + + // Add a time slot for Friday + await fridayItem.getByRole('button', { name: 'Add slot' }).click() + + // Fill start and end times — labels are visually hidden but accessible + await fridayItem.getByLabel('Pick a start time for Friday').fill('09:00') + await fridayItem.getByLabel('Pick a end time for Friday').fill('18:00') + + // Wait for the PROPPATCH save request before clicking + const saveResponse = page.waitForResponse((r) => r.url().includes('/remote.php/dav/calendars/') && r.url().includes('/inbox') && r.request().method() === 'PROPPATCH') + await page.locator('#availability').getByRole('button', { name: 'Save' }).click() + await saveResponse + + await page.reload() + + // After reload Friday should have a slot (no longer shows "No working hours set") + await expect(page.locator('#availability').getByRole('listitem').filter({ hasText: 'Friday' })).not.toContainText('No working hours set') + }) + + test('Users can set their absence', async ({ page }) => { + // Create a specific replacement user + const replacementUser = new User('replacement-user', 'password') + await runOcc(['user:delete', replacementUser.userId]).catch(() => {}) + await addUser(replacementUser) + + try { + await page.goto('settings/user/availability') + + await page.getByRole('heading', { name: /absence/i }).scrollIntoViewIfNeeded() + + const absenceSection = page.locator('#absence') + + // Fill date fields (NcDateTimePickerNative with type="date") + await absenceSection.getByLabel('First day').fill('2024-12-24') + await absenceSection.getByLabel(/Last day/i).fill('2024-12-28') + + // Fill text fields + await absenceSection.getByRole('textbox', { name: /Short absence/i }).fill('Vacation') + await absenceSection.getByRole('textbox', { name: /Long absence/i }).fill('Happy holidays!') + + // Search for the replacement user via NcSelectUsers + const userSearchInput = absenceSection.getByLabel('Out of office replacement (optional)') + const searchResponse = page.waitForResponse((r) => r.url().includes('/apps/files_sharing/api/v1/sharees') && r.url().includes('search=replacement')) + await userSearchInput.click() + await userSearchInput.fill('replacement') + await searchResponse + + await page.getByRole('option', { name: 'replacement-user' }).click() + + // Save and wait for the OCS POST + const saveResponse = page.waitForResponse((r) => r.url().includes('/apps/dav/api/v1/outOfOffice/') && r.request().method() === 'POST') + await absenceSection.getByRole('button', { name: 'Save' }).click() + await saveResponse + + await page.reload() + + // Verify all fields are persisted after reload + await expect(absenceSection.getByLabel('First day')).toHaveValue('2024-12-24') + await expect(absenceSection.getByLabel(/Last day/i)).toHaveValue('2024-12-28') + await expect(absenceSection.getByRole('textbox', { name: /Short absence/i })).toHaveValue('Vacation') + await expect(absenceSection.getByRole('textbox', { name: /Long absence/i })).toHaveValue('Happy holidays!') + // NcSelectUsers (single-select) shows the selected user in .vs__selected and a "Clear selected" button + await expect(absenceSection.locator('.vs__selected')).toContainText('replacement-user') + } finally { + await runOcc(['user:delete', replacementUser.userId]) + } + }) +}) diff --git a/tests/playwright/e2e/files/drag-n-drop.spec.ts b/tests/playwright/e2e/files/drag-n-drop.spec.ts new file mode 100644 index 0000000000000..5102ed369fc7f --- /dev/null +++ b/tests/playwright/e2e/files/drag-n-drop.spec.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createFileDataTransfer, dropFilesOn } from '../../support/utils/drag-drop.ts' + +test.describe('files: Drag and Drop', () => { + test.beforeEach(async ({ filesListPage }) => { + await filesListPage.open() + }) + + test('can drop a file', async ({ page, filesListPage }) => { + const uploaded = page.waitForResponse((r) => r.request().method() === 'PUT' && r.url().includes('/remote.php/dav/files/')) + const dataTransfer = await createFileDataTransfer(page, [{ name: 'single-file.txt', content: 'hello '.repeat(1024) }]) + + await filesListPage.getContentArea().dispatchEvent('dragover', { dataTransfer }) + await expect(filesListPage.getDropArea()).toBeVisible() + + await dropFilesOn(filesListPage.getDropArea(), dataTransfer) + await uploaded + + await expect(filesListPage.getRowForFile('single-file.txt')).toBeVisible() + await expect(filesListPage.getRowSizeForFile('single-file.txt')).toContainText('6 KB') + }) + + test('can drop multiple files', async ({ page, filesListPage }) => { + const dataTransfer = await createFileDataTransfer(page, [ + { name: 'first.txt', content: 'Hello' }, + { name: 'second.txt', content: 'World' }, + ]) + + await filesListPage.getContentArea().dispatchEvent('dragover', { dataTransfer }) + await expect(filesListPage.getDropArea()).toBeVisible() + + await dropFilesOn(filesListPage.getDropArea(), dataTransfer) + + await expect(filesListPage.getRowForFile('first.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('second.txt')).toBeVisible() + }) + + test('ignores dropped folders (legacy File API)', async ({ page, filesListPage }) => { + // A synthetic DataTransfer already uses the legacy File API path; a File + // with the directory mime type stands in for a dropped folder and must be + // skipped with a warning while the real files still upload. + const dataTransfer = await createFileDataTransfer(page, [ + { name: 'first.txt', content: 'Hello' }, + { name: 'second.txt', content: 'World' }, + { name: 'Foo', content: '', type: 'httpd/unix-directory' }, + ]) + + await filesListPage.getContentArea().dispatchEvent('dragover', { dataTransfer }) + await expect(filesListPage.getDropArea()).toBeVisible() + + await dropFilesOn(filesListPage.getDropArea(), dataTransfer) + + await expect(page.locator('.toast-warning')).toBeVisible() + await expect(filesListPage.getRowForFile('first.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('second.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('Foo')).toHaveCount(0) + }) +}) + +// Regression coverage for https://github.com/nextcloud/server/issues/60139: +// per-row drops must route through the same pipeline as the main-list drop and +// upload into the target folder. +test.describe('files: Drag and Drop onto a folder row', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/subfolder') + await filesListPage.open() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + }) + + test('can drop a single file onto a subfolder row', async ({ page, filesListPage }) => { + const uploaded = page.waitForResponse((r) => r.request().method() === 'PUT' && /\/subfolder\/dropped-into-subfolder\.txt$/.test(r.url())) + const dataTransfer = await createFileDataTransfer(page, [{ name: 'dropped-into-subfolder.txt', content: 'hello '.repeat(1024) }]) + + await dropFilesOn(filesListPage.getRowForFile('subfolder'), dataTransfer) + await uploaded + + await filesListPage.navigateToFolder('subfolder') + await expect(filesListPage.getRowForFile('dropped-into-subfolder.txt')).toBeVisible() + }) + + test('can drop multiple files onto a subfolder row', async ({ page, filesListPage }) => { + const uploads = Promise.all([ + page.waitForResponse((r) => r.request().method() === 'PUT' && /\/subfolder\/one\.txt$/.test(r.url())), + page.waitForResponse((r) => r.request().method() === 'PUT' && /\/subfolder\/two\.txt$/.test(r.url())), + ]) + const dataTransfer = await createFileDataTransfer(page, [ + { name: 'one.txt', content: 'A'.repeat(1024) }, + { name: 'two.txt', content: 'B'.repeat(1024) }, + ]) + + await dropFilesOn(filesListPage.getRowForFile('subfolder'), dataTransfer) + await uploads + + await filesListPage.navigateToFolder('subfolder') + await expect(filesListPage.getRowForFile('one.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('two.txt')).toBeVisible() + }) + + test('opens the conflict picker when dropping a colliding name onto a subfolder row', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, 'original', 'text/plain', '/subfolder/collide.txt') + // Reload so the pre-populated file is in the store before the drop + await filesListPage.open() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + let putFired = false + page.on('request', (r) => { + if (r.method() === 'PUT' && r.url().includes('/remote.php/dav/files/')) { + putFired = true + } + }) + + const dataTransfer = await createFileDataTransfer(page, [{ name: 'collide.txt', content: 'replacement '.repeat(1024) }]) + await dropFilesOn(filesListPage.getRowForFile('subfolder'), dataTransfer) + + // The conflict dialog blocks the upload until resolved + await expect(page.getByRole('dialog')).toBeVisible() + expect(putFired).toBe(false) + }) +}) diff --git a/tests/playwright/e2e/files/duplicated-node-regression.spec.ts b/tests/playwright/e2e/files/duplicated-node-regression.spec.ts new file mode 100644 index 0000000000000..e412c0ea5b40e --- /dev/null +++ b/tests/playwright/e2e/files/duplicated-node-regression.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir } from '../../support/utils/dav.ts' + +test.describe('Files: Duplicated node regression', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/only once') + await filesListPage.open() + }) + + /** + * Regression: https://github.com/nextcloud/server/issues/47904 + * Deleting a node and recreating it with the same name left two rows in the list. + */ + test('does not duplicate a node after delete and recreate', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('only once')).toBeVisible() + + const deleted = page.waitForResponse((r) => r.request().method() === 'DELETE' && r.url().includes('/remote.php/dav/files/')) + await filesListPage.triggerActionForFile('only once', 'delete') + await deleted + await expect(filesListPage.getRowForFile('only once')).toHaveCount(0) + + await filesListPage.createFolder('only once') + + await expect(filesListPage.getRowForFile('only once')).toHaveCount(1) + }) +}) diff --git a/tests/playwright/e2e/files/files-actions.spec.ts b/tests/playwright/e2e/files/files-actions.spec.ts new file mode 100644 index 0000000000000..d761ee8f326aa --- /dev/null +++ b/tests/playwright/e2e/files/files-actions.spec.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { rm, uploadContent } from '../../support/utils/dav.ts' + +// A representative subset of the default actions, not the full feature set. +const expectedRowActions = ['move-copy', 'delete', 'details'] +const expectedSelectionActions = ['move-copy', 'delete'] + +test.describe('Files: Actions', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + // New users get welcome.txt — remove it so the list contains only our test file + await rm(page.request, user, '/welcome.txt') + await uploadContent(page.request, user, Buffer.alloc(0), 'image/jpeg', '/image.jpg') + await filesListPage.open() + }) + + test('shows the standard row actions', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('image.jpg')).toBeVisible() + + const menu = await filesListPage.openActionsMenuForFile('image.jpg') + for (const actionId of expectedRowActions) { + await expect(filesListPage.getActionButtonInMenu(menu, actionId)).toBeVisible() + } + }) + + test('shows the standard actions for a selection', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('image.jpg')).toBeVisible() + + await filesListPage.selectRowForFile('image.jpg') + await expect(filesListPage.getSelectionActionsToolbar()).toBeVisible() + + await filesListPage.openSelectionActionsMenu() + for (const actionId of expectedSelectionActions) { + await expect(filesListPage.getSelectionActionEntry(actionId)).toBeVisible() + } + }) +}) diff --git a/tests/playwright/e2e/files/files-copy-move.spec.ts b/tests/playwright/e2e/files/files-copy-move.spec.ts new file mode 100644 index 0000000000000..5bf4636e5abac --- /dev/null +++ b/tests/playwright/e2e/files/files-copy-move.spec.ts @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +const EMPTY = Buffer.alloc(0) + +test.describe('Files: Move or copy files', () => { + test('can copy a file to a new folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, '/new-folder') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToFolder('new-folder') + + await filesListPage.navigateToFolder('new-folder') + await expect(page).toHaveURL(/dir=\/new-folder/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('new-folder')).toHaveCount(0) + }) + + test('can move a file to a new folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, '/new-folder') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.moveToFolder('new-folder') + + // Moved out of the current folder + await expect(filesListPage.getRowForFile('new-folder')).toBeVisible() + await expect(filesListPage.getRowForFile('original.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('new-folder') + await expect(page).toHaveURL(/dir=\/new-folder/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('new-folder')).toHaveCount(0) + }) + + /** Regression: https://github.com/nextcloud/server/issues/41768 */ + test('can move a file to a folder with a similar name', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original') + await mkdir(page.request, user, '/original folder') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original', 'move-copy') + await copyMoveDialog.moveToFolder('original folder') + + await expect(filesListPage.getRowForFile('original folder')).toBeVisible() + await expect(filesListPage.getRowForFile('original')).toHaveCount(0) + + await filesListPage.navigateToFolder('original folder') + await expect(page).toHaveURL(/dir=\/original%20folder/) + await expect(filesListPage.getRowForFile('original')).toBeVisible() + await expect(filesListPage.getRowForFile('original folder')).toHaveCount(0) + }) + + test('can move a file to its parent folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await mkdir(page.request, user, '/new-folder') + await uploadContent(page.request, user, EMPTY, 'text/plain', '/new-folder/original.txt') + await filesListPage.open() + + await filesListPage.navigateToFolder('new-folder') + await expect(page).toHaveURL(/dir=\/new-folder/) + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.goToAllFiles() + await copyMoveDialog.moveToCurrentFolder() + + // The folder is now empty and the file is gone from it + await expect(page.getByText('No files in here')).toBeVisible() + await expect(filesListPage.getRowForFile('original.txt')).toHaveCount(0) + + // Back at the root the file lives next to its former parent + await filesListPage.open() + await expect(filesListPage.getRowForFile('new-folder')).toBeVisible() + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + }) + + test('can copy a file to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('original (1).txt')).toBeVisible() + }) + + test('can copy a file multiple times to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original (1).txt') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('original (2).txt')).toBeVisible() + }) + + /** + * Regression: https://github.com/nextcloud/server/issues/43843 + * A copied folder with a dot must be renamed correctly ("foo.bar" -> "foo.bar (1)"). + */ + test('can copy a folder to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => { + await mkdir(page.request, user, '/foo.bar') + await filesListPage.open() + + await filesListPage.triggerActionForFile('foo.bar', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await expect(filesListPage.getRowForFile('foo.bar')).toBeVisible() + await expect(filesListPage.getRowForFile('foo.bar (1)')).toBeVisible() + }) + + /** Regression: https://github.com/nextcloud/server/issues/43329 */ + test.describe('escaping file and folder names', () => { + test('can handle files with special characters', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, "/can't say") + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToFolder("can't say") + + await filesListPage.navigateToFolder("can't say") + await expect(page).toHaveURL(/dir=\/can%27t%20say/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile("can't say")).toHaveCount(0) + }) + + /** + * Folder names like '
foo' must render as text, not be sanitized + * into markup — Vue already escapes via v-text. + */ + test('does not incorrectly sanitize file names', async ({ page, user, filesListPage, copyMoveDialog }) => { + await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt') + await mkdir(page.request, user, '/foo') + await filesListPage.open() + + await filesListPage.triggerActionForFile('original.txt', 'move-copy') + await copyMoveDialog.copyToFolder('foo') + + await filesListPage.navigateToFolder('foo') + await expect(page).toHaveURL(/dir=\/%3Ca%20href%3D%22%23%22%3Efoo/) + await expect(filesListPage.getRowForFile('original.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('foo')).toHaveCount(0) + }) + }) +}) diff --git a/tests/playwright/e2e/files/files-delete.spec.ts b/tests/playwright/e2e/files/files-delete.spec.ts new file mode 100644 index 0000000000000..e22df5e33a545 --- /dev/null +++ b/tests/playwright/e2e/files/files-delete.spec.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Delete', () => { + test('can delete a file', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + + const row = filesListPage.getRowForFile('file.txt') + await expect(row).toBeVisible() + // Preview must finish loading before delete — a loading preview can lock the file + await expect(row.locator('.files-list__row-icon-preview--loaded')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (r) => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'DELETE', + { timeout: 10000 }, + ) + await filesListPage.triggerActionForFile('file.txt', 'delete') + expect((await deleteResponse).status()).toBe(204) + }) + + test('can delete multiple files', async ({ page, user, filesListPage }) => { + const files = Array.from({ length: 5 }, (_, i) => `file${i}.txt`) + await mkdir(page.request, user, '/root') + for (const file of files) { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/root/${file}`) + } + await filesListPage.open() + await filesListPage.navigateToFolder('root') + + // All 5 preview thumbnails must finish loading before we delete + await expect(page.locator('.files-list__row-icon-preview--loaded')).toHaveCount(5) + + // Retry the bulk delete until the folder is empty. A transient DAV lock + // (423) on a freshly-uploaded file makes its DELETE fail and the app keeps + // the row, so a single pass can leave a file behind. Re-selecting and + // re-deleting whatever remains converges on the empty end state without + // depending on every concurrent DELETE succeeding on the first try. + await expect(async () => { + await filesListPage.selectAll() + await filesListPage.triggerSelectionAction('delete') + await page.getByRole('dialog', { name: 'Confirm deletion' }) + .getByRole('button', { name: 'Delete files' }) + .click() + + await expect(filesListPage.getRows()).toHaveCount(0) + }).toPass({ timeout: 30_000 }) + }) +}) diff --git a/tests/playwright/e2e/files/files-download.spec.ts b/tests/playwright/e2e/files/files-download.spec.ts new file mode 100644 index 0000000000000..e88a493253d7d --- /dev/null +++ b/tests/playwright/e2e/files/files-download.spec.ts @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Download, Page } from '@playwright/test' + +import { User } from '@nextcloud/e2e-test-server' +import { addUser, runOcc } from '@nextcloud/e2e-test-server/docker' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { readFile } from 'node:fs/promises' +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { getZipEntries } from '../../support/utils/zip.ts' + +/** + * Register the download listener before running the trigger and return the + * resulting download. Playwright requires `waitForEvent('download')` to be + * pending before the action that starts the download (the Cypress original + * instead read a file off the downloads folder afterwards). + */ +async function triggerDownload(page: Page, action: () => Promise): Promise { + const downloadPromise = page.waitForEvent('download') + await action() + return downloadPromise +} + +/** + * Read a download's body as UTF-8 text. + * + * @param download The Playwright download event payload + */ +async function readDownloadText(download: Download): Promise { + const path = await download.path() + return readFile(path, 'utf-8') +} + +test.describe('Files: Download files using file actions', () => { + test('can download file', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('file.txt', 'download')) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + test('can download folder', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/subfolder') + await uploadContent(page.request, user, '', 'text/plain', '/subfolder/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('subfolder', 'download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/file.txt', + ]) + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file with hash name', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/#file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('#file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('#file.txt', 'download')) + + expect(download.suggestedFilename()).toBe('#file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file from folder with hash name', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/#folder') + await uploadContent(page.request, user, '', 'text/plain', '/#folder/file.txt') + await filesListPage.open() + + await filesListPage.navigateToFolder('#folder') + // All are visible by default + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('file.txt', 'download')) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) +}) + +test.describe('Files: Download files using default action', () => { + test('can download file', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.getDownloadButtonForFile('file.txt').click()) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file with hash name', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/#file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('#file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.getDownloadButtonForFile('#file.txt').click()) + + expect(download.suggestedFilename()).toBe('#file.txt') + expect(await readDownloadText(download)).toBe('') + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/44855 + */ + test('can download file from folder with hash name', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/#folder') + await uploadContent(page.request, user, '', 'text/plain', '/#folder/file.txt') + await filesListPage.open() + + await filesListPage.navigateToFolder('#folder') + // All are visible by default + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.getDownloadButtonForFile('file.txt').click()) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('') + }) +}) + +test.describe('Files: Download files using selection', () => { + test('can download selected files', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/subfolder') + await uploadContent(page.request, user, '', 'text/plain', '/subfolder/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + await filesListPage.selectRowForFile('subfolder') + + // see that one file is selected + await expect(page.getByText('1 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/file.txt', + ]) + }) + + test('can download multiple selected files', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await uploadContent(page.request, user, '', 'text/plain', '/other file.txt') + await filesListPage.open() + + await filesListPage.selectRowForFile('file.txt') + await filesListPage.selectRowForFile('other file.txt') + + // see that two files are selected + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('download.zip') + expect(await getZipEntries(download)).toEqual([ + 'file.txt', + 'other file.txt', + ]) + }) + + /** + * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 + */ + test('can download selected files with special characters', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, '', 'text/plain', '/1+1.txt') + await uploadContent(page.request, user, '', 'text/plain', '/some@other.txt') + await filesListPage.open() + + await filesListPage.selectRowForFile('some@other.txt') + await filesListPage.selectRowForFile('1+1.txt') + + // see that two files are selected + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('download.zip') + expect(await getZipEntries(download)).toEqual([ + '1+1.txt', + 'some@other.txt', + ]) + }) + + /** + * Regression test of https://help.nextcloud.com/t/unable-to-download-files-on-nextcloud-when-multiple-files-selected/221327/5 + * + * This test does not use the shared `user` fixture: it needs an email-like + * uid, which `createRandomUser()` cannot produce, so it provisions its own + * user via the docker helper and logs in at the API level. + */ + test('can download selected files with email uid', async ({ page, filesListPage }) => { + const uid = crypto.randomUUID() + .split('-', 2) + .reverse() + .join('@') + const emailUser = new User(uid, uid, 'en') + + await addUser(emailUser) + await login(page.request, emailUser) + + try { + await uploadContent(page.request, emailUser, '', 'text/plain', '/file.txt') + await uploadContent(page.request, emailUser, '', 'text/plain', '/other file.txt') + await filesListPage.open() + + await filesListPage.selectRowForFile('file.txt') + await filesListPage.selectRowForFile('other file.txt') + + // see that two files are selected + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('download.zip') + expect(await getZipEntries(download)).toEqual([ + 'file.txt', + 'other file.txt', + ]) + } finally { + await runOcc(['user:delete', uid]) + } + }) +}) diff --git a/tests/playwright/e2e/files/files-favorites.spec.ts b/tests/playwright/e2e/files/files-favorites.spec.ts new file mode 100644 index 0000000000000..e9c2ce5d3802d --- /dev/null +++ b/tests/playwright/e2e/files/files-favorites.spec.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, rm, uploadContent } from '../../support/utils/dav.ts' + +/** + * Run an action that toggles a favorite and wait for the server to store it. + * Toggling hits POST /apps/files/api/v1/files/; the listener is registered + * before the action and awaited after, so later assertions see the stored state. + */ +async function toggleFavorite(page: Page, path: string, action: () => Promise): Promise { + const encoded = path.split('/').map(encodeURIComponent).join('/') + const response = page.waitForResponse((r) => r.url().includes(`/apps/files/api/v1/files/${encoded}`) + && r.request().method() === 'POST') + await action() + await response +} + +test.describe('Files: Favorites', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + // New users get welcome.txt — remove it so the list contains only our test files + await rm(page.request, user, '/welcome.txt') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await mkdir(page.request, user, '/new folder') + await filesListPage.open() + }) + + test('marks a file as favorite from the row actions', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const menu = await filesListPage.openActionsMenuForFile('file.txt') + const favoriteAction = filesListPage.getActionButtonInMenu(menu, 'favorite') + await expect(favoriteAction).toContainText('Add to favorites') + + await toggleFavorite(page, 'file.txt', () => favoriteAction.click()) + + await expect(filesListPage.getFavoriteIconForFile('file.txt')).toBeVisible() + }) + + test('un-marks a file as favorite from the row actions', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Favorite it first + await toggleFavorite(page, 'file.txt', () => filesListPage.triggerActionForFile('file.txt', 'favorite')) + await expect(filesListPage.getFavoriteIconForFile('file.txt')).toBeVisible() + + // Re-open the menu — the action now offers to remove the favorite + const menu = await filesListPage.openActionsMenuForFile('file.txt') + const favoriteAction = filesListPage.getActionButtonInMenu(menu, 'favorite') + await expect(favoriteAction).toContainText('Remove from favorites') + + await toggleFavorite(page, 'file.txt', () => favoriteAction.click()) + + await expect(filesListPage.getFavoriteIconForFile('file.txt')).toHaveCount(0) + }) + + test('shows favorite folders in the navigation', async ({ page, filesListPage, filesNavigation }) => { + const favoritesNav = filesNavigation.getNavigationItem('favorites') + const favoriteEntry = favoritesNav.getByRole('link', { name: 'new folder' }) + + await expect(favoritesNav).toBeVisible() + await expect(favoriteEntry).toHaveCount(0) + + // Favorite the folder — it appears as a (collapsed) child of the favorites view + await toggleFavorite(page, 'new folder', () => filesListPage.triggerActionForFile('new folder', 'favorite')) + await filesNavigation.expandNavigationItem('favorites') + await expect(favoriteEntry).toBeVisible() + + // Un-favorite — it disappears again + await toggleFavorite(page, 'new folder', () => filesListPage.triggerActionForFile('new folder', 'favorite')) + await expect(favoriteEntry).toHaveCount(0) + }) + + test('marks a folder as favorite from the sidebar', async ({ page, filesListPage, filesNavigation, filesSidebar }) => { + await expect(filesListPage.getRowForFile('new folder')).toBeVisible() + + const favoriteEntry = filesNavigation.getNavigationItem('favorites').getByRole('link', { name: 'new folder' }) + await expect(favoriteEntry).toHaveCount(0) + + // Open the sidebar for the folder + await filesListPage.triggerActionForFile('new folder', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + + await toggleFavorite(page, 'new folder', () => filesSidebar.triggerAction('Favorite')) + + await filesSidebar.close() + await expect(filesSidebar.sidebar()).not.toBeVisible() + await expect(filesListPage.getFavoriteIconForFile('new folder')).toBeVisible() + + // Favorite survives a reload + await page.reload() + await expect(filesListPage.getRowForFile('new folder')).toBeVisible() + await expect(filesListPage.getFavoriteIconForFile('new folder')).toBeVisible() + + // Un-favorite again from the sidebar + await filesListPage.triggerActionForFile('new folder', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + + await toggleFavorite(page, 'new folder', () => filesSidebar.triggerAction('Unfavorite')) + + await filesSidebar.close() + await expect(filesSidebar.sidebar()).not.toBeVisible() + await expect(filesListPage.getFavoriteIconForFile('new folder')).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files/files-filtering.spec.ts b/tests/playwright/e2e/files/files-filtering.spec.ts new file mode 100644 index 0000000000000..6fd2852743ddc --- /dev/null +++ b/tests/playwright/e2e/files/files-filtering.spec.ts @@ -0,0 +1,155 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +// A wide viewport keeps the filter categories as inline buttons (rather than +// collapsing into a "Filters" menu), so the interactions are deterministic. +test.use({ viewport: { width: 1920, height: 1080 } }) + +test.describe('files: Filter in files list', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + const request = page.request + await mkdir(request, user, '/folder') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/csv', '/spreadsheet.csv') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/folder/text.txt') + await filesListPage.open() + }) + + test('filters current view by name', async ({ filesNavigation, filesListPage }) => { + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesNavigation.searchInput().fill('folder') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('spreadsheet.csv')).toHaveCount(0) + }) + + test('can reset name filter', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchInput().fill('folder') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await expect(filesNavigation.searchInput()).toHaveValue('folder') + await filesNavigation.searchClearButton().click() + await expect(filesNavigation.searchInput()).toHaveValue('') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + }) + + test('filters current view by type', async ({ filesFilter, filesListPage }) => { + await expect(filesListPage.getRowForFile('spreadsheet.csv')).toBeVisible() + + await filesFilter.openFilter('Type') + const spreadsheets = filesFilter.filterOption('Spreadsheets') + await expect(spreadsheets).toHaveAttribute('aria-pressed', 'false') + await spreadsheets.click() + await expect(spreadsheets).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('spreadsheet.csv')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + }) + + test('can reset filter by type', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Spreadsheets').click() + await expect(filesFilter.filterOption('Spreadsheets')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Spreadsheets').click() + await expect(filesFilter.filterOption('Spreadsheets')).toHaveAttribute('aria-pressed', 'false') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + }) + + test('can reset filter by clicking chip button', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Spreadsheets').click() + await expect(filesFilter.filterOption('Spreadsheets')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + + await filesFilter.removeFilter('Spreadsheets') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + }) + + test('keeps type filter when changing the directory', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Folders').click() + await expect(filesFilter.filterOption('Folders')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + + await filesListPage.navigateToFolder('folder') + + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + await expect(filesListPage.getRowForFile('text.txt')).toHaveCount(0) + }) + + /** Regression test of https://github.com/nextcloud/server/issues/47251 */ + test('keeps filter state when changing the directory', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Folders').click() + await expect(filesFilter.filterOption('Folders')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesFilter.activeFilters()).toHaveCount(1) + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + + await expect(filesFilter.activeFilters()).toHaveCount(1) + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + + // The Folders toggle should still be pressed + await filesFilter.openFilter('Type') + await expect(filesFilter.filterOption('Folders')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + }) + + /** Regression test of https://github.com/nextcloud/server/issues/53038 */ + test('resets name filter when changing the directory', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchInput().fill('folder') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('folder') + + await expect(filesNavigation.searchInput()).toHaveValue('') + await expect(filesListPage.getRowForFile('text.txt')).toBeVisible() + }) + + test('resets filter when changing the view', async ({ page, filesNavigation, filesListPage }) => { + await filesNavigation.searchInput().fill('folder') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await filesNavigation.getNavigationItem('personal').click() + await expect(page).toHaveURL(/apps\/files\/personal/) + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesNavigation.searchInput()).toHaveValue('') + }) +}) diff --git a/tests/playwright/e2e/files/files-navigation.spec.ts b/tests/playwright/e2e/files/files-navigation.spec.ts new file mode 100644 index 0000000000000..b2114d00cd728 --- /dev/null +++ b/tests/playwright/e2e/files/files-navigation.spec.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir } from '../../support/utils/dav.ts' + +test.describe('Files: Navigation', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/foo') + await mkdir(page.request, user, '/foo/bar') + await mkdir(page.request, user, '/foo/bar/baz') + await filesListPage.open() + }) + + test('shows root folder and can navigate to a deeply nested folder', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo')).toBeVisible() + await filesListPage.navigateToFolder('foo/bar/baz') + + // deepest folder is empty — no file rows rendered + await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0) + }) + + test('highlights the previous folder when navigating back and forward', async ({ page, filesListPage }) => { + await filesListPage.navigateToFolder('foo/bar/baz') + await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0) + + // Navigate back through each level — the folder we came from is highlighted + await page.goBack() + await expect(filesListPage.getRowForFile('baz')).toBeVisible() + await expect(filesListPage.getRowForFile('baz')).toBeActiveRow() + + await page.goBack() + await expect(filesListPage.getRowForFile('bar')).toBeVisible() + await expect(filesListPage.getRowForFile('bar')).toBeActiveRow() + + await page.goBack() + await expect(filesListPage.getRowForFile('foo')).toBeVisible() + await expect(filesListPage.getRowForFile('foo')).toBeActiveRow() + + // Navigate forward — the folder we re-entered is highlighted + await page.goForward() + await expect(filesListPage.getRowForFile('bar')).toBeVisible() + await expect(filesListPage.getRowForFile('bar')).toBeActiveRow() + + await page.goForward() + await expect(filesListPage.getRowForFile('baz')).toBeVisible() + await expect(filesListPage.getRowForFile('baz')).toBeActiveRow() + }) +}) diff --git a/tests/playwright/e2e/files/files-renaming.spec.ts b/tests/playwright/e2e/files/files-renaming.spec.ts new file mode 100644 index 0000000000000..e7260381757db --- /dev/null +++ b/tests/playwright/e2e/files/files-renaming.spec.ts @@ -0,0 +1,238 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, rm, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Rename nodes', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + // New users get welcome.txt — remove it so the list contains only our test files + await rm(page.request, user, '/welcome.txt') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + }) + + test('can rename a file', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('other.txt') + await expect(input).toHaveValidationMessage('') + await input.press('Enter') + + await expect(filesListPage.getRowForFile('other.txt')).toBeVisible() + }) + + /** + * If this test gets flaky then the selection is not reliably set to the basename. + * The selection should cover only the name part (without extension) when rename opens. + */ + test('only selects basename of file on rename open', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + + const { selectionStart, selectionEnd } = await input.evaluate((el) => ({ selectionStart: (el as HTMLInputElement).selectionStart, selectionEnd: (el as HTMLInputElement).selectionEnd })) + expect(selectionStart).toBe(0) + expect(selectionEnd).toBe('file'.length) + }) + + test('shows validation error on invalid filename', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('.htaccess') + + await expect(input).toHaveValidationMessage(/reserved name/i) + }) + + test('shows accessible loading state while rename MOVE is in-flight', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Hold MOVE requests until we explicitly release them + let resolveMove!: () => void + const moveAllowed = new Promise((resolve) => { + resolveMove = resolve + }) + await page.route(/remote\.php\/dav\/files\//, async (route) => { + if (route.request().method() === 'MOVE') { + await moveAllowed + } + await route.continue() + }) + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('new-name.txt') + await input.press('Enter') + + // While MOVE is blocked: row shows loading icon, checkbox is hidden + const loadingRow = filesListPage.getRowForFile('new-name.txt') + await expect(loadingRow.getByRole('img', { name: 'File is loading' })).toBeVisible() + await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).not.toBeVisible() + + // Release the MOVE and wait for it to complete + const moveResponse = page.waitForResponse((r) => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE') + resolveMove() + await moveResponse + await page.unroute(/remote\.php\/dav\/files\//) + + // Loading state clears: checkbox reappears, loading icon gone + await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).toBeVisible() + await expect(loadingRow.getByRole('img', { name: 'File is loading' })).not.toBeVisible() + }) + + test('cancel renaming on Escape', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('other.txt') + await expect(input).toHaveValidationMessage('') + await input.press('Escape') + + // Original name kept, rename input removed + await expect(filesListPage.getRowForFile('other.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible() + }) + + test('cancel renaming on Enter when name is unchanged', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.press('Enter') + + // No rename happened, input is gone + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible() + }) + + /** + * Regression: https://github.com/nextcloud/server/issues/47438 + * Virtual scrolling removed the renaming component from DOM before state reset, + * leaving the row permanently stuck in rename mode. + */ + test('correctly resets renaming state after virtual-scroll re-render', async ({ page, user, filesListPage }) => { + // Create 19 more files so virtual scrolling kicks in with a small viewport + for (let i = 1; i <= 19; i++) { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/file${i}.txt`) + } + + // Start with a small viewport so only a few rows fit + await page.setViewportSize({ width: 768, height: 500 }) + await filesListPage.open() + + // Measure the DOM to calculate the exact height that shows only 4 rows + const viewportHeight = await page.evaluate(() => { + const filesList = document.querySelector('[data-cy-files-list]') as HTMLElement + const outerHeight = window.innerHeight - filesList.clientHeight + const beforeHeight = (document.querySelector('.files-list__before') as HTMLElement)?.offsetHeight ?? 0 + const filterHeight = (document.querySelector('.files-list__filters') as HTMLElement)?.offsetHeight ?? 0 + const theadHeight = (document.querySelector('[data-cy-files-list-thead]') as HTMLElement)?.offsetHeight ?? 0 + const rowHeight = (document.querySelector('[data-cy-files-list-tbody] tr') as HTMLElement)?.offsetHeight ?? 0 + return outerHeight + beforeHeight + filterHeight + theadHeight + 4 * rowHeight + }) + await page.setViewportSize({ width: 768, height: viewportHeight }) + await filesListPage.open() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Rename to 'zzz.txt' — sorts last, scrolls out of the visible area + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + const moveResponse = page.waitForResponse((r) => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE') + await input.fill('zzz.txt') + await input.press('Enter') + await moveResponse + + // After rename zzz.txt is sorted to the end — no longer in the visible viewport + await expect(filesListPage.getRowForFile('zzz.txt')).toHaveCount(0) + + // Scroll to the bottom to bring zzz.txt into view + await page.locator('[data-cy-files-list]').evaluate((el) => el.scrollTo(0, el.scrollHeight)) + + // Row must be visible and NOT in rename state + await expect(filesListPage.getRowForFile('zzz.txt')).toBeVisible() + await expect(filesListPage.getRenameInputForFile('zzz.txt')).not.toBeVisible() + }) + + test('shows extension-change warning — keep new extension', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('file.md') + await input.press('Enter') + + await page.getByRole('dialog', { name: 'Change file extension' }) + .getByRole('button', { name: 'Use .md' }) + .click() + + await expect(filesListPage.getRowForFile('file.md')).toBeVisible() + }) + + test('shows extension-change warning — keep old extension', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('document.md') + await input.press('Enter') + + await page.getByRole('dialog', { name: 'Change file extension' }) + .getByRole('button', { name: 'Keep .txt' }) + .click() + + await expect(filesListPage.getRowForFile('document.txt')).toBeVisible() + }) + + test('shows extension-removal warning', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('file') + await input.press('Enter') + + const dialog = page.getByRole('dialog', { name: 'Change file extension' }) + await expect(dialog.getByRole('button', { name: 'Keep .txt' })).toBeVisible() + await dialog.getByRole('button', { name: 'Remove extension' }).click() + + await expect(filesListPage.getRowForFile('file')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + }) + + test('does not show extension warning when renaming a folder with a dot', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder.2024') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder.2024')).toBeVisible() + + await filesListPage.triggerActionForFile('folder.2024', 'rename') + const input = filesListPage.getRenameInputForFolder('folder.2024') + await expect(input).toBeVisible() + await input.fill('folder.2025') + await expect(input).toHaveValidationMessage('') + await input.press('Enter') + + await expect(page.locator('[role="dialog"]')).toHaveCount(0) + await expect(filesListPage.getRowForFile('folder.2025')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files/files-selection.spec.ts b/tests/playwright/e2e/files/files-selection.spec.ts new file mode 100644 index 0000000000000..b3bec5ad425aa --- /dev/null +++ b/tests/playwright/e2e/files/files-selection.spec.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' + +// Names sort ascending, so the on-screen order is: +// archive.zip, audio.mp3, document.pdf, image.jpg, readme.md, video.mp4, welcome.txt +const files: Record = { + 'image.jpg': 'image/jpeg', + 'document.pdf': 'application/pdf', + 'archive.zip': 'application/zip', + 'audio.mp3': 'audio/mpeg', + 'video.mp4': 'video/mp4', + 'readme.md': 'text/markdown', + 'welcome.txt': 'text/plain', +} +const filesCount = Object.keys(files).length + +test.describe('Files: Select files', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + // Uploading welcome.txt overwrites the auto-created one, so the list holds exactly these files + for (const [name, mime] of Object.entries(files)) { + await uploadContent(page.request, user, Buffer.alloc(0), mime, `/${name}`) + } + await filesListPage.open() + }) + + test('selects and deselects all files', async ({ page, filesListPage }) => { + await expect(filesListPage.getRows()).toHaveCount(filesCount) + await expect(filesListPage.getRowCheckboxes()).toHaveCount(filesCount) + + await filesListPage.selectAll() + await expect(page.getByText(`${filesCount} selected`)).toBeVisible() + await expect(filesListPage.getSelectedRowCheckboxes()).toHaveCount(filesCount) + + await filesListPage.deselectAll() + await expect(page.getByText(/\d+ selected/)).toHaveCount(0) + await expect(filesListPage.getSelectedRowCheckboxes()).toHaveCount(0) + }) + + test('selects an arbitrary subset of files', async ({ page, filesListPage }) => { + const subset = ['image.jpg', 'document.pdf', 'audio.mp3', 'readme.md'] + + for (const name of subset) { + await filesListPage.selectRowForFile(name) + } + + await expect(page.getByText(`${subset.length} selected`)).toBeVisible() + await expect(filesListPage.getSelectedRowCheckboxes()).toHaveCount(subset.length) + }) + + test('selects a range of files with the shift key', async ({ page, filesListPage }) => { + // audio.mp3 -> readme.md spans audio.mp3, document.pdf, image.jpg, readme.md + await filesListPage.selectRowForFile('audio.mp3') + await filesListPage.selectRowForFile('readme.md', { shift: true }) + + await expect(page.getByText('4 selected')).toBeVisible() + await expect(filesListPage.getSelectedRowCheckboxes()).toHaveCount(4) + }) +}) diff --git a/tests/playwright/e2e/files/files-settings.spec.ts b/tests/playwright/e2e/files/files-settings.spec.ts new file mode 100644 index 0000000000000..e1288e97acf47 --- /dev/null +++ b/tests/playwright/e2e/files/files-settings.spec.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('files: Set default view', () => { + test('Defaults to the "files" view', async ({ page, filesListPage, filesNavigation }) => { + await filesListPage.open() + + await expect(page).toHaveURL(/\/apps\/files\/files/) + await expect(filesListPage.getBreadcrumbs().getByRole('button').first()).toHaveText('All files') + + const dialog = await filesNavigation.openSettings() + await expect(dialog.getByRole('group', { name: 'Default view' }).getByRole('radio', { name: 'All files' })).toBeChecked() + }) + + test('Can set it to personal files', async ({ page, filesListPage, filesNavigation }) => { + await filesListPage.open() + + const dialog = await filesNavigation.openSettings() + // The next page load only honors the new default once the config PUT has + // been persisted, so wait for it before re-navigating. + const saved = page.waitForResponse((r) => r.url().includes('/apps/files/api/v1/config/default_view')) + // The radio input is `hidden-visually` and can sit below the dialog fold, so + // clicking its visible label is more reliable than checking the input. + await dialog.getByRole('group', { name: 'Default view' }) + .getByText('Personal files', { exact: true }) + .click() + await saved + await expect(dialog.getByRole('group', { name: 'Default view' }).getByRole('radio', { name: 'Personal files' })).toBeChecked() + await filesNavigation.closeSettings() + + await filesListPage.open() + await expect(page).toHaveURL(/\/apps\/files\/personal/) + await expect(filesListPage.getBreadcrumbs().getByRole('button').first()).toHaveText('Personal files') + }) +}) + +test.describe('files: Hide or show hidden files', () => { + // Seed a hidden file, a visible file and a hidden folder for the acting user. + test.beforeEach(async ({ page, user }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/.file') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/visible-file') + await mkdir(page.request, user, '/.folder') + }) + + for (const { view, viewId } of [ + { view: 'All files', viewId: '' }, + { view: 'Personal files', viewId: 'personal' }, + ]) { + test.describe(`view: ${view}`, () => { + test('hides dot-files by default', async ({ filesListPage }) => { + await filesListPage.open(viewId || undefined) + + await expect(filesListPage.getRowForFile('visible-file')).toBeVisible() + await expect(filesListPage.getRowForFile('.file')).toHaveCount(0) + await expect(filesListPage.getRowForFile('.folder')).toHaveCount(0) + }) + + test('can show hidden files', async ({ filesListPage, filesNavigation }) => { + await filesListPage.open(viewId || undefined) + await filesNavigation.setShowHiddenFiles(true) + + await expect(filesListPage.getRowForFile('.file')).toBeVisible() + await expect(filesListPage.getRowForFile('.folder')).toBeVisible() + }) + }) + } + + test.describe('view: Recent files', () => { + // Recent also surfaces files nested in a hidden folder + test.beforeEach(async ({ page, user }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/.folder/other-file') + }) + + test('hides dot-files by default', async ({ filesListPage }) => { + await filesListPage.open('recent') + + await expect(filesListPage.getRowForFile('visible-file')).toBeVisible() + await expect(filesListPage.getRowForFile('.file')).toHaveCount(0) + await expect(filesListPage.getRowForFile('.folder')).toHaveCount(0) + await expect(filesListPage.getRowForFile('other-file')).toHaveCount(0) + }) + + test('can show hidden files', async ({ filesListPage, filesNavigation }) => { + await filesListPage.open('recent') + await filesNavigation.setShowHiddenFiles(true) + + await expect(filesListPage.getRowForFile('visible-file')).toBeVisible() + await expect(filesListPage.getRowForFile('.file')).toBeVisible() + await expect(filesListPage.getRowForFile('.folder')).toBeVisible() + await expect(filesListPage.getRowForFile('other-file')).toBeVisible() + }) + }) +}) diff --git a/tests/playwright/e2e/files/files-sidebar.spec.ts b/tests/playwright/e2e/files/files-sidebar.spec.ts new file mode 100644 index 0000000000000..bf7bc8df6fc93 --- /dev/null +++ b/tests/playwright/e2e/files/files-sidebar.spec.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Sidebar', () => { + let fileId: string + + test.beforeEach(async ({ user, page, filesListPage }) => { + await mkdir(page.request, user, '/folder') + fileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file') + await filesListPage.open() + }) + + test('opens the sidebar', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('file')).toBeVisible() + }) + + test('changes the current fileid', async ({ page, filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(page).toHaveURL(new RegExp(`apps/files/files/${fileId}`)) + }) + + test('changes the sidebar content on other file', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + // Wait for the first file's heading to be stable before switching + await expect(filesSidebar.heading('file')).toBeVisible() + + await filesListPage.triggerActionForFile('folder', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('folder')).toBeVisible() + }) + + test('closes the sidebar on navigation', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + + // Open the sidebar + await filesListPage.triggerActionForFile('file', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + + // Navigate into the folder — sidebar should close + await filesListPage.navigateToFolder('folder') + await expect(filesSidebar.sidebar()).not.toBeVisible() + }) + + test('closes the sidebar on delete', async ({ page, filesListPage, filesSidebar, user }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + // Open the sidebar + await filesListPage.triggerActionForFile('file', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + // Wait for the sidebar to be fully rendered before deleting + await expect(filesSidebar.heading('file')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (response) => response.url().includes(`/remote.php/dav/files/${user.userId}/file`) + && response.request().method() === 'DELETE', + { timeout: 10000 }, + ) + + await filesListPage.triggerActionForFile('file', 'delete') + await deleteResponse + + await expect(filesSidebar.sidebar()).not.toBeVisible() + }) + + test('changes the fileid on delete', async ({ page, filesListPage, filesSidebar, user }) => { + const otherFileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/other') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('other')).toBeVisible() + + // Open the sidebar for the inner file + await filesListPage.triggerActionForFile('other', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(page).toHaveURL(new RegExp(`apps/files/files/${otherFileId}`)) + // Wait for the sidebar to be fully rendered before deleting + await expect(filesSidebar.heading('other')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (response) => response.url().includes(`/remote.php/dav/files/${user.userId}/folder/other`) + && response.request().method() === 'DELETE', + { timeout: 10000 }, + ) + + await filesListPage.triggerActionForFile('other', 'delete') + await deleteResponse + + await expect(filesSidebar.sidebar()).not.toBeVisible() + await expect(page).not.toHaveURL(new RegExp(`apps/files/files/${otherFileId}`)) + }) +}) diff --git a/tests/playwright/e2e/files/files-sorting.spec.ts b/tests/playwright/e2e/files/files-sorting.spec.ts new file mode 100644 index 0000000000000..261992c20bd99 --- /dev/null +++ b/tests/playwright/e2e/files/files-sorting.spec.ts @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, setFavorite, uploadContent } from '../../support/utils/dav.ts' + +const DAY = 86400 + +test.describe('Files: Sorting the file list', () => { + test('Files are sorted by name ascending by default', async ({ page, user, filesListPage }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1 first.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/z last.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/A.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/Ä.txt') + await mkdir(request, user, '/m') + await mkdir(request, user, '/4') + await filesListPage.open() + + // Folders first (4, m), then files by natural name order + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + '4', + 'm', + '1 first.txt', + 'A.txt', + 'Ä.txt', + 'welcome.txt', + 'z last.txt', + ]) + }) + + /** Regression test of https://github.com/nextcloud/server/issues/45829 */ + test('Filenames with numbers are sorted by name ascending by default', async ({ page, user, filesListPage }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name_03.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name_02.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name_01.txt') + // remove the default file so only the seeded ones are asserted + await filesListPage.open() + + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'name.txt', + 'name_01.txt', + 'name_02.txt', + 'name_03.txt', + 'welcome.txt', + ]) + }) + + test('Can sort by size', async ({ page, user, filesListPage }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1 tiny.txt') + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z big.txt') + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a medium.txt') + await mkdir(request, user, '/folder') + await filesListPage.open() + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + '1 tiny.txt', + 'welcome.txt', + 'a medium.txt', + 'z big.txt', + ]) + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + 'z big.txt', + 'a medium.txt', + 'welcome.txt', + '1 tiny.txt', + ]) + }) + + test('Can sort by mtime', async ({ page, user, filesListPage }) => { + const request = page.request + const now = Date.now() / 1000 + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1.txt', now - DAY - 1000) + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z.txt', now - DAY) + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a.txt', now - DAY - 500) + await filesListPage.open() + + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['welcome.txt', 'z.txt', 'a.txt', '1.txt']) + + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['1.txt', 'a.txt', 'z.txt', 'welcome.txt']) + }) + + test('Favorites are sorted first', async ({ page, user, filesListPage }) => { + const request = page.request + const now = Date.now() / 1000 + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1.txt', now - DAY - 1000) + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z.txt', now - DAY) + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a.txt', now - DAY - 500) + await setFavorite(request, user, '/a.txt') + await filesListPage.open() + + // By name - ascending (default): favorite a.txt first + await expect(filesListPage.getColumnHeader('Name')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', '1.txt', 'welcome.txt', 'z.txt']) + + // By name - descending + await filesListPage.sortByColumn('Name') + await expect(filesListPage.getColumnHeader('Name')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', 'z.txt', 'welcome.txt', '1.txt']) + + // By size - ascending + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', '1.txt', 'welcome.txt', 'z.txt']) + + // By size - descending + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', 'z.txt', 'welcome.txt', '1.txt']) + + // By mtime - ascending + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', 'welcome.txt', 'z.txt', '1.txt']) + + // By mtime - descending + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', '1.txt', 'z.txt', 'welcome.txt']) + }) + + test('Sorting works after switching view twice', async ({ page, user, filesListPage, filesNavigation }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1 tiny.txt') + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z big.txt') + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a medium.txt') + await mkdir(request, user, '/folder') + await filesListPage.open() + + // Toggle size sort twice on the files view + await filesListPage.sortByColumn('Size') + await filesListPage.sortByColumn('Size') + + // Switch to personal and toggle twice again + await filesNavigation.getNavigationItem('personal').click() + await filesListPage.sortByColumn('Size') + await filesListPage.sortByColumn('Size') + + // Back to files view and assert sorting still works + await filesNavigation.getNavigationItem('files').click() + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + '1 tiny.txt', + 'welcome.txt', + 'a medium.txt', + 'z big.txt', + ]) + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + 'z big.txt', + 'a medium.txt', + 'welcome.txt', + '1 tiny.txt', + ]) + }) +}) diff --git a/tests/playwright/e2e/files/files-xml-regression.spec.ts b/tests/playwright/e2e/files/files-xml-regression.spec.ts new file mode 100644 index 0000000000000..61c16f8506027 --- /dev/null +++ b/tests/playwright/e2e/files/files-xml-regression.spec.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' + +/** + * Regression: https://github.com/nextcloud/server/issues/43331 + * Files whose names contain XML entities (e.g. "&.txt") were wrongly + * displayed and could no longer be renamed or deleted. + */ +test.describe('Files: XML entities in file names', () => { + test('renames a file to a name with XML entities and keeps it after reload', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/and.txt') + await filesListPage.open() + + await filesListPage.triggerActionForFile('and.txt', 'rename') + const input = filesListPage.getRenameInputForFile('and.txt') + await expect(input).toBeVisible() + + const renamed = page.waitForResponse((r) => r.request().method() === 'MOVE' && r.url().includes('/remote.php/dav/files/')) + await input.fill('&.txt') + await input.press('Enter') + await renamed + + // The literal name is kept, not decoded to "&.txt" + await expect(filesListPage.getRowForFile('&.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('&.txt')).toHaveCount(0) + + await page.reload() + await expect(filesListPage.getRowForFile('&.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('&.txt')).toHaveCount(0) + }) + + test('can delete a file whose name contains XML entities', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/&.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('&.txt')).toBeVisible() + + const deleted = page.waitForResponse((r) => r.request().method() === 'DELETE' && r.url().includes('/remote.php/dav/files/')) + await filesListPage.triggerActionForFile('&.txt', 'delete') + await deleted + + await expect(filesListPage.getRowForFile('&.txt')).toHaveCount(0) + + await page.reload() + await expect(filesListPage.getRowForFile('&.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('&.txt')).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files/files.spec.ts b/tests/playwright/e2e/files/files.spec.ts new file mode 100644 index 0000000000000..4c1e10d9784cd --- /dev/null +++ b/tests/playwright/e2e/files/files.spec.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files', () => { + test('Login with a user and open the files app', async ({ filesListPage }) => { + await filesListPage.open() + await expect(filesListPage.getRowForFile('welcome.txt')).toBeVisible() + }) + + test('Opens a valid file shows it as active', async ({ page, user, filesListPage }) => { + const fileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/original.txt') + + await page.goto(`apps/files/files/${fileId}`) + + const row = filesListPage.getRowForFileId(Number(fileId)) + await expect(row).toBeVisible() + await expect(row).toHaveAttribute('data-cy-files-list-row-name', 'original.txt') + await expect(row).toBeActiveRow() + await expect(page.getByText('The file could not be found')).toHaveCount(0) + }) + + test('Opens a valid folder shows its content', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder') + + await page.goto('apps/files/files?dir=/folder') + await filesListPage.waitForList() + + await expect(filesListPage.getBreadcrumbs()).toContainText('folder') + await expect(page.getByText('The file could not be found')).toHaveCount(0) + }) + + test('Opens an unknown file show an error', async ({ page }) => { + await page.goto('apps/files/files/123456') + + // The error toast is shown once the (failing) PROPFIND resolves + await expect(page.getByText('The file could not be found')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files/hotkeys.spec.ts b/tests/playwright/e2e/files/hotkeys.spec.ts new file mode 100644 index 0000000000000..7ab35ba94f49e --- /dev/null +++ b/tests/playwright/e2e/files/hotkeys.spec.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, rm } from '../../support/utils/dav.ts' + +test.describe('Files hotkey handling', () => { + // Each test seeds its own user with exactly two folders (abcd, zyx) and no + // welcome.txt, so the keyboard-navigation and delete assertions are isolated + // and parallel-safe. + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/abcd') + await mkdir(page.request, user, '/zyx') + await rm(page.request, user, '/welcome.txt') + await filesListPage.open() + }) + + test('Pressing "arrow down" should go to first file', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + const fileId = Number(new URL(page.url()).pathname.split('/').at(-1)) + await expect(filesListPage.getRowForFileId(fileId)).toHaveAttribute('data-cy-files-list-row-name', 'abcd') + }) + + test('Pressing "arrow up" should go to last file', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowUp') + + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + const fileId = Number(new URL(page.url()).pathname.split('/').at(-1)) + await expect(filesListPage.getRowForFileId(fileId)).toHaveAttribute('data-cy-files-list-row-name', 'zyx') + }) + + test('Pressing D should open the sidebar once', async ({ page, filesListPage, filesSidebar }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + + await filesListPage.getFilesList().press('d') + + await expect(filesSidebar.sidebar()).toBeVisible() + }) + + test('Pressing F2 should rename the file', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + + await filesListPage.getFilesList().press('F2') + + await expect(filesListPage.getRenameInputForFolder('abcd')).toBeVisible() + }) + + test('Pressing S should toggle favorite', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + + await filesListPage.getFilesList().press('s') + await expect(filesListPage.getFavoriteIconForFile('abcd')).toBeVisible() + + await filesListPage.getFilesList().press('s') + await expect(filesListPage.getFavoriteIconForFile('abcd')).toHaveCount(0) + }) + + test('Pressing DELETE should delete the folder after confirmation', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + await expect(filesListPage.getRows()).toHaveCount(2) + + await filesListPage.getFilesList().press('Delete') + + await page.getByRole('dialog', { name: 'Confirm deletion' }) + .getByRole('button', { name: 'Delete folder' }) + .click() + + await expect(filesListPage.getRows()).toHaveCount(1) + }) + + test('Cancelling the confirmation of the DELETE hotkey keeps the folder', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + await expect(filesListPage.getRows()).toHaveCount(2) + + await filesListPage.getFilesList().press('Delete') + + const dialog = page.getByRole('dialog', { name: 'Confirm deletion' }) + await dialog.getByRole('button', { name: 'Cancel' }).click() + + await expect(dialog).toBeHidden() + await expect(filesListPage.getRows()).toHaveCount(2) + }) +}) diff --git a/tests/playwright/e2e/files/live-photos.spec.ts b/tests/playwright/e2e/files/live-photos.spec.ts new file mode 100644 index 0000000000000..5ee42bc255420 --- /dev/null +++ b/tests/playwright/e2e/files/live-photos.spec.ts @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { LivePhoto } from '../../support/utils/live-photos.ts' + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { setupLivePhotos } from '../../support/utils/live-photos.ts' + +test.describe('Files: Live photos', () => { + let livePhoto: LivePhoto + + test.beforeEach(async ({ page, user, filesListPage }) => { + livePhoto = await setupLivePhotos(page.request, user) + await filesListPage.open() + }) + + test('Only renders the .jpg file', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(1) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(0) + }) + + test.describe("'Show hidden files' is enabled", () => { + test.beforeEach(async ({ filesNavigation, filesListPage }) => { + await filesNavigation.setShowHiddenFiles(true) + // The .mov becomes visible once hidden files are shown + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toBeVisible() + }) + + test("Shows both files when 'Show hidden files' is enabled", async ({ filesListPage }) => { + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}.jpg`) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}.mov`) + }) + + test('Copies both files when copying the .jpg', async ({ filesListPage, copyMoveDialog }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).mov`)).toHaveCount(1) + }) + + test('Copies both files when copying the .mov', async ({ filesListPage, copyMoveDialog }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.mov`, 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).mov`)).toHaveCount(1) + }) + + test('Keeps live photo link when copying folder', async ({ filesNavigation, filesListPage, copyMoveDialog }) => { + await filesListPage.createFolder('folder') + + // Move the pair into the folder (the .mov follows the .jpg) + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'move-copy') + await copyMoveDialog.moveToFolder('folder') + // The linked .mov is moved server-side without a client event, so it lingers + // as a stale row and the folder shows a transient "pending" state. Reload for + // a settled listing before acting on the folder (else its menu won't open). + await filesListPage.reloadCurrentFolder() + + // Copy the folder itself into the current directory → "folder (1)" + await filesListPage.triggerActionForFile('folder', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await filesListPage.navigateToFolder('folder (1)') + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + + // With the link intact, hiding hidden files hides the .mov again + await filesNavigation.setShowHiddenFiles(false) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(0) + }) + + test('Blocks copying live photo into a folder with a colliding .mov', async ({ page, user, filesListPage, copyMoveDialog }) => { + await filesListPage.createFolder('folder') + await uploadContent(page.request, user, 'mov file', 'video/mov', `/folder/${livePhoto.fileName}.mov`) + // Reload so the pre-seeded .mov is in the store before the copy + await filesListPage.reloadCurrentFolder() + + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'move-copy') + await copyMoveDialog.copyToFolder('folder') + + await filesListPage.navigateToFolder('folder') + // The copy is rejected because the .mov would collide: only the + // pre-existing .mov remains, neither the .jpg nor a "(1)" copy appears + await expect(filesListPage.getRows()).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).jpg`)).toHaveCount(0) + }) + + test('Moves both files when renaming the .jpg', async ({ filesListPage }) => { + await filesListPage.renameFile(`${livePhoto.fileName}.jpg`, `${livePhoto.fileName}_moved.jpg`) + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.jpg`) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.mov`) + }) + + test('Moves both files when renaming the .mov', async ({ filesListPage }) => { + await filesListPage.renameFile(`${livePhoto.fileName}.mov`, `${livePhoto.fileName}_moved.mov`) + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.jpg`) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.mov`) + }) + + test('Deletes both files when deleting the .jpg', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') + // The clicked file leaves the list reactively; the linked .mov is deleted + // server-side, so reload to see the cascaded removal + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await filesListPage.reloadCurrentFolder() + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(0) + + await filesListPage.open('trashbin') + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', new RegExp(`^${livePhoto.fileName}\\.jpg\\.d[0-9]+$`)) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', new RegExp(`^${livePhoto.fileName}\\.mov\\.d[0-9]+$`)) + }) + + test('Blocks deletion when deleting the .mov', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.mov`, 'delete') + await filesListPage.reloadCurrentFolder() + + // Deletion of the video alone is not allowed: both files stay + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + + await filesListPage.open('trashbin') + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(0) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(0) + }) + + test('Restores both files when restoring the .jpg', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') + await filesListPage.open('trashbin') + + await filesListPage.triggerInlineActionForFileId(livePhoto.jpgFileId, 'restore') + // The clicked file leaves the trashbin reactively; the linked .mov is + // restored server-side, so reload to see the cascaded removal + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(0) + await filesListPage.open('trashbin') + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(0) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(0) + + await filesListPage.open() + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + }) + + test('Blocks restoration when restoring the .mov', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') + await filesListPage.open('trashbin') + + await filesListPage.triggerInlineActionForFileId(livePhoto.movFileId, 'restore') + await filesListPage.reloadCurrentFolder() + + // Restoring the video alone is not allowed: both stay in the trashbin + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(1) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(1) + + await filesListPage.open() + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(0) + }) + }) +}) diff --git a/tests/playwright/e2e/files/new-menu.spec.ts b/tests/playwright/e2e/files/new-menu.spec.ts new file mode 100644 index 0000000000000..37deb08a4dbe2 --- /dev/null +++ b/tests/playwright/e2e/files/new-menu.spec.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator } from '@playwright/test' + +import { expect, test } from '../../support/fixtures/files-page.ts' + +/** Read a native input's constraint-validation message (set by the app). */ +function validationMessage(input: Locator): Promise { + return input.evaluate((el: HTMLInputElement) => el.validationMessage) +} + +test.describe('"New"-menu', () => { + test.beforeEach(async ({ filesListPage }) => { + await filesListPage.open() + }) + + test('Create new folder', async ({ filesListPage }) => { + await filesListPage.createFolder('A new folder') + await expect(filesListPage.getRowForFile('A new folder')).toBeVisible() + }) + + test('Does not allow creating forbidden folder names', async ({ filesListPage }) => { + const dialog = await filesListPage.openNewFolderDialog() + const input = dialog.getByRole('textbox', { name: 'Folder name' }) + await input.fill('.htaccess') + + await expect.poll(() => validationMessage(input)).toMatch(/reserved name/i) + await expect(dialog.getByRole('button', { name: 'Create' })).toBeDisabled() + }) + + test('Does not allow creating folders with already existing names', async ({ filesListPage }) => { + await filesListPage.createFolder('already exists') + + const dialog = await filesListPage.openNewFolderDialog() + const input = dialog.getByRole('textbox', { name: 'Folder name' }) + await input.fill('already exists') + + await expect.poll(() => validationMessage(input)).toMatch(/already in use/i) + await expect(dialog.getByRole('button', { name: 'Create' })).toBeDisabled() + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/47530 + */ + test('Create same folder in child folder', async ({ filesListPage }) => { + await filesListPage.createFolder('folder') + await filesListPage.createFolder('other folder') + await filesListPage.navigateToFolder('folder') + + const dialog = await filesListPage.openNewFolderDialog() + const input = dialog.getByRole('textbox', { name: 'Folder name' }) + await input.fill('other folder') + + // A same-named folder in a different parent is allowed + await expect.poll(() => validationMessage(input)).toBe('') + await dialog.getByRole('button', { name: 'Create' }).click() + + await expect(filesListPage.getRowForFile('other folder')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files/recent-view.spec.ts b/tests/playwright/e2e/files/recent-view.spec.ts new file mode 100644 index 0000000000000..e6e069519ced6 --- /dev/null +++ b/tests/playwright/e2e/files/recent-view.spec.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Recent view', () => { + test.beforeEach(async ({ page, user }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + }) + + test('shows a recently created file in the recent view', async ({ filesListPage }) => { + await filesListPage.open('recent') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + }) + + /** + * Regression: the recent view loaded files with an invalid source, so the + * delete action failed. Deleting from the recent view must work and remove + * the file everywhere. + */ + test('can delete a file from the recent view', async ({ page, filesListPage }) => { + await filesListPage.open('recent') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + const deleted = page.waitForResponse((r) => r.request().method() === 'DELETE' && r.url().includes('/remote.php/dav/files/')) + await filesListPage.triggerActionForFile('file.txt', 'delete') + await deleted + + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + // Gone from the default view too + await filesListPage.open() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files/router-query.spec.ts b/tests/playwright/e2e/files/router-query.spec.ts new file mode 100644 index 0000000000000..2aade53a29214 --- /dev/null +++ b/tests/playwright/e2e/files/router-query.spec.ts @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { test as baseTest, expect } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +type SeededIds = { imageId: number, folderId: number, archiveId: number } + +// Seed an image (known viewer type), a folder and an archive (unknown type). +// The `viewer` app is enabled by default in the test server. +const test = baseTest.extend<{ ids: SeededIds }>({ + ids: async ({ page, user }, use) => { + const image = readFileSync(resolve(process.cwd(), 'tests/data/images/image.jpg')) + const imageId = Number(await uploadContent(page.request, user, image, 'image/jpeg', '/image.jpg')) + const folderId = Number(await mkdir(page.request, user, '/folder')) + const archiveId = Number(await uploadContent(page.request, user, Buffer.alloc(0), 'application/zstd', '/archive.zst')) + await use({ imageId, folderId, archiveId }) + }, +}) + +/** Fails the test if a browser download starts during its lifetime. */ +function assertNoDownload(page: Page): void { + page.on('download', (download) => { + throw new Error(`Unexpected download started: ${download.suggestedFilename()}`) + }) +} + +test.describe('Check router query flags', () => { + test.describe('"opendetails"', () => { + for (const { label, key, name } of [ + { label: 'known file type', key: 'imageId' as const, name: 'image.jpg' }, + { label: 'unknown file type', key: 'archiveId' as const, name: 'archive.zst' }, + { label: 'folder', key: 'folderId' as const, name: 'folder' }, + ]) { + test(`open details for ${label}`, async ({ page, ids, filesSidebar }) => { + assertNoDownload(page) + await page.goto(`apps/files/files/${ids[key]}?opendetails`) + + // Sidebar opens for the node … + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading(name)).toBeVisible() + // … but the viewer does not, and nothing is downloaded + await expect(page.getByRole('dialog', { name })).toHaveCount(0) + }) + } + }) + + test.describe('"openfile"', () => { + const viewerShowsImage = async (page: Page) => { + const dialog = page.getByRole('dialog', { name: 'image.jpg' }) + await expect(dialog).toBeVisible() + // The viewer shows a server-rendered preview, or falls back to the + // original file; either way the only gains a box (and so becomes + // visible) once it finishes loading, and a cold preview render on CI can + // exceed the default 5s timeout. Assert the displayed image by its alt + // rather than pinning to the preview URL — the preview-specific `fileId=` + // selector both flakes on slow loads and misses the fallback source. + await expect(dialog.getByRole('img', { name: 'image.jpg' })).toBeVisible({ timeout: 15_000 }) + } + + test('opens files with default action', async ({ page, ids }) => { + await page.goto(`apps/files/files/${ids.imageId}?openfile`) + await viewerShowsImage(page) + }) + + test('opens files with default action using explicit query state', async ({ page, ids }) => { + await page.goto(`apps/files/files/${ids.imageId}?openfile=true`) + await viewerShowsImage(page) + }) + + test('does not open files with default action when using explicit `false`', async ({ page, ids, filesListPage }) => { + await page.goto(`apps/files/files/${ids.imageId}?openfile=false`) + + await expect(filesListPage.getRowForFileId(ids.imageId)).toBeActiveRow() + await expect(page.getByRole('dialog', { name: 'image.jpg' })).toHaveCount(0) + }) + + test('does not open folders but shows details', async ({ page, ids, filesSidebar, filesListPage }) => { + await page.goto(`apps/files/files/${ids.folderId}?openfile`) + + // The query is rewritten to opendetails + await expect(page).toHaveURL(/[?&]opendetails(&|=|$)/) + await expect(page).not.toHaveURL(/openfile/) + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('folder')).toBeVisible() + // the folder was not entered + await expect(filesListPage.getRowForFileId(ids.imageId)).toBeVisible() + }) + + test('does not open unknown file types but shows details', async ({ page, ids, filesSidebar }) => { + assertNoDownload(page) + await page.goto(`apps/files/files/${ids.archiveId}?openfile`) + + await expect(page).toHaveURL(/[?&]opendetails(&|=|$)/) + await expect(page).not.toHaveURL(/openfile/) + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('archive.zst')).toBeVisible() + }) + }) +}) diff --git a/tests/playwright/e2e/files/scrolling.spec.ts b/tests/playwright/e2e/files/scrolling.spec.ts new file mode 100644 index 0000000000000..51010e3010f37 --- /dev/null +++ b/tests/playwright/e2e/files/scrolling.spec.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { rm, uploadContent } from '../../support/utils/dav.ts' +import { fitFilesListToRows, isFullyInViewport } from '../../support/utils/viewport.ts' + +/** + * Seed `count` empty files named `1.txt … {count}.txt` for the given user and + * return their file ids keyed by number. Each test seeds its own user, so the + * data is isolated and the suite is safe to run in parallel. + */ +async function seedNumberedFiles(request: APIRequestContext, user: User, count: number): Promise> { + // Drop the default file so only the numbered ones are present + await rm(request, user, '/welcome.txt') + const fileIds: Record = {} + for (let i = 1; i <= count; i++) { + fileIds[i] = Number(await uploadContent(request, user, Buffer.alloc(0), 'text/plain', `/${i}.txt`)) + } + return fileIds +} + +test.describe('Files: Scrolling to the selected file (list view)', () => { + let fileIds: Record + + test.beforeEach(async ({ page, user, filesListPage }) => { + fileIds = await seedNumberedFiles(page.request, user, 10) + await filesListPage.open() + // Fit exactly six rows so four of the ten files are virtualized off-screen + await fitFilesListToRows(page, 6) + }) + + test('shows the first rows and keeps the rest off-screen', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[1]}`) + await filesListPage.waitForList() + + await expect(filesListPage.getRowForFile('1.txt')).toBeVisible() + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('1.txt'))).toBe(true) + // A file well past the fold exists but is not on screen + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('10.txt'))).toBe(false) + }) + + test('scrolls a file below the fold into view', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[8]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('8.txt'))).toBe(true) + }) + + test('scrolls to the last page and reveals the footer', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[10]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('10.txt'))).toBe(true) + // The last page cannot scroll further, so the summary footer comes into view + const footer = filesListPage.getFilesList().locator('tfoot') + await expect(footer).toContainText('10 files') + await expect.poll(() => isFullyInViewport(footer)).toBe(true) + }) +}) + +test.describe('Files: Scrolling to the selected file (grid view)', () => { + let fileIds: Record + + test.beforeEach(async ({ page, user, filesListPage }) => { + fileIds = await seedNumberedFiles(page.request, user, 12) + await filesListPage.open() + await filesListPage.enableGridView() + // Fit exactly three grid rows so the last row is virtualized off-screen + await fitFilesListToRows(page, 3, true) + }) + + test('shows the first grid rows and keeps the last off-screen', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[1]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('1.txt'))).toBe(true) + // A file in the last grid row exists but is not on screen + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('12.txt'))).toBe(false) + }) + + test('scrolls the last grid row into view', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[12]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('12.txt'))).toBe(true) + }) +}) diff --git a/tests/playwright/e2e/files/search.spec.ts b/tests/playwright/e2e/files/search.spec.ts new file mode 100644 index 0000000000000..0f12b0e04b06f --- /dev/null +++ b/tests/playwright/e2e/files/search.spec.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('files: search', () => { + // Seed the same file tree for each test's own user (read-only tests → isolated + // and parallel-safe). + test.beforeEach(async ({ page, user, filesListPage }) => { + const request = page.request + await mkdir(request, user, '/some folder') + await mkdir(request, user, '/some folder/nested folder') + await mkdir(request, user, '/other folder') + await mkdir(request, user, '/12345') + await uploadContent(request, user, 'content', 'text/plain', '/file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/some folder/a file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/some folder/a second file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/some folder/nested folder/deep file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/other folder/another file.txt') + await filesListPage.open() + }) + + test('updates the query on the URL', async ({ page, filesNavigation }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('file') + await expect(page).toHaveURL(/query=file($|&)/) + }) + + test('can search globally', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('file') + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + }) + + test('filter does also search locally', async ({ filesNavigation, filesListPage }) => { + await filesListPage.navigateToFolder('some folder') + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + + await filesNavigation.searchInput().fill('file') + + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('deep file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(3) + }) + + test('See "search everywhere" button', async ({ filesNavigation, filesListPage }) => { + await expect(filesListPage.getSearchEverywhereButton()).toHaveCount(0) + + await filesNavigation.searchInput().fill('file') + await expect(filesListPage.getSearchEverywhereButton()).toBeVisible() + + await filesNavigation.searchClearButton().click() + await expect(filesListPage.getSearchEverywhereButton()).toHaveCount(0) + }) + + test('can make local search a global search', async ({ filesNavigation, filesListPage }) => { + await filesListPage.navigateToFolder('some folder') + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + + await filesNavigation.searchInput().fill('file') + + // local results + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('deep file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(3) + + await filesListPage.getSearchEverywhereButton().click() + + // global results + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('deep file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + }) + + test('shows empty content when there are no results', async ({ page, filesNavigation, filesListPage }) => { + await filesListPage.navigateToFolder('some folder') + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('xyz') + + const note = page.getByRole('note').filter({ hasText: /No search results for .xyz./ }) + await expect(note).toBeVisible() + await expect(note.getByRole('searchbox', { name: /search for files/i })).toHaveValue('xyz') + }) + + test('can alter search', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('other') + + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('other folder')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(2) + + await filesNavigation.searchInput().fill('other file') + await expect(filesNavigation.searchInput()).toHaveValue('other file') + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(1) + }) + + test('returns to file list if search is cleared', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('other') + + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('other folder')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(2) + + await filesNavigation.searchClearButton().click() + await expect(filesNavigation.searchInput()).toHaveValue('') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(5) + }) + + /** + * Regression: refreshing the search view (via the breadcrumb reload) must keep + * the `query` in the URL — guarded by a navigation guard. + */ + test('keeps the query in the URL', async ({ page, filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('file') + + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(page).toHaveURL(/query=file($|&)/) + + const search = page.waitForResponse((r) => r.request().method() === 'SEARCH' && r.url().includes('/remote.php/dav/')) + await filesListPage.reloadCurrentFolder() + await search + + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(page).toHaveURL(/query=file($|&)/) + }) +}) diff --git a/tests/playwright/e2e/files_external/admin-settings-external-storage.spec.ts b/tests/playwright/e2e/files_external/admin-settings-external-storage.spec.ts new file mode 100644 index 0000000000000..3bc384fc80206 --- /dev/null +++ b/tests/playwright/e2e/files_external/admin-settings-external-storage.spec.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect, test } from '../../support/fixtures/external-storage-page.ts' +import { deleteAllGlobalStorages } from '../../support/utils/files_external.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +// Runs in the serial "admin-settings" project: it configures *global* external +// storages, which are visible to every user, so it must not run concurrently +// with other tests that enumerate files. +test.describe('files_external settings', () => { + test.beforeAll(async () => { + await runOcc(['app:enable', 'files_external']) + }) + + test.beforeEach(async ({ externalStorageSettings }) => { + await deleteAllGlobalStorages() + await externalStorageSettings.open() + }) + + test('can see the settings section', async ({ externalStorageSettings }) => { + await expect(externalStorageSettings.heading()).toBeVisible() + await expect(externalStorageSettings.table()).toBeVisible() + }) + + test('can see the dialog', async ({ externalStorageSettings }) => { + const dialog = await externalStorageSettings.openAddDialog() + + await expect(dialog.getByRole('textbox', { name: 'Folder name' })).toBeVisible() + await expect(externalStorageSettings.comboBox(/External storage/)).toBeVisible() + await expect(externalStorageSettings.comboBox(/Authentication/)).toBeVisible() + await expect(externalStorageSettings.comboBox(/Restrict to/)).toBeVisible() + + const createButton = externalStorageSettings.createButton() + await expect(createButton).toBeVisible() + await expect(createButton).toHaveAttribute('type', 'submit') + }) + + test('can create storage using the dialog', async ({ page, externalStorageSettings }) => { + const dialog = await externalStorageSettings.openAddDialog() + + await dialog.getByRole('textbox', { name: 'Folder name' }).fill('My Storage') + + await externalStorageSettings.selectComboBoxOption(/External storage/, 'WebDAV') + await externalStorageSettings.selectComboBoxOption(/Authentication/, /Login and password/) + + await dialog.getByRole('textbox', { name: 'Login' }).fill('admin') + await dialog.locator('input[type="password"]').fill('admin') + + // First submit is blocked by the still-empty (required, invalid) URL field + await externalStorageSettings.createButton().click() + + const urlField = dialog.getByRole('textbox', { name: 'URL' }) + await expect(urlField).toBeVisible() + await urlField.fill('http://localhost/remote.php/dav/files/admin') + + await dialog.getByRole('checkbox', { name: /Secure/ }).uncheck({ force: true }) + + await externalStorageSettings.createButton().click() + await handlePasswordConfirmation(page, 'admin') + + await expect(page.getByRole('dialog')).toHaveCount(0) + + // The newly created storage is the single row in the table + await expect(externalStorageSettings.rows()).toHaveCount(1) + const row = externalStorageSettings.rows().first() + await expect(row.getByRole('cell', { name: /My Storage/ })).toBeVisible() + await expect(row.getByRole('cell', { name: /WebDAV/ })).toBeVisible() + await expect(row.getByRole('cell', { name: /Login and password/ })).toBeVisible() + await expect(row.getByRole('button', { name: /Edit/ })).toBeVisible() + + const deleteButton = row.getByRole('button', { name: /Delete/ }) + await expect(deleteButton).toBeVisible() + await deleteButton.click() + await handlePasswordConfirmation(page, 'admin') + + await expect(externalStorageSettings.rows()).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_external/admin-settings-home-folder-root-mount.spec.ts b/tests/playwright/e2e/files_external/admin-settings-home-folder-root-mount.spec.ts new file mode 100644 index 0000000000000..e63f384cfe0b1 --- /dev/null +++ b/tests/playwright/e2e/files_external/admin-settings-home-folder-root-mount.spec.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect, test } from '../../support/fixtures/files-page.ts' +import { AuthBackend, createStorageWithConfig, deleteAllGlobalStorages, setStorageMountOptions, StorageBackend } from '../../support/utils/files_external.ts' + +// Mounts a read-only storage at the *root* of the home folder, which makes every +// user's home read-only and flips the global `overwrites_home_folders` app +// config. Both are global side effects, so this runs serially in the +// "admin-settings" project. +test.beforeAll(async () => { + await runOcc(['app:enable', 'files_external']) +}) + +test.afterEach(async () => { + await deleteAllGlobalStorages() +}) + +test('Does not show write actions on read-only storage mounted at the root of the user\'s home folder', async ({ page, filesListPage }) => { + const uploadPicker = page.locator('[data-cy-upload-picker]') + + await filesListPage.open() + expect(await getOverwritesHomeFolders()).toBe('[]') + await expect(uploadPicker).toBeVisible() + + const id = await createStorageWithConfig('/', StorageBackend.LOCAL, AuthBackend.Null, { datadir: '/tmp' }) + await setStorageMountOptions(id, { readonly: true }) + // HACK: a second storage targeting a subpath is needed for the root one to apply + await createStorageWithConfig('/a', StorageBackend.LOCAL, AuthBackend.Null, { datadir: '/tmp' }) + + await filesListPage.open() + await filesListPage.open() + expect(await getOverwritesHomeFolders()).toBe('["files_external"]') + await expect(uploadPicker).toHaveCount(0) + + await deleteAllGlobalStorages() + await filesListPage.open() + expect(await getOverwritesHomeFolders()).toBe('[]') + await expect(uploadPicker).toBeVisible() +}) + +/** Read the `overwrites_home_folders` files app config as a trimmed string. */ +async function getOverwritesHomeFolders(): Promise { + const { stdout } = await runOcc(['config:app:get', 'files', 'overwrites_home_folders', '--default-value=[]']) + return stdout.trim() +} diff --git a/tests/playwright/e2e/files_external/admin-settings-user-credentials.spec.ts b/tests/playwright/e2e/files_external/admin-settings-user-credentials.spec.ts new file mode 100644 index 0000000000000..267c9256e54e6 --- /dev/null +++ b/tests/playwright/e2e/files_external/admin-settings-user-credentials.spec.ts @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { Page } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { FilesListPage } from '../../support/sections/FilesListPage.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { AuthBackend, createStorageWithConfig, deleteAllGlobalStorages, StorageBackend } from '../../support/utils/files_external.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +const ACTION_CREDENTIALS_EXTERNAL_STORAGE = 'credentials-external-storage' + +// The credentials flow (user-provided / global-user auth) only exists on *global* +// storages, which are mounted for every user. These tests therefore run serially +// in the "admin-settings" project, and tests 2 & 3 share one user to exercise +// global-user credential reuse — hence serial mode and shared `beforeAll` state. +test.describe.configure({ mode: 'serial' }) + +test.describe('Files user credentials', () => { + let user1: User + let user2: User + let storageUser: User + + test.beforeAll(async ({ playwright, baseURL }) => { + await runOcc(['app:enable', 'files_external']) + + user1 = await createRandomUser() + user2 = await createRandomUser() + + // This user holds the WebDAV storage backing the mounts + storageUser = await createRandomUser() + const storageContext = await playwright.request.newContext({ baseURL }) + await login(storageContext, storageUser) + const image = readFileSync(resolve(process.cwd(), 'tests/data/images/image.jpg')) + await uploadContent(storageContext, storageUser, image, 'image/jpeg', '/image.jpg') + await storageContext.dispose() + }) + + test.afterEach(async () => { + await deleteAllGlobalStorages() + }) + + test.afterAll(async () => { + for (const user of [user1, user2, storageUser]) { + await runOcc(['user:delete', user.userId]) + } + }) + + test('Create a user storage with user credentials', async ({ page, context }) => { + // Address the server itself can reach (not the public URL) + const host = 'http://localhost/remote.php/dav/files/' + storageUser.userId + await createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host, secure: 'false' }) + + await login(context.request, user1) + const filesListPage = new FilesListPage(page) + + await page.goto('apps/files/extstoragemounts') + await expect(filesListPage.getRowForFile(storageUser.userId)).toBeVisible() + + await setStorageCredentials(page, filesListPage, storageUser.userId, storageUser, user1) + + // Credentials are set, so the "enter credentials" action is gone + await expect(filesListPage.getInlineActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE)).toHaveCount(0) + + // Finally, the storage is accessible + await expectStorageContainsImage(filesListPage, storageUser.userId) + }) + + test('Create a user storage with GLOBAL user credentials', async ({ page, context }) => { + const host = 'http://localhost/remote.php/dav/files/' + storageUser.userId + await createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' }) + + await login(context.request, user2) + const filesListPage = new FilesListPage(page) + + await page.goto('apps/files/extstoragemounts') + await expect(filesListPage.getRowForFile('storage1')).toBeVisible() + + await setStorageCredentials(page, filesListPage, 'storage1', storageUser, user2) + + await expect(filesListPage.getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE)).toHaveCount(0) + + await expectStorageContainsImage(filesListPage, 'storage1') + }) + + test('Create another user storage while reusing GLOBAL user credentials', async ({ page, context }) => { + const host = 'http://localhost/remote.php/dav/files/' + storageUser.userId + await createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' }) + + await login(context.request, user2) + const filesListPage = new FilesListPage(page) + + await page.goto('apps/files/extstoragemounts') + await expect(filesListPage.getRowForFile('storage2')).toBeVisible() + + // user2 already has global user credentials stored, so no action is needed + await expect(filesListPage.getInlineActionEntryForFile('storage1', ACTION_CREDENTIALS_EXTERNAL_STORAGE)).toHaveCount(0) + await expect(filesListPage.getInlineActionEntryForFile('storage2', ACTION_CREDENTIALS_EXTERNAL_STORAGE)).toHaveCount(0) + + await expectStorageContainsImage(filesListPage, 'storage2') + }) +}) + +/** + * Open an external storage mount from the files root and assert it contains the + * backing `image.jpg`. A freshly-credentialed mount needs a moment to connect, + * so this resets to the root and retries the open until the mount is reachable. + * + * @param filesListPage - The files list page object + * @param mountName - The mount point name to open + */ +async function expectStorageContainsImage(filesListPage: FilesListPage, mountName: string): Promise { + await expect(async () => { + await filesListPage.open() + await filesListPage.getRowNameLinkForFile(mountName).click({ timeout: 5000 }) + await expect(filesListPage.getRowForFile('image.jpg')).toBeVisible({ timeout: 3000 }) + }).toPass({ timeout: 45000 }) +} + +/** + * Enter and confirm the credentials for an external storage row: open the + * credentials dialog through the inline action, submit the storage login, then + * clear the password-confirmation dialog and wait for the credentials to persist. + * + * @param page - The Playwright page + * @param filesListPage - The files list page object + * @param mountName - The mount point name of the storage row + * @param storageUser - The user owning the backing WebDAV storage (its credentials) + * @param sessionUser - The logged-in user (for the password-confirmation dialog) + */ +async function setStorageCredentials( + page: Page, + filesListPage: FilesListPage, + mountName: string, + storageUser: User, + sessionUser: User, +): Promise { + const credentialsSet = page.waitForResponse((response) => response.request().method() === 'PUT' + && response.url().includes('/apps/files_external/userglobalstorages/')) + + await filesListPage.triggerInlineActionForFile(mountName, ACTION_CREDENTIALS_EXTERNAL_STORAGE) + + const storageDialog = page.getByRole('dialog', { name: 'Storage credentials' }) + await expect(storageDialog).toBeVisible() + await storageDialog.getByRole('textbox', { name: 'Login' }).fill(storageUser.userId) + await storageDialog.locator('input[type="password"]').fill(storageUser.password) + await storageDialog.getByRole('button', { name: 'Confirm' }).click() + await expect(storageDialog).toHaveCount(0) + + // Submitting the credentials triggers a password-confirmation prompt + await handlePasswordConfirmation(page, sessionUser.password) + await credentialsSet +} diff --git a/tests/playwright/e2e/files_external/files-external-failed.spec.ts b/tests/playwright/e2e/files_external/files-external-failed.spec.ts new file mode 100644 index 0000000000000..e313cd88f5d6c --- /dev/null +++ b/tests/playwright/e2e/files_external/files-external-failed.spec.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect, test } from '../../support/fixtures/files-page.ts' +import { AuthBackend, createStorageWithConfig, StorageBackend, verifyStorage } from '../../support/utils/files_external.ts' + +// Each test creates a *personal* (`--user`) storage, mounted only for its own +// random user, so these run fully in parallel without interfering with each +// other or with any other files test. +test.beforeAll(async () => { + await runOcc(['app:enable', 'files_external']) +}) + +const invalidHost = 'http://cloud.domain.com/remote.php/dav/files/abcdef123456' + +test('Create a failed user storage with invalid url', async ({ page, user, filesListPage }) => { + const id = await createStorageWithConfig( + 'Storage1', + StorageBackend.DAV, + AuthBackend.LoginCredentials, + { host: invalidHost, secure: 'false' }, + user, + ) + await verifyStorage(id) + + await filesListPage.open() + + // The mount may not be in the first PROPFIND; reload once if it is missing yet + const row = filesListPage.getRowForFile('Storage1') + if (!await row.isVisible()) { + await page.reload() + } + + await expect(row).toBeVisible() + // The title naming the storage ("{name} (unavailable)") only landed in 34 + await expect(filesListPage.getRowNameLinkForFile('Storage1')) + .toHaveAttribute('title', 'This node is unavailable') + + // Clicking an unavailable storage must not open it (location stays the same) + const url = page.url() + await filesListPage.getRowNameLinkForFile('Storage1').click() + expect(page.url()).toBe(url) +}) + +test('Create a failed user storage with invalid login credentials', async ({ page, user, filesListPage }) => { + const id = await createStorageWithConfig( + 'Storage2', + StorageBackend.DAV, + AuthBackend.Password, + { + host: invalidHost, + user: 'invaliduser', + password: 'invalidpassword', + secure: 'false', + }, + user, + ) + await verifyStorage(id) + + await filesListPage.open() + + const row = filesListPage.getRowForFile('Storage2') + if (!await row.isVisible()) { + await page.reload() + } + + await expect(row).toBeVisible() + await expect(filesListPage.getRowNameLinkForFile('Storage2')) + .toHaveAttribute('title', 'This node is unavailable') + + const url = page.url() + await filesListPage.getRowNameLinkForFile('Storage2').click() + expect(page.url()).toBe(url) +}) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-download-forbidden.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-download-forbidden.spec.ts new file mode 100644 index 0000000000000..f231d3d216c87 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-download-forbidden.spec.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../support/fixtures/files-sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createShare, DOWNLOAD_DISABLED_ATTRIBUTE, SharePermission, waitForShare } from '../../support/utils/sharing.ts' + +/** + * A share whose download is switched off must not offer a download action to the + * recipient — neither while view-without-download is allowed instance-wide (the + * file is then viewable but not downloadable) nor when it is off. + * + * The browser is logged in as the recipient here; `owner` seeds the share. + */ +test.describe('files_sharing: Download forbidden', () => { + test.beforeEach(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_allow_view_without_download']) + }) + + test.afterAll(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_allow_view_without_download']) + }) + + test('offers no download action for a folder', async ({ page, user, owner, ownerRequest, filesListPage }) => { + await mkdir(ownerRequest, owner, '/folder') + await createShare(ownerRequest, '/folder', user.userId, { + permissions: SharePermission.READ, + attributes: DOWNLOAD_DISABLED_ATTRIBUTE, + }) + await waitForShare(page.request, user, '', 'folder') + + await filesListPage.open() + let menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + + // Also with view-without-download disabled the action stays away + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_allow_view_without_download']) + + await filesListPage.open() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + }) + + test('offers no download action for a file', async ({ page, user, owner, ownerRequest, filesListPage }) => { + await uploadContent(ownerRequest, owner, Buffer.alloc(0), 'text/plain', '/file.txt') + await createShare(ownerRequest, '/file.txt', user.userId, { + permissions: SharePermission.READ, + attributes: DOWNLOAD_DISABLED_ATTRIBUTE, + }) + await waitForShare(page.request, user, '', 'file.txt') + + await filesListPage.open() + let menu = await filesListPage.openActionsMenuForFile('file.txt') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_allow_view_without_download']) + + await filesListPage.open() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + menu = await filesListPage.openActionsMenuForFile('file.txt') + await expect(menu.getByRole('menuitem', { name: 'Download' })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-expiry-date.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-expiry-date.spec.ts new file mode 100644 index 0000000000000..98d89a8952108 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-expiry-date.spec.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { Page } from '@playwright/test' +import type { FilesListPage } from '../../support/sections/FilesListPage.ts' +import type { SharingTab } from '../../support/sections/SharingTab.ts' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, openSharingPanel } from '../../support/utils/sharing.ts' + +/** The fixtures a test hands to {@link shareAndOpenEditor}. */ +interface SharingContext { + page: Page + user: User + recipient: User + filesListPage: FilesListPage + sharingTab: SharingTab +} + +/** The instance-wide default: internal shares expire two days after creation. */ +const EXPIRE_AFTER_DAYS = 2 + +/** `YYYY-MM-DD` of today plus `days`, the format the date input holds. */ +function dateInDays(days: number): string { + const date = new Date() + date.setDate(date.getDate() + days) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +test.describe('files_sharing: Expiry date of internal shares', () => { + test.beforeAll(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_default_internal_expire_date']) + await runOcc(['config:app:set', '--value', String(EXPIRE_AFTER_DAYS), 'core', 'shareapi_internal_expire_after_n_days']) + }) + + test.afterAll(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_default_internal_expire_date']) + await runOcc(['config:app:delete', 'core', 'shareapi_internal_expire_after_n_days']) + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_internal_expire_date']) + }) + + test.beforeEach(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_internal_expire_date']) + }) + + /** + * Share `folder` with the recipient and open the share editor's advanced + * section, where the expiration date lives. + * + * @param folder - The folder to create and share + * @param context - The fixtures to drive + */ + async function shareAndOpenEditor( + folder: string, + { page, user, recipient, filesListPage, sharingTab }: SharingContext, + ): Promise { + await mkdir(page.request, user, `/${folder}`) + await createShare(page.request, `/${folder}`, recipient.userId) + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, folder) + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + } + + test('applies the default expiry date and enforces it', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_enforce_internal_expire_date']) + await shareAndOpenEditor('default-expiry-enforced', { page, user, recipient, filesListPage, sharingTab }) + + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(EXPIRE_AFTER_DAYS)) + // Enforced means the recipient's share cannot outlive the policy + await expect(sharingTab.checkbox(/expiration date/i)).toBeChecked() + await expect(sharingTab.checkbox(/expiration date/i)).toBeDisabled() + }) + + test('applies the default expiry date without enforcing it', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('default-expiry', { page, user, recipient, filesListPage, sharingTab }) + + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(EXPIRE_AFTER_DAYS)) + await expect(sharingTab.checkbox(/expiration date/i)).toBeChecked() + await expect(sharingTab.checkbox(/expiration date/i)).toBeEnabled() + }) + + test('can be set to a custom date', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('custom-expiry', { page, user, recipient, filesListPage, sharingTab }) + + await sharingTab.expirationDateInput().fill(dateInDays(14)) + await sharingTab.save() + + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(14)) + }) + + test('keeps a custom date across a reload', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('custom-expiry-reload', { page, user, recipient, filesListPage, sharingTab }) + + await sharingTab.expirationDateInput().fill(dateInDays(14)) + await sharingTab.save() + + await page.reload() + await openSharingPanel(filesListPage, sharingTab, 'custom-expiry-reload') + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(14)) + }) + + /** + * Regression test of https://github.com/nextcloud/server/pull/50192: an + * unrelated update must not reset the expiry date to the admin default. + */ + test('keeps a custom date when an unrelated field is updated', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await shareAndOpenEditor('custom-expiry-unrelated', { page, user, recipient, filesListPage, sharingTab }) + + await sharingTab.expirationDateInput().fill(dateInDays(14)) + await sharingTab.save() + + // Change only the note … + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await sharingTab.setCheckbox('Note to recipient', true) + await sharingTab.noteInput().fill('Only the note changed') + await sharingTab.save() + + // … and the date is still the custom one + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.expirationDateInput()).toHaveValue(dateInDays(14)) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts new file mode 100644 index 0000000000000..402abe50c6d50 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-limit-to-same-group.spec.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' + +/** + * With `shareapi_only_share_with_group_members` an existing share stays visible + * only while the two accounts still have a group in common. These tests share + * both ways, then remove the recipient from the shared groups one by one. + * + * The Cypress original logged in as the same user in both assertions of every + * step, so it never actually checked the second direction; both directions are + * verified here. + */ +test.describe('files_sharing: Sharing limited to members of the same group', () => { + const groups = [`group-a-${crypto.randomUUID()}`, `group-b-${crypto.randomUUID()}`] + + test.beforeAll(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_only_share_with_group_members']) + // The groups are shared by both tests, so create them once here rather than + // spending two container round-trips inside every test's own timeout. + for (const group of groups) { + await runOcc(['group:add', group], { failOnError: false }) + } + }) + + test.afterAll(async () => { + await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_only_share_with_group_members']) + for (const group of groups) { + await runOcc(['group:delete', group], { failOnError: false }) + } + }) + + test('keeps the shares while one common group is left', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + const { fromUser, fromRecipient } = await seedMutualShares(user, recipient, page.request, recipientRequest) + + // Leaving one of the two groups is not enough to lose the shares + await runOcc(['group:removeuser', groups[0]!, recipient.userId]) + + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromRecipient)).toBeVisible() + + await login(page.request, recipient) + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromUser)).toBeVisible() + }) + + test('hides the shares once no common group is left', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + const { fromUser, fromRecipient } = await seedMutualShares(user, recipient, page.request, recipientRequest) + + for (const group of groups) { + await runOcc(['group:removeuser', group, recipient.userId]) + } + + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromRecipient)).toHaveCount(0) + + await login(page.request, recipient) + await filesListPage.open() + await expect(filesListPage.getRowForFile(fromUser)).toHaveCount(0) + }) + + /** + * Put both accounts in two shared groups, then share a file each way. + * + * @returns the file names, `fromUser` shared by `user`, `fromRecipient` by `recipient` + */ + async function seedMutualShares( + user: User, + recipient: User, + userRequest: Parameters[0], + recipientRequest: Parameters[0], + ): Promise<{ fromUser: string, fromRecipient: string }> { + for (const group of groups) { + await runOcc(['group:adduser', group, user.userId]) + await runOcc(['group:adduser', group, recipient.userId]) + } + + const fromUser = 'shared-by-user.txt' + const fromRecipient = 'shared-by-recipient.txt' + await uploadContent(userRequest, user, 'share to recipient', 'text/plain', `/${fromUser}`) + await uploadContent(recipientRequest, recipient, 'share to user', 'text/plain', `/${fromRecipient}`) + await createShare(userRequest, `/${fromUser}`, recipient.userId) + await createShare(recipientRequest, `/${fromRecipient}`, user.userId) + + await waitForShare(recipientRequest, recipient, '', fromUser) + await waitForShare(userRequest, user, '', fromRecipient) + + return { fromUser, fromRecipient } + } +}) diff --git a/tests/playwright/e2e/files_sharing/admin-settings-permissions-bundle.spec.ts b/tests/playwright/e2e/files_sharing/admin-settings-permissions-bundle.spec.ts new file mode 100644 index 0000000000000..9c52adab46515 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/admin-settings-permissions-bundle.spec.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { FilesListPage } from '../../support/sections/FilesListPage.ts' +import type { SharingTab } from '../../support/sections/SharingTab.ts' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { openSharingPanel, SharePermission } from '../../support/utils/sharing.ts' + +const { READ, UPDATE, CREATE, DELETE, SHARE } = SharePermission + +test.describe('files_sharing: "Allow editing" permission bundle', () => { + test.beforeEach(async () => { + await runOcc(['config:app:delete', 'files_sharing', 'shareapi_exclude_reshare_from_edit']) + }) + + test.afterAll(async () => { + await runOcc(['config:app:delete', 'files_sharing', 'shareapi_exclude_reshare_from_edit']) + }) + + /** + * Share an entry with the recipient, picking the "Allow editing" bundle, and + * return the permissions the server stored. + * + * @param name - The entry to share + * @param context - The fixtures to drive + */ + async function shareWithEditingBundle( + name: string, + { filesListPage, sharingTab, recipient }: { + filesListPage: FilesListPage + sharingTab: SharingTab + recipient: User + }, + ): Promise { + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, name) + await sharingTab.pickRecipient(recipient.userId) + await sharingTab.selectPermissionBundle('upload-edit') + + const share = await sharingTab.save() + return share.permissions + } + + test.describe('by default resharing is part of editing', () => { + test('grants a folder share everything including resharing', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/folder-with-share') + + const permissions = await shareWithEditingBundle('folder-with-share', { filesListPage, sharingTab, recipient }) + + expect(permissions).toBe(READ | UPDATE | CREATE | DELETE | SHARE) + }) + + test('grants a file share what a file can carry including resharing', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await uploadContent(page.request, user, 'content', 'text/plain', '/file-with-share.txt') + + const permissions = await shareWithEditingBundle('file-with-share.txt', { filesListPage, sharingTab, recipient }) + + // A file share has neither CREATE nor DELETE + expect(permissions).toBe(READ | UPDATE | SHARE) + }) + }) + + test.describe('with resharing excluded from editing', () => { + test.beforeEach(async () => { + await runOcc(['config:app:set', '--value', 'yes', 'files_sharing', 'shareapi_exclude_reshare_from_edit']) + }) + + test('grants a folder share everything but resharing', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/folder-no-share') + + const permissions = await shareWithEditingBundle('folder-no-share', { filesListPage, sharingTab, recipient }) + + expect(permissions).toBe(READ | UPDATE | CREATE | DELETE) + }) + + test('grants a file share only read and update', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await uploadContent(page.request, user, 'content', 'text/plain', '/file-no-share.txt') + + const permissions = await shareWithEditingBundle('file-no-share.txt', { filesListPage, sharingTab, recipient }) + + expect(permissions).toBe(READ | UPDATE) + }) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/file-request.spec.ts b/tests/playwright/e2e/files_sharing/file-request.spec.ts new file mode 100644 index 0000000000000..fcc583ed5b829 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/file-request.spec.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { PublicSharePage } from '../../support/sections/PublicSharePage.ts' +import { getFileContent, mkdir } from '../../support/utils/dav.ts' + +const FOLDER = 'test-folder' +const GUEST = 'Guest' + +/** + * The whole file-request round trip: the owner creates the request through the + * "New" menu, a guest identifies itself and uploads, and the upload shows up in + * the owner's folder under the guest's name. + * + * The Cypress original split this across three tests that shared the share URL + * through a closure; it is one flow, so it is one test here. + */ +test('files_sharing: a guest can upload through a file request', async ({ page, browser, user, filesListPage }) => { + await mkdir(page.request, user, `/${FOLDER}`) + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER) + + // The owner creates the file request + await page.locator('[data-cy-upload-picker]').getByRole('button', { name: 'New' }).first().click() + await page.getByRole('menuitem', { name: 'Create file request' }).click() + + const dialog = page.getByRole('dialog', { name: 'Create a file request' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('textbox', { name: 'Upload destination' })).toHaveValue(new RegExp(FOLDER)) + await dialog.getByRole('textbox', { name: 'Request subject' }).fill('Please upload') + await dialog.getByRole('button', { name: 'Continue' }).click() + + // Neither an expiration date nor a password is asked for by default + await expect(dialog.getByRole('checkbox', { name: 'Set a submission expiration date' })).not.toBeChecked() + await expect(dialog.getByRole('checkbox', { name: 'Set a password' })).not.toBeChecked() + await dialog.getByRole('button', { name: 'Continue' }).click() + + const created = page.getByRole('dialog', { name: 'File request created' }) + const shareUrl = await created.getByRole('textbox', { name: 'Share link' }).inputValue() + expect(shareUrl).toContain('/s/') + + // The dialog's own close control, not the dialog chrome's "Close" button + await created.getByRole('button', { name: 'Close', exact: true }).last().click() + await expect(created).toBeHidden() + + // A guest — a separate, anonymous browser context — uploads a file + const guestContext = await browser.newContext() + try { + const guestPage = await guestContext.newPage() + const publicShare = new PublicSharePage(guestPage) + + await publicShare.open(shareUrl) + await publicShare.submitGuestName(GUEST) + + await expect(guestPage.getByText(`Upload files to ${FOLDER}`).first()).toBeVisible() + + await guestPage.getByRole('button', { name: 'Upload', exact: true }).click() + await publicShare.uploadFiles('Upload files', [ + { name: 'file.txt', mimeType: 'text/plain', buffer: Buffer.from('abcdef') }, + ]) + + // The upload lands in a folder named after the guest + await expect.poll(() => getFileContent(page.request, user, `/${FOLDER}/${GUEST}/file.txt`).catch(() => '')) + .toBe('abcdef') + } finally { + await guestContext.close() + } + + // And the owner sees it there + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER) + await expect(filesListPage.getRowForFile(GUEST)).toBeVisible() + + await filesListPage.navigateToFolder(GUEST) + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() +}) diff --git a/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts new file mode 100644 index 0000000000000..da2ded67437d9 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' + +const EMPTY = Buffer.alloc(0) + +test.describe('files_sharing: Move or copy files', () => { + test('can create a file in a shared folder', async ({ page, user, owner, ownerRequest, filesListPage }) => { + await mkdir(ownerRequest, owner, '/folder') + await createShare(ownerRequest, '/folder', user.userId) + await waitForShare(page.request, user, '', 'folder') + + // The recipient adds a file into the shared folder, then sees it there + await uploadContent(page.request, user, EMPTY, 'text/plain', '/folder/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + }) + + test('can copy a file to a shared folder', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => { + await mkdir(ownerRequest, owner, '/folder') + await createShare(ownerRequest, '/folder', user.userId) + await waitForShare(page.request, user, '', 'folder') + + await uploadContent(page.request, user, EMPTY, 'text/plain', '/file.txt') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.triggerActionForFile('file.txt', 'move-copy') + await copyMoveDialog.copyToFolder('folder') + + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + }) + + test('cannot copy a file to a shared folder with no create permission', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => { + await mkdir(ownerRequest, owner, '/folder') + await mkdir(ownerRequest, owner, '/folder/inner-folder') + await createShare(ownerRequest, '/folder', user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.CREATE }) + await uploadContent(page.request, user, EMPTY, 'text/plain', '/file.txt') + + // Wait for the create restriction (no C) to reach the recipient's listing + await waitForShare(page.request, user, '', 'folder', (p) => !p.includes('C')) + + await filesListPage.open() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.triggerActionForFile('file.txt', 'move-copy') + // navigateTo waits for the folder's listing, so the button is past the + // picker's own loading state and only the missing permission can disable it + await copyMoveDialog.navigateTo('folder') + await expect(copyMoveDialog.dialog().getByText(/inner-folder/)).toBeVisible() + await expect(copyMoveDialog.confirmButton('Copy to folder')).toBeDisabled() + }) + + test('cannot move a file from shared folder with no delete permission', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => { + await mkdir(ownerRequest, owner, '/folder') + await uploadContent(ownerRequest, owner, EMPTY, 'text/plain', '/folder/file.txt') + await createShare(ownerRequest, '/folder', user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.DELETE }) + + // create the target + await mkdir(page.request, user, '/owned-folder') + + // Wait for the delete restriction (no D) to reach the recipient's listing + await waitForShare(page.request, user, '/folder', 'file.txt', (p) => !p.includes('D')) + + await filesListPage.open() + await filesListPage.navigateToFolder('folder') + await filesListPage.triggerActionForFile('file.txt', 'move-copy') + await copyMoveDialog.goToAllFiles() + await copyMoveDialog.navigateTo('owned-folder') + + // can copy but not move + await expect(copyMoveDialog.confirmButton('Copy to owned-folder')).toBeVisible() + await expect(copyMoveDialog.confirmButton('Move to owned-folder')).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/files-shares-view.spec.ts b/tests/playwright/e2e/files_sharing/files-shares-view.spec.ts new file mode 100644 index 0000000000000..5c1ca33e4320a --- /dev/null +++ b/tests/playwright/e2e/files_sharing/files-shares-view.spec.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' + +/** + * Regression test of https://github.com/nextcloud/server/issues/46108: the + * shares views list shared entries with an "Open in files" action, which has to + * land in the folder itself. + */ +test('files_sharing: opens a shared folder from the shares views', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/file') + await createShare(page.request, '/folder', recipient.userId) + await waitForShare(recipientRequest, recipient, '', 'folder') + + // The sharer sees it in "Shared with others" + await filesListPage.open('sharingout') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + + await filesListPage.getRowForFile('folder').getByRole('button', { name: /open in files/i }).click() + + await expect(page).toHaveURL(/apps\/files\/files\/.+dir=\/folder/) + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + // And the recipient the same in "Shared with you" + await login(page.request, recipient) + await filesListPage.open('sharingin') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + + await filesListPage.getRowForFile('folder').getByRole('button', { name: /open in files/i }).click() + + await expect(page).toHaveURL(/apps\/files\/files\/.+dir=\/folder/) + await expect(filesListPage.getRowForFile('file')).toBeVisible() +}) diff --git a/tests/playwright/e2e/files_sharing/note-to-recipient.spec.ts b/tests/playwright/e2e/files_sharing/note-to-recipient.spec.ts new file mode 100644 index 0000000000000..c9383394de027 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/note-to-recipient.spec.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createShare, openSharingPanel, waitForShare } from '../../support/utils/sharing.ts' + +const NOTE = 'Hello, this is the note.' + +test.describe('files_sharing: Note to recipient', () => { + test('is shown to the recipient inside the shared folder', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/file') + await createShare(page.request, '/folder', recipient.userId, { note: NOTE }) + await waitForShare(recipientRequest, recipient, '', 'folder') + + await login(page.request, recipient) + await filesListPage.open() + await filesListPage.navigateToFolder('folder') + + await expect(page.getByText(NOTE)).toBeVisible() + }) + + test('is shown to the recipient even when the folder is empty', async ({ page, user, recipient, recipientRequest, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await createShare(page.request, '/folder', recipient.userId, { note: NOTE }) + await waitForShare(recipientRequest, recipient, '', 'folder') + + await login(page.request, recipient) + await filesListPage.open() + await filesListPage.navigateToFolder('folder') + + await expect(page.getByText(NOTE)).toBeVisible() + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/46188, where + * re-opening a share hid the note it already had. + */ + test('is filled in when the share is edited again', async ({ page, user, recipient, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/folder') + await createShare(page.request, '/folder', recipient.userId, { note: NOTE }) + await filesListPage.open() + + await openSharingPanel(filesListPage, sharingTab, 'folder') + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + + await expect(sharingTab.checkbox('Note to recipient')).toBeChecked() + await expect(sharingTab.noteInput()).toHaveValue(NOTE) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/admin-settings-file-drop-terms.spec.ts b/tests/playwright/e2e/files_sharing/public-share/admin-settings-file-drop-terms.spec.ts new file mode 100644 index 0000000000000..925b44ba2cdd4 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/admin-settings-file-drop-terms.spec.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { mkdir } from '../../../support/utils/dav.ts' +import { BUNDLED_PERMISSIONS, createLinkShare } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' +const DISCLAIMER = 'TEST: Some disclaimer text' + +/** + * The disclaimer is an instance-wide setting, so this lives in the serial + * `admin-settings` project rather than next to the other file-drop tests. + */ +test.beforeAll(async () => { + await runOcc(['config:app:set', '--value', DISCLAIMER, '--type', 'string', 'core', 'shareapi_public_link_disclaimertext']) +}) + +test.afterAll(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_public_link_disclaimertext']) +}) + +test('files_sharing: a file drop shows the terms of service', async ({ page, user, ownerRequest, publicShare }) => { + await mkdir(ownerRequest, user, `/${SHARE_NAME}`) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.FILE_DROP, + }) + await publicShare.open(share.url) + + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + await expect(page.getByText('agree to the terms of service')).toBeVisible() + + await page.getByRole('button', { name: /terms of service/i }).click() + + const dialog = page.getByRole('dialog', { name: 'Terms of service' }) + await expect(dialog).toContainText(DISCLAIMER) + + await dialog.getByRole('button', { name: 'Close' }).click() + + await expect(dialog).toBeHidden() +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/admin-settings-required-before-create.spec.ts b/tests/playwright/e2e/files_sharing/public-share/admin-settings-required-before-create.spec.ts new file mode 100644 index 0000000000000..29c24a4602f2d --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/admin-settings-required-before-create.spec.ts @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { Page } from '@playwright/test' +import type { FilesListPage } from '../../../support/sections/FilesListPage.ts' +import type { SharingTab } from '../../../support/sections/SharingTab.ts' + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect, test } from '../../../support/fixtures/sharing-page.ts' +import { openSharingPanel, seedSharedFolder } from '../../../support/utils/sharing.ts' + +/** + * The instance-wide link-share defaults. Each is either off, defaulted or + * enforced, and the share editor has to ask for whatever is missing *before* it + * creates the share. + */ +interface LinkShareDefaults { + /** `shareapi_enable_link_password_by_default` — offer a password field up front. */ + askForPassword?: boolean + /** `shareapi_enforce_links_password` — a password is mandatory. */ + enforcePassword?: boolean + /** `shareapi_default_expire_date` (+ `shareapi_expire_after_n_days`). */ + defaultExpirationDate?: boolean + /** `shareapi_enforce_expire_date` — the expiration date cannot be removed. */ + enforceExpirationDate?: boolean +} + +/** The page objects a test hands to {@link createLinkShareWithDefaults}. */ +interface SharingContext { + page: Page + user: User + filesListPage: FilesListPage + sharingTab: SharingTab +} + +/** The number of days the default expiration date is configured to. */ +const EXPIRE_AFTER_DAYS = 2 + +/** `YYYY-MM-DD` of today plus `days`, i.e. what the editor should pre-fill. */ +function dateInDays(days: number): string { + const date = new Date() + date.setDate(date.getDate() + days) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +async function applyDefaults(defaults: LinkShareDefaults): Promise { + const flag = (value?: boolean) => value ? 'yes' : 'no' + await runOcc(['config:app:set', '--value', flag(defaults.askForPassword), 'core', 'shareapi_enable_link_password_by_default']) + await runOcc(['config:app:set', '--value', flag(defaults.enforcePassword), 'core', 'shareapi_enforce_links_password']) + await runOcc(['config:app:set', '--value', flag(defaults.defaultExpirationDate), 'core', 'shareapi_default_expire_date']) + await runOcc(['config:app:set', '--value', flag(defaults.enforceExpirationDate), 'core', 'shareapi_enforce_expire_date']) + if (defaults.defaultExpirationDate) { + await runOcc(['config:app:set', '--value', String(EXPIRE_AFTER_DAYS), 'core', 'shareapi_expire_after_n_days']) + } +} + +/** + * Create a link share through the editor under the given defaults, asserting on + * the way that the editor asks for exactly what the configuration demands, and + * return the created share's URL. + * + * @param defaults - The instance defaults to apply first + * @param shareName - The folder to share + * @param context - The page objects to drive + */ +async function createLinkShareWithDefaults( + defaults: LinkShareDefaults, + shareName: string, + { page, user, filesListPage, sharingTab }: SharingContext, +): Promise { + await applyDefaults(defaults) + await seedSharedFolder(page.request, user, shareName) + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, shareName) + + // With something still missing, the button opens the "required information" + // dialog instead of creating the share right away. + await sharingTab.panel().getByRole('button', { name: 'Create a new share link' }).click() + const pending = sharingTab.pendingShareDialog() + + if (defaults.enforcePassword) { + await expect(pending.getByRole('checkbox', { name: 'Password protection (enforced)' })).toBeVisible() + } else if (defaults.askForPassword) { + await expect(pending.getByRole('checkbox', { name: 'Password protection' })).toBeVisible() + } + const password = pending.getByRole('textbox', { name: 'Enter a password' }) + await expect(password).toBeVisible() + await expect(password).toBeEnabled() + if (defaults.enforcePassword) { + // An enforced password has to be filled in before the share can be created + await password.fill(`s3cret-${shareName}`) + } + + if (defaults.enforceExpirationDate) { + await expect(pending.getByRole('checkbox', { name: 'Enable link expiration (enforced)' })).toBeVisible() + } else if (defaults.defaultExpirationDate) { + await expect(pending.getByRole('checkbox', { name: 'Enable link expiration' })).toBeVisible() + } + if (defaults.defaultExpirationDate || defaults.enforceExpirationDate) { + // The date comes pre-filled with the configured default + await expect(sharingTab.pendingExpirationDateInput()).toHaveValue(dateInDays(EXPIRE_AFTER_DAYS)) + } + + return sharingTab.confirmPendingLinkShare() +} + +/** + * The Cypress original ran ten cases, but only these five configurations are + * actually distinct — the others repeat one of them with the same effective + * settings (e.g. "not enforced" spelled out rather than left off). + */ +test.describe('files_sharing: Link share defaults asked for before creating', () => { + test.afterEach(async () => { + await runOcc(['config:app:delete', 'core', 'shareapi_enable_link_password_by_default']) + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_links_password']) + await runOcc(['config:app:delete', 'core', 'shareapi_default_expire_date']) + await runOcc(['config:app:delete', 'core', 'shareapi_enforce_expire_date']) + await runOcc(['config:app:delete', 'core', 'shareapi_expire_after_n_days']) + }) + + test('password and expiration date both enforced', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + enforcePassword: true, + defaultExpirationDate: true, + enforceExpirationDate: true, + }, 'password-and-expire-enforced', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('password enforced, expiration date defaulted', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + enforcePassword: true, + defaultExpirationDate: true, + }, 'password-enforced-default-expire', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('password optional, expiration date enforced', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + defaultExpirationDate: true, + enforceExpirationDate: true, + }, 'default-password-expire-enforced', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('password and expiration date both only defaulted', async ({ page, user, filesListPage, sharingTab }) => { + const url = await createLinkShareWithDefaults({ + askForPassword: true, + defaultExpirationDate: true, + }, 'default-password-and-expire', { page, user, filesListPage, sharingTab }) + + expect(url).toMatch(/\/s\//) + }) + + test('nothing defaulted or enforced creates the share directly', async ({ page, user, filesListPage, sharingTab }) => { + await applyDefaults({}) + await seedSharedFolder(page.request, user, 'no-defaults') + await filesListPage.open() + await openSharingPanel(filesListPage, sharingTab, 'no-defaults') + + // Nothing to ask for, so the button creates the share straight away + const url = await sharingTab.createLinkShare() + + expect(url).toMatch(/\/s\//) + await expect(sharingTab.linkShareEntries()).toHaveCount(1) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/copy-move-rename-files.spec.ts b/tests/playwright/e2e/files_sharing/public-share/copy-move-rename-files.spec.ts new file mode 100644 index 0000000000000..be4f7de657b7f --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/copy-move-rename-files.spec.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { BUNDLED_PERMISSIONS, createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +/** + * A public share that allows uploading and editing — the permission bundle the + * editor calls "Allow upload and editing" — so its content can be reorganized + * by a guest. + */ +test.describe('files_sharing: Public share - copy, move and rename files', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE, + }) + await publicShare.open(share.url) + }) + + test('can copy a file to another folder', async ({ page, filesListPage, copyMoveDialog }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.triggerActionForFile('foo.txt', 'move-copy') + await copyMoveDialog.copyToFolder('subfolder') + + // The copy source stays where it is + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.navigateToFolder('subfolder') + + await expect(page).toHaveURL(/dir=\/subfolder/) + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('bar.txt')).toBeVisible() + }) + + test('can move a file to another folder', async ({ page, filesListPage, copyMoveDialog }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.triggerActionForFile('foo.txt', 'move-copy') + await copyMoveDialog.moveToFolder('subfolder') + + // Moved out of the current folder + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + await expect(filesListPage.getRowForFile('foo.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('subfolder') + + await expect(page).toHaveURL(/dir=\/subfolder/) + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + }) + + test('can rename a file', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.renameFile('foo.txt', 'other.txt') + + await expect(filesListPage.getRowForFile('other.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('foo.txt')).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/default-view.spec.ts b/tests/playwright/e2e/files_sharing/public-share/default-view.spec.ts new file mode 100644 index 0000000000000..2a549aba85a9c --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/default-view.spec.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { createLinkShare, GRID_VIEW_ATTRIBUTE, seedSharedFolder } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +/** + * Which view mode a public share opens in. The view is identified by the toggle + * the header offers: "Switch to grid view" means we are in list view, and the + * other way round. + */ +test.describe('files_sharing: Public share - default view mode', () => { + test.beforeEach(async ({ user, ownerRequest }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + }) + + test('opens in list view by default', async ({ page, ownerRequest, publicShare, filesListPage }) => { + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(page.getByRole('button', { name: 'Switch to grid view' })).toBeEnabled() + }) + + test('can be toggled by the visitor', async ({ page, ownerRequest, publicShare, filesListPage }) => { + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await page.getByRole('button', { name: 'Switch to grid view' }).click() + + // The toggle now offers the way back, i.e. we are in grid view + await expect(page.getByRole('button', { name: 'Switch to list view' })).toBeEnabled() + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + }) + + test('opens in grid view when the share asks for it', async ({ page, ownerRequest, publicShare, filesListPage }) => { + // "Show files in grid view" in the share editor stores this attribute + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { attributes: GRID_VIEW_ATTRIBUTE }) + await publicShare.open(share.url) + + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(page.getByRole('button', { name: 'Switch to list view' })).toBeEnabled() + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/download.spec.ts b/tests/playwright/e2e/files_sharing/public-share/download.spec.ts new file mode 100644 index 0000000000000..985ab1dc912c2 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/download.spec.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Download, Page } from '@playwright/test' + +import { readFile } from 'node:fs/promises' +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { uploadContent } from '../../../support/utils/dav.ts' +import { createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' +import { getZipEntries } from '../../../support/utils/zip.ts' + +const SHARE_NAME = 'a-folder-share' + +/** + * Register the download listener before the action that triggers it — Playwright + * needs `waitForEvent('download')` to be pending first. + */ +async function triggerDownload(page: Page, action: () => Promise): Promise { + const downloadPromise = page.waitForEvent('download') + await action() + return downloadPromise +} + +/** Read a download's body as UTF-8 text. */ +async function readDownloadText(download: Download): Promise { + return readFile(await download.path(), 'utf-8') +} + +test.describe('files_sharing: Public share - downloading a shared file', () => { + /** + * A file share behaves like a folder share except for the download: its source + * is the share token, so the displayed name comes from the share itself. + */ + test('can download the shared file', async ({ page, user, ownerRequest, publicShare, filesListPage }) => { + const fileId = await uploadContent(ownerRequest, user, 'foo', 'text/plain', '/file.txt') + const share = await createLinkShare(ownerRequest, '/file.txt') + await publicShare.open(share.url) + + const row = filesListPage.getRowForFileId(Number(fileId)) + await expect(row).toBeVisible() + // The extension is rendered in its own element, so allow whitespace in between + await expect(row.locator('[data-cy-files-list-row-name]')).toHaveText(/file\s*\.txt/) + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFileId(Number(fileId), 'download')) + + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readDownloadText(download)).toBe('foo') + }) +}) + +test.describe('files_sharing: Public share - downloading from a shared folder', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + }) + + test('can download everything by selecting all', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.selectAll() + await expect(page.getByText('2 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe(`${SHARE_NAME}.zip`) + expect(await getZipEntries(download)).toEqual([ + 'foo.txt', + 'subfolder/', + 'subfolder/bar.txt', + ]) + }) + + test('can download a selected folder', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.selectRowForFile('subfolder') + await expect(page.getByText('1 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/bar.txt', + ]) + }) + + test('can download a folder by its row action', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('subfolder', 'download')) + + expect(download.suggestedFilename()).toBe('subfolder.zip') + expect(await getZipEntries(download)).toEqual([ + 'subfolder/', + 'subfolder/bar.txt', + ]) + }) + + test('can download a file by its row action', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerActionForFile('foo.txt', 'download')) + + expect(download.suggestedFilename()).toBe('foo.txt') + expect(await readDownloadText(download)).toBe('foo') + }) + + test('can download a selected file', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await filesListPage.selectRowForFile('foo.txt') + await expect(page.getByText('1 selected')).toBeVisible() + + const download = await triggerDownload(page, () => filesListPage.triggerSelectionAction('download')) + + expect(download.suggestedFilename()).toBe('foo.txt') + expect(await readDownloadText(download)).toBe('foo') + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/file-drop.spec.ts b/tests/playwright/e2e/files_sharing/public-share/file-drop.spec.ts new file mode 100644 index 0000000000000..045df14aca7fb --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/file-drop.spec.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { getFileContent, mkdir, uploadContent } from '../../../support/utils/dav.ts' +import { BUNDLED_PERMISSIONS, createLinkShare } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +/** + * A file-drop share ("File request" in the share editor): visitors may upload + * but never see what is already there. + */ +test.describe('files_sharing: Public share - file drop', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await mkdir(ownerRequest, user, `/${SHARE_NAME}`) + await uploadContent(ownerRequest, user, 'content', 'text/plain', `/${SHARE_NAME}/foo.txt`) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.FILE_DROP, + }) + await publicShare.open(share.url) + }) + + test('cannot see the share content', async ({ user, ownerRequest, publicShare, filesListPage }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + // The file is there … + expect(await getFileContent(ownerRequest, user, `/${SHARE_NAME}/foo.txt`)).toBe('content') + // … but never listed for the visitor + await expect(filesListPage.getRowForFile('foo.txt')).toHaveCount(0) + }) + + test('offers uploading as the only action of the new-content menu', async ({ page, publicShare }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + await page.getByRole('button', { name: 'New' }).click() + + const menu = page.getByRole('menu') + await expect(menu.getByRole('menuitem')).toHaveCount(2) + await expect(menu.getByRole('menuitem', { name: 'Upload files' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Upload folders' })).toBeVisible() + }) + + test('offers the same options on the dedicated upload button', async ({ page, publicShare }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + await page.getByRole('button', { name: 'Upload', exact: true }).click() + + const menu = page.getByRole('menu') + await expect(menu.getByRole('menuitem')).toHaveCount(2) + await expect(menu.getByRole('menuitem', { name: 'Upload files' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Upload folders' })).toBeVisible() + }) + + test('can upload files and reports the progress', async ({ page, user, ownerRequest, publicShare }) => { + await expect(publicShare.fileDropDescription(SHARE_NAME)).toBeVisible() + + // Hold the second upload's *response* back so the progress bar can be + // observed mid-flight: the bytes are sent (which is what progress counts) + // but the upload is not finished yet. Delaying the request instead would + // stall the transfer and never move the bar. + const { promise: held, resolve: release } = Promise.withResolvers() + await page.route(/\/public\.php\/dav\/files\//, async (route) => { + if (route.request().url().includes('first.txt')) { + await route.continue() + return + } + const response = await route.fetch() + await held + await route.fulfill({ response }) + }) + + await page.getByRole('button', { name: 'Upload', exact: true }).click() + await publicShare.uploadFiles('Upload files', [ + { name: 'first.txt', mimeType: 'text/plain', buffer: Buffer.from('8 bytes!') }, + { name: 'second.md', mimeType: 'text/markdown', buffer: Buffer.from('x'.repeat(128)) }, + ]) + + // While the second file is still in flight the bar reports the first one as + // done — a partial value, not a finished upload. The exact percentage is + // the uploader's own byte accounting, so only the range is asserted. + const progress = page.getByRole('progressbar') + await expect(progress).toBeVisible() + await expect.poll(async () => Number(await progress.getAttribute('value') ?? 0), { + message: 'the progress bar should report partial progress', + }).toBeGreaterThan(0) + expect(Number(await progress.getAttribute('value'))).toBeLessThan(100) + + release() + + await expect.poll(() => getFileContent(ownerRequest, user, `/${SHARE_NAME}/first.txt`).catch(() => '')) + .toBe('8 bytes!') + await expect.poll(() => getFileContent(ownerRequest, user, `/${SHARE_NAME}/second.md`).catch(() => '')) + .toBe('x'.repeat(128)) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/header-avatar.spec.ts b/tests/playwright/e2e/files_sharing/public-share/header-avatar.spec.ts new file mode 100644 index 0000000000000..ee38032520c8e --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/header-avatar.spec.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' + +/** + * The guest identification menu of a public share: a visitor is anonymous until + * they set a public name, which is then remembered across shares. + */ +test.describe('files_sharing: Public share - guest identification', () => { + test('shows the anonymous guest menu', async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, 'public1') + const share = await createLinkShare(ownerRequest, '/public1') + await publicShare.open(share.url) + + const menu = await publicShare.openUserMenu() + + await expect(menu.getByRole('note')).toContainText('not identified') + await expect(menu.getByRole('link', { name: /Set public name/i })).toBeVisible() + }) + + test('can set a public name', async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, 'public1') + const share = await createLinkShare(ownerRequest, '/public1') + await publicShare.open(share.url) + + await publicShare.setPublicName('John Doe') + + // The avatar is now the one generated for that guest name + await expect(publicShare.userMenuButton().locator('img')) + .toHaveAttribute('src', /avatar\/guest\/John%20Doe/) + }) + + test('keeps the public name across shares and allows changing it', async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, 'public1') + await seedSharedFolder(ownerRequest, user, 'public2') + const first = await createLinkShare(ownerRequest, '/public1') + const second = await createLinkShare(ownerRequest, '/public2') + + await publicShare.open(first.url) + await publicShare.setPublicName('Jane Doe') + + // The name travels to another share of the same visitor + await publicShare.open(second.url) + const menu = await publicShare.openUserMenu() + await expect(menu.getByRole('note')).toContainText('Your guest name: Jane Doe') + + await publicShare.setPublicName('Foo Bar') + + await expect(publicShare.userMenuButton().locator('img')) + .toHaveAttribute('src', /avatar\/guest\/Foo%20Bar/) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/header-menu.spec.ts b/tests/playwright/e2e/files_sharing/public-share/header-menu.spec.ts new file mode 100644 index 0000000000000..cdf2a81d78e68 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/header-menu.spec.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' +import { getZipEntries } from '../../../support/utils/zip.ts' + +const SHARE_NAME = 'shared' +const FEDERATED_SHARE_API = '/apps/federatedfilesharing/createFederatedShare' + +/** The direct link points at the share's DAV endpoint and downloads it as a zip. */ +const DIRECT_LINK = /\/public\.php\/dav\/files\/.+\/?accept=zip$/ + +test.describe('files_sharing: Public share - header actions menu', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`) + await publicShare.open(share.url) + }) + + test('can download all files', async ({ page, publicShare }) => { + const downloadPromise = page.waitForEvent('download') + await publicShare.primaryAction('Download').click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe(`${SHARE_NAME}.zip`) + expect(await getZipEntries(download)).toEqual([ + `${SHARE_NAME}/`, + `${SHARE_NAME}/foo.txt`, + `${SHARE_NAME}/subfolder/`, + `${SHARE_NAME}/subfolder/bar.txt`, + ]) + }) + + test('offers a direct link and closes the menu when it is used', async ({ publicShare }) => { + await publicShare.openActionsMenu() + + const directLink = publicShare.actionsMenuEntry('Direct link') + await expect(directLink).toHaveAttribute('href', DIRECT_LINK) + + await directLink.click() + + await expect(publicShare.actionsMenu()).toBeHidden() + }) + + test('can create a federated share', async ({ page, publicShare }) => { + await publicShare.openActionsMenu() + await publicShare.actionsMenuEntry(/Add to your/i).click() + + const dialog = publicShare.federatedShareDialog() + await expect(dialog).toBeVisible() + + await dialog.getByRole('textbox').fill('user@nextcloud.local') + + const created = page.waitForResponse((r) => r.url().includes(FEDERATED_SHARE_API)) + await dialog.getByRole('button', { name: 'Create share' }).click() + await created + }) + + test('disables the submit button while the federated share is created', async ({ page, publicShare }) => { + // Hold the response back so the in-flight state can be observed + const { promise: held, resolve: release } = Promise.withResolvers() + await page.route(`**${FEDERATED_SHARE_API}`, async (route) => { + await held + await route.fulfill({ status: 503, body: '' }) + }) + + await publicShare.openActionsMenu() + await publicShare.actionsMenuEntry(/Add to your/i).click() + + const dialog = publicShare.federatedShareDialog() + await dialog.getByRole('textbox').fill('user@nextcloud.local') + + const submit = dialog.getByRole('button', { name: 'Create share' }) + await submit.click() + await expect(submit).toBeDisabled() + + release() + + await expect(submit).toBeEnabled() + }) + + test('validates the federated share input', async ({ publicShare }) => { + await publicShare.openActionsMenu() + await publicShare.actionsMenuEntry(/Add to your/i).click() + + const input = publicShare.federatedShareDialog().getByRole('textbox') + + // A bare domain is missing the user part + await input.fill('nextcloud.local') + await expect(input).toHaveValidationMessage(/user/i) + + // And the domain itself has to be a URL + await input.fill('user@invalid') + await expect(input).toHaveValidationMessage(/invalid.+url/i) + }) + + test('moves the primary action into the menu on small screens', async ({ page, publicShare }) => { + await page.setViewportSize({ width: 490, height: 490 }) + + // Nothing is rendered next to the menu any more + await expect(publicShare.primaryAction('Download')).toHaveCount(0) + await expect(publicShare.primaryAction('Direct link')).toHaveCount(0) + await expect(publicShare.primaryAction(/Add to your/i)).toHaveCount(0) + + const menu = await publicShare.openActionsMenu() + await expect(menu.getByRole('menuitem')).toHaveCount(3) + await expect(publicShare.actionsMenuEntry(/^Download/)).toBeVisible() + await expect(publicShare.actionsMenuEntry('Direct link')).toHaveAttribute('href', DIRECT_LINK) + + await publicShare.actionsMenuEntry(/Add to your/i).click() + + await expect(publicShare.federatedShareDialog()).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/share-editor.spec.ts b/tests/playwright/e2e/files_sharing/public-share/share-editor.spec.ts new file mode 100644 index 0000000000000..363ccab9b036a --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/share-editor.spec.ts @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/sharing-page.ts' +import { mkdir } from '../../../support/utils/dav.ts' +import { createLinkShare, openSharingPanel } from '../../../support/utils/sharing.ts' + +/** The link-share side of the share editor, driven by the share owner. */ +test.describe('files_sharing: Link share editor', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/test') + await createLinkShare(page.request, '/test') + await filesListPage.open() + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/53566, where + * an apostrophe in the label was rendered as `'`. + */ + test('lists a share label with special characters as typed', async ({ filesListPage, sharingTab }) => { + await openSharingPanel(filesListPage, sharingTab, 'test') + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await sharingTab.labelInput().fill('Alice\' share') + await sharingTab.save() + + await expect(sharingTab.linkShareEntries()).toHaveCount(1) + await expect(sharingTab.linkShareEntries().first()).toContainText('Share link (Alice\' share)') + }) + + /** + * Regression test: "Hide download" must survive both re-opening the editor and + * a page reload — the checkbox used to fall back to its default. + */ + test('keeps "Hide download" after saving and reloading', async ({ page, filesListPage, sharingTab }) => { + await openSharingPanel(filesListPage, sharingTab, 'test') + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).not.toBeChecked() + await sharingTab.setCheckbox('Hide download', true) + await sharingTab.save() + + // Still set when the editor is opened again … + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).toBeChecked() + + // … and after a reload, i.e. it was really stored + await page.reload() + await openSharingPanel(filesListPage, sharingTab, 'test') + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).toBeChecked() + }) + + test('cancelling the edition resets to the previous state', async ({ page, filesListPage, sharingTab }) => { + await openSharingPanel(filesListPage, sharingTab, 'test') + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.labelInput()).toHaveValue('') + await sharingTab.labelInput().fill('The label') + await expect(sharingTab.checkbox('Set password')).not.toBeChecked() + await sharingTab.setCheckbox('Set password', true) + // A password is automatically generated and added to the input + await expect(sharingTab.checkbox('Set expiration date')).not.toBeChecked() + await sharingTab.setCheckbox('Set expiration date', true) + // A default expiration date is automatically added to the input + await expect(sharingTab.checkbox('Hide download')).not.toBeChecked() + await sharingTab.setCheckbox('Hide download', true) + await expect(sharingTab.checkbox('Note to recipient')).not.toBeChecked() + await sharingTab.setCheckbox('Note to recipient', true) + await sharingTab.noteInput().fill('The note') + await expect(sharingTab.checkbox('Custom permissions')).not.toBeChecked() + await sharingTab.setCheckbox('Custom permissions', true) + await expect(sharingTab.checkbox('Edit')).not.toBeChecked() + await sharingTab.setCheckbox('Edit', true) + await sharingTab.cancel() + + // Back to the original state when the editor is opened again … + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.labelInput()).toHaveValue('') + await expect(sharingTab.checkbox('Set password')).not.toBeChecked() + await expect(sharingTab.checkbox('Set expiration date')).not.toBeChecked() + await expect(sharingTab.checkbox('Hide download')).not.toBeChecked() + await expect(sharingTab.checkbox('Note to recipient')).not.toBeChecked() + await expect(sharingTab.checkbox('Custom permissions')).not.toBeChecked() + + // … and after a reload, i.e. it was not stored + await page.reload() + await openSharingPanel(filesListPage, sharingTab, 'test') + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.labelInput()).toHaveValue('') + await expect(sharingTab.checkbox('Set password')).not.toBeChecked() + await expect(sharingTab.checkbox('Set expiration date')).not.toBeChecked() + await expect(sharingTab.checkbox('Hide download')).not.toBeChecked() + await expect(sharingTab.checkbox('Note to recipient')).not.toBeChecked() + await expect(sharingTab.checkbox('Custom permissions')).not.toBeChecked() + }) + + test('the password is unchecked after clearing and saving it', async ({ filesListPage, sharingTab }) => { + await openSharingPanel(filesListPage, sharingTab, 'test') + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Set password')).not.toBeChecked() + await sharingTab.setCheckbox('Set password', true) + // A password is automatically generated and added to the input + await sharingTab.save() + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Set password')).toBeChecked() + await sharingTab.setCheckbox('Set password', false) + await sharingTab.save() + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Set password')).not.toBeChecked() + }) +}) + +test.describe('files_sharing: Email share editor', () => { + /** + * A brand new email share must keep the "Hide download" option that was set + * before it was ever saved. + */ + test('keeps "Hide download" set while creating the share', async ({ page, user, filesListPage, sharingTab }) => { + await mkdir(page.request, user, '/test') + await filesListPage.open() + + await openSharingPanel(filesListPage, sharingTab, 'test') + await sharingTab.pickRecipient('test@example.com', { external: true }) + + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).not.toBeChecked() + await sharingTab.setCheckbox('Hide download', true) + await sharingTab.save() + + await sharingTab.openLinkShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Hide download')).toBeChecked() + }) +}) diff --git a/tests/playwright/e2e/files_sharing/public-share/view-only.spec.ts b/tests/playwright/e2e/files_sharing/public-share/view-only.spec.ts new file mode 100644 index 0000000000000..87bcc31cc6cef --- /dev/null +++ b/tests/playwright/e2e/files_sharing/public-share/view-only.spec.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/public-share-page.ts' +import { BUNDLED_PERMISSIONS, createLinkShare, seedSharedFolder } from '../../../support/utils/sharing.ts' + +const SHARE_NAME = 'shared' + +test.describe('files_sharing: Public share - view only', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.READ_ONLY, + }) + await publicShare.open(share.url) + }) + + test('can see the files list', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + }) + + test('can navigate to a subfolder', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.navigateToFolder('subfolder') + + await expect(filesListPage.getRowForFile('bar.txt')).toBeVisible() + }) + + test('cannot upload files', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + // Without CREATE permission the share offers no way to add content + await expect(page.getByRole('button', { name: 'New' })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Upload/ })).toHaveCount(0) + }) + + test('offers downloading as the only file action', async ({ page, filesListPage }) => { + const menu = await filesListPage.openActionsMenuForFile('foo.txt') + + await expect(menu.getByRole('menuitem')).toHaveCount(1) + await expect(menu.getByRole('menuitem', { name: 'Download' })).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + await menu.getByRole('menuitem', { name: 'Download' }).click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('foo.txt') + }) +}) + +test.describe('files_sharing: Public share - view only without download', () => { + test.beforeEach(async ({ user, ownerRequest, publicShare }) => { + await seedSharedFolder(ownerRequest, user, SHARE_NAME) + const share = await createLinkShare(ownerRequest, `/${SHARE_NAME}`, { + permissions: BUNDLED_PERMISSIONS.READ_ONLY, + hideDownload: true, + }) + await publicShare.open(share.url) + }) + + test('can see the files list', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + }) + + test('offers no file actions at all', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await expect(filesListPage.getRowForFile('foo.txt').getByRole('button', { name: 'Actions' })).toHaveCount(0) + }) + + test('can navigate to a subfolder, which also has no actions', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + await filesListPage.navigateToFolder('subfolder') + + await expect(filesListPage.getRowForFile('bar.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('bar.txt').getByRole('button', { name: 'Actions' })).toHaveCount(0) + }) + + test('cannot upload files', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo.txt')).toBeVisible() + + await expect(page.getByRole('button', { name: 'New' })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Upload/ })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/files_sharing/share-editor.spec.ts b/tests/playwright/e2e/files_sharing/share-editor.spec.ts new file mode 100644 index 0000000000000..3f8453f56ec90 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/share-editor.spec.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, openSharingPanel } from '../../support/utils/sharing.ts' + +test.describe('files_sharing: User share editor', () => { + test.beforeEach(async ({ page, user, recipient, filesListPage }) => { + await mkdir(page.request, user, '/test') + await createShare(page.request, '/test', recipient.userId) + await filesListPage.open() + }) + + test('cancelling the edition resets to the previous state', async ({ page, filesListPage, sharingTab }) => { + await openSharingPanel(filesListPage, sharingTab, 'test') + + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Set expiration date')).not.toBeChecked() + await sharingTab.setCheckbox('Set expiration date', true) + // A default expiration date is automatically added to the input + await expect(sharingTab.checkbox('Allow download and sync')).toBeChecked() + await sharingTab.setCheckbox('Allow download and sync', false) + await expect(sharingTab.checkbox('Note to recipient')).not.toBeChecked() + await sharingTab.setCheckbox('Note to recipient', true) + await sharingTab.noteInput().fill('The note') + await expect(sharingTab.checkbox('Custom permissions')).not.toBeChecked() + await sharingTab.setCheckbox('Custom permissions', true) + await expect(sharingTab.checkbox('Edit')).toBeChecked() + await sharingTab.setCheckbox('Edit', false) + await sharingTab.cancel() + + // Back to the original state when the editor is opened again … + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Set expiration date')).not.toBeChecked() + await expect(sharingTab.checkbox('Allow download and sync')).toBeChecked() + await expect(sharingTab.checkbox('Note to recipient')).not.toBeChecked() + await expect(sharingTab.checkbox('Custom permissions')).not.toBeChecked() + + // … and after a reload, i.e. it was not stored + await page.reload() + await openSharingPanel(filesListPage, sharingTab, 'test') + await sharingTab.openShareDetails() + await sharingTab.openAdvancedSettings() + await expect(sharingTab.checkbox('Set expiration date')).not.toBeChecked() + await expect(sharingTab.checkbox('Allow download and sync')).toBeChecked() + await expect(sharingTab.checkbox('Note to recipient')).not.toBeChecked() + await expect(sharingTab.checkbox('Custom permissions')).not.toBeChecked() + }) +}) diff --git a/tests/playwright/e2e/files_sharing/share-status-action.spec.ts b/tests/playwright/e2e/files_sharing/share-status-action.spec.ts new file mode 100644 index 0000000000000..c889b2d1d7c11 --- /dev/null +++ b/tests/playwright/e2e/files_sharing/share-status-action.spec.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/e2e-test-server' +import { addUser, runOcc } from '@nextcloud/e2e-test-server/docker' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '../../support/fixtures/sharing-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' + +test.describe('files_sharing: Sharing status action', () => { + /** + * Regression test of https://github.com/nextcloud/server/issues/45723: a + * purely numerical user id used to be mistaken for a share, so an unshared + * folder was flagged as shared. + */ + test('shows no sharing status for a numerical user id without shares', async ({ page, filesListPage }) => { + const uid = crypto.getRandomValues(new Uint32Array(1))[0].toString() + const numericalUser = new User(uid, uid, 'en') + await addUser(numericalUser) + + try { + await login(page.request, numericalUser) + await mkdir(page.request, numericalUser, '/folder') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('folder').getByRole('button', { name: 'Shared' })).toHaveCount(0) + } finally { + await runOcc(['user:delete', uid], { failOnError: false }) + } + }) + + test('offers a quick sharing action that opens the sharing tab', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await filesListPage.open() + + await filesListPage.getRowForFile('folder').hover() + await filesListPage.getRowForFile('folder') + .getByRole('button', { name: /Sharing options/ }) + .click({ force: true }) + + // The sidebar opens straight on the Sharing tab + await expect(page.getByRole('tab', { name: 'Sharing', selected: true })).toBeVisible() + }) + + test.describe('for a shared folder', () => { + test.beforeEach(async ({ page, user, recipient, recipientRequest }) => { + await mkdir(page.request, user, '/folder') + await createShare(page.request, '/folder', recipient.userId) + await waitForShare(recipientRequest, recipient, '', 'folder') + }) + + test('names the recipient for the sharer', async ({ filesListPage }) => { + await filesListPage.open() + + const status = filesListPage.getInlineActionEntryForFile('folder', 'sharing-status') + await expect(status).toBeVisible() + await expect(status).toHaveAttribute('aria-label', /^Shared with /) + await expect(status).toHaveAttribute('title', /^Shared with /) + }) + + test('names the recipient for the sharer in grid view', async ({ filesListPage }) => { + await filesListPage.open() + await filesListPage.enableGridView() + + const menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: /shared with/i })).toBeVisible() + }) + + test('names the owner for the recipient', async ({ page, user, recipient, filesListPage }) => { + await login(page.request, recipient) + await filesListPage.open() + + const status = filesListPage.getInlineActionEntryForFile('folder', 'sharing-status') + await expect(status).toBeVisible() + await expect(status).toHaveAttribute('aria-label', `Shared by ${user.userId}`) + }) + + test('names the owner for the recipient in grid view', async ({ page, user, recipient, filesListPage }) => { + await login(page.request, recipient) + await filesListPage.open() + await filesListPage.enableGridView() + + const menu = await filesListPage.openActionsMenuForFile('folder') + await expect(menu.getByRole('menuitem', { name: `Shared by ${user.userId}` })).toBeVisible() + }) + }) +}) diff --git a/tests/playwright/e2e/files_trashbin/files-trash-action.spec.ts b/tests/playwright/e2e/files_trashbin/files-trash-action.spec.ts new file mode 100644 index 0000000000000..adf8e4e0d11be --- /dev/null +++ b/tests/playwright/e2e/files_trashbin/files-trash-action.spec.ts @@ -0,0 +1,54 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { rm, uploadContent } from '../../support/utils/dav.ts' + +const FILE_COUNT = 5 + +test.describe('files_trashbin: empty trashbin action', () => { + test.beforeEach(async ({ page, user }) => { + // Create FILE_COUNT files and move them all to the trash + for (let index = 0; index < FILE_COUNT; index++) { + await uploadContent(page.request, user, '', 'text/plain', `/file${index}.txt`) + await rm(page.request, user, `/file${index}.txt`) + } + }) + + test('can empty trashbin', async ({ page, filesListPage }) => { + await filesListPage.open() + // Home holds only the default welcome file and offers no empty-trash action + await expect(filesListPage.getRows()).toHaveCount(1) + await expect(filesListPage.getListActionButton('empty-trash')).toHaveCount(0) + + await filesListPage.open('trashbin') + await expect(filesListPage.getRows()).toHaveCount(FILE_COUNT) + + const emptied = page.waitForResponse((r) => r.request().method() === 'DELETE' && r.url().includes('/remote.php/dav/trashbin/')) + await filesListPage.triggerListAction('empty-trash') + + // Confirm in the dialog + await page.getByRole('dialog') + .getByRole('button', { name: 'Empty deleted files' }) + .click() + + expect((await emptied).status()).toBe(204) + await expect(filesListPage.getRows()).toHaveCount(0) + }) + + test('cancelling the empty trashbin action does not delete anything', async ({ page, filesListPage }) => { + await filesListPage.open('trashbin') + await expect(filesListPage.getRows()).toHaveCount(FILE_COUNT) + + await filesListPage.triggerListAction('empty-trash') + + // Cancel the dialog: no request is sent and the files remain + await page.getByRole('dialog') + .getByRole('button', { name: 'Cancel' }) + .click() + + await expect(filesListPage.getRows()).toHaveCount(FILE_COUNT) + }) +}) diff --git a/tests/playwright/e2e/files_trashbin/files.spec.ts b/tests/playwright/e2e/files_trashbin/files.spec.ts new file mode 100644 index 0000000000000..69f536c37cb93 --- /dev/null +++ b/tests/playwright/e2e/files_trashbin/files.spec.ts @@ -0,0 +1,102 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' +import type { TrashbinListPage } from '../../support/sections/TrashbinListPage.ts' + +import { readFile } from 'node:fs/promises' +import { expect, test } from '../../support/fixtures/files-trashbin-page.ts' +import { mkdir, rm, uploadContent } from '../../support/utils/dav.ts' +import { createShare, ShareType } from '../../support/utils/sharing.ts' +import { setUserDisplayName } from '../../support/utils/users.ts' + +test.describe('files_trashbin: download files', () => { + let fileIds: [number, number] + + test.beforeEach(async ({ page, user, filesListPage }) => { + const first = await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await rm(page.request, user, '/file.txt') + const second = await uploadContent(page.request, user, '', 'text/plain', '/other-file.txt') + await rm(page.request, user, '/other-file.txt') + fileIds = [Number(first), Number(second)] + + await filesListPage.open('trashbin') + }) + + test('can download a file', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFileId(fileIds[0])).toBeVisible() + await expect(filesListPage.getRowForFileId(fileIds[1])).toBeVisible() + + await expectFileDownload(page, () => filesListPage.triggerActionForFileId(fileIds[0], 'download')) + }) + + test('can download a file using the default action', async ({ page, filesListPage }) => { + await expectFileDownload(page, () => { + // The inline "Download" button is the row's default action; force past the sticky header + return filesListPage.getRowForFileId(fileIds[0]) + .getByRole('button', { name: 'Download' }) + .click({ force: true }) + }) + }) + + // Trashbin has no bulk download: the webdav zip-folder plugin does not work for + // the trashbin (and never did with the legacy ajax download either). + test('does not offer bulk download', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowCheckboxes()).toHaveCount(2) + await filesListPage.selectAll() + await expect(page.getByText('2 selected')).toBeVisible() + + await expect(filesListPage.getSelectionActionEntry('restore')).toBeVisible() + await expect(filesListPage.getSelectionActionEntry('download')).toHaveCount(0) + }) +}) + +test.describe('files_trashbin: file row', () => { + test('shows data for a file deleted by the owner', async ({ user, aliceRequest, filesListPage }) => { + const fileId = Number(await uploadContent(aliceRequest, user, '', 'text/plain', '/test-file.txt')) + await rm(aliceRequest, user, '/test-file.txt') + + await filesListPage.open('trashbin') + + // The owner's own deletions render as "You" regardless of display name + await expectTrashbinRow(filesListPage, fileId, 'test-file .txt', 'All files', 'You') + }) + + test('shows data for a file deleted by a sharee in a group share', async ({ user, aliceRequest, bob, bobRequest, group, filesListPage }) => { + await setUserDisplayName(bobRequest, bob.userId, 'Bob') + await mkdir(aliceRequest, user, '/Shared') + await createShare(aliceRequest, '/Shared', group, { shareType: ShareType.GROUP }) + + const fileId = Number(await uploadContent(aliceRequest, user, '', 'text/plain', '/Shared/test-file.txt')) + // Bob (the sharee) deletes the file from his view of the shared folder + await rm(bobRequest, bob, '/Shared/test-file.txt') + + await filesListPage.open('trashbin') + + await expectTrashbinRow(filesListPage, fileId, 'test-file .txt', 'Shared', 'Bob') + }) +}) + +/** Run `trigger`, then assert it downloaded `file.txt` with the expected content. */ +async function expectFileDownload(page: Page, trigger: () => Promise) { + const downloadPromise = page.waitForEvent('download') + await trigger() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readFile(await download.path(), 'utf-8')).toBe('') +} + +/** Assert a trashbin row's name and custom columns (the deleted time is always recent). */ +async function expectTrashbinRow(filesListPage: TrashbinListPage, rowId: number, name: string, location: string, deletedBy: string) { + const row = filesListPage.getRowForFileId(rowId) + await expect(row).toBeVisible() + // Name and extension render as separate spans, so the composed text has a space + await expect(filesListPage.fileNameCell(row)).toHaveText(name) + await expect(filesListPage.originalLocationCell(row)).toHaveText(location) + await expect(filesListPage.deletedByCell(row)).toHaveText(deletedBy) + // Match any relative-time string ("a few seconds ago", "a minute ago", …) + // rather than a fixed string that breaks when setup is slow. + await expect(filesListPage.deletedAtCell(row)).toHaveText(/ago/) +} diff --git a/tests/playwright/e2e/files_versions/version-creation.spec.ts b/tests/playwright/e2e/files_versions/version-creation.spec.ts new file mode 100644 index 0000000000000..0862d80813a65 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-creation.spec.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-versions-tab-page.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const FILE_NAME = 'creation.txt' + +test.describe('files_versions: versions creation', () => { + test.beforeEach(async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + }) + + test('opens the versions panel and shows the three versions', async ({ versionsTab }) => { + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.version(0)).toContainText('Current version') + await expect(versionsTab.version(2)).toContainText('Initial version') + }) + + test('shows yourself as the version author', async ({ versionsTab }) => { + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.authorName(0)).toContainText('You') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-cross-share-move-and-copy.spec.ts b/tests/playwright/e2e/files_versions/version-cross-share-move-and-copy.spec.ts new file mode 100644 index 0000000000000..ad840637c2f9a --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-cross-share-move-and-copy.spec.ts @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' +import type { CopyMoveDialogPage } from '../../support/sections/CopyMoveDialogPage.ts' +import type { FilesListPage } from '../../support/sections/FilesListPage.ts' +import type { VersionsTab } from '../../support/sections/VersionsTab.ts' + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const SHARED_FOLDER = 'cross-share' +const FILE_NAME = 'file.txt' + +/** + * Seed a versioned file inside the shared folder for the owner, share the folder + * with the recipient (full permissions), and wait for the file to propagate. + * Parent directories of `filePath` (relative to the shared folder) are created + * first. + * + * @param owner - The file owner + * @param ownerRequest - A request context authenticated as the owner + * @param recipient - The share recipient + * @param recipientRequest - A request context authenticated as the recipient + * @param filePath - The file path relative to the shared folder (e.g. "sub/deep/file.txt") + */ +async function seedSharedVersionedFile( + owner: User, + ownerRequest: APIRequestContext, + recipient: User, + recipientRequest: APIRequestContext, + filePath: string, +): Promise { + await mkdir(ownerRequest, owner, `/${SHARED_FOLDER}`) + // Create any intermediate folders of the file path inside the shared folder + const segments = filePath.split('/') + let current = SHARED_FOLDER + for (const segment of segments.slice(0, -1)) { + current += `/${segment}` + await mkdir(ownerRequest, owner, `/${current}`) + } + await seedThreeVersions(ownerRequest, owner, `${SHARED_FOLDER}/${filePath}`) + await createShare(ownerRequest, `/${SHARED_FOLDER}`, recipient.userId) + + const parent = [SHARED_FOLDER, ...segments.slice(0, -1)].join('/') + await waitForShare(recipientRequest, recipient, parent, segments.at(-1)!) +} + +/** + * As the recipient, open the versions panel of the file, name its initial + * version "v1", and close the sidebar. + */ +async function nameInitialVersion( + filesListPage: FilesListPage, + versionsTab: VersionsTab, + folderPath: string, + fileName: string, +): Promise { + await filesListPage.open() + await filesListPage.navigateToFolder(folderPath) + await openVersionsPanel(filesListPage, versionsTab, fileName) + await expect(versionsTab.versions()).toHaveCount(3) + await versionsTab.nameVersion(2, 'v1') + await expect(versionsTab.version(2)).toContainText('v1') +} + +/** + * Reload from the recipient's root, open the versions of the file at `filePath` + * and assert all three versions travelled with the move/copy (content v3/v2/v1). + * A fresh reload avoids the stale sibling rows a cross-storage move can leave + * behind. `expectLabel` asserts the "v1" label survived — only moves preserve + * version metadata, copies do not. + */ +async function assertVersionsContent( + filesListPage: FilesListPage, + versionsTab: VersionsTab, + filePath: string, + { expectLabel }: { expectLabel: boolean }, +): Promise { + const segments = filePath.split('/') + const fileName = segments.at(-1)! + const folderPath = segments.slice(0, -1).join('/') + + await filesListPage.open() + if (folderPath) { + await filesListPage.navigateToFolder(folderPath) + } + await openVersionsPanel(filesListPage, versionsTab, fileName) + + await expect(versionsTab.versions()).toHaveCount(3) + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v2') + expect(await versionsTab.getVersionContent(2)).toBe('v1') + + if (expectLabel) { + await expect(versionsTab.version(2)).toContainText('v1') + } +} + +/** Move the given entry out of the current folder to the recipient's root. */ +async function moveToRoot(filesListPage: FilesListPage, copyMoveDialog: CopyMoveDialogPage, name: string): Promise { + await filesListPage.triggerActionForFile(name, 'move-copy') + await copyMoveDialog.goToAllFiles() + await copyMoveDialog.moveToCurrentFolder() +} + +/** Copy the given entry to the recipient's root. */ +async function copyToRoot(filesListPage: FilesListPage, copyMoveDialog: CopyMoveDialogPage, name: string): Promise { + await filesListPage.triggerActionForFile(name, 'move-copy') + await copyMoveDialog.goToAllFiles() + await copyMoveDialog.copyToCurrentFolder() +} + +test.describe('files_versions: versions across a share move/copy', () => { + // Every test here seeds a share, boots the files app twice, + // and walks the sidebar, the versions list and the move/copy picker. + test.slow() + + test('moves the versions when the file is moved out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, FILE_NAME) + await nameInitialVersion(filesListPage, versionsTab, SHARED_FOLDER, FILE_NAME) + await filesSidebar.close() + + await moveToRoot(filesListPage, copyMoveDialog, FILE_NAME) + + await assertVersionsContent(filesListPage, versionsTab, FILE_NAME, { expectLabel: true }) + }) + + test('copies the versions when the file is copied out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, FILE_NAME) + await nameInitialVersion(filesListPage, versionsTab, SHARED_FOLDER, FILE_NAME) + await filesSidebar.close() + + await copyToRoot(filesListPage, copyMoveDialog, FILE_NAME) + + // A copy keeps version content but not the version metadata (label) + await assertVersionsContent(filesListPage, versionsTab, FILE_NAME, { expectLabel: false }) + }) + + test('moves the versions when a containing folder is moved out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + const subFolder = 'sub' + const subSubFolder = 'deep' + const relPath = `${subFolder}/${subSubFolder}/${FILE_NAME}` + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, relPath) + await nameInitialVersion(filesListPage, versionsTab, `${SHARED_FOLDER}/${subFolder}/${subSubFolder}`, FILE_NAME) + await filesSidebar.close() + + await filesListPage.navigateToBreadcrumb(SHARED_FOLDER) + await moveToRoot(filesListPage, copyMoveDialog, subFolder) + + await assertVersionsContent(filesListPage, versionsTab, relPath, { expectLabel: true }) + }) + + test('copies the versions when a containing folder is copied out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + const subFolder = 'sub' + const subSubFolder = 'deep' + const relPath = `${subFolder}/${subSubFolder}/${FILE_NAME}` + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, relPath) + await nameInitialVersion(filesListPage, versionsTab, `${SHARED_FOLDER}/${subFolder}/${subSubFolder}`, FILE_NAME) + await filesSidebar.close() + + await filesListPage.navigateToBreadcrumb(SHARED_FOLDER) + await copyToRoot(filesListPage, copyMoveDialog, subFolder) + + await assertVersionsContent(filesListPage, versionsTab, relPath, { expectLabel: false }) + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-deletion.spec.ts b/tests/playwright/e2e/files_versions/version-deletion.spec.ts new file mode 100644 index 0000000000000..ce5e5c2c436da --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-deletion.spec.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'shared_folder' +const FILE_NAME = 'file.txt' +const FILE_PATH = `/${FOLDER_NAME}/${FILE_NAME}` + +test.describe('files_versions: versions deletion', () => { + test('deletes the initial version of an own file', async ({ page, user, filesListPage, versionsTab }) => { + await mkdir(page.request, user, `/${FOLDER_NAME}`) + await seedThreeVersions(page.request, user, FILE_PATH) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + // The initial version is the oldest (last) entry + await versionsTab.delete(2) + await expect(versionsTab.versions()).toHaveCount(2) + }) + + test('deletes versions of a shared file with delete permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + // Wait for the delete permission (D) to reach the recipient's listing + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => p.includes('D')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + await versionsTab.delete(2) + await expect(versionsTab.versions()).toHaveCount(2) + }) + + test('cannot delete versions of a shared file without delete permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.DELETE }) + // Wait for the delete restriction (no D) to reach the recipient's listing + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => !p.includes('D')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + await versionsTab.expectActionMissing(0, 'delete') + await versionsTab.expectActionMissing(1, 'delete') + await versionsTab.expectActionMissing(2, 'delete') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-download.spec.ts b/tests/playwright/e2e/files_versions/version-download.spec.ts new file mode 100644 index 0000000000000..6b737b707e7e2 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-download.spec.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { createShare, DOWNLOAD_DISABLED_ATTRIBUTE, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FILE_NAME = 'download.txt' + +test.describe('files_versions: versions download', () => { + test('downloads versions of an own file and asserts their content', async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v2') + expect(await versionsTab.getVersionContent(2)).toBe('v1') + }) + + test('downloads versions of a shared file with download permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await seedThreeVersions(ownerRequest, owner, `/${FILE_NAME}`) + await createShare(ownerRequest, `/${FILE_NAME}`, user.userId) + await waitForShare(page.request, user, '', FILE_NAME) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v2') + expect(await versionsTab.getVersionContent(2)).toBe('v1') + }) + + test('does not offer download of a shared file without download permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await seedThreeVersions(ownerRequest, owner, `/${FILE_NAME}`) + await createShare(ownerRequest, `/${FILE_NAME}`, user.userId, { attributes: DOWNLOAD_DISABLED_ATTRIBUTE }) + await waitForShare(page.request, user, '', FILE_NAME) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // The current version's only possible actions (label, download) are both + // unavailable here, so it offers no actions menu at all + await versionsTab.expectNoActionsMenu(0) + await versionsTab.expectActionMissing(1, 'download') + await versionsTab.expectActionMissing(2, 'download') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-expiration.spec.ts b/tests/playwright/e2e/files_versions/version-expiration.spec.ts new file mode 100644 index 0000000000000..6ec8ecf4722c3 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-expiration.spec.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect, test } from '../../support/fixtures/files-versions-tab-page.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const FILE_NAME = 'expiration.txt' + +/** + * Run the versioning expiration for a single user with a retention obligation + * that keeps only the current version (and any named versions). The obligation + * is a system config, so it is reset to the default afterwards even on failure; + * the expiry itself is scoped to `user` so it never touches other tests' files. + */ +async function expireVersions(user: User): Promise { + await runOcc(['config:system:set', 'versions_retention_obligation', '--value', '0, 0']) + try { + await runOcc(['versions:expire', user.userId]) + } finally { + await runOcc(['config:system:set', 'versions_retention_obligation', '--value', 'auto']) + } +} + +test.describe('files_versions: versions expiration', () => { + test('expires all but the current version', async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + await expireVersions(user) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(1) + await expect(versionsTab.version(0)).toContainText('Current version') + expect(await versionsTab.getVersionContent(0)).toBe('v3') + }) + + test('keeps named versions when expiring', async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // Name the initial version so it survives expiration + await versionsTab.nameVersion(2, 'v1') + await expireVersions(user) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(2) + await expect(versionsTab.version(0)).toContainText('Current version') + await expect(versionsTab.version(1)).toContainText('v1') + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v1') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-naming.spec.ts b/tests/playwright/e2e/files_versions/version-naming.spec.ts new file mode 100644 index 0000000000000..3ec476e3da809 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-naming.spec.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'share' +const FILE_NAME = 'file.txt' +const FILE_PATH = `${FOLDER_NAME}/${FILE_NAME}` + +test.describe('files_versions: versions naming', () => { + test('names the versions of an own file', async ({ page, user, filesListPage, versionsTab }) => { + await mkdir(page.request, user, `/${FOLDER_NAME}`) + await seedThreeVersions(page.request, user, FILE_PATH) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + await versionsTab.nameVersion(2, 'v1') + await expect(versionsTab.version(2)).toContainText('v1') + await expect(versionsTab.version(2)).not.toContainText('Initial version') + + await versionsTab.nameVersion(1, 'v2') + await expect(versionsTab.version(1)).toContainText('v2') + + await versionsTab.nameVersion(0, 'v3') + await expect(versionsTab.version(0)).toContainText('v3 (Current version)') + }) + + test('names the versions of a shared file with edit permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + await versionsTab.nameVersion(2, 'v1 - shared') + await expect(versionsTab.version(2)).toContainText('v1 - shared') + await expect(versionsTab.version(2)).not.toContainText('Initial version') + + await versionsTab.nameVersion(1, 'v2 - shared') + await expect(versionsTab.version(1)).toContainText('v2 - shared') + + await versionsTab.nameVersion(0, 'v3 - shared') + await expect(versionsTab.version(0)).toContainText('v3 - shared (Current version)') + }) + + test('cannot name versions of a shared file without edit permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.UPDATE }) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => !p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // Without edit permission the current version offers no actions menu, and + // the older versions offer no label action + await versionsTab.expectNoActionsMenu(0) + await versionsTab.expectActionMissing(1, 'label') + await versionsTab.expectActionMissing(2, 'label') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-restoration.spec.ts b/tests/playwright/e2e/files_versions/version-restoration.spec.ts new file mode 100644 index 0000000000000..c59ff3e27e098 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-restoration.spec.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { VersionsTab } from '../../support/sections/VersionsTab.ts' + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'share' +const FILE_NAME = 'file.txt' +const FILE_PATH = `${FOLDER_NAME}/${FILE_NAME}` + +/** + * Assert the versions list after restoring the initial version ("v1"): the + * restored content becomes the current version, the previous current ("v3") and + * "v2" follow. + * + * @param versionsTab - The versions tab page object + */ +async function expectRestoredToInitial(versionsTab: VersionsTab): Promise { + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.version(0)).toContainText('Current version') + await expect(versionsTab.version(2)).not.toContainText('Initial version') + + expect(await versionsTab.getVersionContent(0)).toBe('v1') + expect(await versionsTab.getVersionContent(1)).toBe('v3') + expect(await versionsTab.getVersionContent(2)).toBe('v2') +} + +test.describe('files_versions: versions restoration', () => { + test('restores the initial version of an own file', async ({ page, user, filesListPage, versionsTab }) => { + await mkdir(page.request, user, `/${FOLDER_NAME}`) + await seedThreeVersions(page.request, user, FILE_PATH) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // The current version cannot be restored onto itself + await versionsTab.expectActionMissing(0, 'restore') + await versionsTab.restore(2) + + await expectRestoredToInitial(versionsTab) + }) + + test('restores versions of a shared file with update permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + await versionsTab.restore(2) + + await expectRestoredToInitial(versionsTab) + }) + + test('cannot restore versions of a shared file without update permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.UPDATE }) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => !p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // Without update permission the current version offers no actions menu, and + // the older versions offer no restore action + await versionsTab.expectNoActionsMenu(0) + await versionsTab.expectActionMissing(1, 'restore') + await versionsTab.expectActionMissing(2, 'restore') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-sharing.spec.ts b/tests/playwright/e2e/files_versions/version-sharing.spec.ts new file mode 100644 index 0000000000000..ce4267b6469f5 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-sharing.spec.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'shared-folder' +const FILE_NAME = 'file.txt' + +test.describe('files_versions: versions on shares', () => { + test('shows the version author display name to the sharee', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + // The owner creates the versions, so the recipient must see the owner as author + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + await seedThreeVersions(ownerRequest, owner, `${FOLDER_NAME}/${FILE_NAME}`) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.authorName(0)).toContainText(owner.userId) + }) +}) diff --git a/tests/playwright/e2e/login/login-redirect.spec.ts b/tests/playwright/e2e/login/login-redirect.spec.ts new file mode 100644 index 0000000000000..51b3afe59cf84 --- /dev/null +++ b/tests/playwright/e2e/login/login-redirect.spec.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '@playwright/test' +import { LoginPage } from '../../support/sections/LoginPage.ts' + +test.describe('Login: Redirect', () => { + let user: User + + test.beforeAll(async () => { + user = await createRandomUser() + }) + + test.afterAll(async () => { + await runOcc(['user:delete', user.userId]) + }) + + test('redirects to login with redirect_url when session expires', async ({ page, context }) => { + await login(context.request, user) + await page.goto('/settings/user#profile') + + // Wait for the profile settings checkbox to confirm the page has loaded + await expect(page.getByRole('checkbox', { name: /Enable profile/i })).toBeVisible() + + // Simulate session expiry by clearing all cookies + await context.clearCookies() + + // Clicking the checkbox triggers an authenticated request that returns 302 to login + await page.getByRole('checkbox', { name: /Enable profile/i }).click({ force: true }) + + await expect(page).toHaveURL(/\/login/i) + await expect(page).toHaveURL(/redirect_url=/) + }) + + test('redirect_url parameter redirects to the original page after login', async ({ page }) => { + const redirectTarget = 'settings/user#profile' + await page.goto(redirectTarget) + await expect(page).toHaveURL(new RegExp(`/login\\?redirect_url=(/index.php/)?${redirectTarget}`)) + + const loginPage = new LoginPage(page) + await expect(loginPage.usernameInput()).toBeVisible() + await loginPage.login(user.userId, user.password) + + await expect(page).toHaveURL(/\/settings\/user/) + await expect(page.getByRole('checkbox', { name: /Enable profile/i })).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/login/login.spec.ts b/tests/playwright/e2e/login/login.spec.ts new file mode 100644 index 0000000000000..6101e80f2e009 --- /dev/null +++ b/tests/playwright/e2e/login/login.spec.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest, expect } from '@playwright/test' +import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts' +import { LoginPage } from '../../support/sections/LoginPage.ts' + +const test = baseTest.extend<{ + user: User + disabledUser: User +}>({ + user: async ({}, use) => { + const user = await createRandomUser() + await use(user) + await runOcc(['user:delete', user.userId]) + }, + disabledUser: async ({}, use) => { + const user = await createRandomUser() + await runOcc(['user:disable', user.userId]) + await use(user) + await runOcc(['user:delete', user.userId]) + }, +}) + +test.describe('Login', () => { + test.beforeAll(async () => { + await runOcc(['config:system:set', 'auth.bruteforce.protection.enabled', '--value', 'false', '--type', 'bool']) + }) + + test.afterAll(async () => { + await runOcc(['config:system:delete', 'auth.bruteforce.protection.enabled']) + }) + + test('successful login lands on the dashboard', async ({ page, user }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(user.userId, user.password) + + await expect(page).toHaveURL(/apps\/dashboard(\/|$)/) + }) + + test('wrong password shows error and marks password field invalid', async ({ page, user }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(user.userId, `${user.password}--wrong`) + + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText(/Wrong login or password/i)).toBeVisible() + await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + }) + + test('wrong account name shows error and marks password field invalid', async ({ page, user }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(`${user.userId}--wrong`, user.password) + + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText(/Wrong login or password/i)).toBeVisible() + await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + }) + + test('disabled account shows disabled error', async ({ page, disabledUser }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.login(disabledUser.userId, disabledUser.password) + + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText(/Account.*disabled/i)).toBeVisible() + await expect(loginPage.passwordInput().and(page.locator(':invalid'))).toHaveCount(1) + }) + + test('logout redirects to the login page', async ({ page, context, user }) => { + await login(context.request, user) + await page.goto('/') + + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + await accountMenu.entry('Log out').getByRole('link').click() + + await expect(page).toHaveURL(/\/login($|\?)/) + }) +}) diff --git a/tests/playwright/e2e/login/webauth.spec.ts b/tests/playwright/e2e/login/webauth.spec.ts new file mode 100644 index 0000000000000..7b3267aec761c --- /dev/null +++ b/tests/playwright/e2e/login/webauth.spec.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { BrowserContext } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '@playwright/test' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +test.describe('Login: WebAuthn', () => { + test.skip(({ browserName }) => browserName !== 'chromium', 'WebAuthn emulator only is supported in Chromium-based browsers') + + let user: User + let cdpSession: Awaited> + let authenticatorId: string + + test.beforeEach(async ({ page, context }) => { + user = await createRandomUser() + await login(context.request, user) + + cdpSession = await page.context().newCDPSession(page) + await cdpSession.send('WebAuthn.enable', { enableUI: false }) + const result = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + ctap2Version: 'ctap2_1', + hasUserVerification: true, + transport: 'usb', + automaticPresenceSimulation: true, + isUserVerified: true, + }, + }) + authenticatorId = result.authenticatorId + }) + + test.afterEach(async () => { + await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) + await runOcc(['user:delete', user.userId]) + }) + + test('add and delete a WebAuthn device', async ({ page }) => { + const registrationChallenge = page.waitForResponse((r) => r.url().includes('/settings/api/personal/webauthn/registration')) + await page.goto('/settings/user/security') + + const securitySection = page.locator('#security-webauthn') + await expect(securitySection.getByRole('note').filter({ hasText: /No devices configured/i })).toBeVisible() + + await page.getByRole('button', { name: /Add WebAuthn device/i }).click() + await handlePasswordConfirmation(page, user.password) + await registrationChallenge + + const deviceNameInput = page.getByLabel('Device name') + await expect(deviceNameInput).toBeVisible() + + const registrationComplete = page.waitForResponse((r) => r.url().includes('/settings/api/personal/webauthn/registration')) + await deviceNameInput.fill('test device') + await deviceNameInput.press('Enter') + await registrationComplete + + const deviceList = page.getByRole('list', { name: /following devices/i }) + await expect(deviceList).toBeVisible() + const deviceItem = deviceList.getByRole('listitem').filter({ hasText: 'test device' }) + await expect(deviceItem).toBeVisible() + + await deviceItem.getByRole('button', { name: 'Actions' }).click() + await handlePasswordConfirmation(page, user.password) + await page.getByRole('menuitem', { name: 'Delete' }).click() + await handlePasswordConfirmation(page, user.password) + + await expect(securitySection.getByRole('note').filter({ hasText: /No devices configured/i })).toBeVisible() + await expect(deviceList).toHaveCount(0) + + await page.reload() + await expect(securitySection.getByRole('note').filter({ hasText: /No devices configured/i })).toBeVisible() + }) + + test('add a WebAuthn device and use it to log in', async ({ page, context }) => { + const registrationChallenge = page.waitForResponse((r) => r.url().includes('/settings/api/personal/webauthn/registration') && r.request().method() === 'GET') + await page.goto('/settings/user/security') + + await page.getByRole('button', { name: /Add WebAuthn device/i }).click() + await handlePasswordConfirmation(page, user.password) + await registrationChallenge + + const registrationComplete = page.waitForResponse((r) => r.url().includes('/settings/api/personal/webauthn/registration') && r.request().method() === 'POST') + const deviceNameInput = page.getByLabel('Device name') + await deviceNameInput.fill('test device') + await deviceNameInput.press('Enter') + await registrationComplete + + const deviceList = page.getByRole('list', { name: /following devices/i }) + await expect(deviceList.getByRole('listitem').filter({ hasText: 'test device' })).toBeVisible() + + // Log out and return to the login page + await context.clearCookies() + await page.goto('/login') + + // Switch to passwordless login form + await page.getByRole('button', { name: /Log in with a device/i }).click() + + const passwordlessForm = page.getByRole('form', { name: /Log in with a device/i }) + await expect(passwordlessForm).toBeVisible() + + await passwordlessForm.getByLabel('Login or email').fill(user.userId) + + const webauthnLogin = page.waitForResponse((r) => r.url().includes('/login/webauthn/start') && r.request().method() === 'POST') + await page.getByRole('button', { name: 'Log in' }).click() + await webauthnLogin + + await expect(page).toHaveURL(/apps\/dashboard(\/|$)/) + }) +}) diff --git a/tests/playwright/e2e/settings/access-levels.spec.ts b/tests/playwright/e2e/settings/access-levels.spec.ts new file mode 100644 index 0000000000000..1955e6f3fff06 --- /dev/null +++ b/tests/playwright/e2e/settings/access-levels.spec.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { test as adminTest } from '../../support/fixtures/admin-session.ts' +import { test as userTest } from '../../support/fixtures/random-user-session.ts' +import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts' + +/** + * The settings navigation is rendered by `apps/settings/templates/settings/frame.php`. + * Both captions are only emitted when there is an administration section to + * separate from, so a regular account sees neither of them. + * + * @param page - The page to query + */ +function settingsNavigation(page: Page) { + return page.locator('#app-navigation') +} + +userTest.describe('Settings: Access levels – regular user', () => { + userTest('cannot see the Administration section in the settings navigation', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + await accountMenu.entry('Settings').getByRole('link').click() + await expect(page).toHaveURL(/\/settings\/user$/) + + const navigation = settingsNavigation(page) + await expect(navigation).toBeVisible() + await expect(navigation.getByRole('link', { name: /Personal info/i })).toBeVisible() + // Regular users must not see the Administration section + await expect(navigation.locator('#app-navigation-caption-personal')).toHaveCount(0) + await expect(navigation.locator('#app-navigation-caption-administration')).toHaveCount(0) + }) +}) + +adminTest.describe('Settings: Access levels – admin user', () => { + adminTest('can see the Administration section in the settings navigation', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + await accountMenu.entry('Personal settings').getByRole('link').click() + await expect(page).toHaveURL(/\/settings\/user$/) + + const navigation = settingsNavigation(page) + await expect(navigation).toBeVisible() + await expect(navigation.getByRole('link', { name: /Personal info/i })).toBeVisible() + // Admins must see both sections + await expect(navigation.locator('#app-navigation-caption-personal')).toBeVisible() + await expect(navigation.locator('#app-navigation-caption-administration')).toBeVisible() + await expect(navigation.getByRole('link', { name: /Overview/i })).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/settings/personal-info.spec.ts b/tests/playwright/e2e/settings/personal-info.spec.ts new file mode 100644 index 0000000000000..3612dc67ec6ef --- /dev/null +++ b/tests/playwright/e2e/settings/personal-info.spec.ts @@ -0,0 +1,446 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page, Response } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect } from '@playwright/test' +import { test as userSessionTest } from '../../support/fixtures/random-user-session.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +// ── Visibility scope labels exactly as rendered in the UI ───────────────────── +const Visibility = { + Private: 'Private', + Local: 'Local', + Federated: 'Federated', + Published: 'Published', +} as const +type Visibility = typeof Visibility[keyof typeof Visibility] + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Register a listener for the next personal-info PUT. Call BEFORE triggering + * the save action; await the result after the action and any password dialog. + */ +function waitForSave(page: Page): Promise { + return page.waitForResponse((r) => r.request().method() === 'PUT' && r.url().includes('/ocs/v2.php/cloud/users/')) +} + +/** + * Click the scope (visibility) control for `property` and select `scope`. + * `property` is the lowercase readable name as it appears in the button's + * aria-label (e.g. "email", "full name", "phone number"). + */ +async function changeVisibility(page: Page, property: string, scope: Visibility, password: string): Promise { + const saved = waitForSave(page) + await page.getByRole('button', { name: new RegExp(`change scope level of ${property}`, 'i') }).click() + await page.getByRole('menuitemradio', { name: scope }).click() + await handlePasswordConfirmation(page, password) + await saved +} + +// ── Fixture ─────────────────────────────────────────────────────────────────── + +// Ensure English UI language and locale so string assertions are stable +const test = userSessionTest.extend({ + user: async ({ user: baseUser }, use) => { + await runOcc(['user:setting', baseUser.userId, 'core', 'lang', 'en']) + await runOcc(['user:setting', baseUser.userId, 'core', 'locale', 'en_US']) + await use(baseUser) + }, +}) + +// ── Spec ────────────────────────────────────────────────────────────────────── + +test.describe('Settings: Change personal information', () => { + test.beforeAll(async () => { + // Prevent the Fediverse section from making outbound HTTP requests + await runOcc(['config:system:set', 'has_internet_connection', '--type', 'bool', '--value', 'false']) + // Let each user choose their own language and locale + await runOcc(['config:system:delete', 'force_language']) + await runOcc(['config:system:delete', 'force_locale']) + }) + + test.afterAll(async () => { + await runOcc(['config:system:delete', 'has_internet_connection']) + // Restore English defaults so other test suites are unaffected + await runOcc(['config:system:set', 'force_language', '--value', 'en']) + await runOcc(['config:system:set', 'force_locale', '--value', 'en_US']) + }) + + // ── Profile ─────────────────────────────────────────────────────────────── + + test('can enable and disable the profile', async ({ page, user }) => { + // Profile is enabled by default: the public profile page shows the user id + await page.goto(`/u/${user.userId}`) + await expect(page.getByRole('heading', { name: user.userId })).toBeVisible() + + await page.goto('/settings/user') + const saved1 = waitForSave(page) + await page.getByRole('checkbox', { name: 'Enable profile' }).uncheck({ force: true }) + await handlePasswordConfirmation(page, user.password) + await saved1 + + // Profile is disabled: the public profile page shows a "not found" heading + await page.goto(`/u/${user.userId}`, { waitUntil: 'networkidle' }) + await expect(page.getByRole('heading', { name: /Profile not found/i })).toBeVisible() + + // Re-enable the profile + await page.goto('/settings/user') + const saved2 = waitForSave(page) + await page.getByRole('checkbox', { name: 'Enable profile' }).check({ force: true }) + await handlePasswordConfirmation(page, user.password) + await saved2 + + await page.goto(`/u/${user.userId}`) + await expect(page.getByRole('heading', { name: user.userId })).toBeVisible() + }) + + // ── Language ────────────────────────────────────────────────────────────── + + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need the user fixture to ensure the test user is created and cleaned up + test('can change language', async ({ page, user }) => { + await page.goto('/settings/user') + + // NcSelect: type to filter, click the option (teleported to ) + await page.getByRole('combobox', { name: 'Language' }).scrollIntoViewIfNeeded() + await page.getByRole('combobox', { name: 'Language' }).fill('Ned') + await page.getByRole('option', { name: /Neder\s?lands/ }).click() + + // Language change triggers a full page reload; wait for Dutch UI + await expect(page.getByRole('combobox', { name: 'Taal' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText('Help met vertalen')).toBeVisible() + }) + + // ── Locale ──────────────────────────────────────────────────────────────── + + test('can change locale', async ({ page }) => { + await page.goto('/settings/user') + + await page.getByRole('combobox', { name: 'Locale' }).fill('German') + await page.getByRole('option', { name: /^German/ }).filter({ hasText: /\(Germany\)/ }).click() + + // Locale change triggers a full page reload + await page.waitForLoadState('networkidle') + // After reload the German locale option is reflected in the combobox + await expect(page.getByRole('combobox', { name: 'Locale' })).toBeVisible() + await expect(page.getByText(/German \(Germany\)/)).toBeVisible() + }) + + // ── Primary email ───────────────────────────────────────────────────────── + + test('can set primary email and change its visibility', async ({ page, user }) => { + await page.goto('/settings/user') + + const emailInput = page.getByRole('textbox', { name: 'Email' }) + // HTML5 email validation: 'foo bar' is not a valid address + await emailInput.fill('foo bar') + await expect(emailInput.and(page.locator(':invalid'))).toHaveCount(1) + + // Set a valid email + const saved = waitForSave(page) + await emailInput.fill('hello@example.com') + await handlePasswordConfirmation(page, user.password) + await saved + + await page.reload() + await expect(emailInput).toHaveValue('hello@example.com') + + // Change visibility and verify it persists across a reload + await changeVisibility(page, 'email', Visibility.Local, user.password) + await page.reload() + await expect(page.getByRole('button', { name: /change scope level of email.*local/i })).toBeVisible() + + // With Local visibility the address is visible on the public profile + await page.goto(`/u/${user.userId}`) + await expect(page.getByRole('link', { name: 'hello@example.com' })).toBeVisible() + }) + + test('can delete primary email', async ({ page, user }) => { + await page.goto('/settings/user') + + const saved1 = waitForSave(page) + const emailInput = page.getByRole('textbox', { name: 'Email' }) + await emailInput.fill('hello@example.com') + await handlePasswordConfirmation(page, user.password) + await saved1 + + await page.reload() + await expect(emailInput).toHaveValue('hello@example.com') + + const saved2 = waitForSave(page) + // The "Remove primary email" button is visually inside the input row + await page.getByRole('button', { name: 'Remove primary email' }).click({ force: true }) + await handlePasswordConfirmation(page, user.password) + await saved2 + + await page.reload() + await expect(emailInput).toHaveValue('') + }) + + // ── Additional emails ───────────────────────────────────────────────────── + + test('can set and delete additional emails', async ({ page, user }) => { + await page.goto('/settings/user') + + // "Add additional email" is disabled until a primary email exists + await expect(page.getByRole('button', { name: 'Add additional email' })).toBeDisabled() + + // Set a primary email first + const emailInput = page.getByRole('textbox', { name: 'Email' }) + const saved1 = waitForSave(page) + await emailInput.fill('primary@example.com') + await handlePasswordConfirmation(page, user.password) + await saved1 + + // Add first additional email + await page.getByRole('button', { name: 'Add additional email' }).click() + // Disabled again until the new field has a value + await expect(page.getByRole('button', { name: 'Add additional email' })).toBeDisabled() + + const saved2 = waitForSave(page) + await page.getByRole('textbox', { name: 'Additional email address 1' }).fill('1@example.com') + await handlePasswordConfirmation(page, user.password) + await saved2 + + // Add second additional email + await page.getByRole('button', { name: 'Add additional email' }).click() + + const saved3 = waitForSave(page) + await page.getByRole('textbox', { name: 'Additional email address 2' }).fill('2@example.com') + await handlePasswordConfirmation(page, user.password) + await saved3 + + // Both additional addresses persist across a reload + await page.reload() + await expect(page.getByRole('textbox', { name: 'Additional email address 1' })).toHaveValue('1@example.com') + await expect(page.getByRole('textbox', { name: 'Additional email address 2' })).toHaveValue('2@example.com') + + // Delete the first additional email via its options menu + await page.getByRole('button', { name: 'Options for additional email address 1' }).click({ force: true }) + const saved4 = waitForSave(page) + await page.getByRole('menuitem', { name: 'Delete email' }).click({ force: true }) + await handlePasswordConfirmation(page, user.password) + await saved4 + + // After deletion the second address shifts into position 1 + await page.reload() + await expect(page.getByRole('textbox', { name: 'Additional email address' })).toHaveValue('2@example.com') + }) + + // ── Full name ───────────────────────────────────────────────────────────── + + test('can set full name and change its visibility', async ({ page, user }) => { + await page.goto('/settings/user') + + const saved = waitForSave(page) + await page.getByRole('textbox', { name: 'Full name' }).fill('Jane Doe') + await handlePasswordConfirmation(page, user.password) + await saved + + await page.reload() + await expect(page.getByRole('textbox', { name: 'Full name' })).toHaveValue('Jane Doe') + + await changeVisibility(page, 'full name', Visibility.Local, user.password) + await page.reload() + await expect(page.getByRole('button', { name: /change scope level of full name.*local/i })).toBeVisible() + + // With Local visibility the display name appears on the public profile + await page.goto(`/u/${user.userId}`) + await expect(page.getByRole('heading', { name: 'Jane Doe' })).toBeVisible() + }) + + // ── Phone number ────────────────────────────────────────────────────────── + + test('can set phone number and its visibility', async ({ page, user }) => { + await page.goto('/settings/user') + + const saved = waitForSave(page) + const phoneInput = page.getByRole('textbox', { name: 'Phone number' }) + await phoneInput.fill('+49 89 721010 99701') + await handlePasswordConfirmation(page, user.password) + await saved + + // Server normalises to E.164 format + await page.reload() + await expect(phoneInput).toHaveValue('+498972101099701') + + await changeVisibility(page, 'phone number', Visibility.Private, user.password) + await page.reload() + await expect(page.getByRole('button', { name: /change scope level of phone number.*private/i })).toBeVisible() + }) + + test('can set phone number with phone region', async ({ page, user }) => { + await page.goto('/settings/user') + const phoneInput = page.getByRole('textbox', { name: 'Phone number' }) + + // Without a phone region, a local-format number is rejected + await phoneInput.fill('0 40 428990') + // NcTextField marks the field with an error class but we verify via the saved value + // being empty after reload (the server rejects the malformed number) + + // Set the default region and reload + await runOcc(['config:system:set', 'default_phone_region', '--value', 'DE']) + await page.reload() + + const saved = waitForSave(page) + await phoneInput.fill('0 40 428990') + await handlePasswordConfirmation(page, user.password) + await saved + + await page.reload() + await expect(phoneInput).toHaveValue('+4940428990') + + await runOcc(['config:system:delete', 'default_phone_region']) + }) + + test('can reset phone number', async ({ page, user }) => { + await page.goto('/settings/user') + const phoneInput = page.getByRole('textbox', { name: 'Phone number' }) + + const saved1 = waitForSave(page) + await phoneInput.fill('+49 40 428990') + await handlePasswordConfirmation(page, user.password) + await saved1 + + await page.reload() + await expect(phoneInput).toHaveValue('+4940428990') + + const saved2 = waitForSave(page) + await phoneInput.clear() + await handlePasswordConfirmation(page, user.password) + await saved2 + + await page.reload() + await expect(phoneInput).toHaveValue('') + }) + + // ── Social media ────────────────────────────────────────────────────────── + + test('can reset a social media property', async ({ page, user }) => { + await page.goto('/settings/user') + const fediverseInput = page.getByRole('textbox', { name: 'Fediverse (e.g. Mastodon)' }) + + const saved1 = waitForSave(page) + await fediverseInput.fill('@nextcloud@mastodon.social') + await handlePasswordConfirmation(page, user.password) + await saved1 + + // The server strips the leading '@' + await page.reload() + await expect(fediverseInput).toHaveValue('nextcloud@mastodon.social') + + const saved2 = waitForSave(page) + await fediverseInput.clear() + await handlePasswordConfirmation(page, user.password) + await saved2 + + await page.reload() + await expect(fediverseInput).toHaveValue('') + }) + + // ── Website ─────────────────────────────────────────────────────────────── + + test('can set website and change its visibility', async ({ page, user }) => { + await page.goto('/settings/user') + + const websiteInput = page.getByRole('textbox', { name: 'Website' }) + // HTML5 URL validation: 'foo bar' is not a valid URL + await websiteInput.fill('foo bar') + await expect(websiteInput.and(page.locator(':invalid'))).toHaveCount(1) + + const saved = waitForSave(page) + await websiteInput.fill('http://example.com') + await handlePasswordConfirmation(page, user.password) + await saved + + await page.reload() + await expect(websiteInput).toHaveValue('http://example.com') + + await changeVisibility(page, 'website', Visibility.Private, user.password) + await page.reload() + await expect(page.getByRole('button', { name: /change scope level of website.*private/i })).toBeVisible() + + // Change to Local so the URL appears on the public profile + await changeVisibility(page, 'website', Visibility.Local, user.password) + await page.goto(`/u/${user.userId}`) + await expect(page.getByText('http://example.com')).toBeVisible() + }) + + // ── Generic properties (any value, all visibility levels) ───────────────── + // Each property is tested in its own test so failures are isolated. + + const genericProperties = [ + { label: 'Location', scopeProperty: 'location', value: 'Berlin' }, + { label: 'Fediverse (e.g. Mastodon)', scopeProperty: 'fediverse', value: 'nextcloud@mastodon.xyz' }, + ] as const + + for (const { label, scopeProperty, value } of genericProperties) { + test(`can set ${label} and change its visibility`, async ({ page, user }) => { + await page.goto('/settings/user') + + const saved = waitForSave(page) + await page.getByRole('textbox', { name: label }).fill(value) + await handlePasswordConfirmation(page, user.password) + await saved + + await expect(page.getByRole('textbox', { name: label })).toHaveValue(value) + await expect(page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*local`, 'i') })).toHaveCount(1) + + // Cycle Private → Local and verify the final state persists + await changeVisibility(page, scopeProperty, Visibility.Federated, user.password) + await expect(page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*federated`, 'i') })).toBeVisible() + + await page.reload() + await expect(page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*federated`, 'i') })).toBeVisible() + + await changeVisibility(page, scopeProperty, Visibility.Private, user.password) + await expect(page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*private`, 'i') })).toBeVisible() + + // With Local visibility the value appears on the public profile + await page.goto(`/u/${user.userId}`) + await expect(page.getByText(value)).toBeVisible() + }) + } + + // ── Non-federated properties (Local and Private only) ───────────────────── + + const nonfederatedProperties = [ + { label: 'Organisation', scopeProperty: 'organisation' }, + { label: 'Role', scopeProperty: 'role' }, + { label: 'Headline', scopeProperty: 'headline' }, + { label: 'About', scopeProperty: 'about' }, + ] as const + + for (const { label, scopeProperty } of nonfederatedProperties) { + test(`can set ${label} and change its visibility`, async ({ page, user }) => { + // Use a value unique to this property to identify it on the profile page + const uniqueValue = `${label.toUpperCase()} ${label.toLowerCase()}` + await page.goto('/settings/user') + + const input = page.getByRole('textbox', { name: label }) + + const saved = waitForSave(page) + await input.fill(uniqueValue) + await handlePasswordConfirmation(page, user.password) + await saved + + await page.reload() + await expect(input).toHaveValue(uniqueValue) + + // Toggle Private → Local (the two supported scopes for these properties) + await changeVisibility(page, scopeProperty, Visibility.Private, user.password) + await page.reload() + await expect(page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*private`, 'i') })).toBeVisible() + + await changeVisibility(page, scopeProperty, Visibility.Local, user.password) + + // With Local visibility the value appears on the public profile + await page.goto(`/u/${user.userId}`) + await expect(page.getByText(uniqueValue)).toBeVisible() + }) + } +}) diff --git a/tests/playwright/e2e/systemtags/admin-settings-restrictions.spec.ts b/tests/playwright/e2e/systemtags/admin-settings-restrictions.spec.ts new file mode 100644 index 0000000000000..0cc1eea013b3b --- /dev/null +++ b/tests/playwright/e2e/systemtags/admin-settings-restrictions.spec.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/systemtags-files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { clearTags, createTag } from '../../support/utils/systemtags.ts' + +test.beforeAll(async () => await runOcc(['config:app:set', 'systemtags', 'restrict_creation_to_admin', '--value', '1'])) +test.afterAll(async () => await runOcc(['config:app:delete', 'systemtags', 'restrict_creation_to_admin'])) +test.afterAll(async () => await clearTags()) + +test.beforeEach(async ({ filesListPage, page, user }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file1.txt') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file2.txt') + await filesListPage.open() +}) + +test('Cannot create tag if restriction is in place', async ({ filesListPage }) => { + const tag = crypto.randomUUID() + await createTag(tag, 'public') + + await filesListPage.expectInlineTagsForFile('file1.txt', []) + await filesListPage.selectAll() + const picker = await filesListPage.openTagPickerForSelection() + + // When restricted, the input label changes and create/color buttons are absent + await expect(picker.getByLabel('Search or create tag')).toHaveCount(0) + await expect(picker.getByLabel('Search tag')).toBeVisible() + + await picker.getByLabel('Search tag').fill(crypto.randomUUID()) + await expect(picker.getByRole('button', { name: /Create new tag/i })).toHaveCount(0) + + await picker.getByLabel('Search tag').clear() + await picker.getByLabel('Search tag').fill(tag) + + await expect(picker.getByRole('checkbox')).toHaveCount(1) + await expect(picker.getByRole('button', { name: /Create new tag/i })).toHaveCount(0) + await expect(picker.getByRole('button', { name: 'Change tag color' })).toHaveCount(0) + + // Can still assign the existing admin-created tag + await picker.getByRole('checkbox', { name: tag }).click({ force: true }) + await filesListPage.applyTagPicker() + await filesListPage.expectInlineTagsForFile('file1.txt', [tag]) +}) diff --git a/tests/playwright/e2e/systemtags/admin-settings.spec.ts b/tests/playwright/e2e/systemtags/admin-settings.spec.ts new file mode 100644 index 0000000000000..cf796fe127002 --- /dev/null +++ b/tests/playwright/e2e/systemtags/admin-settings.spec.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-session.ts' +import { createTag, deleteTag, listTags } from '../../support/utils/systemtags.ts' + +const tagName = 'foo' +const updatedTagName = 'bar' + +test.describe('System tags admin settings', () => { + test.beforeEach(async () => { + const tags = await listTags() + for (const tag of tags) { + await deleteTag(tag.id) + } + }) + + test('Can create a tag', async ({ page }) => { + await page.goto('settings/admin/server') + + // Scroll the collaborative tags section into view — the admin settings page is long + await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded() + + const tagNameInput = page.getByLabel('Tag name') + await expect(tagNameInput).toHaveValue('') + + // Create the tag and intercept the DAV POST + const createResponse = page.waitForResponse((r) => r.url().includes('/remote.php/dav/systemtags') && r.request().method() === 'POST') + await tagNameInput.fill(tagName) + await page.getByRole('button', { name: 'Create' }).click() + expect((await createResponse).status()).toBe(201) + + // The form resets after creation — verify the tag now appears in the selection dropdown + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await expect(page.getByRole('option', { name: tagName })).toBeVisible() + }) + + test('Can update a tag', async ({ page }) => { + await createTag(tagName) + + await page.goto('settings/admin/server') + await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded() + + // Select the tag to edit + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await page.getByRole('option', { name: tagName }).click() + + // Verify the form reflects the selected tag + await expect(page.getByLabel('Tag name')).toHaveValue(tagName) + // NcSelect single-select: selected level appears inline in .vs__selected + await expect(page.locator('.system-tag-form__group:has(#system-tag-level) .vs__selected')).toContainText('Public') + + // Update the name + await page.getByLabel('Tag name').fill(updatedTagName) + + // Change the level — click opens the teleported VueSelect dropdown + await page.locator('#system-tag-level').click() + await page.getByRole('option', { name: 'Invisible' }).click() + + const updateResponse = page.waitForResponse((r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'PROPPATCH') + await page.getByRole('button', { name: 'Update' }).click() + expect((await updateResponse).status()).toBe(207) + + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + // NcEllipsisedOption splits names ≥ 10 chars across two spans, breaking the accessible name. + // "bar (invisible)" (15 chars) splits at position 8 → accessible name "bar (inv isible)". + // Use filter({ hasText }) to match on text content instead of the exact accessible name. + await expect(page.getByRole('option').filter({ hasText: updatedTagName })).toBeVisible() + }) + + test('Can delete a tag', async ({ page }) => { + await createTag(tagName) + + await page.goto('settings/admin/server') + await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded() + + // Select the invisible tag to delete + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await page.getByRole('option').filter({ hasText: tagName }).click() + + // Verify the form reflects the selected tag + await expect(page.getByLabel('Tag name')).toHaveValue(tagName) + + const deleteResponse = page.waitForResponse((r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'DELETE') + await page.locator('.system-tag-form__row').getByRole('button', { name: 'Delete' }).click() + expect((await deleteResponse).status()).toBe(204) + + // Verify the tag is gone from the dropdown + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await expect(page.getByRole('option').filter({ hasText: tagName })).not.toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts b/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts new file mode 100644 index 0000000000000..7614d8c2c9dfb --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as baseTest } from '../../support/fixtures/systemtags-files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { assignTagsToFile, clearTags, createTag } from '../../support/utils/systemtags.ts' + +// Extends the base fixture with per-test file IDs so tests in parallel each get +// their own isolated file IDs rather than sharing module-level mutable state. +const test = baseTest.extend<{ fileIds: [string, string] }>({ + fileIds: [async ({ page, user, filesListPage }, use) => { + const fileId1 = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file1.txt') + const fileId2 = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file2.txt') + await filesListPage.open() + await use([fileId1, fileId2]) + }, { auto: true }], +}) + +test.describe('Systemtags: Files bulk action', () => { + test.afterAll(async () => await clearTags()) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Needed to execute the upload by PlayWright + test('Can assign tag to selection', async ({ filesListPage, fileIds }) => { + const tag = crypto.randomUUID() + + await filesListPage.expectInlineTagsForFile('file1.txt', []) + await filesListPage.expectInlineTagsForFile('file2.txt', []) + + await filesListPage.selectRowForFile('file1.txt') + await filesListPage.selectRowForFile('file2.txt') + + await filesListPage.openTagPickerForSelection() + await filesListPage.createNewTagInPicker(tag) + await filesListPage.applyTagPicker() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag]) + }) + + test('Can assign multiple tags to selection', async ({ filesListPage }) => { + const tag1 = crypto.randomUUID() + const tag2 = crypto.randomUUID() + + await filesListPage.expectInlineTagsForFile('file1.txt', []) + await filesListPage.expectInlineTagsForFile('file2.txt', []) + + await filesListPage.selectRowForFile('file1.txt') + await filesListPage.selectRowForFile('file2.txt') + + await filesListPage.openTagPickerForSelection() + await filesListPage.createNewTagInPicker(tag1) + await filesListPage.createNewTagInPicker(tag2) + await filesListPage.applyTagPicker() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2]) + }) + + test('Can remove tag from selection', async ({ filesListPage, page, fileIds }) => { + const tag1 = crypto.randomUUID() + const tag2 = crypto.randomUUID() + await assignTagsToFile(fileIds[0], [tag1, tag2]) + await assignTagsToFile(fileIds[1], [tag1, tag2]) + await page.reload() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2]) + + await filesListPage.selectRowForFile('file1.txt') + await filesListPage.selectRowForFile('file2.txt') + + await filesListPage.openTagPickerForSelection() + await filesListPage.unselectTagInPicker(tag2) + await filesListPage.applyTagPicker() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag1]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag1]) + }) + + test('Can remove multiple tags from selection', async ({ filesListPage, page, fileIds }) => { + const tag1 = crypto.randomUUID() + const tag2 = crypto.randomUUID() + const tag3 = crypto.randomUUID() + await assignTagsToFile(fileIds[0], [tag1, tag2, tag3]) + await assignTagsToFile(fileIds[1], [tag1, tag2, tag3]) + await page.reload() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2, tag3]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2, tag3]) + + await filesListPage.selectRowForFile('file1.txt') + await filesListPage.selectRowForFile('file2.txt') + + await filesListPage.openTagPickerForSelection() + await filesListPage.unselectTagInPicker(tag2) + await filesListPage.unselectTagInPicker(tag3) + await filesListPage.applyTagPicker() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag1]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag1]) + }) + + test('Can assign and remove multiple tags', async ({ filesListPage, page, fileIds }) => { + const tag1 = crypto.randomUUID() + const tag2 = crypto.randomUUID() + const tag3 = crypto.randomUUID() + await assignTagsToFile(fileIds[0], [tag1, tag2]) + await assignTagsToFile(fileIds[1], [tag1, tag2]) + await page.reload() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2]) + + await filesListPage.selectRowForFile('file1.txt') + await filesListPage.selectRowForFile('file2.txt') + + await filesListPage.openTagPickerForSelection() + await filesListPage.unselectTagInPicker(tag2) + await filesListPage.createNewTagInPicker(tag3) + await filesListPage.applyTagPicker() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag3]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag3]) + }) + + test('Can search for tags with insensitive case', async ({ filesListPage }) => { + const tag = crypto.randomUUID().toLowerCase() + await createTag(tag, 'public') + + await filesListPage.expectInlineTagsForFile('file1.txt', []) + await filesListPage.expectInlineTagsForFile('file2.txt', []) + + await filesListPage.selectRowForFile('file1.txt') + await filesListPage.selectRowForFile('file2.txt') + + await filesListPage.openTagPickerForSelection() + await filesListPage.selectTagInPicker(tag.toUpperCase()) + await filesListPage.applyTagPicker() + + await filesListPage.expectInlineTagsForFile('file1.txt', [tag]) + await filesListPage.expectInlineTagsForFile('file2.txt', [tag]) + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-inline-action.spec.ts b/tests/playwright/e2e/systemtags/files-inline-action.spec.ts new file mode 100644 index 0000000000000..a2383af3f15e7 --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-inline-action.spec.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/systemtags-files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { clearTags } from '../../support/utils/systemtags.ts' + +test.describe('Systemtags: Files integration', () => { + test.afterAll(async () => await clearTags()) + + test.beforeEach(async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + }) + + test('See first assigned tag in the file list', async ({ page, filesListPage }) => { + const tag = crypto.randomUUID() + + await filesListPage.openTagPickerForFile('file.txt') + await filesListPage.createNewTagInPicker(tag) + await filesListPage.applyTagPicker() + await page.reload() + + const tagList = filesListPage.getInlineTagsForFile('file.txt') + await expect(tagList.getByRole('listitem')).toHaveCount(1) + await expect(tagList.getByRole('listitem')).toBeVisible() + await expect(tagList.getByRole('listitem')).toContainText(tag) + }) + + test('See two assigned tags are also shown in the file list', async ({ page, filesListPage }) => { + const tag1 = crypto.randomUUID() + const tag2 = crypto.randomUUID() + + await filesListPage.openTagPickerForFile('file.txt') + await filesListPage.createNewTagInPicker(tag1) + await filesListPage.createNewTagInPicker(tag2) + await filesListPage.applyTagPicker() + await page.reload() + + const tagList = filesListPage.getInlineTagsForFile('file.txt') + // 2 tags, no overflow — both li elements are visible + await expect(tagList.locator('li')).toHaveCount(2) + await expect(tagList).toContainText(tag1) + await expect(tagList).toContainText(tag2) + }) + + test('See three assigned tags result in overflow entry', async ({ page, filesListPage }) => { + const tag1 = crypto.randomUUID() + const tag2 = crypto.randomUUID() + const tag3 = crypto.randomUUID() + + await filesListPage.openTagPickerForFile('file.txt') + await filesListPage.createNewTagInPicker(tag1) + await filesListPage.createNewTagInPicker(tag2) + await filesListPage.createNewTagInPicker(tag3) + await filesListPage.applyTagPicker() + await page.reload() + + const tagList = filesListPage.getInlineTagsForFile('file.txt') + // 3 tags with overflow: 1 visible + "+2" (aria-hidden, role=presentation) + 2 hidden-visually = 4 li elements + await expect(tagList.locator('li')).toHaveCount(4) + + // First li is the visible tag; second li is the aria-hidden overflow indicator + await expect(tagList.locator('li').first()).toBeVisible() + await expect(tagList.locator('li').nth(1)).toContainText('+2') + + // All 3 tag names are present in the list (1 visible, 2 hidden-visually) + await expect(tagList).toContainText(tag1) + await expect(tagList).toContainText(tag2) + await expect(tagList).toContainText(tag3) + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-sidebar.spec.ts b/tests/playwright/e2e/systemtags/files-sidebar.spec.ts new file mode 100644 index 0000000000000..3decfaf866d0d --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-sidebar.spec.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/systemtags-files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { clearTags } from '../../support/utils/systemtags.ts' + +test.describe('Systemtags: Files sidebar integration', () => { + test.afterAll(async () => await clearTags()) + + test.beforeEach(async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + }) + + test('Can assign tags using the sidebar', async ({ filesListPage, filesSidebar }) => { + const tag = crypto.randomUUID() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Open the file details sidebar + await filesListPage.triggerActionForFile('file.txt', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + + // Open the sidebar's Actions menu and click "Add tags" + await filesSidebar.triggerAction('Add tags') + + // Create and apply the new tag via the picker + await expect(filesListPage.getTagPicker()).toBeVisible() + await filesListPage.createNewTagInPicker(tag) + await filesListPage.applyTagPicker() + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-view.spec.ts b/tests/playwright/e2e/systemtags/files-view.spec.ts new file mode 100644 index 0000000000000..dc2fe681c8da3 --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-view.spec.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/systemtags-files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { clearTags } from '../../support/utils/systemtags.ts' + +test.describe('Systemtags: Files view', () => { + test.afterAll(async () => await clearTags()) + + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + }) + + test('See first assigned tag in the file list', async ({ page, filesListPage }) => { + const tag = crypto.randomUUID() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Assign tag to the folder via the Manage Tags picker + await filesListPage.openTagPickerForFile('folder') + await filesListPage.createNewTagInPicker(tag) + await filesListPage.applyTagPicker() + + // Navigate to the tags view + await page.goto('apps/files/tags') + await expect(filesListPage.getRowForFile('folder')).not.toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).not.toBeVisible() + + // The tag should appear as a cell in the tags list view + await expect(page.getByRole('cell', { name: tag })).toBeVisible() + await page.getByRole('cell', { name: tag }).click() + + // Only the folder (tagged) is shown; file.txt (untagged) is absent + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).not.toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts b/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts new file mode 100644 index 0000000000000..00a861fb10f43 --- /dev/null +++ b/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '@playwright/test' +import { resolve } from 'node:path' + +const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast'] + +const testCases = { + 'Main text': { + foregroundColors: ['color-main-text', 'color-text-maxcontrast'], + backgroundColors: ['color-main-background', 'color-background-hover', 'color-background-dark'], + }, + 'blurred background': { + foregroundColors: ['color-main-text', 'color-text-maxcontrast-blur'], + backgroundColors: ['color-main-background-blur'], + }, + Primary: { + foregroundColors: ['color-primary-text'], + backgroundColors: ['color-primary'], + }, + 'Primary light': { + foregroundColors: ['color-primary-light-text'], + backgroundColors: ['color-primary-light', 'color-primary-light-hover'], + }, + 'Primary element': { + foregroundColors: ['color-primary-element-text', 'color-primary-element-text-dark'], + backgroundColors: ['color-primary-element', 'color-primary-element-hover'], + }, + 'Primary element light': { + foregroundColors: ['color-primary-element-light-text'], + backgroundColors: ['color-primary-element-light', 'color-primary-element-light-hover'], + }, + 'Severity information texts': { + foregroundColors: ['color-error-text', 'color-warning-text', 'color-success-text', 'color-info-text'], + backgroundColors: ['color-main-background', 'color-background-hover'], + }, + 'Severity information on blur': { + foregroundColors: ['color-error-text', 'color-success-text'], + backgroundColors: ['color-main-background-blur'], + }, +} + +for (const theme of themesToTest) { + test(`Accessibility of Nextcloud theming colors: ${theme}`, async ({ page, context }) => { + const user = await createRandomUser() + const failures: string[] = [] + + try { + await runOcc(['user:setting', '--', user.userId, 'theming', 'enabled-themes', `["${theme}"]`]) + await login(context.request, user) + await page.goto('') + + await page.addScriptTag({ path: resolve(process.cwd(), 'node_modules/axe-core/axe.min.js') }) + + for (const [groupName, { foregroundColors, backgroundColors }] of Object.entries(testCases)) { + for (const foreground of foregroundColors) { + for (const background of backgroundColors) { + await page.evaluate(({ foregroundValue, backgroundValue }) => { + document.body.style.backgroundImage = 'unset' + const root = document.querySelector('#content') + if (!root) { + throw new Error('No test root found') + } + + root.innerHTML = '' + + const wrapper = document.createElement('div') + wrapper.style.padding = '14px' + wrapper.style.color = `var(--${foregroundValue})` + wrapper.style.backgroundColor = `var(--${backgroundValue})` + if (backgroundValue.includes('blur')) { + wrapper.style.backdropFilter = 'var(--filter-background-blur)' + } + + const testCase = document.createElement('div') + testCase.innerText = `${foregroundValue} ${backgroundValue}` + testCase.setAttribute('data-cy-testcase', '') + + wrapper.append(testCase) + root.append(wrapper) + }, { + foregroundValue: foreground, + backgroundValue: background, + }) + + const axeResult = await page.evaluate(async () => { + const axe = (window as any).axe + if (!axe) { + throw new Error('axe is not loaded') + } + + return axe.run('[data-cy-testcase]', { + runOnly: { + type: 'rule', + values: ['color-contrast'], + }, + }) + }) + + if (axeResult.violations.length > 0) { + failures.push(`${groupName}: ${foreground} on ${background}`) + } + } + } + } + } finally { + await runOcc(['user:delete', user.userId]) + } + + expect(failures).toEqual([]) + }) +} diff --git a/tests/playwright/e2e/theming/admin-settings-background.spec.ts b/tests/playwright/e2e/theming/admin-settings-background.spec.ts new file mode 100644 index 0000000000000..a2bb749dc09bc --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-background.spec.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { expect } from '@playwright/test' +import { resolve } from 'node:path' +import { test } from '../../support/fixtures/admin-theming-page.ts' +import { getBodyThemingSnapshot, pickColor } from '../../support/utils/theming.ts' + +test.describe('Admin theming background settings', () => { + test.describe.configure({ mode: 'serial' }) + + test.beforeEach(async ({ adminThemingPage, page }) => { + await adminThemingPage.reset() + await adminThemingPage.open() + if (await adminThemingPage.disableUserThemingCheckbox().isChecked()) { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.disableUserThemingCheckbox().uncheck({ force: true }), + ]) + } + }) + + test('Remove default background and restore it', async ({ adminThemingPage, page }) => { + await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible() + if (await adminThemingPage.removeBackgroundImageCheckbox().isChecked()) { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().uncheck({ force: true }), + ]) + } + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }), + ]) + + await page.goto('/index.php/logout') + await page.goto('/index.php/login') + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + + await adminThemingPage.reset() + await page.goto('settings/admin/theming') + await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible() + }) + + test('Disable user theming', async ({ adminThemingPage, page, context }) => { + await expect(adminThemingPage.disableUserThemingCheckbox()).not.toBeChecked() + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.disableUserThemingCheckbox().check({ force: true }), + ]) + + const user = await createRandomUser() + try { + await login(context.request, user) + await page.goto('settings/user/theming') + await expect(page.getByText('Customization has been disabled by your administrator')).toBeVisible() + } finally { + await runOcc(['user:delete', user.userId]) + } + }) + + test('Remove default background with custom color', async ({ adminThemingPage, page }) => { + await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible() + const backgroundColorButton = page.getByRole('button', { name: /Background color/ }) + const selectedColor = await pickColor(page, backgroundColorButton, 2) + expect(selectedColor).toBeTruthy() + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }), + ]) + + await page.goto('/index.php/logout') + await page.goto('/index.php/login') + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + }) + + test('User default background reflects admin custom background and color', async ({ page, context }) => { + const imagePath = resolve(process.cwd(), 'tests/data/images/image.jpg') + + await page.locator('input[type="file"][name="background"]').setInputFiles(imagePath) + await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/uploadImage') && response.request().method() === 'POST') + + const backgroundColorButton = page.getByRole('button', { name: /Background color/ }) + await pickColor(page, backgroundColorButton, 1) + await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST') + + await page.goto('/index.php/logout') + const user = await createRandomUser() + try { + await login(context.request, user) + await page.goto('settings/user/theming') + await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true') + const snapshot = await getBodyThemingSnapshot(page) + expect(snapshot.backgroundImage).toContain('/apps/theming/image/background?v=') + } finally { + await runOcc(['user:delete', user.userId]) + } + }) + + test('User default background reflects admin removed background', async ({ adminThemingPage, page, context }) => { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }), + ]) + + await page.goto('/index.php/logout') + const user = await createRandomUser() + try { + await login(context.request, user) + await page.goto('settings/user/theming') + await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true') + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + } finally { + await runOcc(['user:delete', user.userId]) + } + }) +}) diff --git a/tests/playwright/e2e/theming/admin-settings-branding.spec.ts b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts new file mode 100644 index 0000000000000..f5d1aede68776 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-theming-page.ts' + +test.describe('Admin theming branding settings', () => { + test.beforeEach(async ({ adminThemingPage }) => { + await adminThemingPage.reset() + await adminThemingPage.open() + }) + + test('Set project links and verify persisted values', async ({ adminThemingPage, page }) => { + await expect(adminThemingPage.webLinkInput()).toHaveAttribute('type', 'url') + await expect(adminThemingPage.legalNoticeLinkInput()).toHaveAttribute('type', 'url') + await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveAttribute('type', 'url') + + await adminThemingPage.webLinkInput().fill('http://example.com/path?query#fragment') + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.webLinkInput().press('Enter'), + ]) + + await adminThemingPage.legalNoticeLinkInput().fill('http://example.com/legal?query#fragment') + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.legalNoticeLinkInput().press('Enter'), + ]) + + await adminThemingPage.privacyPolicyLinkInput().fill('http://privacy.local/path?query#fragment') + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.privacyPolicyLinkInput().press('Enter'), + ]) + + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/path?query#fragment') + await expect(adminThemingPage.legalNoticeLinkInput()).toHaveValue('http://example.com/legal?query#fragment') + await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveValue('http://privacy.local/path?query#fragment') + }) + + test('Set and undo login fields', async ({ adminThemingPage, page }) => { + const name = 'ABCdef123' + const url = 'https://example.com' + const slogan = 'Testing is fun' + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.nameInput().fill(name), + ]) + await adminThemingPage.nameInput().press('Enter') + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.webLinkInput().fill(url), + ]) + await adminThemingPage.webLinkInput().press('Enter') + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.sloganInput().fill(slogan), + ]) + await adminThemingPage.sloganInput().press('Enter') + + await expect(adminThemingPage.undoChangesButtons()).toHaveCount(3) + + for (let index = 0; index < 3; index++) { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/undoChanges') && response.request().method() === 'POST'), + adminThemingPage.undoChangesButtons().first().click(), + ]) + } + await expect(adminThemingPage.undoChangesButtons()).toHaveCount(0) + }) + + test('Web link corner cases', async ({ adminThemingPage, page }) => { + await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/%22path%20with%20space%22') + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%20with%20space%22') + + await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"path"') + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%22') + + await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"the%20path"') + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22the%20path%22') + }) +}) + +async function setUrlFieldAndWait(page: Page, locator: Locator, value: string) { + await locator.fill(value) + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + locator.press('Enter'), + ]) +} diff --git a/tests/playwright/e2e/theming/admin-settings-colors.spec.ts b/tests/playwright/e2e/theming/admin-settings-colors.spec.ts new file mode 100644 index 0000000000000..9becf0aef0369 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-colors.spec.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-theming-page.ts' +import { pickColor } from '../../support/utils/theming.ts' + +test.beforeEach(async ({ adminThemingPage }) => { + await adminThemingPage.reset() + await adminThemingPage.open() +}) + +test('Change the primary color and reset it', async ({ adminThemingPage, page }) => { + await page.getByRole('heading', { name: 'Background and color' }).scrollIntoViewIfNeeded() + + const primaryColorButton = page.getByRole('button', { name: /Primary color/ }) + const updateStylesheetResponse = page.waitForResponse((response) => { + return response.url().includes('/apps/theming/ajax/updateStylesheet') + && response.request().method() === 'POST' + }) + await pickColor(page, primaryColorButton, 3) + expect(await updateStylesheetResponse).toBeTruthy() + + await page.goto('settings/admin/theming') + await adminThemingPage.reset() + await page.goto('settings/admin/theming') + await expect(page.getByRole('heading', { name: 'Background and color' })).toBeVisible() +}) diff --git a/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts new file mode 100644 index 0000000000000..35777d5e0a431 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-theming-page.ts' +import { NavigationHeaderPage } from '../../support/sections/NavigationHeaderPage.ts' + +test.describe('Admin theming set default apps', () => { + // we need serial mode to reset the default app setting after each test + // and to restore the default app to dashboard at the end of the tests. + // Otherwise, the tests would influence each other and lead to random failures (race condition when run in parallel). + test.describe.configure({ mode: 'serial' }) + + test.beforeEach(async ({ adminThemingPage, page }) => { + await runOcc(['config:system:set', 'defaultapp', '--value', 'dashboard']) + await adminThemingPage.reset() + await page.goto('') + }) + + test.afterAll(async () => { + await runOcc(['config:system:set', 'defaultapp', '--value', 'dashboard']) + }) + + test('See the current default app is the dashboard', async ({ page }) => { + const navigationHeader = new NavigationHeaderPage(page) + + await expect(page).toHaveURL(/apps\/dashboard/) + await navigationHeader.logo().click() + await expect(page).toHaveURL(/apps\/dashboard/) + }) + + test('Can configure and switch the default app to files', async ({ adminThemingPage }) => { + await adminThemingPage.open() + await expect(adminThemingPage.defaultAppSwitch()).toBeVisible() + if (await adminThemingPage.defaultAppSwitch().isChecked()) { + await adminThemingPage.defaultAppSwitch().uncheck({ force: true }) + } + await expect(adminThemingPage.defaultAppSwitch()).not.toBeChecked() + + await adminThemingPage.defaultAppSwitch().check({ force: true }) + await expect(adminThemingPage.defaultAppSwitch()).toBeChecked() + await expect(adminThemingPage.defaultAppRegion()).toBeVisible() + + await expect(adminThemingPage.defaultAppSelectedValue('Dashboard')).toBeVisible() + await expect(adminThemingPage.defaultAppSelectedValue('Files')).toBeVisible() + + await expect(adminThemingPage.appOrderEntries()).toHaveCount(2) + await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') + await expect(adminThemingPage.appOrderEntries().nth(1)).toContainText('Files') + + await adminThemingPage.moveUpButton('Files').click() + await expect(adminThemingPage.moveUpButton('Files')).toHaveCount(0) + await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Files') + await expect(adminThemingPage.appOrderEntries().nth(1)).toContainText('Dashboard') + + await adminThemingPage.defaultAppSwitch().uncheck({ force: true }) + await expect(adminThemingPage.defaultAppSwitch()).not.toBeChecked() + await expect(adminThemingPage.defaultAppRegion()).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/theming/user-settings-app-order.spec.ts b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts new file mode 100644 index 0000000000000..305334635d687 --- /dev/null +++ b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/random-user-session.ts' +import { NavigationHeaderPage } from '../../support/sections/NavigationHeaderPage.ts' +import { UserThemingPage } from '../../support/sections/UserThemingPage.ts' + +test('User can change personal app order', async ({ page }) => { + const userThemingPage = new UserThemingPage(page) + const navigationHeader = new NavigationHeaderPage(page) + + await userThemingPage.open() + + await expect(userThemingPage.appOrderEntries()).toHaveCount(2) + await expect(userThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') + await expect(userThemingPage.appOrderEntries().nth(1)).toContainText('Files') + + await expect(navigationHeader.navigationEntries().nth(0)).toContainText('Dashboard') + await expect(navigationHeader.navigationEntries().nth(1)).toContainText('Files') + + const initialFirstEntry = await userThemingPage.appOrderEntries().nth(0).innerText() + if (/Dashboard/i.test(initialFirstEntry)) { + const moveUpButton = userThemingPage.appEntry('Files').locator('button[aria-label="Move up"]').first() + if (await moveUpButton.count() > 0) { + await moveUpButton.evaluate((element) => { + (element as HTMLButtonElement).click() + }) + } + } + + const currentOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim()) + expect(currentOrder).toContain('Dashboard') + expect(currentOrder).toContain('Files') + + await page.reload() + const reloadedOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim()) + expect(reloadedOrder).toContain('Dashboard') + expect(reloadedOrder).toContain('Files') + await expect(navigationHeader.navigationEntries().nth(0)).toContainText(reloadedOrder[0]!) + await expect(navigationHeader.navigationEntries().nth(1)).toContainText(reloadedOrder[1]!) +}) diff --git a/tests/playwright/e2e/theming/user-settings-background.spec.ts b/tests/playwright/e2e/theming/user-settings-background.spec.ts new file mode 100644 index 0000000000000..3105d88d2d841 --- /dev/null +++ b/tests/playwright/e2e/theming/user-settings-background.spec.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/random-user-session.ts' +import { BackgroundFilePickerDialogPage } from '../../support/sections/BackgroundFilePickerDialogPage.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { getBodyThemingSnapshot, pickColor } from '../../support/utils/theming.ts' + +test('User can configure background and plain color', async ({ page }) => { + await page.goto('settings/user/theming') + await page.getByRole('heading', { name: 'Background and color' }).waitFor({ state: 'visible' }) + + await expect(page.getByRole('button', { name: 'Default background', pressed: true })).toBeVisible() + + const darkBackground = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' + const darkBackgroundName = 'Background picture of a red-ish butterfly wing under microscope' + await page.getByRole('button', { name: darkBackgroundName, pressed: false }).click() + await expect(page.getByRole('button', { name: darkBackgroundName, pressed: true })).toBeVisible() + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toContain(darkBackground) + + const brightBackground = 'bernie-cetonia-aurata-take-off-composition.jpg' + const brightBackgroundName = 'Montage of a cetonia aurata bug that takes off with white background' + await page.getByRole('button', { name: brightBackgroundName, pressed: false }).click() + await expect(page.getByRole('button', { name: brightBackgroundName, pressed: true })).toBeVisible() + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toContain(brightBackground) + + const plainBackgroundButton = page.getByRole('button', { name: 'Plain background' }) + await pickColor(page, plainBackgroundButton, 7) + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + + await page.reload() + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') +}) + +test('User can pick a custom background from their files', { + annotation: { type: 'issue', description: 'https://github.com/nextcloud/server/issues/58645' }, +}, async ({ page, user }) => { + await mkdir(page.request, user, '/folder') + + // this is a minimal image (1x1 red pixel), encoded as base64 + const imageBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWL6z8DwHwAAAP//A3ONEwAAAAZJREFUAwAFCgIByRpMngAAAABJRU5ErkJggg==' + // Buffer.alloc(0) did not work when selecting image as background, using base64 image instead + await uploadContent(page.request, user, Buffer.from(imageBase64, 'base64'), 'image/jpeg', '/folder/image.jpg') + + await page.goto('settings/user/theming') + await page.getByRole('heading', { name: 'Background and color' }).waitFor({ state: 'visible' }) + + await page.getByRole('button', { name: 'Custom background' }).click() + + const filePicker = new BackgroundFilePickerDialogPage(page) + await filePicker.openFolder('folder') + await filePicker.selectFile('image.jpg') + await filePicker.confirm() + + await expect(page.getByRole('button', { name: 'Custom background', pressed: true })).toBeVisible() + // backgroundImage is like this: "url(\"/apps/theming/background?v=\")" + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toContain('/apps/theming/background?') +}) diff --git a/tests/playwright/e2e/users/users-columns.spec.ts b/tests/playwright/e2e/users/users-columns.spec.ts new file mode 100644 index 0000000000000..8df06216d2a95 --- /dev/null +++ b/tests/playwright/e2e/users/users-columns.spec.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-session.ts' +import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts' + +test.describe('Settings: Show and hide columns', () => { + test.beforeEach(async ({ page }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + // Reset: open settings, uncheck all optional columns, re-enable last-login + await settingsPage.openSettingsDialog() + const dialog = settingsPage.settingsDialog() + + // Uncheck both optional columns + for (const name of ['Show language', 'Show last login']) { + const checkbox = dialog.getByRole('checkbox', { name }) + if (await checkbox.isChecked()) { + await checkbox.uncheck({ force: true }) + } + } + + // Re-enable last-login so each test starts from a known baseline + await dialog.getByRole('checkbox', { name: 'Show last login' }).check({ force: true }) + await settingsPage.closeSettingsDialog() + }) + + test('can show the Language column', async ({ page }) => { + const settingsPage = new SettingsUsersPage(page) + + // Language column must not be visible before the toggle + await expect(page.getByRole('columnheader', { name: /Language/i })).toHaveCount(0) + await expect(page.locator('[data-cy-user-list-cell-language]').first()).toHaveCount(0) + + await settingsPage.openSettingsDialog() + const dialog = settingsPage.settingsDialog() + const checkbox = dialog.getByRole('checkbox', { name: 'Show language' }) + await expect(checkbox).not.toBeChecked() + await checkbox.check({ force: true }) + await expect(checkbox).toBeChecked() + await settingsPage.closeSettingsDialog() + + // Language column header must now be visible + await expect(page.getByRole('columnheader', { name: /Language/i })).toBeVisible() + // Every row must have a language cell + await expect(page.locator('[data-cy-user-list-cell-language]').first()).toBeVisible() + + // Reload to verify the preference is persisted (stored in DB, not just localStorage) + await page.evaluate(() => localStorage.clear()) + await page.reload() + await expect(page.getByRole('columnheader', { name: /Language/i })).toBeVisible() + }) + + test('can hide the Last login column', async ({ page }) => { + const settingsPage = new SettingsUsersPage(page) + + // Last login column must be visible (enabled in beforeEach) + await expect(page.getByRole('columnheader', { name: /Last login/i })).toBeVisible() + await expect(page.locator('[data-cy-user-list-cell-last-login]').first()).toBeVisible() + + await settingsPage.openSettingsDialog() + const dialog = settingsPage.settingsDialog() + const checkbox = dialog.getByRole('checkbox', { name: 'Show last login' }) + await expect(checkbox).toBeChecked() + await checkbox.uncheck({ force: true }) + await expect(checkbox).not.toBeChecked() + await settingsPage.closeSettingsDialog() + + // Column header must now be gone + await expect(page.getByRole('columnheader', { name: /Last login/i })).toHaveCount(0) + await expect(page.locator('[data-cy-user-list-cell-last-login]').first()).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/users/users-disable.spec.ts b/tests/playwright/e2e/users/users-disable.spec.ts new file mode 100644 index 0000000000000..6c2202b35110c --- /dev/null +++ b/tests/playwright/e2e/users/users-disable.spec.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-with-user.ts' +import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts' + +test.describe('Settings: Disable and enable users', () => { + test('can disable a user', async ({ page, user }) => { + // Ensure user is enabled + await runOcc(['user:enable', user.userId]) + + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await expect(settingsPage.userRow(user.userId)).toBeVisible() + + await settingsPage.openActionsMenu(user.userId) + await page.getByRole('menuitem', { name: 'Disable account' }).click() + + // User should no longer be in the main list + await expect(settingsPage.userRow(user.userId)).toHaveCount(0) + + // Disabled accounts nav link should now appear + const disabledLink = settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i }) + await expect(disabledLink).toBeVisible() + + // Navigate to disabled users + await disabledLink.click() + await expect(page).toHaveURL(/\/disabled/) + + // The disabled user should be in the list + await settingsPage.userList().waitFor({ state: 'visible' }) + await expect(settingsPage.userRow(user.userId)).toBeVisible() + }) + + test('can enable a user', async ({ page, user }) => { + // Ensure user is disabled + await runOcc(['user:disable', user.userId]) + + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + // Navigate to disabled users + const disabledLink = settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i }) + await expect(disabledLink).toBeVisible() + await disabledLink.click() + await expect(page).toHaveURL(/\/disabled/) + await settingsPage.userList().waitFor({ state: 'visible' }) + + const waitForEnableRequest = page.waitForResponse((r) => r.request().url().match(/\/ocs\/v2\.php\/cloud\/users\/[^/]+\/enable/) !== null) + await settingsPage.openActionsMenu(user.userId) + await page.getByRole('menuitem', { name: 'Enable account' }).click() + await waitForEnableRequest + + // Disabled accounts section should disappear (no more disabled users) + await expect(settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })).toHaveCount(0) + + // After reload, still no disabled accounts section + await page.reload() + await expect(settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/users/users-group-admin.spec.ts b/tests/playwright/e2e/users/users-group-admin.spec.ts new file mode 100644 index 0000000000000..ace1dd4bd9976 --- /dev/null +++ b/tests/playwright/e2e/users/users-group-admin.spec.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest, expect } from '@playwright/test' +import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' +import { makeSubAdmin } from '../../support/utils/users.ts' + +const test = baseTest.extend<{ subadmin: User, group: string }>({ + group: async ({}, use) => { + const groupName = crypto.randomUUID() + await runOcc(['group:add', groupName]) + await use(groupName) + await runOcc(['group:delete', groupName]).catch(() => {}) + }, + subadmin: async ({ group, request }, use) => { + const user = await createRandomUser() + await runOcc(['group:adduser', group, user.userId]) + await makeSubAdmin(request, user.userId, group) + await use(user) + await runOcc(['user:delete', user.userId]) + }, +}) + +test.describe('Settings: Create accounts as a group admin', () => { + test('can create a user with the group pre-filled', async ({ page, context, subadmin, group }) => { + // Log in as the subadmin (not as admin) + await login(context.request, subadmin) + + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openNewUserDialog() + const dialog = settingsPage.newUserDialog() + + // The subadmin's single group must be pre-selected in the groups field. + // NcSelect renders selected values as .vs__selected (no accessible role). + await expect(dialog.locator('.vs__selected').filter({ hasText: group })).toBeVisible() + + // Fill in the new user details and submit + const newUserId = crypto.randomUUID() + await dialog.getByLabel(/Account name/).fill(newUserId) + await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123') + + await dialog.getByRole('button', { name: 'Add new account' }).click() + await handlePasswordConfirmation(page, subadmin.password) + await dialog.waitFor({ state: 'hidden' }) + + await expect(settingsPage.userRow(newUserId)).toContainText(newUserId) + + await runOcc(['user:delete', newUserId]) + }) +}) diff --git a/tests/playwright/e2e/users/users-groups.spec.ts b/tests/playwright/e2e/users/users-groups.spec.ts new file mode 100644 index 0000000000000..afeffc20c7de8 --- /dev/null +++ b/tests/playwright/e2e/users/users-groups.spec.ts @@ -0,0 +1,228 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-with-user.ts' +import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +// ── Create group ────────────────────────────────────────────────────────────── + +test('Account Management: Can create a group', async ({ page }) => { + const groupName = crypto.randomUUID() + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + try { + const createGroupsResponsePromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups($|\?)/) + + await page.getByRole('button', { name: 'Create group' }).click() + await page.getByLabel('Group name').fill(groupName) + await page.getByLabel('Group name').press('Enter') + + await handlePasswordConfirmation(page) + await createGroupsResponsePromise + + await expect(settingsPage.customGroupsList()).toContainText(groupName) + } finally { + await runOcc(['group:delete', groupName]).catch(() => {}) + } +}) + +// ── Assign user to group ────────────────────────────────────────────────────── + +const userGroupTest = test.extend<{ testGroup: string }>({ + async testGroup({}, use) { + const testGroup = crypto.randomUUID() + await runOcc(['group:add', testGroup]) + await use(testGroup) + await runOcc(['group:delete', testGroup]) + }, +}) + +userGroupTest('Account Management: Assign user to a group', async ({ page, testGroup, user }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + // group is in the list with no members + await expect(settingsPage.groupListItem(testGroup)).toBeVisible() + // Counter bubble is absent when member count is 0 + await expect(settingsPage.groupMemberCount(testGroup)).toHaveCount(0) + // user is in the list + await expect(settingsPage.userRow(user.userId)).toBeVisible() + + // can assign the group in the user row + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'groups') + await cell.scrollIntoViewIfNeeded() + + const updateRequest = page.waitForResponse((r) => r.url().includes(`/ocs/v2.php/cloud/users/${user.userId}/groups`) && r.request().method() === 'POST') + const groupsCombobox = cell.getByRole('combobox', { name: 'Add account to group' }) + const searchRequest = page.waitForResponse((r) => r.request().url().match(new RegExp('/ocs/v2\\.php/cloud/groups/details\\?(.+&|)search=' + testGroup.slice(0, 5))) !== null) + await groupsCombobox.click({ force: true }) + await groupsCombobox.fill(testGroup.slice(0, 5)) + await searchRequest + + // The groups select is not appended to the body, so its options stay inside the cell + await cell.getByRole('option', { name: new RegExp(testGroup.slice(0, 8)) }).click({ force: true }) + + await handlePasswordConfirmation(page) + await updateRequest + + // user is now group now shows 1 member + await expect(settingsPage.groupMemberCount(testGroup)).toHaveText('1') + // backend confirms the user is in the group + const { stdout: jsonList } = await runOcc(['user:info', '--output=json', user.userId]) + const { groups } = JSON.parse(jsonList) + expect(groups).toContain(testGroup) +}) + +// ── Delete an empty group ───────────────────────────────────────────────────── + +test.describe('Settings: Delete an empty group', () => { + const groupName = crypto.randomUUID() + + test.beforeAll(async () => { + await runOcc(['group:add', groupName]) + }) + + test.afterAll(async () => { + await runOcc(['group:delete', groupName]).catch(() => {}) + }) + + test('can delete an empty group', async ({ page }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await expect(settingsPage.groupListItem(groupName)).toBeVisible() + + await settingsPage.openGroupActionsMenu(groupName) + + // and delete the group + await page.getByRole('button', { name: 'Delete group' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click() + await handlePasswordConfirmation(page) + + // Group must be gone from the UI + await expect(settingsPage.groupListItem(groupName)).toHaveCount(0) + + // Verify backend + // `group:list --output=json` returns the group map directly ({"name":[members]}), + // with no `groups` wrapper — unlike `user:info`. + const { stdout: jsonList } = await runOcc(['group:list', '--output=json']) + const groups = JSON.parse(jsonList) + expect(Object.keys(groups)).not.toContain(groupName) + }) +}) + +// ── Delete a non-empty group ────────────────────────────────────────────────── + +test.describe('Settings: Delete a non-empty group', () => { + const groupName = crypto.randomUUID() + let testUser: User + + test.beforeAll(async () => { + testUser = await createRandomUser() + await runOcc(['group:add', groupName]) + await runOcc(['group:adduser', groupName, testUser.userId]) + }) + + test.afterAll(async () => { + await runOcc(['user:delete', testUser.userId]) + await runOcc(['group:delete', groupName]).catch(() => {}) + }) + + test('can delete a non-empty group', async ({ page }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await expect(settingsPage.groupListItem(groupName)).toBeVisible() + + await settingsPage.openGroupActionsMenu(groupName) + + // and delete the group + await page.getByRole('button', { name: 'Delete group' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click() + await handlePasswordConfirmation(page) + + await expect(settingsPage.groupListItem(groupName)).toHaveCount(0) + + // `group:list --output=json` returns the group map directly ({"name":[members]}), + // with no `groups` wrapper — unlike `user:info`. + const { stdout: jsonList } = await runOcc(['group:list', '--output=json']) + const groups = JSON.parse(jsonList) + expect(Object.keys(groups)).not.toContain(groupName) + }) +}) + +// ── Sort groups ─────────────────────────────────────────────────────────────── +const sortGroupsTest = test.extend<{ testGroups: [string, string] }>({ + async testGroups({ user }, use) { + const suffix = crypto.randomUUID().slice(0, 8) + const groupA = `A-${suffix}` + const groupB = `B-${suffix}` + + await runOcc(['group:add', groupA]) + await runOcc(['group:add', groupB]) + await runOcc(['group:adduser', groupB, user.userId]) + await use([groupA, groupB]) + await runOcc(['group:delete', groupA]).catch(() => {}) + await runOcc(['group:delete', groupB]).catch(() => {}) + }, +}) + +sortGroupsTest('Settings: Sort groups by member count and then by name', async ({ page, testGroups }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + // ── sort by member count ── + await settingsPage.openSettingsDialog() + await settingsPage.settingsDialog() + .getByRole('radio', { name: 'By member count' }) + .check({ force: true }) + await settingsPage.closeSettingsDialog() + + // B (1 member) must come before A (0 members) + await checkGroupOrder([testGroups[1], testGroups[0]], settingsPage) + + // Reload to confirm persistence + await page.reload() + await checkGroupOrder([testGroups[1], testGroups[0]], settingsPage) + + // ── sort by name ── + await settingsPage.openSettingsDialog() + await settingsPage.settingsDialog().getByRole('radio', { name: 'By name' }).check({ force: true }) + await settingsPage.closeSettingsDialog() + + // A comes before B alphabetically + await checkGroupOrder([testGroups[0], testGroups[1]], settingsPage) + + // Reload to confirm persistence + await page.reload() + await checkGroupOrder([testGroups[0], testGroups[1]], settingsPage) +}) + +/** + * Check that the groups are in the expected order in the UI. + * + * @param order - The expected group order + * @param settingsPage - The settings page + */ +async function checkGroupOrder(order: string[], settingsPage: SettingsUsersPage) { + // B (1 member) must come before A (0 members) + const listItems = settingsPage.customGroupsList().getByRole('listitem') + for (const group of order) { + await expect(listItems.filter({ hasText: group })).toHaveCount(1) + } + + const contents = (await listItems.allTextContents()) + .map((text) => text.trim().replaceAll(/\s+.*/g, '')) // trim and remove member count + .filter((text) => order.includes(text)) // filter out other groups that might be in the list + expect(contents).toEqual(order) +} diff --git a/tests/playwright/e2e/users/users-manager.spec.ts b/tests/playwright/e2e/users/users-manager.spec.ts new file mode 100644 index 0000000000000..4013c2eab9e4b --- /dev/null +++ b/tests/playwright/e2e/users/users-manager.spec.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type User } from '@nextcloud/e2e-test-server' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { expect } from '@playwright/test' +import { test as adminUserTest } from '../../support/fixtures/admin-with-user.ts' +import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +const test = adminUserTest.extend<{ manager: User }>({ + manager: async ({}, use) => { + const manager = await createRandomUser() + await use(manager) + await runOcc(['user:delete', manager.userId]).catch(() => {}) + }, +}) + +test.describe('Settings: User Manager Management', () => { + test('can assign a manager in the user row', async ({ page, user, manager }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'manager') + await cell.scrollIntoViewIfNeeded() + await expect(cell.locator('.vs__selected')).toHaveCount(0) + + const updateRequest = page.waitForResponse((response) => + response.url().includes(`/ocs/v2.php/cloud/users/${user.userId}`) && response.request().method() === 'PUT') + + // The manager select is appended to the body, so its options live outside the row + const managerCombobox = cell.getByRole('combobox', { name: 'Set line manager' }) + await managerCombobox.click({ force: true }) + await managerCombobox.fill(manager.userId) + await page.getByRole('option', { name: manager.userId }).click({ force: true }) + + await handlePasswordConfirmation(page) + await updateRequest + + await expect(cell.locator('.vs__selected')).toContainText(manager.userId) + + // Verify via OCS API (page shares admin auth cookies) + const response = await page.request.get( + `/ocs/v2.php/cloud/users/${user.userId}`, + { headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } }, + ) + const data = await response.json() + expect(data?.ocs?.data?.manager).toBe(manager.userId) + }) + + test('can remove a manager in the user row', async ({ page, user, manager }) => { + // Set manager via OCC first + await runOcc([ + 'user:setting', + user.userId, + 'settings', + 'manager', + `["${manager.userId}"]`, + ]) + + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'manager') + await cell.scrollIntoViewIfNeeded() + await expect(cell.locator('.vs__selected')).toContainText(manager.userId) + + const updateRequest = page.waitForResponse((response) => + response.url().includes(`/ocs/v2.php/cloud/users/${user.userId}`) && response.request().method() === 'PUT') + + // Clear the currently-set manager using the NcSelect's clear button + await cell.getByRole('button', { name: /Clear Selected/i }).click({ force: true }) + + await handlePasswordConfirmation(page) + await updateRequest + + await expect(cell.locator('.vs__selected')).toHaveCount(0) + + // Verify backend: manager must be empty + const response = await page.request.get( + `/ocs/v2.php/cloud/users/${user.userId}`, + { headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } }, + ) + const data = await response.json() + expect(data?.ocs?.data?.manager).toBeFalsy() + }) +}) diff --git a/tests/playwright/e2e/users/users-modify.spec.ts b/tests/playwright/e2e/users/users-modify.spec.ts new file mode 100644 index 0000000000000..26f6ddb111cc9 --- /dev/null +++ b/tests/playwright/e2e/users/users-modify.spec.ts @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-with-user.ts' +import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +test.describe('Settings: Change user properties', () => { + test('can change the display name', async ({ page, user }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'displayname') + await expect(cell.locator('input')).toHaveValue(user.userId) + + await settingsPage.submitInlineTextField(user.userId, 'displayname', 'John Doe') + + await expect(page.getByText(/Display name was successfully changed/i)).toBeVisible() + + // Verify backend + const { stdout: jsonList } = await runOcc(['user:info', '--output=json', user.userId]) + const info = JSON.parse(jsonList) + expect(info?.display_name).toBe('John Doe') + }) + + test('can change the password', async ({ page, user, context }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'password') + await expect(cell.locator('input')).toHaveValue('') + + await settingsPage.submitInlineTextField(user.userId, 'password', 'newpassword123') + + await expect(page.getByText(/Password was successfully changed/i)).toBeVisible() + // The password input is emptied once the change went through + await expect(cell.locator('input')).toHaveValue('') + + // Verify by logging in with the new password + await login(context.request, { ...user, password: 'newpassword123' }) + await page.goto('/apps/dashboard') + await expect(page).toHaveURL(/\/apps\/dashboard/) + }) + + test('can change the email address', async ({ page, user }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'email') + await expect(cell.locator('input')).toHaveValue('') + + await settingsPage.submitInlineTextField(user.userId, 'email', 'mymail@example.com') + + await expect(page.getByText(/Email was successfully changed/i)).toBeVisible() + + // Verify backend + const { stdout: jsonList } = await runOcc(['user:info', '--output=json', user.userId]) + const info = JSON.parse(jsonList) + expect(info?.email).toBe('mymail@example.com') + }) + + test('can change the user quota to a predefined value', async ({ page, user }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'quota') + await cell.scrollIntoViewIfNeeded() + await expect(cell.locator('.vs__selected')).toContainText('Unlimited') + + // The quota select is not appended to the body, so its options stay inside the cell + const updateRequest = page.waitForResponse((response) => + response.url().includes(`/ocs/v2.php/cloud/users/${user.userId}`) && response.request().method() === 'PUT') + await cell.getByRole('combobox', { name: 'Select account quota' }).click({ force: true }) + await cell.getByRole('option', { name: '5 GB' }).click({ force: true }) + + await handlePasswordConfirmation(page) + await updateRequest + + await expect(cell.locator('.vs__selected')).toContainText('5 GB') + + // Verify backend + const { stdout: jsonList } = await runOcc(['user:info', '--output=json', user.userId]) + const info = JSON.parse(jsonList) + expect(info?.quota).toBe('5 GB') + }) + + test('can change the user quota to a custom value', async ({ page, user }) => { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'quota') + await cell.scrollIntoViewIfNeeded() + await expect(cell.locator('.vs__selected')).toContainText('Unlimited') + + const updateRequest = page.waitForResponse((response) => + response.url().includes(`/ocs/v2.php/cloud/users/${user.userId}`) && response.request().method() === 'PUT') + const quotaCombobox = cell.getByRole('combobox', { name: 'Select account quota' }) + await quotaCombobox.fill('4 MB') + await quotaCombobox.press('Enter') + + await handlePasswordConfirmation(page) + await updateRequest + + // Verify backend + const { stdout: jsonList } = await runOcc(['user:info', '--output=json', user.userId]) + const info = JSON.parse(jsonList) + expect(info?.quota).not.toBe('none') + }) + + test('can make user a subadmin of a group', async ({ page, user }) => { + const groupName = crypto.randomUUID().slice(0, 6) + const shortName = groupName.slice(0, 4) + await runOcc(['group:add', groupName]) + + try { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openInlineEdit(user.userId) + const cell = settingsPage.userRowCell(user.userId, 'subadmins') + await cell.scrollIntoViewIfNeeded() + await expect(cell.locator('.vs__selected')).toHaveCount(0) + + const subadminCombobox = cell.getByRole('combobox', { name: 'Set account as admin for' }) + await subadminCombobox.click({ force: true }) + + const waitForSearch = page + .waitForResponse((r) => r.request().url().includes(`ocs/v2.php/cloud/groups/details?search=${shortName}`)) + await subadminCombobox.fill(shortName) + await waitForSearch + + await cell.getByRole('option', { name: new RegExp(groupName) }).click({ force: true }) + await handlePasswordConfirmation(page) + + await expect(cell.locator('.vs__selected')).toContainText(groupName) + + // Verify backend via OCS API (page shares admin auth state) + const response = await page.request.get( + `/ocs/v2.php/cloud/users/${user.userId}/subadmins`, + { headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } }, + ) + const data = await response.json() + expect(data?.ocs?.data).toContain(groupName) + } finally { + await runOcc(['group:delete', groupName]) + } + }) +}) diff --git a/tests/playwright/e2e/users/users.spec.ts b/tests/playwright/e2e/users/users.spec.ts new file mode 100644 index 0000000000000..bb5731393f20d --- /dev/null +++ b/tests/playwright/e2e/users/users.spec.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-session.ts' +import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' + +test.describe('Settings: Create and delete accounts', () => { + test('can create a user with username and password', async ({ page }) => { + const newUserId = crypto.randomUUID() + try { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openNewUserDialog() + + const dialog = settingsPage.newUserDialog() + await dialog.getByLabel(/Account name/).fill(newUserId) + await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123') + + await dialog.getByRole('button', { name: 'Add new account' }).click() + await handlePasswordConfirmation(page) + await dialog.waitFor({ state: 'hidden' }) + + await expect(settingsPage.userRow(newUserId)).toContainText(newUserId) + } finally { + await runOcc(['user:delete', newUserId], { failOnError: false }) + } + }) + + test('can create a user with display name and email', async ({ page }) => { + const newUserId = crypto.randomUUID() + try { + const settingsPage = new SettingsUsersPage(page) + await settingsPage.open() + + await settingsPage.openNewUserDialog() + + const dialog = settingsPage.newUserDialog() + await dialog.getByLabel(/Account name/).fill(newUserId) + await dialog.getByLabel('Display name').fill('John Smith') + await dialog.getByLabel(/Email/).fill('john@example.org') + await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123') + + await dialog.getByRole('button', { name: 'Add new account' }).click() + await handlePasswordConfirmation(page) + await dialog.waitFor({ state: 'hidden' }) + + await expect(settingsPage.userRow(newUserId)).toContainText(newUserId) + } finally { + await runOcc(['user:delete', newUserId]) + } + }) + + test('can delete a user', async ({ page }) => { + const testUser = await createRandomUser() + const settingsPage = new SettingsUsersPage(page) + + try { + await settingsPage.open() + await expect(settingsPage.userRow(testUser.userId)).toBeVisible() + + await settingsPage.openActionsMenu(testUser.userId) + await page.getByRole('menuitem', { name: 'Delete account' }).click() + await handlePasswordConfirmation(page) + + // Confirm the deletion in the confirmation dialog + await page.getByRole('dialog').getByRole('button', { name: `Delete ${testUser.userId}` }).click() + + await expect(settingsPage.userRow(testUser.userId)).toHaveCount(0) + } finally { + await runOcc(['user:delete', testUser.userId]).catch(() => {}) + } + }) +}) diff --git a/tests/playwright/merge.config.ts b/tests/playwright/merge.config.ts new file mode 100644 index 0000000000000..6d99e6887f15a --- /dev/null +++ b/tests/playwright/merge.config.ts @@ -0,0 +1,11 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Needed to merge multiple Playwright reports +// when they are ran on self-hosted and github runners (different test directories are used) +export default { + testDir: 'tests/playwright/e2e', + reporter: [['html', { open: 'never' }]], +} diff --git a/tests/playwright/start-nextcloud-server.js b/tests/playwright/start-nextcloud-server.js new file mode 100644 index 0000000000000..e3ec044d9a57e --- /dev/null +++ b/tests/playwright/start-nextcloud-server.js @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { configureNextcloud, docker, getContainer, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server/docker' +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + +function getMounts() { + const mounts = { + '3rdparty': resolve(rootDir, '3rdparty'), + apps: resolve(rootDir, 'apps'), + core: resolve(rootDir, 'core'), + dist: resolve(rootDir, 'dist'), + lib: resolve(rootDir, 'lib'), + ocs: resolve(rootDir, 'ocs'), + 'ocs-provider': resolve(rootDir, 'ocs-provider'), + resources: resolve(rootDir, 'resources'), + tests: resolve(rootDir, 'tests'), + 'console.php': resolve(rootDir, 'console.php'), + 'cron.php': resolve(rootDir, 'cron.php'), + 'index.php': resolve(rootDir, 'index.php'), + occ: resolve(rootDir, 'occ'), + 'public.php': resolve(rootDir, 'public.php'), + 'remote.php': resolve(rootDir, 'remote.php'), + 'status.php': resolve(rootDir, 'status.php'), + 'version.php': resolve(rootDir, 'version.php'), + } + + return Object.fromEntries(Object.entries(mounts).filter(([, path]) => existsSync(path))) +} + +async function start() { + const port = Number.parseInt(process.env.NEXTCLOUD_PORT ?? '8042', 10) + const ip = await startNextcloud(process.env.BRANCH, false, { + mounts: getMounts(), + exposePort: port, + forceRecreate: true, + }) + + if (process.env.PLAYWRIGHT_SETUP) { + // The installer (setup) tests need to reach the database service containers + // (mysql, mariadb, …) that CI exposes on the GitHub Actions network. Join it + // when present; a no-op locally and in the normal test job where it is absent. + await connectToActionsNetwork() + } + + await waitOnNextcloud(ip) + await configureNextcloud(process.env.PLAYWRIGHT_SETUP ? [] : ['viewer']) + + if (process.env.PLAYWRIGHT_SETUP) { + // When the apps folder is mounted, configureNextcloud writes an + // apps.config.php declaring a writable apps path at + // `/var/www/html/apps_writable`, but it only creates that directory as a + // side effect of installing an app into it. The setup job installs no + // apps (empty list above), so the directory is never created. The setup + // tests remove config.php in beforeEach — leaving apps.config.php — and + // the wizard then fails to boot with `App directory + // "/var/www/html/apps_writable" not found`. Create it up front. + await runExec(['mkdir', '-p', '/var/www/html/apps_writable'], { user: 'root' }) + await runExec(['chown', 'www-data:www-data', '/var/www/html/apps_writable'], { user: 'root' }) + process.stdout.write('├─ Created writable apps folder for the setup tests\n') + } + + process.stdout.write('\nApply custom configuration for Playwright tests\n') + await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");']) + process.stdout.write('├─ Enabled SQLite WAL mode for better performance\n') + + await runOcc(['config:system:set', 'cache_app_config', '--value', 'false', '--type', 'boolean']) + process.stdout.write('├─ Disabled caching AppConfig\n') // otherwise test setup using OCC will need to wait 3s so that web cache TTL expires + + await runOcc(['config:system:set', 'appstoreenabled', '--value', 'false', '--type', 'boolean']) + process.stdout.write('├─ Disabled app store\n') + + // createRandomUser() generates short passwords that the policy would reject + await runOcc(['app:disable', 'password_policy']) + process.stdout.write('├─ Disabled password policy for random test users\n') + + process.stdout.write('├─ Initialize cron job...\n') + await runExec(['php', 'cron.php']) + process.stdout.write('│ └─ OK !\n') + process.stdout.write('└─ Nextcloud container ready to run Playwright tests\n') +} + +/** + * Connect the Nextcloud container to the GitHub Actions bridge network (named + * `github_network*`) if it exists, so it can resolve the database service + * containers by hostname. Does nothing when the network is absent. + */ +async function connectToActionsNetwork() { + const networks = await docker.listNetworks() + const network = networks.find((n) => n.Name.startsWith('github_network')) + if (!network) { + return + } + + await docker.getNetwork(network.Id).connect({ Container: getContainer().id }) + process.stdout.write('├─ Connected to the GitHub Actions network for the setup tests\n') +} + +async function stop() { + process.stderr.write('Stopping Nextcloud server…\n') + await stopNextcloud() + process.exit(0) +} + +process.on('SIGTERM', stop) +process.on('SIGINT', stop) + +await start() + +while (true) { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 5000)) +} diff --git a/tests/playwright/support/fixtures/admin-appstore-page.ts b/tests/playwright/support/fixtures/admin-appstore-page.ts new file mode 100644 index 0000000000000..41f12c9017ffd --- /dev/null +++ b/tests/playwright/support/fixtures/admin-appstore-page.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { AppstorePage } from '../sections/AppstorePage.ts' +import { test as adminSessionTest } from './admin-session.ts' + +export const test = adminSessionTest.extend<{ appstorePage: AppstorePage }>({ + appstorePage: async ({ page }, use) => { + const appstorePage = new AppstorePage(page) + await use(appstorePage) + }, +}) diff --git a/tests/playwright/support/fixtures/admin-session.ts b/tests/playwright/support/fixtures/admin-session.ts new file mode 100644 index 0000000000000..f552f13ce9372 --- /dev/null +++ b/tests/playwright/support/fixtures/admin-session.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/e2e-test-server' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest } from '@playwright/test' + +const admin = new User('admin', 'admin') + +export const test = baseTest.extend({ + page: async ({ page, context }, use) => { + try { + await login(context.request, admin) + } catch (error) { + console.info('Failed to authenticate as admin, retrying', error) + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(context.request, admin) + } + await use(page) + }, +}) diff --git a/tests/playwright/support/fixtures/admin-theming-page.ts b/tests/playwright/support/fixtures/admin-theming-page.ts new file mode 100644 index 0000000000000..e7a59b46bf312 --- /dev/null +++ b/tests/playwright/support/fixtures/admin-theming-page.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { AdminThemingPage } from '../sections/AdminThemingPage.ts' +import { test as adminSessionTest } from './admin-session.ts' + +export const test = adminSessionTest.extend<{ adminThemingPage: AdminThemingPage }>({ + adminThemingPage: async ({ page }, use) => { + const adminThemingPage = new AdminThemingPage(page) + await use(adminThemingPage) + }, +}) diff --git a/tests/playwright/support/fixtures/admin-with-user.ts b/tests/playwright/support/fixtures/admin-with-user.ts new file mode 100644 index 0000000000000..ec8421dfe15a5 --- /dev/null +++ b/tests/playwright/support/fixtures/admin-with-user.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as adminTest } from './admin-session.ts' +import { test as randomUserTest } from './random-user.ts' + +/** + * Admin session combined with a freshly-created random `user` fixture. + * The page is logged in as admin; the user is available via the `user` fixture. + */ +export const test = mergeTests(adminTest, randomUserTest) diff --git a/tests/playwright/support/fixtures/external-storage-page.ts b/tests/playwright/support/fixtures/external-storage-page.ts new file mode 100644 index 0000000000000..9b75e2ddb0530 --- /dev/null +++ b/tests/playwright/support/fixtures/external-storage-page.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ExternalStorageSettingsPage } from '../sections/ExternalStorageSettingsPage.ts' +import { test as adminTest } from './admin-session.ts' + +type ExternalStorageFixtures = { + externalStorageSettings: ExternalStorageSettingsPage +} + +/** + * Admin session plus the {@link ExternalStorageSettingsPage} page object. The + * browser is logged in as admin (external storage configuration is an admin task). + */ +export const test = adminTest.extend({ + externalStorageSettings: async ({ page }, use) => { + await use(new ExternalStorageSettingsPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/files-page.ts b/tests/playwright/support/fixtures/files-page.ts new file mode 100644 index 0000000000000..ae22da35b36d8 --- /dev/null +++ b/tests/playwright/support/fixtures/files-page.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { CopyMoveDialogPage } from '../sections/CopyMoveDialogPage.ts' +import { FilesFilterPage } from '../sections/FilesFilterPage.ts' +import { FilesListPage } from '../sections/FilesListPage.ts' +import { FilesNavigationPage } from '../sections/FilesNavigationPage.ts' +import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts' +import { test as baseTest } from './random-user-session.ts' + +type FilesFixtures = { + filesListPage: FilesListPage + filesNavigation: FilesNavigationPage + filesFilter: FilesFilterPage + filesSidebar: FilesSidebarPage + copyMoveDialog: CopyMoveDialogPage +} + +export const test = baseTest.extend({ + filesListPage: async ({ page }, use) => { + await use(new FilesListPage(page)) + }, + + filesNavigation: async ({ page }, use) => { + await use(new FilesNavigationPage(page)) + }, + + filesFilter: async ({ page }, use) => { + await use(new FilesFilterPage(page)) + }, + + filesSidebar: async ({ page }, use) => { + await use(new FilesSidebarPage(page)) + }, + + copyMoveDialog: async ({ page }, use) => { + await use(new CopyMoveDialogPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/files-sharing-page.ts b/tests/playwright/support/fixtures/files-sharing-page.ts new file mode 100644 index 0000000000000..ebf2eed440ed5 --- /dev/null +++ b/tests/playwright/support/fixtures/files-sharing-page.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { test as filesTest } from './files-page.ts' + +type SharingFixtures = { + owner: User + /** + * A request context authenticated as `owner` via basic auth, with no browser + * session cookies — needed because cookies would otherwise win over basic auth + * and the seeding would run as the logged-in recipient instead. + */ + ownerRequest: APIRequestContext +} + +/** + * Files fixtures plus a second `owner` user. The browser is logged in as `user` + * (the share recipient); `owner` owns and shares the folder via `ownerRequest` + * and is never logged into the page. + */ +export const test = filesTest.extend({ + owner: async ({}, use) => { + const owner = await createRandomUser() + await use(owner) + await runOcc(['user:delete', owner.userId]) + }, + + ownerRequest: async ({ playwright, owner, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so + // credentials must be sent preemptively (DAV would challenge, OCS won't) + httpCredentials: { username: owner.userId, password: owner.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/files-trashbin-page.ts b/tests/playwright/support/fixtures/files-trashbin-page.ts new file mode 100644 index 0000000000000..41fa082761651 --- /dev/null +++ b/tests/playwright/support/fixtures/files-trashbin-page.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext, PlaywrightWorkerArgs } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { TrashbinListPage } from '../sections/TrashbinListPage.ts' +import { test as filesTest } from './files-page.ts' + +/** + * Build a request context authenticated as `user` via basic auth, with no + * browser cookies (cookies would otherwise win over basic auth). Used to seed + * data as a given user without driving the UI. + */ +function basicAuthContext( + playwright: PlaywrightWorkerArgs['playwright'], + baseURL: string | undefined, + user: User, +): Promise { + return playwright.request.newContext({ + baseURL, + // send: 'always' — OCS issues no Basic auth challenge, so send credentials preemptively + httpCredentials: { username: user.userId, password: user.password, send: 'always' }, + }) +} + +type TrashbinFixtures = { + /** + * A request context authenticated as `user` (the trashbin owner, "alice") via + * basic auth, with no browser cookies — used to seed the group share without + * the (flaky) sharing sidebar. + */ + aliceRequest: APIRequestContext + /** A second user ("bob") who receives the group share and deletes a file in it. */ + bob: User + /** + * A request context authenticated as `bob` via basic auth, with no browser + * cookies — bob deletes the shared file (and sets his display name) through it. + */ + bobRequest: APIRequestContext + /** A group containing `bob`, used to share a folder with him. */ + group: string + /** FilesListPage extended with trashbin-specific column accessors. */ + filesListPage: TrashbinListPage +} + +/** + * Files fixtures for the trashbin "file row" scenarios. The browser is logged in + * as `user` (the owner, "alice") who views the trash; `bob` and the `group` model + * a file deleted by a sharee. All fixtures are lazy, so the simpler single-user + * trashbin tests pull none of this setup. + */ +export const test = filesTest.extend({ + filesListPage: async ({ page }, use) => { + await use(new TrashbinListPage(page)) + }, + + aliceRequest: async ({ playwright, user, baseURL }, use) => { + const context = await basicAuthContext(playwright, baseURL, user) + await use(context) + await context.dispose() + }, + + bob: async ({}, use) => { + const bob = await createRandomUser() + await use(bob) + await runOcc(['user:delete', bob.userId]) + }, + + bobRequest: async ({ playwright, bob, baseURL }, use) => { + const context = await basicAuthContext(playwright, baseURL, bob) + await use(context) + await context.dispose() + }, + + group: async ({ bob }, use) => { + // Derive the group id from bob's random id so parallel workers never collide + const group = `trashbin-group-${bob.userId}` + await runOcc(['group:add', group]) + await runOcc(['group:adduser', group, bob.userId]) + await use(group) + await runOcc(['group:delete', group]) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/files-versions-tab-page.ts b/tests/playwright/support/fixtures/files-versions-tab-page.ts new file mode 100644 index 0000000000000..12d2812952797 --- /dev/null +++ b/tests/playwright/support/fixtures/files-versions-tab-page.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { VersionsTab } from '../sections/VersionsTab.ts' +import { test as filesTest } from './files-page.ts' + +type VersionsFixtures = { + versionsTab: VersionsTab +} + +/** Files fixtures plus the `versionsTab` page object, for single-user version tests. */ +export const test = filesTest.extend({ + versionsTab: async ({ page }, use) => { + await use(new VersionsTab(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/public-share-page.ts b/tests/playwright/support/fixtures/public-share-page.ts new file mode 100644 index 0000000000000..ca9bee50ce630 --- /dev/null +++ b/tests/playwright/support/fixtures/public-share-page.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' + +import { CopyMoveDialogPage } from '../sections/CopyMoveDialogPage.ts' +import { FilesListPage } from '../sections/FilesListPage.ts' +import { PublicSharePage } from '../sections/PublicSharePage.ts' +import { test as randomUserTest } from './random-user.ts' + +type PublicShareFixtures = { + /** + * A request context authenticated as the share `owner` via basic auth. Seed + * the shared content and the share itself with this — the browser page stays + * a guest, so it must never carry the owner's session. + */ + ownerRequest: APIRequestContext + /** The public share page (header actions, guest identification, file drop). */ + publicShare: PublicSharePage + /** The files list as rendered on the public share. */ + filesListPage: FilesListPage + /** The file picker of the list's "Move or copy" action. */ + copyMoveDialog: CopyMoveDialogPage +} + +/** + * Fixtures for public (link) shares. Unlike the other files fixtures the `page` + * here is a plain, **not logged in** browser context — that is what a guest + * visiting a share link is. The share owner exists as `user` and is only acted + * on through {@link PublicShareFixtures.ownerRequest}. + */ +export const test = randomUserTest.extend({ + ownerRequest: async ({ playwright, user, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so + // credentials must be sent preemptively (DAV would challenge, OCS won't) + httpCredentials: { username: user.userId, password: user.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, + + publicShare: async ({ page }, use) => { + await use(new PublicSharePage(page)) + }, + + filesListPage: async ({ page }, use) => { + await use(new FilesListPage(page)) + }, + + copyMoveDialog: async ({ page }, use) => { + await use(new CopyMoveDialogPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/random-user-session.ts b/tests/playwright/support/fixtures/random-user-session.ts new file mode 100644 index 0000000000000..32fe5796c45ad --- /dev/null +++ b/tests/playwright/support/fixtures/random-user-session.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { login } from '@nextcloud/e2e-test-server/playwright' +import { test as randomUserTest } from './random-user.ts' + +/** + * Extends the random-user fixture with a `page` logged in as that user. + * The page runs in an isolated browser context — no admin session leaks in. + */ +export const test = randomUserTest.extend({ + page: async ({ browser, user }, use) => { + const page = await browser.newPage() + try { + await login(page.request, user) + } catch (error) { + console.info('Failed to authenticate as random user, retrying', error) + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(page.request, user) + } + await use(page) + }, +}) diff --git a/tests/playwright/support/fixtures/random-user.ts b/tests/playwright/support/fixtures/random-user.ts new file mode 100644 index 0000000000000..ebef3792f4934 --- /dev/null +++ b/tests/playwright/support/fixtures/random-user.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest } from '@playwright/test' + +/** + * Extends the base test with a freshly-created random `user`. + * The user is deleted in teardown regardless of test outcome. + */ +export const test = baseTest.extend<{ user: User }>({ + user: async ({}, use) => { + let user: User + try { + user = await createRandomUser() + } catch { + // Retry once on transient failure + await new Promise((resolve) => setTimeout(resolve, 800)) + user = await createRandomUser() + } + await use(user) + await runOcc(['user:delete', user.userId], { failOnError: false }) + }, +}) diff --git a/tests/playwright/support/fixtures/sharing-page.ts b/tests/playwright/support/fixtures/sharing-page.ts new file mode 100644 index 0000000000000..823772a740bee --- /dev/null +++ b/tests/playwright/support/fixtures/sharing-page.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser } from '@nextcloud/e2e-test-server/playwright' +import { SharingTab } from '../sections/SharingTab.ts' +import { test as filesTest } from './files-page.ts' + +type SharingFixtures = { + /** + * A second account to share with. It is never logged into the browser — use + * {@link recipientRequest} to act as it, or log in with the harness `login()` + * on the page's request context to swap sessions mid-test. + */ + recipient: User + /** + * A request context authenticated as `recipient` via basic auth, with no + * browser session cookies — cookies would otherwise win over basic auth and + * the request would run as the logged-in sharer instead. + */ + recipientRequest: APIRequestContext + /** The share editor in the files sidebar. */ + sharingTab: SharingTab +} + +/** + * Files fixtures for driving the share editor: the browser is logged in as + * `user` (the sharer) and `recipient` is a second account to share with. + * + * This mirrors `files-sharing-page.ts`, which is the other way round (the + * browser is the recipient of a share seeded by `owner`) — pick whichever side + * the spec drives through the UI. + */ +export const test = filesTest.extend({ + recipient: async ({}, use) => { + const recipient = await createRandomUser() + await use(recipient) + await runOcc(['user:delete', recipient.userId], { failOnError: false }) + }, + + recipientRequest: async ({ playwright, recipient, baseURL }, use) => { + const context = await playwright.request.newContext({ + baseURL, + // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so + // credentials must be sent preemptively (DAV would challenge, OCS won't) + httpCredentials: { username: recipient.userId, password: recipient.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, + + sharingTab: async ({ page }, use) => { + await use(new SharingTab(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/fixtures/systemtags-files-page.ts b/tests/playwright/support/fixtures/systemtags-files-page.ts new file mode 100644 index 0000000000000..894ea1c029989 --- /dev/null +++ b/tests/playwright/support/fixtures/systemtags-files-page.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { SystemTagsFilesListPage } from '../sections/SystemTagsFilesListPage.ts' +import { test as filesTest } from './files-page.ts' + +type SystemTagsFixtures = { + filesListPage: SystemTagsFilesListPage +} + +/** + * Extends the base files-page fixture by replacing `filesListPage` with a + * {@link SystemTagsFilesListPage}, which adds SystemTagPicker actions and + * inline-tags assertion helpers on top of the standard file list interactions. + */ +export const test = filesTest.extend({ + filesListPage: async ({ page }, use) => { + await use(new SystemTagsFilesListPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/matchers.ts b/tests/playwright/support/matchers.ts new file mode 100644 index 0000000000000..2eda10cf16f01 --- /dev/null +++ b/tests/playwright/support/matchers.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator } from '@playwright/test' + +import { expect as baseExpect } from '@playwright/test' + +export const expect = baseExpect.extend({ + /** + * Asserts that a file-list row has the active highlight class. + * A row becomes active when it was the last folder navigated into + * (e.g. after a browser back/forward traversal). + */ + async toBeActiveRow(received: Locator, options?: { timeout?: number }) { + let pass: boolean + let failMessage: string | undefined + try { + await baseExpect(received).toHaveClass(/files-list__row--active/, options) + pass = true + } catch (e: unknown) { + pass = false + failMessage = (e as Error).message + } + return { + message: () => pass + ? 'Expected row not to have class \'files-list__row--active\'' + : failMessage ?? 'Expected row to have class \'files-list__row--active\'', + pass, + } + }, + /** + * Asserts that an input element has a specific HTML5 validation message. + * An empty string means the input is valid (no validation error). + * Retries until the message matches or the timeout expires. + */ + async toHaveValidationMessage(received: Locator, expected: string | RegExp, options?: { timeout?: number }) { + let pass = false + let actual = '' + const getMsg = async () => received.evaluate((el) => (el as HTMLInputElement).validationMessage) + try { + if (typeof expected === 'string') { + await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toBe(expected) + } else { + await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toMatch(expected) + } + pass = true + } catch { + actual = await getMsg().catch(() => '') + } + return { + message: () => pass + ? `Expected validation message not to equal ${JSON.stringify(expected)}` + : `Expected validation message ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + pass, + } + }, +}) + +declare module '@playwright/test' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Matchers { + toBeActiveRow(options?: { timeout?: number }): R + toHaveValidationMessage(expected: string | RegExp, options?: { timeout?: number }): R + } +} diff --git a/tests/playwright/support/sections/AccountMenuPage.ts b/tests/playwright/support/sections/AccountMenuPage.ts new file mode 100644 index 0000000000000..b6e532a2e47ba --- /dev/null +++ b/tests/playwright/support/sections/AccountMenuPage.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +/** + * The "Settings menu" (account / user menu) in the Nextcloud header bar. + * Rendered by AccountMenu.vue using NcHeaderMenu (id="user-menu", is-nav). + * + * Each entry is a NcListItem rendered as an
  • inside + *