diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 27ae0f37a4..491f422d1f 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -3,7 +3,7 @@ name: Auto Label and Add to Project on: issues: types: [opened] - pull_request: + pull_request_target: types: [opened] jobs: diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index e31382ccd4..e785f2c84e 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -9,16 +9,6 @@ on: type: string required: true default: 'develop' - build_wasm: - description: 'BUILD_WASM. Valid values: yes, no' - type: string - required: false - default: 'yes' - build_storybook: - description: 'BUILD_STORYBOOK. Valid values: yes, no' - type: string - required: false - default: 'yes' workflow_call: inputs: gh_ref: @@ -26,29 +16,21 @@ on: type: string required: true default: 'develop' - build_wasm: - description: 'BUILD_WASM. Valid values: yes, no' - type: string - required: false - default: 'yes' - build_storybook: - description: 'BUILD_STORYBOOK. Valid values: yes, no' - type: string - required: false - default: 'yes' concurrency: group: ${{ github.workflow }}-${{ inputs.gh_ref }} cancel-in-progress: true jobs: - build-bundle: - name: Build and Upload Penpot Bundle + # ── 1. Decide whether there is anything to build ─────────────────────── + check: + name: Check current bundle runs-on: penpot-runner-01 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + timeout-minutes: 10 + outputs: + gh_ref: ${{ steps.vars.outputs.gh_ref }} + bundle_version: ${{ steps.vars.outputs.bundle_version }} + exists: ${{ steps.check.outputs.exists }} steps: - name: Checkout repository @@ -63,10 +45,52 @@ jobs: echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT + # The uploaded zip carries its version as S3 metadata. If the + # existing object was already built from this same commit, the + # whole build job is skipped. + - name: Check if this bundle is already built + id: check + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + run: | + EXISTING_VERSION=$(aws s3api head-object \ + --bucket ${{ secrets.S3_BUCKET }} \ + --key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \ + --query 'Metadata."bundle-version"' \ + --output text 2>/dev/null || echo "none") + + if [ "$EXISTING_VERSION" = "${{ steps.vars.outputs.bundle_version }}" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + { + echo "### ⏭️ Bundle build skipped" + echo "" + echo "The bundle in S3 was already built from \`${{ steps.vars.outputs.bundle_version }}\`." + } >> "$GITHUB_STEP_SUMMARY" + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + # ── 2. Build and upload, only when needed ────────────────────────────── + build: + name: Build and Upload Penpot Bundle + runs-on: penpot-runner-01 + timeout-minutes: 90 + needs: check + if: needs.check.outputs.exists == 'false' + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.gh_ref }} + - name: Build bundle env: - BUILD_WASM: ${{ inputs.build_wasm }} - BUILD_STORYBOOK: ${{ inputs.build_storybook }} + BUILD_WASM: 'yes' + BUILD_STORYBOOK: 'yes' run: ./manage.sh build-bundle - name: Prepare directories for zipping @@ -80,18 +104,32 @@ jobs: zip -r zips/penpot.zip penpot - name: Upload Penpot bundle to S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | - aws s3 cp zips/penpot.zip s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip --metadata bundle-version=${{ steps.vars.outputs.bundle_version }} + aws s3 cp zips/penpot.zip \ + s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \ + --metadata bundle-version=${{ needs.check.outputs.bundle_version }} + # ── 3. Single failure notification for the whole workflow ───────────── + notify: + name: Notify failure + runs-on: penpot-runner-01 + timeout-minutes: 5 + needs: [check, build] + if: failure() + + steps: - name: Notify Mattermost - if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd TEXT: | ❌ 📦 *[PENPOT] Error building penpot bundles.* - 📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}` - Bundle version: `${{ steps.vars.outputs.bundle_version }}` + 📄 Triggered from ref: `${{ needs.check.outputs.gh_ref || inputs.gh_ref }}` + Bundle version: `${{ needs.check.outputs.bundle_version || 'n/a' }}` 🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} @infra diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index 8125c81e12..2da1ab2a31 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -11,8 +11,6 @@ jobs: secrets: inherit with: gh_ref: "develop" - build_wasm: "yes" - build_storybook: "yes" build-docker: needs: build-bundle @@ -20,3 +18,9 @@ jobs: secrets: inherit with: gh_ref: "develop" + + build-admin-console-docker: + uses: ./.github/workflows/build-docker-admin-console.yml + secrets: inherit + with: + gh_ref: "develop" diff --git a/.github/workflows/build-docker-admin-console.yml b/.github/workflows/build-docker-admin-console.yml new file mode 100644 index 0000000000..b3f8384636 --- /dev/null +++ b/.github/workflows/build-docker-admin-console.yml @@ -0,0 +1,91 @@ +name: Admin Console Docker Builder + +on: + workflow_dispatch: + inputs: + gh_ref: + description: 'Name of the branch or ref to build in penpot-nitrate' + type: string + required: true + default: 'develop' + dispatch_ref: + description: 'Branch of penpot-nitrate from which the workflow definition is read' + type: string + required: false + default: 'develop' + workflow_call: + inputs: + gh_ref: + description: 'Name of the branch or ref to build in penpot-nitrate' + type: string + required: true + dispatch_ref: + description: 'Branch of penpot-nitrate from which the workflow definition is read' + type: string + required: false + default: 'develop' + secrets: + ORG_WORKFLOW_TOKEN: + description: 'Token with Actions write access on penpot-nitrate' + required: true + +jobs: + build-nitrate-docker: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} + REPO: penpot/penpot-nitrate + WORKFLOW: build-docker-admin-console.yml + GH_REF: ${{ inputs.gh_ref }} + DISPATCH_REF: ${{ inputs.dispatch_ref }} + steps: + - name: Trigger nitrate docker build + id: dispatch + run: | + DISTINCT_ID="${{ github.run_id }}-${{ github.run_attempt }}" + CALLER_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \ + -f gh_ref="$GH_REF" \ + -f caller_run_id="$DISTINCT_ID" \ + -f caller_run_url="$CALLER_URL" + + # Locate the dispatched run using the correlation id embedded in its run-name + RUN_ID="" + for i in $(seq 1 24); do + sleep 5 + RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" \ + --limit 10 --json databaseId,displayTitle \ + --jq ".[] | select(.displayTitle | contains(\"$DISTINCT_ID\")) | .databaseId" \ + | head -n1) + [ -n "$RUN_ID" ] && break + done + + if [ -z "$RUN_ID" ]; then + echo "::error::Could not locate the dispatched run in $REPO" + exit 1 + fi + + RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID" + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + echo "run_url=$RUN_URL" >> "$GITHUB_OUTPUT" + echo "::notice title=Nitrate docker build::$RUN_URL" + + - name: Wait for nitrate docker build + run: | + gh run watch "${{ steps.dispatch.outputs.run_id }}" \ + --repo "$REPO" \ + --interval 30 \ + --exit-status + + - name: Report result + if: always() && steps.dispatch.outputs.run_id != '' + run: | + CONCLUSION=$(gh run view "${{ steps.dispatch.outputs.run_id }}" \ + --repo "$REPO" --json conclusion --jq '.conclusion') + { + echo "### 🐳 Nitrate docker build" + echo "" + echo "- Result: \`${CONCLUSION:-in_progress}\`" + echo "- Run: ${{ steps.dispatch.outputs.run_url }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index f94064833e..b0340d329b 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -20,12 +20,19 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Login to Docker Registry + - name: Login to Docker Registry (push destination) uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} + - name: Login to Docker Hardened Images registry (base image pull) + uses: docker/login-action@v4 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + - name: Build and push DevEnv Docker image uses: docker/build-push-action@v7 env: @@ -35,12 +42,14 @@ jobs: file: ./docker/devenv/Dockerfile platforms: linux/amd64,linux/arm64 push: true + provenance: mode=max + sbom: true tags: ${{ env.DOCKER_IMAGE }}:latest cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Notify Mattermost - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0972f1e25d..0d03490194 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -20,55 +20,117 @@ concurrency: group: ${{ github.workflow }}-${{ inputs.gh_ref }} cancel-in-progress: true +env: + ALL_IMAGES: backend frontend exporter storybook mcp + # All runner instances live on the same server, so the bundle is + # downloaded from S3 once and shared between build jobs through this + # host-local directory. Each build job falls back to S3 if the file is + # missing (e.g. if runners ever move to separate machines). + BUNDLE_CACHE: /var/tmp/penpot-bundle-cache + jobs: - build-and-push: - name: Build and Push Penpot Docker Images + # ── 1. Resolve the build key and check the whole set at once ─────────── + prepare: + name: Prepare runs-on: penpot-runner-02 + timeout-minutes: 15 + outputs: + gh_ref: ${{ steps.vars.outputs.gh_ref }} + bundle_version: ${{ steps.vars.outputs.bundle_version }} + build_key: ${{ steps.vars.outputs.build_key }} + exists: ${{ steps.check.outputs.exists }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ inputs.gh_ref }} + + - name: Extract some useful variables + id: vars + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + run: | + GH_REF="${{ inputs.gh_ref || github.ref_name }}" + echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT + + BUNDLE_VERSION=$(aws s3api head-object \ + --bucket ${{ secrets.S3_BUCKET }} \ + --key "penpot-$GH_REF.zip" \ + --query 'Metadata."bundle-version"' \ + --output text) + echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT + + # Image content = bundle + docker build context, so the build key + # combines both. + CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12) + echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT + + # The image set is a single block, so a single set-level check is + # enough: `promote` drops a marker object in S3 only after every + # image was built AND every branch tag was moved. Marker present + # means there is nothing at all to do for this build key. + - name: Check if this image set is already built + id: check + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + run: | + if aws s3api head-object \ + --bucket ${{ secrets.S3_BUCKET }} \ + --key "markers/images-${{ steps.vars.outputs.build_key }}" \ + > /dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + { + echo "### ⏭️ Image set build skipped" + echo "" + echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`." + } >> "$GITHUB_STEP_SUMMARY" + else + echo "exists=false" >> $GITHUB_OUTPUT + + # Stage the bundle in the host-local cache, once, for all the + # build jobs. Download to a temp name and mv for atomicity; + # prune stale bundles while at it. + mkdir -p "$BUNDLE_CACHE" + find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true + ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip" + if [ ! -f "$ZIP" ]; then + aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" + mv "$ZIP.$$.tmp" "$ZIP" + fi + fi + + # ── 2. One build per image, in parallel, only when needed ────────────── + build: + name: Build ${{ matrix.image }} + runs-on: penpot-runner-02 + timeout-minutes: 60 + needs: prepare + if: needs.prepare.outputs.exists == 'false' + strategy: + fail-fast: true + # 4 runner slots are available for build jobs on this server; cap the + # matrix at 3 so short jobs (prepare and other workflows' checks) + # never queue behind long builds. + max-parallel: 3 + matrix: + image: [backend, frontend, exporter, storybook, mcp] steps: - name: Set common environment variables run: | # Each job execution will use its own docker configuration. - echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV + echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV - name: Checkout code uses: actions/checkout@v6 with: - fetch-depth: 0 ref: ${{ inputs.gh_ref }} - - name: Extract some useful variables - id: vars - run: | - echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT - - - name: Download Penpot Bundles - id: bundles - env: - FILE_NAME: penpot-${{ steps.vars.outputs.gh_ref }}.zip - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} - run: | - tmp=$(aws s3api head-object \ - --bucket ${{ secrets.S3_BUCKET }} \ - --key "$FILE_NAME" \ - --query 'Metadata."bundle-version"' \ - --output text) - echo "bundle_version=$tmp" >> $GITHUB_OUTPUT - pushd docker/images - aws s3 cp s3://${{ secrets.S3_BUCKET }}/$FILE_NAME . - unzip $FILE_NAME > /dev/null - mv penpot/backend bundle-backend - mv penpot/frontend bundle-frontend - mv penpot/exporter bundle-exporter - mv penpot/storybook bundle-storybook - mv penpot/mcp bundle-mcp - popd - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - name: Login to Docker Registry uses: docker/login-action@v4 with: @@ -85,103 +147,140 @@ jobs: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} + # Images now build FROM Docker Hardened Images (dhi.io). DHI + # is free (Apache 2.0, no subscription), but pulling from it + # still requires an authenticated login -- a separate `docker + # login` against a different registry host, even though it + # reuses the same PUB_DOCKER_* credentials as the DockerHub + # login above. + - name: Login to Docker Hardened Images registry (base image pull) + uses: docker/login-action@v4 + with: + registry: dhi.io + username: ${{ secrets.PUB_DOCKER_USERNAME }} + password: ${{ secrets.PUB_DOCKER_PASSWORD }} + + # Bundle staged once by `prepare` on this host; the S3 fallback only + # triggers if the cache is unavailable (runners on another machine, + # cache pruned mid-run, ...). + - name: Prepare Penpot bundle + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + run: | + ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip" + if [ ! -f "$ZIP" ]; then + echo "Bundle not found in host cache; falling back to S3." + mkdir -p "$BUNDLE_CACHE" + aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.prepare.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" + mv "$ZIP.$$.tmp" "$ZIP" + fi + # Extract only the bundle this job needs. + pushd docker/images + unzip -q "$ZIP" "penpot/${{ matrix.image }}/*" + mv "penpot/${{ matrix.image }}" "bundle-${{ matrix.image }}" + popd + + - name: Set up QEMU (stable) + uses: docker/setup-qemu-action@v4 + with: + platforms: linux/amd64,linux/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v6 with: - images: - frontend - backend - exporter - storybook - mcp + images: ${{ matrix.image }} labels: | - bundle_version=${{ steps.bundles.outputs.bundle_version }} + bundle_version=${{ needs.prepare.outputs.bundle_version }} - - name: Build and push Backend Docker image + - name: Build and push Docker image uses: docker/build-push-action@v7 - env: - DOCKER_IMAGE: 'backend' - BUNDLE_PATH: './bundle-backend' with: context: ./docker/images/ - file: ./docker/images/Dockerfile.backend + file: ./docker/images/Dockerfile.${{ matrix.image }} platforms: linux/amd64,linux/arm64 push: true - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }} + provenance: mode=max + sbom: true + # Immutable tag only; branch tags are moved atomically for the + # whole image set by the `promote` job. + tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max + cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max - - name: Build and push Frontend Docker image - uses: docker/build-push-action@v7 - env: - DOCKER_IMAGE: 'frontend' - BUNDLE_PATH: './bundle-frontend' + # ── 3. Move the branch tags of ALL images together ───────────────────── + # Runs only when every build succeeded (default `needs` semantics); if + # the set was already complete, `build` is skipped and so is this job — + # the S3 marker guarantees the branch tags were already moved. + promote: + name: Promote image set + runs-on: penpot-runner-02 + timeout-minutes: 10 + needs: [prepare, build] + + steps: + - name: Set common environment variables + run: | + echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to Docker Registry + uses: docker/login-action@v4 with: - context: ./docker/images/ - file: ./docker/images/Dockerfile.frontend - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max + registry: ${{ secrets.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push Exporter Docker image - uses: docker/build-push-action@v7 + - name: Point branch tags to the new build key + run: | + set -e + for image in $ALL_IMAGES; do + docker buildx imagetools create \ + -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ + "${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}" + done + + # The marker is written LAST: its presence certifies that all five + # images exist and all branch tags point to this build key. + - name: Write set-completed marker env: - DOCKER_IMAGE: 'exporter' - BUNDLE_PATH: './bundle-exporter' - with: - context: ./docker/images/ - file: ./docker/images/Dockerfile.exporter - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + run: | + echo "${{ github.run_id }}" | aws s3 cp - \ + "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}" + { + echo "### ✅ Image set promoted" + echo "" + echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`." + } >> "$GITHUB_STEP_SUMMARY" - - name: Build and push Storybook Docker image - uses: docker/build-push-action@v7 - env: - DOCKER_IMAGE: 'storybook' - BUNDLE_PATH: './bundle-storybook' - with: - context: ./docker/images/ - file: ./docker/images/Dockerfile.storybook - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - - - name: Build and push MCP Docker image - uses: docker/build-push-action@v7 - env: - DOCKER_IMAGE: 'mcp' - BUNDLE_PATH: './bundle-mcp' - with: - context: ./docker/images/ - file: ./docker/images/Dockerfile.mcp - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max + # ── 4. Single failure notification for the whole workflow ───────────── + notify: + name: Notify failure + runs-on: penpot-runner-02 + timeout-minutes: 5 + needs: [prepare, build, promote] + if: failure() + steps: - name: Notify Mattermost - if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd TEXT: | - ❌ 🐳 *[PENPOT] Error building penpot docker images.* - 📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}` - 📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}` + ❌ 🐳 *[PENPOT] Error building/promoting the penpot docker image set.* + 📄 Triggered from ref: `${{ needs.prepare.outputs.gh_ref || inputs.gh_ref }}` + 📦 Bundle: `${{ needs.prepare.outputs.bundle_version || 'n/a' }}` 🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} @infra diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 572d8c2a95..2ae5ee13b0 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -11,8 +11,6 @@ jobs: secrets: inherit with: gh_ref: "staging" - build_wasm: "yes" - build_storybook: "yes" build-docker: needs: build-bundle @@ -20,3 +18,9 @@ jobs: secrets: inherit with: gh_ref: "staging" + + build-admin-console-docker: + uses: ./.github/workflows/build-docker-admin-console.yml + secrets: inherit + with: + gh_ref: "staging" diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index 58fa0413c0..f488c911a7 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -12,8 +12,6 @@ jobs: secrets: inherit with: gh_ref: ${{ github.ref_name }} - build_wasm: "yes" - build_storybook: "yes" build-docker: needs: build-bundle @@ -26,10 +24,9 @@ jobs: name: Notifications runs-on: ubuntu-24.04 needs: build-docker - steps: - name: Notify Mattermost - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 51be85e45e..7208646c1a 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -131,7 +131,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index 137ba6f7fa..cbc8e109cd 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -114,7 +114,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 47f0d1cc24..29d2ac4fea 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -129,7 +129,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76e3d91964..47305864f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,7 @@ jobs: - name: Notify Mattermost if: failure() - uses: mattermost/action-mattermost-notify@master + uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 with: MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_CHANNEL: bot-alerts-cicd diff --git a/.github/workflows/tests-composable-suite.yml b/.github/workflows/tests-composable-suite.yml new file mode 100644 index 0000000000..1ad48c0edf --- /dev/null +++ b/.github/workflows/tests-composable-suite.yml @@ -0,0 +1,69 @@ +name: "CI: Composable Test Suite" + +# Runs the composable component test suite (it exercises component semantics +# through the real Plugin API against the full frontend, so it needs the +# frontend bundle + the plugin runtime, but no backend): the driver serves the +# prebuilt frontend bundle and intercepts every backend RPC with Playwright +# fixtures. See plugins/apps/composable-test-suite/README.md ("Running in CI"). + +defaults: + run: + shell: bash + +on: + pull_request: + paths: + - 'plugins/**' + - 'frontend/**' + - 'common/**' + types: + - opened + - synchronize + - ready_for_review + + push: + branches: + - develop + - staging + paths: + - 'plugins/**' + - 'frontend/**' + - 'common/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + composable-test-suite: + if: ${{ !github.event.pull_request.draft }} + name: "Run composable test suite (mocked backend)" + runs-on: penpot-runner-02 + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + + steps: + - uses: actions/checkout@v6 + + # The driver serves the prebuilt bundle from frontend/resources/public. + - name: Build frontend bundle + working-directory: ./frontend + run: ./scripts/build + + - name: Install deps + working-directory: ./plugins + run: | + corepack enable; + corepack install; + pnpm install; + + - name: Install Playwright Chromium + working-directory: ./plugins + run: pnpm --filter composable-test-suite exec playwright install --with-deps chromium + + - name: Run composable test suite (mocked) + working-directory: ./plugins + run: pnpm --filter composable-test-suite run test:ci diff --git a/.nvmrc b/.nvmrc index 5bcf9c6e6a..87d8620cc6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.18.0 +v24.18.1 diff --git a/.opencode/skills/testing/SKILL.md b/.opencode/skills/testing/SKILL.md new file mode 100644 index 0000000000..5ad5d04e6e --- /dev/null +++ b/.opencode/skills/testing/SKILL.md @@ -0,0 +1,57 @@ +--- +name: testing +description: Enforce TDD workflow and testing best practices for Penpot. Use when implementing features, fixing bugs, or modifying behavior. Reads testing memory for full guidance. +--- + +# Testing Skill + +Enforces test-driven development and Penpot testing conventions. + +## When to Use + +- Implementing new logic or behavior +- Fixing any bug (reproduction test required) +- Modifying existing functionality +- Adding edge case handling + +**Skip:** Pure configuration changes, documentation updates, or static content with no behavioral impact. + +## Workflow + +Follow TDD (Red → Green → Refactor) whenever practical: + +1. **RED** — Write a failing test first +2. **GREEN** — Write minimal code to pass +3. **REFACTOR** — Clean up while tests stay green + +For bug fixes, use the Prove-It Pattern: write a test that reproduces the bug, confirm it fails, implement the fix, confirm it passes. + +## Required Reading + +Before writing any test, read: + +1. `.serena/memories/testing.md` — cross-cutting testing principles, TDD workflow, anti-patterns, execution discipline +2. Module-specific testing memory for the affected module: + - `mem:common/testing` — CLJC unit tests + - `mem:frontend/testing` — CLJS unit tests, Playwright E2E + - `mem:backend/core` — JVM clojure.test conventions + +## Key Rules + +- Every behavior change needs a test +- Test state, not interactions +- DAMP over DRY — tests are specifications; duplication is OK if each test is self-contained and readable +- Prefer Real > Fake > Stub > Mock +- Arrange-Act-Assert structure +- One assertion per concept +- Never pipe test output to filters — redirect to file first +- Register new test files in the module's runner/entrypoint + +## Verification + +After completing implementation: + +- [ ] Every new behavior has a test +- [ ] All tests pass for touched modules +- [ ] Bug fixes include a reproduction test +- [ ] Lint/formatter passes diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 6530f74308..708d30fc5d 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -92,8 +92,8 @@ Fixtures can populate local data for manual testing/perf work. From the backend IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. -* **Linting:** `clj-kondo --lint ../common/src/ src/`. -* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs. +* **Linting:** `pnpm run lint:clj`. +* **Formatting:** `pnpm run check-fmt:clj` to check, `pnpm run fmt:clj` to fix. After running `fmt:clj`, `check-fmt:clj` is redundant. Avoid unrelated whitespace diffs. **Before linting:** if delimiter errors are suspected (after LLM edits), run `scripts/paren-repair` on the affected files first. Delimiter errors produce diff --git a/.serena/memories/common/component-data-model.md b/.serena/memories/common/component-data-model.md index 4ed1a47f9a..ef3343797c 100644 --- a/.serena/memories/common/component-data-model.md +++ b/.serena/memories/common/component-data-model.md @@ -24,6 +24,12 @@ Variant masters are main instances and component roots. Their descendants may th Masters are not normally touched through `set-shape-attr`, but touched flags can appear on master shapes through cloning/duplication paths. `add-touched-from-ref-chain` in `app.common.logic.variants` unions touched flags from ancestors into the copy being processed, so upstream/master touched state can affect downstream switch behavior. +## Swap slots and positional matching + +- A swap slot (stored via `ctk/set-swap-slot`, a `:touched` group `swap-slot-`) marks a copy sub-head that was SWAPPED to another component; `compare-children` then pairs it to the main child by slot instead of by `shape-ref`. +- Copy sub-heads without a slot are paired to main children by `shape-ref` (seek, not index). `find-near-match` (positional) is only a validator/repair heuristic; validity requires membership of the ref among the near-main parent's children, not index equality (`mem:common/file-change-validation-migration-subtleties`). +- Copy child ORDER converges to the main's via the async sync (`moved` branch of `compare-children`); local code must never reorder copy children directly (guards in `:mov-objects`/`:reorder-children`). + ## Cloning paths `make-component-instance` in `app.common.types.container` produces a clean component copy through `update-new-shape`, dissociating attrs such as `:touched`, `:variant-id`, and `:variant-name` on cloned shapes. diff --git a/.serena/memories/common/file-change-validation-migration-subtleties.md b/.serena/memories/common/file-change-validation-migration-subtleties.md index 7ff830a38d..014a458a30 100644 --- a/.serena/memories/common/file-change-validation-migration-subtleties.md +++ b/.serena/memories/common/file-change-validation-migration-subtleties.md @@ -7,6 +7,8 @@ - `set-shape-attr` treats `:position-data` as derived and never touched. Geometry/content-path changes use approximate equality; geometry differences under about 1px can be ignored for touched purposes. - Width/height are excluded from the `is-geometry?` branch in `set-shape-attr`; do not assume all geometry-group attrs follow identical ignore-geometry behavior. - `process-touched-change` marks the owning component modified when a touched shape belongs to a main instance; component-data changes can come from shape ops through this second pass. +- Copy structure is guarded at change application: `:mov-objects` (`is-valid-move?`) and `:reorder-children` both refuse to alter children of shapes inside component copies unless the change carries `allow-altering-copies` (sync/swap flows set it). New structural change types must follow the same rule. +- `cls/generate-delete-shapes` propagates deletions from INSIDE a component main to the copy shapes referencing them (transitively, all pages of the file) so no dangling `shape-ref`s remain; skipped when the main root itself is deleted (copies then resolve into the deleted component) and for `allow-altering-copies` flows (swap replaces the shape; sync reconciles). ## Shape tree edits @@ -19,6 +21,7 @@ - Full referential/semantic validation currently runs only when file features contain `"components/v2"`. - Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details. - `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes. +- `:missing-slot` fires only for a REAL swap: a copy sub-head whose `shape-ref` is no longer a child of the near main parent. A pure positional mismatch (ref still a sibling elsewhere) is a reorder — valid, realigned by the async component sync; do not "repair" it by assigning swap slots (a slot freezes the child out of normal sync). `fix-missing-swap-slots` (migration 0019) follows the same membership rule. ## Migrations diff --git a/.serena/memories/common/layout-grid-subtleties.md b/.serena/memories/common/layout-grid-subtleties.md index a3070efefb..2a48b9d149 100644 --- a/.serena/memories/common/layout-grid-subtleties.md +++ b/.serena/memories/common/layout-grid-subtleties.md @@ -8,6 +8,9 @@ ## Grid assignment - Grid `assign-cells` ensures at least one column and row, skips absolute-position children, creates non-tracked rows/cols when children exceed tracked cells, and asserts that assigned cells do not overlap. +- `position-absolute?` counts HIDDEN shapes as absolute: hiding a grid child frees its cell on the next `assign-cells`. +- `reorder-grid-children` rewrites the parent's `:shapes` to the REVERSE of the sorted cell order, but children with no cell (hidden/absolute) keep their original index — do not "fix" this into moving them to an end; that broke copy/main positional slot alignment (referential-integrity crash). +- The `:reorder-children` change it emits is refused on parents inside component copies unless `allow-altering-copies` (same rule as `:mov-objects`); `pcb/reorder-grid-children` also skips copy grids producer-side. Copy child order is owned by the component sync engine. - Grid deassignment removes cells for shapes that are no longer direct children or have become absolute-positioned. - Auto-positioning is not just sorting: some auto cells are converted to manual when empty/manual/span state would break the auto sequence, then auto single-span items can be compacted. - `fix-overlaps` is marked dev-only and removes one overlapping cell, preferring empty cells first. Avoid depending on it as normal production repair. \ No newline at end of file diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 82e4e704f3..0651f872fe 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -25,7 +25,9 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par ## Worker policy -Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`. +Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`. + +Each workspace is independent and can be started/stopped in any order. Shared infra (postgres, minio, etc.) is shut down only when no instances remain running. ## Port layout @@ -63,8 +65,8 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi ## CLI surface -- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up. -- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra. +- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). +- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra. - `run-devenv`: legacy alias, ws0 non-agentic attached. - `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing. - `run-devenv-shell [--instance 0|wsN|N] [cmd...]`: bash in target instance. (`--instance` flag not yet renamed to `--ws`.) diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md new file mode 100644 index 0000000000..865a381133 --- /dev/null +++ b/.serena/memories/frontend/composable-component-tests.md @@ -0,0 +1,152 @@ +# Composable component tests + +A framework concept for systematically testing Penpot's component subsystem +(synchronisation/propagation, swaps, variant switches, nesting, overrides), implemented in TWO test +suites that share the principles below: + +1. **ClojureScript suite** — in the frontend test tree (`frontend/test/frontend_tests/ + composable_tests/`), driving a minimally-assembled real app headlessly. The original. +2. **TypeScript suite** — a Penpot plugin (`plugins/apps/composable-test-suite/`), driving the FULL + production app end-to-end through the Plugin API, with a slightly more elaborate set of + abstractions. Runs interactively (panel), remotely (Playwright), and headlessly in CI. Its + README is the authoritative operational reference. + +## Shared core idea +A test is a **composition of operations** over a starting configuration, plus assertions. You +describe a test as data (a setup + a sequence of operations) rather than writing bespoke imperative +code, and coverage grows by COMPOSITION: a new variation is one combinator wrapped around existing +pieces, not a copied test. Choice points (one-of alternatives, optional steps) EXPAND the +composition into a full sweep of variants — one written case stands for a whole matrix of concrete +tests. + +## Shared principles +- **Every producing object is the accessor interface to what it produces downstream.** An + operation — and related objects such as content-creation strategies — is not merely an action: + the SAME object instance the case holds is the typed interface through which everything it + created or changed is later retrieved, checked, and asserted, parameterized by the situation. A + foundation operation exposes accessors for the participants it built; an edit operation exposes + its dual check (`assertHasChangedProperty` / `has-property-of`); a choice is recovered by asking + the one-of object (`getChoice`/`get-choice`); "did this step run" is asked of the step + (`wasApplied`/`applied?`). NEVER reach into a situation (or the document) for something an + upstream object produced — ask the producer. This is what keeps sweeps sound (object identity + ties the question to the exact node that ran) and what keeps retrieval logic in exactly one + place. Particularly explicit in the TS OOP implementation, where these accessors are methods on + the operation/strategy classes; repeatedly violating it (reading the document directly, + duplicating retrieval) was the most common review correction while building the suites. +- **Operations are data with identity.** Each operation node has a unique id at construction and + records what it did under that id; interrogation is by identity. Bind an operation to a value + ONCE and reuse it in the composition and in every query about it. +- **Drive the real production pipeline.** Operations route through genuine Penpot logic — real + change functions / real workspace events / the real Plugin API, never raw field writes — so the + production watcher's AUTOMATIC propagation is what's under test. +- **Roles, not internals.** A starting configuration names its participants (roles). Role→id + capture happens when the configuration is built; operation TARGETS resolve at apply-time and may + be re-bound, so an operation targeting a role follows it as state-building ops re-point it — + which lets a single operation be swept across depth. +- **Enumeration is authored, not exhaustive.** Compose only VALID cases, so outcomes are just + pass / fail / error — no not-applicable cells. +- **Naming discipline.** Penpot domain nouns ("component", "variant") must not name framework + abstractions; an operation may name the domain ACTION it performs. +- **Operator algebra** (same in both suites): sequence (cartesian product of the steps' variants), + one-of (union, choice recorded), optional(X) = one-of([X, skip]), inline assertion ops, trailing + asserters. +- **Case authoring:** a case carries a CamelCase identifier and a plain-terms description in three + parts — situation setup, actions/variations, asserted requirement. + +--- + +# ClojureScript suite (frontend test tree) + +Test-only `.cljs` code in the frontend test tree (nothing "common" about it). A **situation** = +the in-memory file value + named roles + `:vars` + an ordered applied-log. Operations are records +implementing `IOperation`/`apply-to` (`apply` collides with core). Assertions = inline `Test` ops +and/or a trailing asserter; the runner makes no judgment. Failures carry `describe-applied` (the +transcript), which is what makes a failing variant in a sweep identifiable. + +Layout: `core.cljs` (the domain-agnostic engine: situation, identity/transcript, roles/targets, +operators, runners), `comp/setups.cljs` (setups + role accessors), `comp/nodes.cljs` (the component +operations and their check duals), `interpreter.cljs` (runs cases against the real frontend), +`comp/sync_test.cljs` (the cases; registered in `frontend_tests/runner.cljs`). Case letters B..N; +the sweeps (K: depth × edit-precedence; L: swaps; M: variant switches; N: rotated-instance +geometry, on the #10109 fix branch until merged) are the flagship pattern — read them before +writing a new sweep. + +**Scenario lineage model** (behind the sweeps): scenario ops track named component lineages as +objects under `:vars`, each holding the FIXED deepest origin (`:remote-*`), the ADVANCING outer +main (`:main-*`), and per-nesting-level data whose `:nested-head` (the deepest instance at that +level, found by descending the `:shape-ref` chain — matching chain MEMBERSHIP, not terminus) is +the swap/switch target, anchored by its swap-stable parent. Nesting seeks the FIXED origin, not +the advancing main — that is what makes each level's `:nested-head` land on the deepest instance. +A variant nesting re-points the lineage's remote to the chosen member. Construction lesson: +cross-level propagation requires progressively NESTED levels (one variant + plain wraps); sibling +nestings do not propagate between each other. + +**Interpreter:** installs the situation's files into the global `st/state` (aux files tagged +`:library-of`), starts the real `watch-component-changes` (+ harness `watch-undo-stack`), maps +event-ops to REAL workspace events (`dwsh/update-shapes`, `dwl/component-swap`, +`dwv/variants-switch`, `dwt/increase-rotation` — which runs the `check-delta` placement +classification — `dwt/update-dimensions`, `dwu/undo`, `dwl/sync-file`, …) and runs sync-ops' +`apply-to` against the live store file; awaits settlement (idle-gap heuristic + per-op grace) and +re-reads `:file` each step so the shared accessors keep working. +STORE-SWAP IMMUNITY: other test namespaces `set!` `st/state`/`st/stream` and never restore, while +the `app.main.refs` lenses stay bound to the ORIGINAL atoms — propagation then dies silently. The +interpreter captures the atoms at namespace-load time and re-`set!`s them per variant. + +Running: `cd frontend && pnpm run build:test`, then +`node target/tests/test.js --focus frontend-tests.composable-tests.comp.sync-test` +(var-level focus for one case). + +**Fidelity warning:** the harness drives a MINIMALLY-ASSEMBLED app — only some +`initialize-workspace` subscriptions are wired. Risk = SILENT UNDER-WIRING (e.g. undo needs the +harness `watch-undo-stack`). When a case needs app behaviour beyond a raw edit, check for an +unwired subscription and verify by PROBING store state, not by trusting a green assertion. + +**Caveats:** inline `Test` exceptions are UNCAUGHT on the frontend (crash the runner — assert in +the trailing asserter). `(optional (in-sequence …))` is not flattened for the interpreter — use +independent optionals. The Serena/clj-kondo cache for `nodes.cljs` goes stale (phantom symbols) — +trust the build. Cross-namespace global-state leaks land in this suite first; suspect them before +the framework on inexplicable full-run-only failures. Case H's `sync-file` schedules a delayed RPC +that fails headless (benign; absorbed by per-op grace). + +--- + +# TypeScript suite (the plugin) — full e2e + +`plugins/apps/composable-test-suite/` — same principles against the FULL production app through the +Plugin API (real frontend, real propagation). Continuation of the CLJS suite per issue #10584. +Operational details (build/run, connect URL, remote control, reading logs, auto-reload, CI): the +plugin README. + +Distinguishing abstractions (the OOP articulation of the shared principles): +- `TestCase {identifier, description, operation}` with the three-part description mandated in the + constructor docstring. +- The accessor-interface principle is class-level: foundation operations (e.g. + `OpCreateSimpleComponentWithCopy`) expose the roles they build; **content-creation strategies** + (pluggable: what content a foundation builds around) expose accessors for the content they + created; edit operations expose their checks (`OpChangeProperty.assertHasChangedProperty`); + `OpOneOf`/`OpOptional` are queried for what ran. Tests never grope the document for something a + producer can be asked for. +- `ShapeProp` model: property duals with numeric tolerance; rotation is a writable attr, height + goes via resize (readonly in the Plugin API). +- `TestSuite` enumerates cases into a `TestTree` with stable per-test ids; + `run(ids, TestRunObserver)` is the ONLY output channel — the framework is UI-free by + construction. `plugin.ts` (panel adapter), `main.ts` (panel UI) and `src/ci/headless.ts` + (CI adapter) are three thin consumers. +- Cases live in `src/composable-tests/cases/` as `case.ts` (e.g. `MainEditSyncs` — the + sweep that found #10109). +- Panel checkboxes carry stable DOM ids (case identifier / `Identifier-N` composites) for remote + control via Playwright; recipe in the README. + +## CI +Headless per-PR gate: `.github/workflows/tests-composable-suite.yml` runs +`pnpm --filter composable-test-suite run test:ci` — mocked backend (frontend e2e static server + +Playwright RPC fixtures, no backend/login), the in-sandbox bundle injected via `ɵloadPlugin`, +results streamed via console markers, `TEST_FILTER` by identifier substring. The mocked backend is +NOT a limitation for this suite (everything asserted is frontend store logic; empirically +confirmed against the interactive runs). Architecture mirrors `plugin-api-test-suite`'s CI driver; +the mock harness exists in THREE places that must stay in sync (provenance note in `ci/run-ci.ts`). +Details: README, "Running in CI". + +## Substrate +`mem:common/test-setup`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, +`mem:frontend/testing`. diff --git a/.serena/memories/frontend/core.md b/.serena/memories/frontend/core.md index b229e2b124..b5fb47dce4 100644 --- a/.serena/memories/frontend/core.md +++ b/.serena/memories/frontend/core.md @@ -23,7 +23,7 @@ From `frontend/`: - JS lint currently no-ops via `pnpm run lint:js`. - SCSS lint: `pnpm run lint:scss`. - Format checks: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss`. -- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. +- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. After running `fmt:*`, `check-fmt:*` is redundant. - Translation formatting after i18n edits: `pnpm run translations`. **Before linting:** if delimiter errors are suspected (after LLM edits, or diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index 13bde56149..a92c199e22 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -42,15 +42,15 @@ PR descriptions follow this structure: ## What - + ## Why - + ## How - + ``` The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. @@ -59,6 +59,8 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. - **Write for humans.** The diff shows what changed. The description explains why. - **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it? +- **Prefer bullets over paragraphs.** Short bullet items, grouped by area with bold lead-ins where helpful, are far easier to digest than prose; keep any remaining paragraph to a few sentences. +- **No manual line wraps.** Markdown renders adapting to the viewport; hard-wrapped lines degrade rendering. One line per paragraph or bullet, however long. - **Skip the obvious.** Don't explain what `git diff` already shows. ### What NOT to Include diff --git a/.serena/project.yml b/.serena/project.yml index b7e8941c75..4b63be5624 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,26 +1,31 @@ -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "penpot" - -# list of languages for which language servers are started; choose from: -# al ansible bash clojure cpp -# cpp_ccls crystal csharp csharp_omnisharp dart -# elixir elm erlang fortran fsharp +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript # go groovy haskell haxe hlsl -# java json julia kotlin lean4 -# lua luau markdown matlab msl -# nix ocaml pascal perl php -# php_phpactor powershell python python_jedi python_ty -# r rego ruby ruby_solargraph rust -# scala solidity swift systemverilog terraform -# toml typescript typescript_vts vue yaml -# zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# html java json julia kotlin +# latex lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -54,12 +59,19 @@ ignore_all_files_in_gitignore: true # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" # Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] @@ -130,13 +142,38 @@ ignored_memory_patterns: [] # See https://oraios.github.io/serena/02-usage/050_configuration.html#modes added_modes: -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# list of additional workspace folder paths for cross-package reference support. # Paths can be absolute or relative to the project root. # Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. # Example: # additional_workspace_folders: # - ../sibling-package # - ../shared-lib -additional_workspace_folders: [] +ls_additional_workspace_folders: [] + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- . + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 diff --git a/AGENTS.md b/AGENTS.md index 05284c1c73..ac4da5c663 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS. - **Never amend a commit that has been pushed** unless the user explicitly asks. If the user pushes, treat that commit as final from the agent's side. +- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.). + Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file. + This prevents hiding test failures. See `mem:testing` for details. - **Read the workflow memory BEFORE the corresponding action**: - Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer) - Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type) diff --git a/CHANGES.md b/CHANGES.md index 293ebff0c5..bcfd77b8bc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,29 @@ # CHANGELOG +## 2.18.0 (Unreleased) + +### :bug: Bugs fixed + +- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392)) +- Fix synced component copy not reflowing children after spacing token update [#9892](https://github.com/penpot/penpot/issues/9892) +- Fix spacebar activating pan mode while typing a comment (by @Krishcode264) [#10285](https://github.com/penpot/penpot/issues/10285) (PR: [#10287](https://github.com/penpot/penpot/pull/10287)) +- Fix plugin API rejecting negative letterSpacing values (by @filipsajdak) [#9780](https://github.com/penpot/penpot/issues/9780) (PR: [#10257](https://github.com/penpot/penpot/pull/10257)) +- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359)) +- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541)) +- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535)) +- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523)) +- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381)) + +### :sparkles: New features & Enhancements + +- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354)) +- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677)) +- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433)) +- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146)) +- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461)) +- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459)) + + ## 2.17.1 (Unreleased) ### :bug: Bugs fixed diff --git a/backend/deps.edn b/backend/deps.edn index c60adf10ca..2599066e0b 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -6,7 +6,7 @@ org.clojure/clojure {:mvn/version "1.12.5"} org.clojure/tools.namespace {:mvn/version "1.5.1"} - com.github.luben/zstd-jni {:mvn/version "1.5.7-11"} + com.github.luben/zstd-jni {:mvn/version "1.5.7-12"} io.prometheus/simpleclient {:mvn/version "0.16.0"} io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} @@ -34,15 +34,15 @@ :exclusions [org.slf4j/slf4j-api]} com.github.seancorfield/next.jdbc - {:mvn/version "1.3.1108"} + {:mvn/version "1.3.1118"} metosin/reitit-core {:mvn/version "0.10.1"} nrepl/nrepl {:mvn/version "1.7.0"} - org.postgresql/postgresql {:mvn/version "42.7.12"} - org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"} + org.postgresql/postgresql {:mvn/version "42.7.13"} + org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"} - com.zaxxer/HikariCP {:mvn/version "7.0.2"} + com.zaxxer/HikariCP {:mvn/version "7.1.0"} io.whitfin/siphash {:mvn/version "2.0.0"} @@ -51,10 +51,10 @@ com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} - org.jsoup/jsoup {:mvn/version "1.22.2"} + org.jsoup/jsoup {:mvn/version "1.23.1"} at.yawk.lz4/lz4-java - {:mvn/version "1.11.0"} + {:mvn/version "1.11.1"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} @@ -63,8 +63,8 @@ ;; Pretty Print specs pretty-spec/pretty-spec {:mvn/version "0.1.4"} - software.amazon.awssdk/s3 {:mvn/version "2.46.18"} - software.amazon.awssdk/sts {:mvn/version "2.46.18"}} + software.amazon.awssdk/s3 {:mvn/version "2.50.1"} + software.amazon.awssdk/sts {:mvn/version "2.50.1"}} :paths ["src" "resources" "target/classes"] :aliases diff --git a/backend/package.json b/backend/package.json index 96bd4cbada..9fbce288ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -19,8 +19,8 @@ "ws": "^8.21.0" }, "scripts": { - "lint": "clj-kondo --parallel --lint ../common/src src/", - "check-fmt": "cljfmt check --parallel=true src/ test/", - "fmt": "cljfmt fix --parallel=true src/ test/" + "lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "fmt:clj": "cljfmt fix --parallel=true src/ test/" } } diff --git a/backend/resources/app/email/invite-to-org/en.txt b/backend/resources/app/email/invite-to-org/en.txt deleted file mode 100644 index ff8eabf194..0000000000 --- a/backend/resources/app/email/invite-to-org/en.txt +++ /dev/null @@ -1,10 +0,0 @@ -Hello! - -{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”. - -Accept invitation using this link: - -{{ public-uri }}/#/auth/verify-token?token={{token}} - -Enjoy! -The Penpot team. diff --git a/backend/resources/app/email/invite-to-org/en.html b/backend/resources/app/email/invite-to-organization/en.html similarity index 83% rename from backend/resources/app/email/invite-to-org/en.html rename to backend/resources/app/email/invite-to-organization/en.html index ce3a9846b3..2a23407f4b 100644 --- a/backend/resources/app/email/invite-to-org/en.html +++ b/backend/resources/app/email/invite-to-organization/en.html @@ -195,21 +195,45 @@
- +
+ background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}" + style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black"> {% if organization.initials %}{{organization.initials}}{% endif %}
- - {{ organization.name|abbreviate:50 }} + + {{ organization.name|abbreviate:50 }}
+ + {% if organization.sso-active %} + + +
+ "{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its + teams and files now goes through your organization's identity provider. +
+ + + + +
+ If you can't get in, your account probably isn't in the directory yet. + To get access, contact the organization owner. +
+ + + {% endif %} + diff --git a/backend/resources/app/email/invite-to-org/en.subj b/backend/resources/app/email/invite-to-organization/en.subj similarity index 100% rename from backend/resources/app/email/invite-to-org/en.subj rename to backend/resources/app/email/invite-to-organization/en.subj diff --git a/backend/resources/app/email/invite-to-organization/en.txt b/backend/resources/app/email/invite-to-organization/en.txt new file mode 100644 index 0000000000..72c97eead7 --- /dev/null +++ b/backend/resources/app/email/invite-to-organization/en.txt @@ -0,0 +1,17 @@ +Hello! + +{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:50 }}”. + +{% if organization.sso-active %} +"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes +through your organization's identity provider. + +If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. +{% endif %} + +Accept invitation using this link: + +{{ public-uri }}/#/auth/verify-token?token={{token}} + +Enjoy! +The Penpot team. diff --git a/backend/resources/app/email/invite-to-team/en.html b/backend/resources/app/email/invite-to-team/en.html index 9ebc59231f..02e148a2ec 100644 --- a/backend/resources/app/email/invite-to-team/en.html +++ b/backend/resources/app/email/invite-to-team/en.html @@ -186,10 +186,31 @@
- {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %} - part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.
+ {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:50 }}”{% if + organization %} + part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}. + {% if organization.sso-active %} + + +
+ "{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to + its teams and files now goes through your organization's identity provider. +
+ + + + +
+ If you can't get in, your account probably isn't in the directory yet. + To get access, contact the organization owner. +
+ + + {% endif %} diff --git a/backend/resources/app/email/invite-to-team/en.txt b/backend/resources/app/email/invite-to-team/en.txt index 3482fab0a5..ecbd5d0be1 100644 --- a/backend/resources/app/email/invite-to-team/en.txt +++ b/backend/resources/app/email/invite-to-team/en.txt @@ -1,6 +1,13 @@ Hello! -{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}. +{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:50 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}. + +{% if organization.sso-active %} +"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes +through your organization's identity provider. + +If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. +{% endif %} Accept invitation using this link: diff --git a/backend/resources/app/email/organization-setup-sso/en.html b/backend/resources/app/email/organization-setup-sso/en.html new file mode 100644 index 0000000000..4d1f3395c6 --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.html @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + +
+
+ Hi, +
+
+
+ "{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its + teams and files now goes through your organization's identity provider. +
+
+
+ If you can't get in, your account probably isn't in the directory yet. To get access, contact the + organization owner. +
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + diff --git a/backend/resources/app/email/organization-setup-sso/en.subj b/backend/resources/app/email/organization-setup-sso/en.subj new file mode 100644 index 0000000000..1a34f020f6 --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.subj @@ -0,0 +1 @@ +“{{ organization-name|abbreviate:25 }}” uses single sign-on diff --git a/backend/resources/app/email/organization-setup-sso/en.txt b/backend/resources/app/email/organization-setup-sso/en.txt new file mode 100644 index 0000000000..976809e451 --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.txt @@ -0,0 +1,8 @@ +Hi, + +"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes +through your organization's identity provider. + +If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. + +The Penpot team. diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 81ad2db211..09d34532b9 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -459,9 +459,10 @@ (let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})] (if (= status 200) (let [data (json/decode body) - data {:token/access (get data :access_token) - :token/id (get data :id_token) - :token/type (get data :token_type)}] + data {:token/access (get data :access_token) + :token/id (get data :id_token) + :token/type (get data :token_type) + :token/expires-in (get data :expires_in)}] (l/trc :hint "access token fetched" :token-id (:token/id data) :token-type (:token/type data) @@ -619,6 +620,9 @@ (some? (:external-session-id state)) (assoc :external-session-id (:external-session-id state)) + (some? (:token/expires-in tdata)) + (assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)})) + ;; If state token comes with props, merge them. The state token ;; props can contain pm_ and utm_ prefixed query params. (map? (:props state)) @@ -761,21 +765,112 @@ ;; ORG SSO HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn prepare-org-sso-provider - "Build an OIDC provider map dynamically from the Nitrate org SSO config. - Uses OIDC discovery via :base-url (or :issuer as fallback) when - token/auth/user URIs are absent." - [cfg {:keys [client-id client-secret base-url issuer scopes]}] +(defn- non-blank-uri + [value] + (when-not (str/blank? value) value)) + +(defn organization-sso-discovery-uri + "Return the OIDC discovery URI from an organization SSO config." + [sso] + (non-blank-uri (:issuer sso))) + +(defn prepare-organization-sso-provider + "Build an OIDC provider map dynamically from the Nitrate organization SSO config. + Uses OIDC discovery via :issuer when token/auth/user URIs are absent." + [cfg {:keys [client-id client-secret issuer]}] (prepare-oidc-provider cfg {:type "oidc" :client-id client-id :client-secret client-secret - :base-uri (some-> (or base-url issuer) + :base-uri (some-> (non-blank-uri issuer) (str/rtrim "/") (str "/")) - :scopes (into default-oidc-scopes (or scopes #{})) + :scopes default-oidc-scopes :skip-ssrf-check? true})) +(defn build-organization-sso-auth-redirect-uri + "Build the OIDC authorization redirect URI for an organization SSO config. + Raises if the config is incomplete or OIDC discovery fails." + [cfg sso & {:keys [dest-url organization-id provider]}] + (let [organization-id (or organization-id (:organization-id sso)) + issuer (organization-sso-discovery-uri sso) + dest-url (or dest-url (str (cf/get :public-uri)))] + (when-not issuer + (ex/raise :type :validation + :code :invalid-sso-config + :hint "missing issuer")) + (let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso)) + state-token (tokens/generate cfg {:iss "oidc" + :dest-url dest-url + :organization-id organization-id + :issuer issuer + :exp (ct/in-future "4h")})] + (build-auth-redirect-uri oidc-provider state-token)))) + +(def ^:private probe-auth-code "penpot-sso-config-probe") + +(defn- decode-token-error-response + [body] + (when (and (string? body) (pos? (count body))) + (try + (json/decode body) + (catch Throwable _ nil)))) + +(defn- token-endpoint-error + [response] + (some-> response :body decode-token-error-response :error d/name)) + +(defn- token-endpoint-error-description + [response] + (some-> response :body decode-token-error-response :error-description)) + +(defn- token-endpoint-valid-client-error? + "Token endpoint rejected the dummy auth code but accepted the client credentials." + [response] + (= "invalid_grant" (token-endpoint-error response))) + +(defn- token-endpoint-invalid-client-error? + "Token endpoint rejected the client credentials." + [{:keys [status] :as response}] + (let [error (token-endpoint-error response) + description (str/lower (or (token-endpoint-error-description response) ""))] + (or (= status 401) + (#{"invalid_client" "unauthorized_client"} error) + (and (= error "access_denied") + (str/includes? description "unauthorized"))))) + +(defn- probe-organization-sso-client-credentials + "Probe the token endpoint with a dummy authorization code. + Valid client credentials are expected to answer with `invalid_grant`." + [cfg provider] + (let [params {:client_id (:client-id provider) + :client_secret (:client-secret provider) + :code probe-auth-code + :grant_type "authorization_code" + :redirect_uri (build-redirect-uri)} + req {:method :post + :headers {"content-type" "application/x-www-form-urlencoded" + "accept" "application/json"} + :uri (:token-uri provider) + :body (u/map->query-string params)} + response (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})] + (cond + (token-endpoint-valid-client-error? response) true + (token-endpoint-invalid-client-error? response) false + :else false))) + +(defn is-organization-sso-config-valid? + "Return true when the SSO config can be discovered, can build a login URL, + and the client credentials are accepted by the token endpoint." + [cfg sso] + (try + (if (organization-sso-discovery-uri sso) + (let [provider (prepare-organization-sso-provider cfg sso)] + (and (build-organization-sso-auth-redirect-uri cfg sso :provider provider) + (probe-organization-sso-client-credentials cfg provider))) + false) + (catch Throwable _ false))) + (defn- auth-handler [cfg {:keys [params] :as request}] (let [provider (resolve-provider cfg params) @@ -802,17 +897,15 @@ state (get params :state) state (tokens/verify cfg {:token state :iss "oidc"})] - ;; Org SSO flow: state carries :dest-url — exchange the authorization + ;; Organization SSO flow: state carries :dest-url — exchange the authorization ;; code with the OIDC provider to verify authentication actually occurred. (if-let [dest-url (:dest-url state)] - (let [team-id (:team-id state) - organization-id (:organization-id state) - sso (nitrate/call cfg :get-org-sso-by-team {:team-id team-id}) - provider (prepare-org-sso-provider cfg sso) - ;; verify token or throw error - _info (get-info cfg provider state code) + (let [organization-id (:organization-id state) + sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id}) + provider (prepare-organization-sso-provider cfg sso) + info (get-info cfg provider state code) session (session/get-session request) - exp (ct/in-future {:hours 48})] + exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))] (when (and session organization-id) (let [props (-> (or (:props session) {}) (update :sso assoc organization-id exp))] diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index f15c6cbbc1..e069b2908b 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -419,10 +419,19 @@ :id ::change-email :schema schema:change-email)) +(def ^:private schema:organization-data + [:map + [:name ::sm/text] + [:initials {:optional true} [:maybe :string]] + [:logo {:optional true} [:maybe ::sm/uri]] + [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe ::sm/boolean]]]) + (def ^:private schema:invite-to-team [:map [:invited-by ::sm/text] [:team ::sm/text] + [:organization {:optional true} [:maybe schema:organization-data]] [:token ::sm/text]]) (def invite-to-team @@ -431,27 +440,28 @@ :id ::invite-to-team :schema schema:invite-to-team)) -(def ^:private schema:organization-data - [:map - [:name ::sm/text] - [:initials [:maybe :string]] - [:logo [:maybe ::sm/uri]] - [:avatar-bg-url [:maybe ::sm/uri]]]) - -(def ^:private schema:invite-to-org +(def ^:private schema:invite-to-organization [:map [:invited-by ::sm/text] [:user-name [:maybe ::sm/text]] [:token ::sm/text] [:organization schema:organization-data]]) -(def invite-to-org - "Org member invitation email." +(def invite-to-organization + "Organization member invitation email." (template-factory - :id ::invite-to-org - :schema schema:invite-to-org)) + :id ::invite-to-organization + :schema schema:invite-to-organization)) +(def ^:private schema:organization-setup-sso + [:map + [:organization-name ::sm/text]]) +(def organization-setup-sso + "Email when an organization set up SSO" + (template-factory + :id ::organization-setup-sso + :schema schema:organization-setup-sso)) (def ^:private schema:renewal-notice [:map diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index aa0d750203..1458b06d27 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -31,7 +31,8 @@ #{"file-media-object" "file-object-thumbnail" "team-font-variant" - "file-data-fragment"}) + "file-data-fragment" + "organization"}) (defn get-id [{:keys [path-params]}] diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index e9f4b5679a..614942c072 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -226,19 +226,19 @@ (-> (db/exec-one! cfg [sql (:profile-id session) (:id session)]) (db/get-update-count)))) -(def ^:private sql:clear-org-sso-sessions +(def ^:private sql:clear-organization-sso-sessions (str "UPDATE http_session_v2 " "SET props = props #- ARRAY['~:sso', ?]::text[] " "WHERE props IS NOT NULL " "AND jsonb_exists(props -> '~:sso', ?)")) -(defn clear-org-sso-sessions! +(defn clear-organization-sso-sessions! "Remove the SSO entry for organization-id from the props of every session that currently holds it. The key is transit-encoded as the string '~u' under the '~:sso' path." [pool organization-id] - (let [org-key (str "~u" organization-id)] - (db/exec! pool [sql:clear-org-sso-sessions org-key org-key]))) + (let [organization-key (str "~u" organization-id)] + (db/exec! pool [sql:clear-organization-sso-sessions organization-key organization-key]))) (defn- renew-session? [{:keys [id modified-at] :as session}] diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 1141ade205..f68209255b 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -88,7 +88,8 @@ #{:session-id :password :old-password - :token}) + :token + :client-secret}) (defn extract-utm-params "Extracts additional data from params and namespace them under @@ -153,7 +154,7 @@ ;; COLLECTOR API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(declare ^:private prepare-context-from-request) +(declare prepare-context-from-request) ;; Defines a service that collects the audit/activity log using ;; internal database. Later this audit log can be transferred to @@ -182,7 +183,7 @@ (def valid-event? (sm/validator schema:event)) -(defn- prepare-context-from-request +(defn prepare-context-from-request "Prepare backend event context from request" [request] (let [client-event-origin (get-client-event-origin request) @@ -413,7 +414,7 @@ (update :ip-addr d/nilv "0.0.0.0") (update :props d/nilv {}) (update :context d/nilv {}) - (assoc :source "backend") + (update :source d/nilv "backend") (d/without-nils))] (submit* cfg event))) @@ -430,7 +431,7 @@ (update :profile-id d/nilv uuid/zero) (update :props d/nilv {}) (update :context d/nilv {}) - (assoc :source "backend") + (update :source d/nilv "backend") (select-keys event-keys) (check-event))] (db/run! cfg append-audit-entry event)))) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 55a988b52c..2edb8614d5 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -495,6 +495,9 @@ {:name "0151-mod-file-tagged-object-thumbnail-table" :fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")} + {:name "0152-improve-uuid-defaults-and-drop-extension" + :fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")} + {:name "0152-rename-version-and-add-indexes-to-server-error-report" :fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}]) diff --git a/backend/src/app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql b/backend/src/app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql new file mode 100644 index 0000000000..ae0fe29372 --- /dev/null +++ b/backend/src/app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql @@ -0,0 +1,29 @@ +-- Migration: Replace uuid_generate_v4() defaults with gen_random_uuid() +-- and remove uuid-ossp extension. +-- +-- gen_random_uuid() is built into PostgreSQL >= 13 and requires no extension. +-- The application already generates IDs explicitly via uuid/next in all +-- code paths; this migration adds gen_random_uuid() as a safety-net default +-- instead of the extension-dependent uuid_generate_v4(). + +ALTER TABLE access_token ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE audit_log ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE comment ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE comment_thread ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE file ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE file_change ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE file_media_object ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE profile ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE project ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE project_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE scheduled_task_history ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE share_link ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE storage_object ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE task ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE team ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE team_access_request ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE team_font_variant ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE team_invitation ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE team_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE team_project_profile_rel ALTER COLUMN id SET DEFAULT gen_random_uuid(); +ALTER TABLE usage_quote ALTER COLUMN id SET DEFAULT gen_random_uuid(); diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index b5f63add0e..70a072b7c0 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -14,19 +14,49 @@ [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] - [app.common.types.organization :as cto] + [app.common.types.organization :as cto + :refer [schema:nitrate-sso]] + [app.common.uri :as u] [app.config :as cf] [app.http.client :as http] [app.http.session :as session] [app.rpc :as-alias rpc] [app.setup :as-alias setup] + [app.util.cache :as cache] [clojure.core :as c] + [clojure.string :as str] [integrant.core :as ig])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- join-path-segments + "Build a single relative path from Nitrate URI segments, normalizing slashes." + [segments] + (let [path (->> segments (map str) (str/join "/"))] + (->> (str/split path #"/") + (remove str/blank?) + (str/join "/")))) + +(defn- join-base-uri + "Join path segments to a base URI." + [base-uri & segments] + (u/join (u/ensure-path-slash base-uri) + (join-path-segments segments))) + +(defn- generate-nitrate-uri + "Joins relative path segments to the Nitrate backend URI. + Segments must not start with `/`" + [& segments] + (apply join-base-uri (cf/get :nitrate-backend-uri) segments)) + +(defn- generate-public-uri + "Joins relative path segments to the public backend URI. + Segments must not start with `/`" + [& segments] + (apply join-base-uri (cf/get :public-uri) segments)) + (defn- request-builder [cfg method uri shared-key profile-id request-params] (fn [] @@ -132,7 +162,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private schema:org-summary +(def ^:private schema:organization-summary [:map [:id ::sm/uuid] [:name ::sm/text] @@ -143,13 +173,6 @@ [:id ::sm/uuid] [:is-your-penpot :boolean]]]]]) -(def ^:private schema:profile-org - [:map - [:is-member :boolean] - [:organization-id {:optional true} [:maybe ::sm/uuid]] - [:default-team-id {:optional true} [:maybe ::sm/uuid]]]) - - ;; TODO Unify with schemas on backend/src/app/http/management.clj (def ^:private schema:timestamp (sm/type-schema @@ -166,6 +189,13 @@ :decode/json ct/inst :encode/json inst-ms}})) +(def ^:private schema:profile-organization + [:map + [:is-member :boolean] + [:organization-id {:optional true} [:maybe ::sm/uuid]] + [:default-team-id {:optional true} [:maybe ::sm/uuid]] + [:created-at {:optional true} [:maybe schema:timestamp]]]) + (def ^:private schema:subscription [:map {:title "Subscription"} [:id ::sm/text] @@ -223,60 +253,52 @@ [:map [:licenses ::sm/boolean]]) -(defn- get-team-org-api +(defn- get-team-organization-api [cfg {:keys [team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/teams/" - team-id) - cto/schema:team-with-organization params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/teams/" team-id) + cto/schema:team-with-organization params)) -(defn- get-org-membership-api +(defn- get-organization-membership-api [cfg {:keys [profile-id organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/members/" - profile-id) - schema:profile-org params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "members/" + profile-id) + schema:profile-organization params)) -(defn- get-org-membership-by-team-api +(defn- get-organization-membership-by-team-api [cfg {:keys [profile-id team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/teams/" - team-id - "/users/" - profile-id) - schema:profile-org params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/teams/" + team-id + "users/" + profile-id) + schema:profile-organization params)) - -(defn- get-org-summary-api +(defn- get-organization-summary-api [cfg {:keys [organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/summary") - schema:org-summary params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "summary") + schema:organization-summary params)) -(defn- get-owned-orgs-api +(defn- get-owned-organizations-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/users/" - profile-id - "/owned-organizations") - [:vector schema:org-summary] - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/users/" + profile-id + "owned-organizations") + [:vector schema:organization-summary] + params)) -(def ^:private schema:org-summary-counts +(def ^:private schema:organization-summary-counts [:map [:id ::sm/uuid] [:name ::sm/text] @@ -286,101 +308,94 @@ [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] [:logo-id {:optional true} [:maybe ::sm/uuid]]]) -(defn- get-owned-orgs-summary-api +(defn- get-owned-organizations-summary-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - orgs (request-to-nitrate cfg :get - (str baseuri - "/api/users/" - profile-id - "/owned-organizations-summary") - [:vector schema:org-summary-counts] - params)] - (mapv (fn [org] - (if-let [logo-id (:logo-id org)] - (assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id)) - org)) - orgs))) + (let [organizations (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/users/" + profile-id + "owned-organizations-summary") + [:vector schema:organization-summary-counts] + params)] + (mapv (fn [organization] + (if-let [logo-id (:logo-id organization)] + (assoc organization :custom-photo (generate-public-uri "assets/by-id/" logo-id)) + organization)) + organizations))) (defn- cleanup-deleted-penpot-user-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :post - (str baseuri - "/api/users/" - profile-id - "/cleanup-after-deletion") - nil params))) + (request-to-nitrate cfg :post + (generate-nitrate-uri + "api/users/" + profile-id + "cleanup-after-deletion") + nil params)) -(defn- set-team-org-api +(defn- set-team-organization-api [cfg {:keys [organization-id team-id is-default] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - params (assoc params :request-params {:team-id team-id + (let [params (assoc params :request-params {:team-id team-id :is-your-penpot (true? is-default)}) team (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/add-team") + (generate-nitrate-uri + "api/organizations/" + organization-id + "add-team") cto/schema:team-with-organization params) custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])] - (str (cf/get :public-uri) "/assets/by-id/" logo-id))] + (generate-public-uri "assets/by-id/" logo-id))] (cond-> team custom-photo (assoc-in [:organization :custom-photo] custom-photo)))) -(defn- add-profile-to-org-api +(defn- add-profile-to-organization-api [cfg {:keys [profile-id organization-id team-id email] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - request-params (cond-> {:user-id profile-id :team-id team-id} + (let [request-params (cond-> {:user-id profile-id :team-id team-id} (some? email) (assoc :email email)) params (assoc params :request-params request-params)] (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/add-user") - schema:profile-org params))) + (generate-nitrate-uri + "api/organizations/" + organization-id + "add-user") + schema:profile-organization params))) -(defn- remove-profile-from-org-api - [cfg {:keys [profile-id organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - params (assoc params :request-params {:user-id profile-id})] +(defn- remove-profile-from-organization-api + [cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}] + (let [request-params (cond-> {:user-id profile-id} + (some? user-who-delete-member) + (assoc :user-who-delete-member user-who-delete-member) + (some? deleted-by-role) + (assoc :deleted-by-role deleted-by-role)) + params (assoc params :request-params request-params)] (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/remove-user") + (generate-nitrate-uri + "api/organizations/" + organization-id + "remove-user") nil params))) -(defn- remove-team-from-org-api +(defn- remove-team-from-organization-api [cfg {:keys [team-id organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - params (assoc params :request-params {:team-id team-id})] + (let [params (assoc params :request-params {:team-id team-id})] (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/remove-team") + (generate-nitrate-uri + "api/organizations/" + organization-id + "remove-team") nil params))) (defn- delete-team-api [cfg {:keys [team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :delete - (str baseuri - "/api/teams/" - team-id) - nil params))) + (request-to-nitrate cfg :delete + (generate-nitrate-uri "api/teams/" team-id) + nil params)) (defn- get-subscription-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/subscriptions/" - profile-id) - schema:subscription params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/subscriptions/" profile-id) + schema:subscription params)) (def ^:private schema:subscription-warning [:maybe @@ -392,80 +407,79 @@ (defn- get-subscription-warning-api [cfg {:keys [penpot-id profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - penpot-id (or penpot-id profile-id)] + (let [penpot-id (or penpot-id profile-id)] (request-to-nitrate cfg :get - (str baseuri - "/api/subscription-warning/" - penpot-id) + (generate-nitrate-uri "api/subscription-warning/" penpot-id) schema:subscription-warning params))) (defn- get-connectivity-api [cfg params] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/connectivity") - schema:connectivity params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/connectivity") + schema:connectivity params)) + +(def ^:private schema:identity + [:map + [:nitrate-id ::sm/text] + [:public-key ::sm/text]]) + +(defn- get-identity-api + [cfg params] + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/identity") + schema:identity params)) (def ^:private schema:redeem-result [:map [:cancel-at [:maybe schema:timestamp]]]) -(defn- get-org-permissions-api +(defn- get-organization-permissions-api [cfg {:keys [organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/permissions") - [:map - [:organization-id ::sm/uuid] - [:owner-id ::sm/uuid] - [:permissions [:map-of :keyword :string]]] - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "permissions") + [:map + [:organization-id ::sm/uuid] + [:owner-id ::sm/uuid] + [:permissions [:map-of :keyword :string]]] + params)) -(def ^:private schema:nitrate-sso - [:map - [:organization-id ::sm/uuid] - [:active [:maybe :boolean]] - [:provider [:maybe :string]] - [:client-id [:maybe :string]] - [:base-url [:maybe :string]] - [:client-secret [:maybe :string]] - [:issuer [:maybe :string]] - [:scopes [:maybe [::sm/set ::sm/text]]]]) +(defn- get-organization-sso-api + "Fetches the SSO configuration for an organization from Nitrate." + [cfg {:keys [organization-id] :as params}] + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "sso") + schema:nitrate-sso + params)) -(defn- get-org-sso-by-team-api +(defn- get-organization-sso-by-team-api [cfg {:keys [team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/teams/" - team-id - "/sso") - schema:nitrate-sso - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/teams/" team-id "sso") + schema:nitrate-sso + params)) -(defn- get-org-members-api +(defn- get-organization-members-api [cfg {:keys [organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/members-list") - [:vector ::sm/uuid] - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "members-list") + [:vector ::sm/uuid] + params)) (defn- redeem-activation-code-api [cfg params] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :post - (str baseuri "/api/activation-codes/redeem") - schema:redeem-result - (assoc params :throw-on-error? true)))) + (request-to-nitrate cfg :post + (generate-nitrate-uri "api/activation-codes/redeem") + schema:redeem-result + (assoc params :throw-on-error? true))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION @@ -474,39 +488,85 @@ (defmethod ig/init-key ::client [_ cfg] (when (contains? cf/flags :nitrate) - {:get-team-org (partial get-team-org-api cfg) - :set-team-org (partial set-team-org-api cfg) - :get-org-membership (partial get-org-membership-api cfg) - :get-org-membership-by-team (partial get-org-membership-by-team-api cfg) - :get-org-summary (partial get-org-summary-api cfg) - :get-owned-orgs (partial get-owned-orgs-api cfg) - :get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg) - :get-org-members (partial get-org-members-api cfg) + {:get-team-organization (partial get-team-organization-api cfg) + :set-team-organization (partial set-team-organization-api cfg) + :get-organization-membership (partial get-organization-membership-api cfg) + :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) + :get-organization-summary (partial get-organization-summary-api cfg) + :get-owned-organizations (partial get-owned-organizations-api cfg) + :get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg) + :get-organization-members (partial get-organization-members-api cfg) :cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg) - :add-profile-to-org (partial add-profile-to-org-api cfg) - :remove-profile-from-org (partial remove-profile-from-org-api cfg) - :get-org-permissions (partial get-org-permissions-api cfg) - :get-org-sso-by-team (partial get-org-sso-by-team-api cfg) + :add-profile-to-organization (partial add-profile-to-organization-api cfg) + :remove-profile-from-organization (partial remove-profile-from-organization-api cfg) + :get-organization-permissions (partial get-organization-permissions-api cfg) + :get-organization-sso-by-team (partial get-organization-sso-by-team-api cfg) + :get-organization-sso (partial get-organization-sso-api cfg) :delete-team (partial delete-team-api cfg) - :remove-team-from-org (partial remove-team-from-org-api cfg) + :remove-team-from-organization (partial remove-team-from-organization-api cfg) :get-subscription (partial get-subscription-api cfg) :get-subscription-warning (partial get-subscription-warning-api cfg) :connectivity (partial get-connectivity-api cfg) + :get-identity (partial get-identity-api cfg) :redeem-activation-code (partial redeem-activation-code-api cfg)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; UTILS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defonce ^:private team-organization-owner-cache + ;; Short TTL: permission checks run on the read path, so we avoid an + ;; HTTP call to nitrate per check. The organization owner of a team rarely + ;; changes, and stale entries only grant read access for a few seconds. + (cache/create :expire "30s" :max-size 2048)) + +(defn- nitrate-client? + "True when `cfg` is a config map carrying the nitrate client (i.e. not + a raw db connection/pool passed by an internal caller)." + [cfg] + (and (map? cfg) (some? (get cfg ::client)))) + +(def ^:private cache-miss ::no-organization-owner) + +(defn- get-team-organization-owner-id + "Returns the organization owner-id for `team-id`, or nil. Cached + briefly, including negative results (teams with no organization) so + repeated unauthorized probes don't each hit nitrate." + [cfg team-id] + (let [owner-id (cache/get team-organization-owner-cache team-id + (fn [team-id] + (let [team-with-organization (call cfg :get-team-organization {:team-id team-id})] + (or (get-in team-with-organization [:organization :owner-id]) + cache-miss))))] + (when-not (= owner-id cache-miss) + owner-id))) + +(defn organization-owner-of-team? + "True if `profile-id` is the owner of the organization that owns + `team-id`. Used to grant non-member organization owners read-only access to the + teams of their organizations. `cfg` must be a config map with the + nitrate client; raw db connections/pools yield false so internal + callers are unaffected. Returns false when the :nitrate flag is off." + [cfg profile-id team-id] + (boolean + (when (and (contains? cf/flags :nitrate) + (nitrate-client? cfg) + (some? team-id) + (some? profile-id)) + (= profile-id (get-team-organization-owner-id cfg team-id))))) + (defn sso-session-authorized? - "Fetches the org-SSO config for the given team and checks whether - the HTTP request has a valid session entry for it. Returns a map + "Fetches the organization-SSO config for the given organization or team and checks + whether the HTTP request has a valid session entry for it. Returns a map with :authorized and :sso keys." - [cfg team-id request] - (let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})] + [cfg organization-id team-id request] + (let [session (session/get-session request) + sso (if organization-id + (call cfg :get-organization-sso {:organization-id organization-id}) + (call cfg :get-organization-sso-by-team {:team-id team-id}))] (if-not (:active sso) {:authorized true :sso sso} - (if (or (:issuer sso) (:base-url sso)) + (if-not (str/blank? (:issuer sso)) (let [props (:props session) sso-map (get props :sso {}) organization-id (:organization-id sso) @@ -536,21 +596,21 @@ :cause cause) profile))))) -(defn add-org-info-to-team +(defn add-organization-info-to-team "Enriches a team map with organization information from Nitrate. Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. - Returns the original team unchanged if the request fails or org data is nil. + Returns the original team unchanged if the request fails or organization data is nil. Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." [cfg team params] (try (let [params (assoc (or params {}) :team-id (:id team)) - team-with-org (call cfg :get-team-org params) - org (:organization team-with-org)] - (if (some? org) - (-> (cto/apply-organization team (assoc org :custom-photo - (when-let [logo-id (:logo-id org)] - (str (cf/get :public-uri) "/assets/by-id/" logo-id)))) - (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org))))) + team-with-organization (call cfg :get-team-organization params) + organization (:organization team-with-organization)] + (if (some? organization) + (-> (cto/apply-organization team (assoc organization :custom-photo + (when-let [logo-id (:logo-id organization)] + (generate-public-uri "assets/by-id/" logo-id)))) + (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) team)) (catch Throwable cause (if (= :nitrate-unavailable (-> cause ex-data :type)) @@ -570,10 +630,10 @@ :team-id (:id team) :organization-id (:organization-id params) :is-default (:is-default params)) - result (call cfg :set-team-org params)] + result (call cfg :set-team-organization params)] (when (nil? result) (ex/raise :type :internal - :code :failed-to-set-team-org + :code :failed-to-set-team-organization :context {:team-id (:id team) :organization-id (:organization-id params)})) team)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 905710cd6a..3196b5c855 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -250,64 +250,71 @@ f)) -(defonce ^:private org-sso-auth-cache +(defonce ^:private organization-sso-auth-cache (cache/create :expire "15m" :max-size 1024)) -(defn invalidate-org-sso-cache-by-org! - "Invalidates all org-SSO authorization cache entries for the given organization-id." +(defn invalidate-organization-sso-cache-by-organization! + "Invalidates all organization-SSO authorization cache entries for the given organization-id." [organization-id] - (cache/invalidate-if org-sso-auth-cache #(= (:organization-id %) organization-id))) + (cache/invalidate-if organization-sso-auth-cache #(= (:organization-id %) organization-id))) (defn- wrap-nitrate-sso "Enforce Nitrate organization SSO authentication for RPC handlers. - Resolves the team context from request params using priority order: - 1. Explicit :team-id param - 2. Explicit :project-id param → lookup project.team_id - 3. Explicit :file-id param → lookup file's team via join - 4. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file) + Resolves the organization/team context from request params using priority order: + 1. Explicit :organization-id param + 2. Explicit :team-id param + 3. Explicit :project-id param -> lookup project.team_id + 4. Explicit :file-id param -> lookup file's team via join + 5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file) - Once team-id is resolved, checks if the user is authorized within that org's SSO - session using nitrate/sso-session-authorized?. Results are cached by [profile-id cache-ref] - for 15 minutes to avoid repeated lookups. + Once the context is resolved, checks if the user is authorized within that organization's + SSO session using nitrate/sso-session-authorized?. Authorized results are cached + by [profile-id cache-ref] for 15 minutes to avoid repeated lookups. Only activates when: - Nitrate flag is enabled - Endpoint requires authentication (::auth true by default) - - Endpoint is not marked with ::nitrate/org-sso false + - Endpoint is not marked with ::nitrate/organization-sso false - Raises :nitrate-sso-required error if user is not authorized in the org." + Raises :nitrate-sso-required error if user is not authorized in the organization." [_ f mdata] (if (and (contains? cf/flags :nitrate) (::auth mdata true) ;; only for endpoints that needs auth (::nitrate/sso mdata true)) (fn [cfg params] ;; Resolve team/project/file from explicit keys or from :id via metadata - (let [id-type (::id-type mdata) - id (uuid/coerce (:id params)) - team-id (or (uuid/coerce (:team-id params)) - (when (= id-type :team) id)) - project-id (or (uuid/coerce (:project-id params)) - (when (= id-type :project) id)) - file-id (or (uuid/coerce (:file-id params)) - (when (= id-type :file) id))] - (if (or team-id project-id file-id) - (let [cache-ref (or team-id project-id file-id) - profile-id (::profile-id params) + (let [profile-id (::profile-id params) + organization-id (uuid/coerce (:organization-id params)) + id-type (::id-type mdata) + id (uuid/coerce (:id params)) + team-id (or (uuid/coerce (:team-id params)) + (when (= id-type :team) id)) + project-id (or (uuid/coerce (:project-id params)) + (when (= id-type :project) id)) + file-id (or (uuid/coerce (:file-id params)) + (when (= id-type :file) id))] + (if (and profile-id + (or organization-id team-id project-id file-id)) + (let [cache-ref (or organization-id team-id project-id file-id) + cache-key [profile-id cache-ref] - cached (cache/get org-sso-auth-cache cache-key) + cached (cache/get organization-sso-auth-cache cache-key) result (if (some? cached) cached - (let [team-id (or team-id - (when project-id - (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) - (:id (teams/get-team-for-file cfg file-id))) + (let [team-id (when-not organization-id + (or team-id + (when project-id + (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) + (:id (teams/get-team-for-file cfg file-id)))) request (-> (meta params) (get ::http/request)) - {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request) + {:keys [authorized sso]} (if organization-id + (nitrate/sso-session-authorized? cfg organization-id nil request) + (nitrate/sso-session-authorized? cfg nil team-id request)) entry {:authorized authorized :organization-id (:organization-id sso)}] (when authorized - (cache/get org-sso-auth-cache cache-key (constantly entry))) + (cache/get organization-sso-auth-cache cache-key (constantly entry))) entry))] (if (:authorized result) (f cfg params) diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 90ab826e2b..0aa20ba3c1 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -37,7 +37,8 @@ (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) - token (tokens/generate cfg {:iss "access-token" + token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token") + token (tokens/generate cfg {:iss token-iss :uid profile-id :iat created-at :tid token-id}) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index ec27d455b8..79b0bf7cf9 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -74,8 +74,8 @@ ::doc/changes [["2.12" "Remove version parameter, only one version is supported"]] ::webhooks/event? true ::sm/params schema:export-binfile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] - (files/check-read-permissions! pool profile-id file-id) + [cfg {:keys [::rpc/profile-id file-id] :as params}] + (files/check-read-permissions! cfg profile-id file-id) (sse/response (partial export-binfile cfg params))) ;; --- Command: import-binfile diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 3383d3d343..6a926d1e98 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -230,8 +230,8 @@ {::doc/added "1.15" ::sm/params schema:get-comment-threads} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-comment-threads conn profile-id file-id)))) (defn- get-comment-threads-sql @@ -328,8 +328,8 @@ {::doc/added "1.15" ::sm/params schema:get-comment-thread} [cfg {:keys [::rpc/profile-id file-id id share-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id]) (decode-row))))) @@ -347,9 +347,9 @@ {::doc/added "1.15" ::sm/params schema:get-comments} [cfg {:keys [::rpc/profile-id thread-id share-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] (let [{:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-comments conn thread-id))))) (def sql:get-comments @@ -406,8 +406,8 @@ ::doc/changes ["1.15" "Imported from queries and renamed."] ::sm/params schema:get-profiles-for-file-comments} [cfg {:keys [::rpc/profile-id file-id share-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-comment-permissions! conn profile-id file-id share-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-comment-permissions! cfg profile-id file-id share-id) (get-file-comments-users conn file-id profile-id)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -534,9 +534,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-status ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (upsert-comment-thread-status! conn profile-id id))) ;; --- COMMAND: Update Comment Thread @@ -552,9 +552,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id is-resolved share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:is-resolved is-resolved} {:id id}) @@ -582,7 +582,7 @@ {:keys [team-id project-id] :as file} (get-file cfg file-id page-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (quotes/check! cfg {::quotes/id ::quotes/comments-per-file ::quotes/profile-id profile-id @@ -653,7 +653,7 @@ {:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) ;; Don't allow edit comments to not owners (when-not (= owner-id profile-id) @@ -690,9 +690,9 @@ {::doc/added "1.15" ::sm/params schema:delete-comment-thread ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (when-not (= owner-id profile-id) (ex/raise :type :validation :code :not-allowed)) @@ -713,14 +713,14 @@ {::doc/added "1.15" ::sm/params schema:delete-comment ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}] (let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::sql/for-update true) {:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (when-not (= owner-id profile-id) (ex/raise :type :validation :code :not-allowed)) @@ -743,9 +743,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-position ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:modified-at request-at :position (db/pgpoint position) @@ -767,9 +767,9 @@ {::doc/added "1.15" ::sm/params schema:update-comment-thread-frame ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}] (let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)] - (files/check-comment-permissions! conn profile-id file-id share-id) + (files/check-comment-permissions! cfg profile-id file-id share-id) (db/update! conn :comment-thread {:modified-at request-at :frame-id frame-id} diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 4daa2dd32a..e10c85a7bd 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -84,10 +84,10 @@ (perms/make-edition-predicate-fn bfc/get-file-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn bfc/get-file-permissions)) + (perms/make-read-predicate-fn perms/get-file-read-permissions)) (def has-comment-permissions? - (perms/make-comment-predicate-fn bfc/get-file-permissions)) + (perms/make-comment-predicate-fn perms/get-file-read-permissions)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) @@ -99,8 +99,8 @@ ;; explicit comment permissions through the share-id (defn check-comment-permissions! - [conn profile-id file-id share-id] - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + [cfg profile-id file-id share-id] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) can-read (has-read-permissions? perms) can-comment (has-comment-permissions? perms)] (when-not (or can-read can-comment) @@ -152,7 +152,7 @@ (defn- get-minimal-file-with-perms [cfg {:keys [:id ::rpc/profile-id]}] (let [mfile (get-minimal-file cfg id) - perms (bfc/get-file-permissions cfg profile-id id)] + perms (perms/get-file-read-permissions cfg profile-id id)] (assoc mfile :permissions perms))) (defn get-file-etag @@ -173,7 +173,7 @@ ::sm/params schema:get-file ::sm/result schema:file-with-permissions ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}] + [cfg {:keys [::rpc/profile-id id project-id] :as params}] ;; The COND middleware makes initial request for a file and ;; permissions when the incoming request comes with an ;; ETAG. When ETAG does not matches, the request is resolved @@ -181,10 +181,10 @@ ;; will be already prefetched and we just reuse them instead ;; of making an additional database queries. (let [perms (or (:permissions (::cond/object params)) - (bfc/get-file-permissions conn profile-id id))] + (perms/get-file-read-permissions cfg profile-id id))] (check-read-permissions! perms) - (let [team (teams/get-team conn + (let [team (teams/get-team cfg :profile-id profile-id :project-id project-id :file-id id) @@ -244,7 +244,7 @@ ::sm/result schema:file-fragment} [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] - (let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] (check-read-permissions! perms) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) @@ -288,7 +288,7 @@ ::sm/params schema:get-project-files ::sm/result schema:files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}] - (projects/check-read-permissions! pool profile-id project-id) + (projects/check-read-permissions! cfg profile-id project-id) (get-project-files pool project-id)) ;; --- COMMAND QUERY: has-file-libraries @@ -306,7 +306,7 @@ ::sm/result ::sm/boolean} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! pool profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-has-file-libraries conn file-id))) (def ^:private sql:has-file-libraries @@ -339,7 +339,7 @@ ::sm/result ::sm/int} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! pool profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-library-usage conn file-id))) (def ^:private sql:get-library-usage @@ -389,7 +389,7 @@ :code :params-validation :hint "page-id is required when object-id is provided")) - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) file (bfc/get-file cfg file-id :read-only? true) proj (db/get conn :project {:id (:project-id file)}) @@ -440,8 +440,8 @@ ::sm/params schema:get-page} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - (check-read-permissions! conn profile-id file-id share-id) + (fn [cfg] + (check-read-permissions! cfg profile-id file-id share-id) (get-page cfg (assoc params :profile-id profile-id))))) ;; --- COMMAND QUERY: get-team-shared-files @@ -564,7 +564,7 @@ (defn- get-team-shared-files [{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}] - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (let [process-row (fn [{:keys [id library-file-ids]}] @@ -677,8 +677,8 @@ ::sm/params schema:get-file-stats ::sm/result schema:get-file-stats-result ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}] - (check-read-permissions! conn profile-id id) + [cfg {:keys [::rpc/profile-id id]}] + (check-read-permissions! cfg profile-id id) (get-file-stats cfg id)) @@ -721,7 +721,7 @@ ::sm/params schema:get-library-file-references} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id file-id) + (check-read-permissions! cfg profile-id file-id) (get-library-file-references conn file-id))) ;; --- COMMAND QUERY: get-team-recent-files @@ -765,7 +765,7 @@ ::sm/params schema:get-team-recent-files} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (get-team-recent-files conn team-id))) @@ -810,8 +810,8 @@ {::doc/added "2.12" ::sm/params schema:get-team-deleted-files} [cfg {:keys [::rpc/profile-id team-id]}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (teams/check-read-permissions! conn profile-id team-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (teams/check-read-permissions! cfg profile-id team-id) (get-team-deleted-files conn team-id)))) ;; --- COMMAND QUERY: get-file-info diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 38caa0aa05..7baac52428 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -22,6 +22,7 @@ [app.rpc.commands.files :as files] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] + [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.util.services :as sv])) @@ -33,8 +34,8 @@ {::doc/added "1.20" ::sm/params schema:get-file-snapshots} [cfg {:keys [::rpc/profile-id file-id] :as params}] - (db/run! cfg (fn [{:keys [::db/conn]}] - (files/check-read-permissions! conn profile-id file-id) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] + (files/check-read-permissions! cfg profile-id file-id) (fsnap/get-visible-snapshots conn file-id)))) ;; --- COMMAND QUERY: get-file-snapshot @@ -52,8 +53,8 @@ ::sm/params schema:get-file-snapshot ::sm/result files/schema:file-with-permissions ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] - (let [perms (bfc/get-file-permissions conn profile-id file-id)] + [cfg {:keys [::rpc/profile-id file-id id] :as params}] + (let [perms (perms/get-file-read-permissions cfg profile-id file-id)] (files/check-read-permissions! perms) (let [snapshot (fsnap/get-snapshot cfg file-id id)] (when-not snapshot diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 024bce17e7..46a4bc04ac 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -85,7 +85,7 @@ ::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}] (dm/with-open [conn (db/open pool)] - (files/check-read-permissions! conn profile-id file-id) + (files/check-read-permissions! cfg profile-id file-id) (if tag (get-object-thumbnails-by-tag conn file-id tag) (get-object-thumbnails conn file-id)))) @@ -197,9 +197,9 @@ ::sm/params schema:get-file-data-for-thumbnail ::sm/result schema:partial-file} [cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}] - (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-read-permissions! conn profile-id file-id) - (let [team (teams/get-team conn + (db/run! cfg (fn [cfg] + (files/check-read-permissions! cfg profile-id file-id) + (let [team (teams/get-team cfg :profile-id profile-id :file-id file-id) file (bfc/get-file cfg file-id diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 5f031a4583..4d9eb77636 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -6,7 +6,6 @@ (ns app.rpc.commands.fonts (:require - [app.binfile.common :as bfc] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] @@ -30,6 +29,7 @@ [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] + [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.storage :as sto] [app.storage.tmp :as tmp] @@ -71,14 +71,14 @@ (cond (uuid? team-id) (do - (teams/check-read-permissions! conn profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (db/query conn :team-font-variant {:team-id team-id :deleted-at nil})) (uuid? project-id) (let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})] - (projects/check-read-permissions! conn profile-id project-id) + (projects/check-read-permissions! cfg profile-id project-id) (db/query conn :team-font-variant {:team-id (:team-id project) :deleted-at nil})) @@ -86,7 +86,7 @@ (uuid? file-id) (let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]}) project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]}) - perms (bfc/get-file-permissions conn profile-id file-id share-id)] + perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] (files/check-read-permissions! perms) (db/query conn :team-font-variant {:team-id (:team-id project) @@ -400,7 +400,7 @@ ::sm/params schema:download-font} [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] (let [variant (db/get pool :team-font-variant {:id id})] - (teams/check-read-permissions! pool profile-id (:team-id variant)) + (teams/check-read-permissions! cfg profile-id (:team-id variant)) ;; Try to get the best available font format (prefer TTF for broader compatibility). (let [media-id (or (:ttf-file-id variant) @@ -432,7 +432,7 @@ (ex/raise :type :not-found :code :object-not-found)) - (teams/check-read-permissions! pool profile-id (:team-id (first variants))) + (teams/check-read-permissions! cfg profile-id (:team-id (first variants))) (let [tempfile (tmp/tempfile :suffix ".zip") ffamily (-> variants first :font-family)] diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index c56f07ef83..41931f53ec 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -176,7 +176,7 @@ ;; profile-id is present; it can be ommited if this function is ;; called from SREPL helpers where no profile is available (when (uuid? profile-id) - (teams/check-read-permissions! conn profile-id team-id)) + (teams/check-read-permissions! cfg profile-id team-id)) (binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})] (let [projs (bfc/get-team-projects cfg team-id) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index f7cbe5cb4f..55b7a8e6d2 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -11,6 +11,7 @@ [app.auth.oidc :as oidc] [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.json :as json] [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.nitrate-permissions :as nitrate-perms] @@ -21,9 +22,11 @@ [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] + [app.rpc.nitrate.emails-helper :as neh] + [app.rpc.nitrate.organization-helper :as noh] [app.rpc.notifications :as notifications] - [app.tokens :as tokens] - [app.util.services :as sv])) + [app.util.services :as sv] + [buddy.core.codecs :as bc])) (defn assert-is-owner [cfg profile-id team-id] @@ -39,11 +42,11 @@ :code :cant-move-default-team)))) (defn assert-membership [cfg profile-id organization-id] - (let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id - :organization-id organization-id})] + (let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id + :organization-id organization-id})] (when-not (:organization-id membership) (ex/raise :type :validation - :code :organization-doesnt-exists)) + :code :organization-does-not-exist)) (when-not (:is-member membership) (ex/raise :type :validation @@ -113,6 +116,35 @@ :cause cause) (throw cause))))))) +(def ^:private activation-code-request-filename + "penpot-activation-code-request.txt") + +(sv/defmethod ::get-nitrate-activation-code-request + "Returns a Base64-encoded JSON file requesting a Nitrate activation code. + Payload includes nitrateId, publicKey, email and iat." + {::rpc/auth true + ::doc/added "2.20" + ::sm/params [:map] + ::sm/result ::sm/text} + [cfg {:keys [::rpc/profile-id]}] + (let [profile (db/get cfg :profile {:id profile-id}) + nitrate-identity (nitrate/call cfg :get-identity {})] + (when-not nitrate-identity + (ex/raise :type :validation + :code :nitrate-identity-unavailable + :hint "Unable to retrieve nitrate identity")) + (-> (json/encode {:nitrate-id (:nitrate-id nitrate-identity) + :public-key (:public-key nitrate-identity) + :email (:email profile) + :iat (ct/seconds (ct/now))} + :key-fn json/write-camel-key) + (bc/str->bytes) + (bc/bytes->b64-str) + (rph/wrap) + (rph/with-header "content-type" "text/plain") + (rph/with-header "content-disposition" + (str "attachment; filename=\"" activation-code-request-filename "\""))))) + (def ^:private sql:prefix-team-name-and-unset-default "UPDATE team SET name = ? || name, @@ -149,7 +181,7 @@ {}))) {})) -(defn- build-leave-org-plan +(defn- build-leave-organization-plan [{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?] (let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id)) files-counts (get-team-files-counts conn all-teams) @@ -162,18 +194,18 @@ {:deletable-team-ids deletable :keep-default-team? keep-default? :delete-default-team? (boolean (and default-team-id (not keep-default?))) - :detach-from-org-team-ids to-detach})) + :detach-from-organization-team-ids to-detach})) -(defn get-leave-org-summary +(defn get-leave-organization-summary [cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count] - (let [{:keys [deletable-team-ids detach-from-org-team-ids]} - (build-leave-org-plan cfg default-team-id teams-to-delete nil)] + (let [{:keys [deletable-team-ids detach-from-organization-team-ids]} + (build-leave-organization-plan cfg default-team-id teams-to-delete nil)] {:teams-to-delete (count deletable-team-ids) :teams-to-transfer teams-to-transfer-count :teams-to-exit teams-to-exit-count - :teams-to-detach (count detach-from-org-team-ids)})) + :teams-to-detach (count detach-from-organization-team-ids)})) -(def ^:private schema:leave-org +(def ^:private schema:leave-organization [:map [:id ::sm/uuid] [:name ::sm/text] @@ -186,47 +218,49 @@ [:id ::sm/uuid] [:reassign-to {:optional true} ::sm/uuid]]]]]) -(def ^:private schema:get-leave-org-summary-result +(def ^:private schema:get-leave-organization-summary-result [:map [:teams-to-delete ::sm/int] [:teams-to-transfer ::sm/int] [:teams-to-exit ::sm/int] - [:teams-to-detach ::sm/int]]) + [:teams-to-detach ::sm/int] + [:member-added-at [:maybe ct/schema:inst]] + [:organization-member-count-before ::sm/int]]) -(def ^:private schema:get-leave-org-summary +(def ^:private schema:get-leave-organization-summary [:map [:id ::sm/uuid] [:default-team-id ::sm/uuid]]) (defn- get-organization-teams-for-user - [{:keys [::db/conn] :as cfg} org-summary profile-id] - (let [org-team-ids (->> (:teams org-summary) - (map :id)) - ids-array (db/create-array conn "uuid" org-team-ids)] + [{:keys [::db/conn] :as cfg} organization-summary profile-id] + (let [organization-team-ids (->> (:teams organization-summary) + (map :id)) + ids-array (db/create-array conn "uuid" organization-team-ids)] (db/exec! conn [sql:get-member-teams-info profile-id ids-array]))) (defn- calculate-valid-teams - ([org-teams default-team-id] + ([organization-teams default-team-id] (let [;; valid default team is the one which id is default-team-id - valid-default-team (d/seek #(= default-team-id (:id %)) org-teams) + valid-default-team (d/seek #(= default-team-id (:id %)) organization-teams) ;; Remove your-penpot for the rest of validations - org-teams (remove #(= default-team-id (:id %)) org-teams) + organization-teams (remove #(= default-team-id (:id %)) organization-teams) ;; valid teams to delete are those that the user is owner, and only have one member - valid-teams-to-delete-ids (->> org-teams + valid-teams-to-delete-ids (->> organization-teams (filter #(and (:is-owner %) (= (:num-members %) 1))) (map :id) (into #{})) ;; valid teams to transfer are those that the user is owner, and have more than one member - valid-teams-to-transfer (->> org-teams + valid-teams-to-transfer (->> organization-teams (filter #(and (:is-owner %) (> (:num-members %) 1)))) ;; valid teams to exit are those that the user isn't owner, and have more than one member - valid-teams-to-exit (->> org-teams + valid-teams-to-exit (->> organization-teams (filter #(and (not (:is-owner %)) (> (:num-members %) 1))))] {:valid-teams-to-delete-ids valid-teams-to-delete-ids @@ -235,17 +269,17 @@ :valid-default-team valid-default-team}))) (defn get-valid-teams [cfg organization-id profile-id default-team-id] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - org-teams (get-organization-teams-for-user cfg org-summary profile-id)] - (calculate-valid-teams org-teams default-team-id))) + (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id}) + organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)] + (calculate-valid-teams organization-teams default-team-id))) (defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - org-teams (get-organization-teams-for-user cfg org-summary profile-id) + (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id}) + organization-teams (get-organization-teams-for-user cfg organization-summary profile-id) {:keys [valid-teams-to-delete-ids valid-teams-to-transfer valid-teams-to-exit - valid-default-team]} (calculate-valid-teams org-teams default-team-id) + valid-default-team]} (calculate-valid-teams organization-teams default-team-id) @@ -263,7 +297,7 @@ ;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and ;; the reassign-to is a member of the team and not the current user; ;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit - teams-by-id (d/index-by :id org-teams) + teams-by-id (d/index-by :id organization-teams) valid-teams-to-leave? (and (= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{}))) (every? (fn [{:keys [id reassign-to]}] @@ -274,10 +308,10 @@ (contains? members reassign-to))) (contains? valid-teams-to-exit-ids id))) teams-to-leave))] - ;; the org owner cannot leave - (when (= (:owner-id org-summary) profile-id) + ;; the organization owner cannot leave + (when (= (:owner-id organization-summary) profile-id) (ex/raise :type :validation - :code :org-owner-cannot-leave)) + :code :organization-owner-cannot-leave)) (when (or (not valid-teams-to-delete?) @@ -288,13 +322,14 @@ -(defn leave-org +(defn leave-organization [{:keys [::db/conn] :as cfg} - {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?]}] - (let [org-prefix (str "[" (d/sanitize-string name) "] ") + {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested? + user-who-delete-member deleted-by-role]}] + (let [organization-prefix (str "[" (d/sanitize-string name) "] ") {:keys [deletable-team-ids keep-default-team? - detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)] + detach-from-organization-team-ids]} (build-leave-organization-plan cfg default-team-id teams-to-delete keep-default-team-requested?)] ;; assert that the received teams are valid, checking the different constraints (when-not skip-validation @@ -311,62 +346,77 @@ (doseq [{:keys [id reassign-to]} teams-to-leave] (teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to})) - ;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete. + ;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete. (when default-team-id (if keep-default-team? - (db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id]) + (db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id]) (teams/delete-team cfg {:profile-id profile-id :team-id default-team-id}))) ;; Detach retained owned teams from the organization in Nitrate. - ;; Nitrate will rehome them to its fallback/default org. - (doseq [team-id detach-from-org-team-ids] - (nitrate/call cfg :remove-team-from-org {:team-id team-id - :organization-id id})) + ;; Nitrate will rehome them to its fallback/default organization. + (doseq [team-id detach-from-organization-team-ids] + (nitrate/call cfg :remove-team-from-organization {:team-id team-id + :organization-id id})) ;; Api call to nitrate - (nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id}) + (nitrate/call cfg :remove-profile-from-organization + {:profile-id profile-id + :organization-id id + :user-who-delete-member user-who-delete-member + :deleted-by-role deleted-by-role}) nil)) -(sv/defmethod ::leave-org +(sv/defmethod ::leave-organization {::rpc/auth true ::doc/added "2.15" - ::sm/params schema:leave-org + ::sm/params schema:leave-organization ::db/transaction true} [cfg {:keys [::rpc/profile-id] :as params}] - (leave-org cfg (assoc params :profile-id profile-id))) + (leave-organization cfg (assoc params + :profile-id profile-id + :user-who-delete-member profile-id + :deleted-by-role "organization-member"))) -(sv/defmethod ::get-leave-org-summary +(sv/defmethod ::get-leave-organization-summary {::rpc/auth true ::doc/added "2.18" - ::sm/params schema:get-leave-org-summary - ::sm/result schema:get-leave-org-summary-result + ::sm/params schema:get-leave-organization-summary + ::sm/result schema:get-leave-organization-summary-result ::db/transaction true} [cfg {:keys [::rpc/profile-id id default-team-id]}] (let [{:keys [valid-teams-to-delete-ids valid-teams-to-transfer valid-teams-to-exit valid-default-team]} (get-valid-teams cfg id profile-id default-team-id) + membership (nitrate/call cfg :get-organization-membership + {:profile-id profile-id + :organization-id id}) + organization-members (nitrate/call cfg :get-organization-members + {:organization-id id}) teams-to-transfer-count (count valid-teams-to-transfer) teams-to-exit-count (count valid-teams-to-exit)] (when-not valid-default-team (ex/raise :type :validation :code :not-valid-teams)) - (get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count))) + (assoc + (get-leave-organization-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count) + :member-added-at (:created-at membership) + :organization-member-count-before (count organization-members)))) -(def ^:private schema:remove-team-from-org +(def ^:private schema:remove-team-from-organization [:map [:team-id ::sm/uuid] [:organization-id ::sm/uuid] [:organization-name ::sm/text]]) -(sv/defmethod ::remove-team-from-org +(sv/defmethod ::remove-team-from-organization {::doc/added "2.17" - ::sm/params schema:remove-team-from-org} + ::sm/params schema:remove-team-from-organization} [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}] (assert-is-owner cfg profile-id team-id) @@ -374,32 +424,26 @@ (assert-membership cfg profile-id organization-id) ;; Check moveTeams permission on the source organization (when (contains? cf/flags :nitrate) - (let [org-perms (nitrate/call cfg :get-org-permissions - {:organization-id organization-id})] - (if (nil? org-perms) + (let [organization-perms (nitrate/call cfg :get-organization-permissions + {:organization-id organization-id})] + (if (nil? organization-perms) (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions") (when-not (nitrate-perms/allowed? :move-team - {:org-perms org-perms + {:organization-perms organization-perms :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed :hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner."))))) ;; Api call to nitrate - (nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id}) + (nitrate/call cfg :remove-team-from-organization {:team-id team-id :organization-id organization-id}) ;; Notify connected users - (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org") + (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-organization") nil) -(def ^:private sql:get-team-invitation-emails - "SELECT email_to - FROM team_invitation - WHERE team_id = ? - AND valid_until > now()") - (def ^:private sql:delete-team-external-invitations "DELETE FROM team_invitation WHERE team_id = ? @@ -413,23 +457,22 @@ AND deleted_at IS NULL") (defn- get-external-invitation-info - "Returns info about external (non-org-member) invitations pending for a team. - External invitations are those sent to users who are not members of the given org. + "Returns info about external (non-organization-member) invitations pending for a team. + External invitations are those sent to users who are not members of the given organization. Returns {:allows-anybody bool :external-emails [...]}" [{:keys [::db/conn] :as cfg} team-id organization-id] - (let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id}) - allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})] + (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id}) + allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization-perms})] (if allows-anybody {:allows-anybody true :external-emails []} - (let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id]) - emails (map :email-to invitation-emails)] + (let [emails (map :email (noh/get-team-invitation-emails conn team-id))] (if (empty? emails) {:allows-anybody false :external-emails []} (let [emails-array (db/create-array conn "text" (vec emails)) profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) - org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id})) + organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) external-emails (->> profiles - (remove #(contains? org-member-ids (:id %))) + (remove #(contains? organization-member-ids (:id %))) (map :email) (vec))] {:allows-anybody false :external-emails external-emails})))))) @@ -444,81 +487,90 @@ ::doc/added "2.17" ::sm/params schema:add-team-to-organization ::db/transaction true} - [cfg {:keys [::rpc/profile-id team-id organization-id]}] + [cfg {:keys [::rpc/profile-id team-id organization-id]}] (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) (assert-membership cfg profile-id organization-id) (when (contains? cf/flags :nitrate) - (let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) - source-org-id (get-in team-with-org [:organization :id]) - source-org-perms (when source-org-id - (nitrate/call cfg :get-org-permissions - {:organization-id source-org-id})) - target-org-perms (nitrate/call cfg :get-org-permissions - {:organization-id organization-id}) - target-org-same-owner? (and (some? source-org-perms) - (some? target-org-perms) - (= (:owner-id source-org-perms) - (:owner-id target-org-perms)))] - (when (nil? target-org-perms) + (let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) + team-with-organization (nitrate/call cfg :get-team-organization {:team-id team-id}) + source-organization-id (get-in team-with-organization [:organization :id]) + source-organization-perms (when source-organization-id + (nitrate/call cfg :get-organization-permissions + {:organization-id source-organization-id})) + target-organization-perms (nitrate/call cfg :get-organization-permissions + {:organization-id organization-id}) + target-organization-same-owner? (and (some? source-organization-perms) + (some? target-organization-perms) + (= (:owner-id source-organization-perms) + (:owner-id target-organization-perms)))] + (when (nil? target-organization-perms) (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions")) - ;; Team already belongs to an organization: check move-teams on source org. - (when (some? source-org-id) - (when (nil? source-org-perms) + ;; Team already belongs to an organization: check move-teams on the source organization. + (when (some? source-organization-id) + (when (nil? source-organization-perms) (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions")) (when-not (nitrate-perms/allowed? :move-team - {:org-perms source-org-perms + {:organization-perms source-organization-perms :profile-id profile-id - :target-org-same-owner? target-org-same-owner?}) + :target-organization-same-owner? target-organization-same-owner?}) (ex/raise :type :validation :code :not-allowed :hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner."))) ;; Always check target create-teams permission (new/add and move flows). (when-not (nitrate-perms/allowed? :create-team - {:org-perms target-org-perms + {:organization-perms target-organization-perms :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed - :hint "You are not allowed to add teams in this organization"))) + :hint "You are not allowed to add teams in this organization")) - (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] - ;; Add teammates to the org if needed - (doseq [{member-id :profile-id} team-members - :when (not= member-id profile-id)] - (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) + ;; Add teammates to the organization if needed + (let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) + new-member-ids (->> team-members + (map :profile-id) + (remove #{profile-id}) + (remove organization-member-ids-before))] + (doseq [member-id new-member-ids] + (teams/initialize-user-in-nitrate-organization cfg member-id organization-id))) - ;; Api call to nitrate - (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] + ;; Api call to nitrate + (let [team (nitrate/call cfg :set-team-organization {:team-id team-id + :organization-id organization-id + :is-default false})] + ;; Notify connected users + (notifications/notify-team-change cfg team "dashboard.team-belong-organization")) - ;; Notify connected users - (notifications/notify-team-change cfg team "dashboard.team-belong-org")) + ;; Delete pending invitations for users who are not members of the target organization + (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] + (when (and (not allows-anybody) (seq external-emails)) + (let [conn (::db/conn cfg) + emails-array (db/create-array conn "text" external-emails)] + (db/exec! conn [sql:delete-team-external-invitations team-id emails-array])))) - ;; Delete pending invitations for users who are not members of the target organization - (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] - (when (and (not allows-anybody) (seq external-emails)) - (let [conn (::db/conn cfg) - emails-array (db/create-array conn "text" external-emails)] - (db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))) + ;; Send warnings via email if the organization has sso + (neh/send-organization-setup-sso-emails-for-team! + cfg organization-id team-id organization-member-ids-before))) nil) -(def ^:private schema:check-org-members-params - [:map {:title "CheckOrgMembersParams"} +(def ^:private schema:check-organization-members-params + [:map {:title "CheckOrganizationMembersParams"} [:organization-id ::sm/uuid] [:emails [:vector ::sm/email]]]) -(sv/defmethod ::check-org-members +(sv/defmethod ::check-organization-members {::rpc/auth true ::doc/added "2.17" - ::sm/params schema:check-org-members-params + ::sm/params schema:check-organization-members-params ::sm/result [:map-of :string :boolean] ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}] @@ -527,23 +579,23 @@ (let [emails-array (db/create-array conn "text" emails) profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles) - org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))] + organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))] (into {} (map (fn [email] (let [pid (get email->id email)] - [email (boolean (and pid (contains? org-member-ids pid)))]))) + [email (boolean (and pid (contains? organization-member-ids pid)))]))) emails))) {})) -(def ^:private schema:all-org-members-in-team-params - [:map {:title "CheckOrgMembersInTeamParams"} +(def ^:private schema:all-organization-members-in-team-params + [:map {:title "CheckOrganizationMembersInTeamParams"} [:team-id ::sm/uuid] [:organization-id ::sm/uuid]]) -(sv/defmethod ::all-org-members-in-team +(sv/defmethod ::all-organization-members-in-team {::rpc/auth true ::doc/added "2.17" - ::sm/params schema:all-org-members-in-team-params + ::sm/params schema:all-organization-members-in-team-params ::sm/result ::sm/boolean} [cfg {:keys [::rpc/profile-id team-id organization-id]}] (if (contains? cf/flags :nitrate) @@ -552,22 +604,22 @@ (ex/raise :type :validation :code :insufficient-permissions)) (assert-membership cfg profile-id organization-id) - (let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id}) - org-member-ids (into #{} org-members) + (let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id}) + organization-member-ids (into #{} organization-members) team-members (db/query cfg :team-profile-rel {:team-id team-id}) team-member-ids (into #{} (map :profile-id team-members))] - (every? #(contains? team-member-ids %) org-member-ids))) + (every? #(contains? team-member-ids %) organization-member-ids))) false)) -(def ^:private schema:all-team-members-in-orgs-params - [:map {:title "CheckTeamMembersInOrgsParams"} +(def ^:private schema:all-team-members-in-organizations-params + [:map {:title "CheckTeamMembersInOrganizationsParams"} [:team-id ::sm/uuid] [:organization-ids [:vector ::sm/uuid]]]) -(sv/defmethod ::all-team-members-in-orgs +(sv/defmethod ::all-team-members-in-organizations {::rpc/auth true ::doc/added "2.17" - ::sm/params schema:all-team-members-in-orgs-params + ::sm/params schema:all-team-members-in-organizations-params ::sm/result [:map-of ::sm/uuid ::sm/boolean]} [cfg {:keys [::rpc/profile-id team-id organization-ids]}] (if (contains? cf/flags :nitrate) @@ -578,15 +630,15 @@ (let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) team-member-ids (into #{} (map :profile-id team-members))] - ;; Validate requester membership in all orgs before fetching members. + ;; Validate requester membership in all organizations before fetching members. (run! #(assert-membership cfg profile-id %) organization-ids) (into {} (map (fn [organization-id] - (let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id}) - org-member-ids (into #{} org-members)] + (let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id}) + organization-member-ids (into #{} organization-members)] [organization-id - (every? #(contains? org-member-ids %) team-member-ids)]))) + (every? #(contains? organization-member-ids %) team-member-ids)]))) organization-ids))) {})) @@ -621,13 +673,17 @@ (def ^:private schema:check-nitrate-sso - [:map {:title "AuthSsoParams"} - [:team-id ::sm/uuid] - [:url ::sm/uri]]) + [:and + [:map {:title "CheckNitrateSsoParams"} + [:team-id {:optional true} ::sm/uuid] + [:organization-id {:optional true} ::sm/uuid] + [:url ::sm/uri]] + [::sm/contains-any #{:team-id :organization-id}]]) (sv/defmethod ::check-nitrate-sso "Check if a user needs to login into the organization SSO. - Returns {:authorized true} when SSO is not active for the team. + Accepts either team-id (to look up the organization via the team) or organization-id directly. + Returns {:authorized true} when SSO is not active or the user cannot access the team. Returns {:authorized false :redirect-uri } when SSO is active; the client must redirect there. The OIDC provider itself handles re-authentication transparently if the user already has an active SSO session." @@ -635,24 +691,22 @@ ::doc/added "2.19" ::sm/params schema:check-nitrate-sso ::nitrate/sso false} - [cfg {:keys [team-id url] :as params}] + [cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}] (if (contains? cf/flags :nitrate) - (let [request (rph/get-request params) - {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)] - (if authorized - {:authorized true} - (if-let [issuer (or (:issuer sso) (:base-url sso))] - (let [oidc-provider (oidc/prepare-org-sso-provider cfg sso) - organization-id (:organization-id sso) - state-token (tokens/generate cfg {:iss "oidc" - :dest-url url - :team-id team-id - :organization-id organization-id - :issuer issuer - :exp (ct/in-future "4h")}) - redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)] + (if (and team-id + (not (teams/has-read-permissions? cfg profile-id team-id))) + ;; Let the destination RPC enforce its own permissions. Starting SSO before + ;; access is established sends unrelated users through the organization's IdP. + {:authorized true} + (let [request (rph/get-request params) + {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)] + (if authorized + {:authorized true} + (if (oidc/organization-sso-discovery-uri sso) {:authorized false - :redirect-uri redirect-uri}) - {:authorized false - :redirect-uri nil}))) + :redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso + :dest-url url + :organization-id organization-id)} + {:authorized false + :redirect-uri nil})))) {:authorized true})) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 9941638196..e56752b1b8 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -54,18 +54,23 @@ [:newsletter-news {:optional true} ::sm/boolean] [:onboarding-team-id {:optional true} ::sm/uuid] [:onboarding-viewed {:optional true} ::sm/boolean] + [:nitrate-onboarding-viewed {:optional true} ::sm/boolean] [:v2-info-shown {:optional true} ::sm/boolean] [:welcome-file-id {:optional true} [:maybe ::sm/boolean]] [:release-notes-viewed {:optional true} [::sm/text {:max 100}]] [:notifications {:optional true} schema:props-notifications] - [:workspace-visited {:optional true} ::sm/boolean]]) + [:workspace-visited {:optional true} ::sm/boolean] + [:custom-shortcuts {:optional true} + [:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]]) (def schema:profile [:map {:title "Profile"} [:id ::sm/uuid] [:fullname [::sm/word-string {:max 250}]] [:email ::sm/email] + [:theme {:optional true} :string] + [:is-admin {:optional true} ::sm/boolean] [:is-active {:optional true} ::sm/boolean] [:is-blocked {:optional true} ::sm/boolean] [:is-demo {:optional true} ::sm/boolean] @@ -491,10 +496,10 @@ {:id profile-id}) ;; Delete owned organizations on the fly (no grace period). - ;; Nitrate iterates the user's owned orgs and, per org, calls + ;; Nitrate iterates the user's owned organizations and, per organization, calls ;; Penpot back through two paths: ::notify-user-organizations-deletion - ;; (during delete-owned-orgs) and ::notify-organization-deletion. - ;; Both preserve org teams unchanged and only prefix or delete + ;; (during delete-owned-organizations) and ::notify-organization-deletion. + ;; Both preserve organization teams unchanged and only prefix or delete ;; imported "Your Penpot" teams according to whether they still have files. ;; Let Nitrate clean up the data associated with the deleted Penpot user: ;; owned organizations, remaining memberships, and subscription cancellation. @@ -558,7 +563,7 @@ ::sm/result schema:get-owned-organizations-summary-result} [cfg {:keys [::rpc/profile-id]}] (if (contains? cf/flags :nitrate) - (or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) []) + (or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) []) [])) ;; --- HELPERS diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 0fdb9fb88f..12da9bb7c5 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -10,6 +10,7 @@ [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.uuid :as uuid] [app.db :as db] [app.db.sql :as-alias sql] [app.features.logical-deletion :as ldel] @@ -56,11 +57,16 @@ :can-edit (or is-owner is-admin can-edit) :can-read true}))) +(defn- get-read-permissions + [cfg profile-id project-id] + (or (get-permissions cfg profile-id project-id) + (perms/get-organization-owner-permissions cfg profile-id :project-id project-id))) + (def has-edit-permissions? (perms/make-edition-predicate-fn get-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn get-permissions)) + (perms/make-read-predicate-fn get-read-permissions)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) @@ -159,10 +165,10 @@ {::doc/added "1.18" ::rpc/id-type :project ::sm/params schema:get-project} - [{:keys [::db/pool]} {:keys [::rpc/profile-id id]}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] (dm/with-open [conn (db/open pool)] (let [project (db/get-by-id conn :project id)] - (check-read-permissions! conn profile-id id) + (check-read-permissions! cfg profile-id id) project))) @@ -179,7 +185,8 @@ timestamp (::rpc/request-at params)] (teams/create-project-role conn profile-id (:id project) :owner) (db/insert! conn :team-project-profile-rel - {:project-id (:id project) + {:id (uuid/next) + :project-id (:id project) :profile-id profile-id :created-at timestamp :modified-at timestamp @@ -230,8 +237,8 @@ ::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id) ::webhooks/event? true ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}] - (check-read-permissions! conn profile-id id) + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}] + (check-read-permissions! cfg profile-id id) (db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned]) nil) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 50458c27f8..817f8f7848 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -60,6 +60,11 @@ :can-edit (or is-owner is-admin can-edit) :can-read true}))) +(defn get-read-permissions + [cfg profile-id team-id] + (or (get-permissions cfg profile-id team-id) + (perms/get-organization-owner-permissions cfg profile-id :team-id team-id))) + (def has-admin-permissions? (perms/make-admin-predicate-fn get-permissions)) @@ -67,7 +72,7 @@ (perms/make-edition-predicate-fn get-permissions)) (def has-read-permissions? - (perms/make-read-predicate-fn get-permissions)) + (perms/make-read-predicate-fn get-read-permissions)) (def check-admin-permissions! (perms/make-check-fn has-admin-permissions?)) @@ -180,7 +185,6 @@ sql (if (contains? cf/flags :subscriptions) sql:get-teams-with-permissions-and-subscription sql:get-teams-with-permissions)] - (->> (db/exec! conn [sql (:default-team-id profile) profile-id]) (into [] xform:process-teams)))) @@ -194,7 +198,7 @@ (dm/with-open [conn (db/open pool)] (cond->> (get-teams conn profile-id) (contains? cf/flags :nitrate) - (map #(nitrate/add-org-info-to-team cfg % params)) + (map #(nitrate/add-organization-info-to-team cfg % params)) (contains? cf/flags :nitrate) (remove #(get-in % [:organization :expired-license]))))) @@ -238,19 +242,34 @@ {::doc/added "1.17" ::rpc/id-type :team ::sm/params schema:get-team} - [{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}] - (get-team pool :profile-id profile-id :team-id id :file-id file-id)) + [cfg {:keys [::rpc/profile-id id file-id] :as params}] + (let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)] + (if (contains? cf/flags :nitrate) + (nitrate/add-organization-info-to-team cfg team params) + team))) + +(defn- get-organization-owner-viewer-team + "When `profile-id` is a non-member owner of the organization that owns + the requested team, returns the team shaped with viewer permissions; + otherwise nil. `cfg` must carry the nitrate client." + [cfg profile-id default-team-id params] + (when-let [team-id (perms/resolve-team-id cfg params)] + (when (nitrate/organization-owner-of-team? cfg profile-id team-id) + (when-let [team (db/get* cfg :team {:id team-id})] + (when-not (db/is-row-deleted? team) + (-> team + (decode-row) + (merge perms/viewer-role-flags) + (assoc :is-default (= team-id default-team-id)) + (process-permissions))))))) (defn get-team - [conn & {:keys [profile-id team-id project-id file-id] :as params}] + [cfg & {:keys [profile-id team-id project-id file-id] :as params}] (assert (uuid? profile-id) "profile-id is mandatory") - (assert (or (db/connection? conn) - (db/pool? conn)) - "connection or pool is mandatory") (let [{:keys [default-team-id] :as profile} - (profile/get-profile conn profile-id) + (profile/get-profile cfg profile-id) sql (if (contains? cf/flags :subscriptions) @@ -262,14 +281,14 @@ (some? team-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT * FROM teams WHERE id=?")] - (db/exec-one! conn [sql default-team-id profile-id team-id])) + (db/exec-one! cfg [sql default-team-id profile-id team-id])) (some? project-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT t.* FROM teams AS t " " JOIN project AS p ON (p.team_id = t.id) " " WHERE p.id=?")] - (db/exec-one! conn [sql default-team-id profile-id project-id])) + (db/exec-one! cfg [sql default-team-id profile-id project-id])) (some? file-id) (let [sql (str "WITH teams AS (" sql ") " @@ -277,17 +296,18 @@ " JOIN project AS p ON (p.team_id = t.id) " " JOIN file AS f ON (f.project_id = p.id) " " WHERE f.id=?")] - (db/exec-one! conn [sql default-team-id profile-id file-id])) + (db/exec-one! cfg [sql default-team-id profile-id file-id])) :else (throw (IllegalArgumentException. "invalid arguments")))] - (when-not result - (ex/raise :type :not-found - :code :team-does-not-exist)) - (-> result - (decode-row) - (process-permissions)))) + (if result + (-> result + (decode-row) + (process-permissions)) + (or (get-organization-owner-viewer-team cfg profile-id default-team-id params) + (ex/raise :type :not-found + :code :team-does-not-exist))))) ;; --- Query: Team Members @@ -316,7 +336,7 @@ ::sm/params schema:get-team-memebrs} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-members conn team-id))) ;; --- Query: Team Users @@ -342,10 +362,10 @@ (dm/with-open [conn (db/open pool)] (if team-id (do - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-users conn team-id)) (let [{team-id :id} (get-team-for-file conn file-id)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-users conn team-id))))) ;; This is a similar query to team members but can contain more data @@ -432,7 +452,7 @@ ::sm/params schema:get-team-stats} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-stats conn team-id))) (def sql:team-stats @@ -468,7 +488,7 @@ ::sm/params schema:get-team-invitations} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (get-team-invitations conn team-id))) @@ -515,17 +535,17 @@ (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile ::quotes/profile-id profile-id}) - ;; When creating inside an org, verify the user has permission to do so. - ;; Fail closed: if org permissions cannot be fetched, deny the operation. + ;; When creating inside an organization, verify the user has permission to do so. + ;; Fail closed: if organization permissions cannot be fetched, deny the operation. (when (and organization-id (contains? cf/flags :nitrate)) - (let [org-perms (nitrate/call cfg :get-org-permissions - {:organization-id organization-id})] - (if (nil? org-perms) + (let [organization-perms (nitrate/call cfg :get-organization-permissions + {:organization-id organization-id})] + (if (nil? organization-perms) (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions") (when-not (nitrate-perms/allowed? :create-team - {:org-perms org-perms + {:organization-perms organization-perms :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed @@ -543,7 +563,7 @@ {::audit/props {:id (:id team)}}))) -(defn create-default-org-team +(defn create-default-organization-team [cfg profile-id organization-id] (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile ::quotes/profile-id profile-id}) @@ -559,11 +579,11 @@ team (create-team cfg params)] (select-keys team [:id]))) -(defn initialize-user-in-nitrate-org +(defn initialize-user-in-nitrate-organization "If needed, create a default team for the user on the organization, - and notify Nitrate that an user has been added to an org." + and notify Nitrate that a user has been added to an organization." ([cfg profile-id organization-id] - (initialize-user-in-nitrate-org cfg profile-id organization-id nil)) + (initialize-user-in-nitrate-organization cfg profile-id organization-id nil)) ([cfg profile-id organization-id email] (assert (db/connection-map? cfg) "expected cfg with valid connection") @@ -572,24 +592,24 @@ cfg (fn [{:keys [::db/conn] :as tx-cfg}] - (let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id - :organization-id organization-id})] + (let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id + :organization-id organization-id})] ;; Only when the user doesn't belong to the organization yet (when (and (some? (:organization-id membership)) ;; the organization exists - (not (:is-member membership))) ;; the user is not a member of the org yet + (not (:is-member membership))) ;; the user is not a member of the organization yet (let [organization-id organization-id - default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id) + default-team (create-default-organization-team (assoc tx-cfg ::db/conn conn) profile-id organization-id) default-team-id (:id default-team) - result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id - :team-id default-team-id - :organization-id organization-id} - (some? email) (assoc :email email)))] + result (nitrate/call tx-cfg :add-profile-to-organization (cond-> {:profile-id profile-id + :team-id default-team-id + :organization-id organization-id} + (some? email) (assoc :email email)))] (when (not (:is-member result)) (ex/raise :type :internal - :code :failed-add-profile-org-nitrate + :code :failed-add-profile-organization-nitrate :context {:profile-id profile-id :organization-id organization-id :default-team-id default-team-id})) @@ -602,13 +622,13 @@ (assert (db/connection-map? cfg) "expected cfg with valid connection") (when (contains? cf/flags :nitrate) - (let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})] + (let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})] ;; Only when the team belong to an organization and the user is not a member (when (and (some? (:organization-id membership)) ;; the team do belong to an organization - (not (:is-member membership))) ;; the user is not a member of the org yet - (initialize-user-in-nitrate-org cfg profile-id (:organization-id membership))))) - (db/insert! conn :team-profile-rel params options))) + (not (:is-member membership))) ;; the user is not a member of the organization yet + (initialize-user-in-nitrate-organization cfg profile-id (:organization-id membership))))) + (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options))) (defn create-team "This is a complete team creation process, it creates the team @@ -679,7 +699,8 @@ (defn create-project-role [conn profile-id project-id role] (let [params {:project-id project-id - :profile-id profile-id}] + :profile-id profile-id + :id (uuid/next)}] (->> (perms/assign-role-flags params role) (db/insert! conn :project-profile-rel)))) @@ -783,16 +804,16 @@ (let [team (get-team conn :profile-id profile-id :team-id team-id) team (if (contains? cf/flags :nitrate) - (nitrate/add-org-info-to-team cfg team params) + (nitrate/add-organization-info-to-team cfg team params) team) perms (get team :permissions) - org (:organization team) - in-org? (and (contains? cf/flags :nitrate) org) + organization (:organization team) + in-organization? (and (contains? cf/flags :nitrate) organization) can-delete? - (if in-org? + (if in-organization? (nitrate-perms/allowed? :delete-team - {:org-perms {:owner-id (dm/get-in team [:organization :owner-id]) - :permissions (dm/get-in team [:organization :permissions])} + {:organization-perms {:owner-id (dm/get-in team [:organization :owner-id]) + :permissions (dm/get-in team [:organization :permissions])} :profile-id profile-id :team-perms perms}) (boolean (:is-owner perms)))] @@ -802,8 +823,8 @@ :code :only-owner-can-delete-team)) ;; Protect the user's personal default team from deletion. - ;; Org-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files. - (when (and (:is-default team) (not in-org?)) + ;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files. + (when (and (:is-default team) (not in-organization?)) (ex/raise :type :validation :code :non-deletable-team :hint "impossible to delete default team")) @@ -930,12 +951,23 @@ (db/delete! conn :team-profile-rel {:profile-id member-id :team-id team-id}) - (mbus/pub! msgbus - :topic member-id - :message {:type :team-membership-change - :change :removed - :team-id team-id - :team-name (:name team)}) + + ;; A removed member that owns the organization of this team keeps + ;; read-only access to it, so instead of kicking them out we degrade + ;; their session to viewer, same as any other role change. + (if (nitrate/organization-owner-of-team? cfg member-id team-id) + (mbus/pub! msgbus + :topic member-id + :message {:type :team-role-change + :topic member-id + :team-id team-id + :role :viewer}) + (mbus/pub! msgbus + :topic member-id + :message {:type :team-membership-change + :change :removed + :team-id team-id + :team-name (:name team)})) nil)) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 50d3ba691b..679ea1dd10 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -44,7 +44,7 @@ update set role = ?, valid_until = ?, updated_at = now() returning *") -(def sql:upsert-org-invitation +(def sql:upsert-organization-invitation "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until) values (?, null, ?, ?, ?, ?, ?) on conflict(org_id, email_to) where team_id is null do @@ -86,15 +86,17 @@ [:role types.team/schema:role] [:email ::sm/email]]) -(def ^:private schema:create-org-invitation - [:map {:title "params:create-org-invitation"} +(def ^:private schema:create-organization-invitation + [:map {:title "params:create-organization-invitation"} [::rpc/profile-id ::sm/uuid] [:organization [:map [:id ::sm/uuid] [:name :string] [:initials [:maybe :string]] - [:logo ::sm/uri]]] + [:logo ::sm/uri] + [:avatar-bg-url [:maybe ::sm/uri]] + [:sso-active [:maybe ::sm/boolean]]]] [:profile [:map [:id ::sm/uuid] @@ -105,8 +107,8 @@ (def ^:private check-create-invitation-params (sm/check-fn schema:create-invitation)) -(def ^:private check-create-org-invitation-params - (sm/check-fn schema:create-org-invitation)) +(def ^:private check-create-organization-invitation-params + (sm/check-fn schema:create-organization-invitation)) (defn- allow-invitation-emails? [member] @@ -114,23 +116,24 @@ (not= :none (:email-invites notifications)))) (defn- assert-email-can-be-invited - "Asserts that member is an org member when the org + "Asserts that member is an organization member when the organization restricts who can be added to teams." - [member org-member-ids] - (when (some? org-member-ids) - (let [is-member? (and (some? member) (contains? org-member-ids (:id member)))] + [member organization-member-ids] + (when (some? organization-member-ids) + (let [is-member? (and (some? member) (contains? organization-member-ids (:id member)))] (when-not is-member? (ex/raise :type :validation - :code :email-not-org-member + :code :email-not-organization-member :hint "The invited email is not a member of the organization"))))) (defn- create-invitation - [{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}] + [{:keys [::db/conn] :as cfg} + {:keys [team organization profile role email organization-member-ids all-organization-member-ids] :as params}] (assert (db/connection-map? cfg) "expected cfg with valid connection") (if organization - (assert (check-create-org-invitation-params params)) + (assert (check-create-organization-invitation-params params)) (assert (check-create-invitation-params params))) (let [email (profile/clean-email email) @@ -142,11 +145,11 @@ :code :email-domain-is-not-allowed :hint "email domain is in the blacklist")) - ;; When nitrate is active and the team belongs to an org, check that - ;; the email is already an org member unless the org explicitly allows adding anybody. + ;; When nitrate is active and the team belongs to an organization, check that + ;; the email is already an organization member unless the organization explicitly allows adding anybody. (when (and (contains? cf/flags :nitrate) (:organization team)) - (assert-email-can-be-invited member org-member-ids)) + (assert-email-can-be-invited member organization-member-ids)) ;; When we have email verification disabled and invitation user is @@ -162,9 +165,9 @@ (get types.team/permissions-for-role role))] (if organization - ;; Insert the invited member to the org + ;; Insert the invited member to the organization (when (contains? cf/flags :nitrate) - (teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization) email)) + (teams/initialize-user-in-nitrate-organization cfg (:id member) (:id organization) email)) ;; Insert the invited member to the team (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})) @@ -187,7 +190,7 @@ (ct/in-future "876000h") ;; Organization invitations doesn't expire (ct/in-future "168h")) ;; 7 days invitation (db/exec-one! conn (if organization - [sql:upsert-org-invitation id + [sql:upsert-organization-invitation id (:id organization) (str/lower email) (:id profile) @@ -201,6 +204,7 @@ (name role) expire])) updated? (not= id (:id invitation)) profile-id (:id profile) + team-organization-id (get-in team [:organization :id]) tprops {:profile-id profile-id :invitation-id (:id invitation) :valid-until expire @@ -210,18 +214,37 @@ :member-email (:email-to invitation) :member-id (:id member) :role role} + audit-props + (cond-> {:invitation-id (:id invitation) + :valid-until expire + :team-id (:id team) + :organization-id (:id organization) + :organization-name (:name organization) + :member-email (:email-to invitation) + :member-id (:id member) + :role role} + organization + (assoc :user-who-send-invitation (str profile-id)) + + (not organization) + (assoc :team-belongs-to-organization (boolean team-organization-id) + :adds-invitee-to-organization (boolean team-organization-id) + :invitee-already-organization-member + (boolean + (and team-organization-id + member + (contains? all-organization-member-ids (:id member)))))) itoken (create-invitation-token cfg tprops) ptoken (create-profile-identity-token cfg profile-id)] (when (contains? cf/flags :log-invitation-tokens) (l/info :hint "invitation token" :token itoken)) - (let [props (-> (dissoc tprops :profile-id) - (audit/clean-props)) + (let [props (audit/clean-props audit-props) evname (cond - (and updated? organization) "update-org-invitation" + (and updated? organization) "update-organization-invitation" updated? "update-team-invitation" - organization "create-org-invitation" + organization "create-organization-invitation" :else "create-team-invitation") event (-> (audit/event-from-rpc-params params) (assoc :name evname) @@ -232,7 +255,7 @@ (if organization (when (contains? cf/flags :nitrate) (eml/send! {::eml/conn conn - ::eml/factory eml/invite-to-org + ::eml/factory eml/invite-to-organization :public-uri (cf/get :public-uri) :to email :invited-by (:fullname profile) @@ -246,13 +269,13 @@ :to email :invited-by (:fullname profile) :team (:name team) - :organization (dm/get-in team [:organization :name]) + :organization (:organization team) :token itoken :extra-data ptoken}))) itoken))))) -(defn create-org-invitation +(defn create-organization-invitation [cfg {:keys [::rpc/profile-id] :as params}] (let [profile (db/get-by-id cfg :profile profile-id)] (create-invitation cfg @@ -322,16 +345,21 @@ - emails (set) + role (single role for all emails) - invitations (vector of {:email :role} maps)" [{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}] - (let [;; Enrich team with org info once for all invitations when nitrate is active + (let [;; Enrich team with organization info once for all invitations when nitrate is active team (if (contains? cf/flags :nitrate) - (nitrate/add-org-info-to-team cfg team {}) + (nitrate/add-organization-info-to-team cfg team {}) team) - org (:organization team) - org-id (:id org) - restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org}))) - org-member-ids (when restricted? - (into #{} (nitrate/call cfg :get-org-members {:organization-id org-id}))) - params (assoc params :team team :org-member-ids org-member-ids) + organization (:organization team) + organization-id (:id organization) + restricted? (and organization-id (not (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization}))) + all-organization-member-ids + (when organization-id + (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))) + organization-member-ids (when restricted? all-organization-member-ids) + params (assoc params + :team team + :organization-member-ids organization-member-ids + :all-organization-member-ids all-organization-member-ids) ;; Normalize input to a consistent format: [{:email :role}] invitation-data (cond @@ -539,7 +567,7 @@ ::doc/module :teams ::sm/params schema:get-team-invitation-token} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (teams/check-read-permissions! pool profile-id team-id) + (teams/check-read-permissions! cfg profile-id team-id) (let [email (profile/clean-email email) invit (-> (db/get pool :team-invitation {:team-id team-id diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index e2433fec95..99fabd2e8b 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -85,13 +85,33 @@ ::audit/props (audit/profile->props profile) ::audit/profile-id (:id profile)})))) +(defn- with-nitrate-licence + [profile cfg] + (if (contains? cf/flags :nitrate) + (nitrate/add-nitrate-licence-to-profile cfg profile) + profile)) + (defmethod process-token :auth [{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}] - (let [profile (profile/get-profile conn profile-id)] + (let [profile (-> (profile/get-profile conn profile-id) + (profile/strip-private-attrs) + (update :props profile/filter-props) + (with-nitrate-licence cfg))] (assoc claims :profile profile))) ;; --- Team Invitation +(def ^:private sql:get-organization-invitation + "SELECT * + FROM team_invitation + WHERE email_to = ? + AND org_id = ?") + +(def ^:private sql:delete-organization-invitation + "DELETE FROM team_invitation + WHERE email_to = ? + AND org_id = ?") + (defn- accept-invitation [{:keys [::db/conn] :as cfg} {:keys [team-id organization-id role member-email] :as claims} invitation member] @@ -115,9 +135,9 @@ (get types.team/permissions-for-role role)) accepted-team-id (if organization-id - ;; Insert the invited member to the org + ;; Insert the invited member to the organization (when (contains? cf/flags :nitrate) - (teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email)) + (teams/initialize-user-in-nitrate-organization cfg id-member organization-id member-email)) ;; Insert the invited member to the team (do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) team-id))] @@ -136,10 +156,11 @@ {:id id-member})) ;; Delete the invitation - (db/delete! conn :team-invitation - (cond-> {:email-to member-email} - team-id (assoc :team-id team-id) - organization-id (assoc :org-id organization-id))) + (if organization-id + (db/exec-one! conn [sql:delete-organization-invitation member-email organization-id]) + (db/delete! conn :team-invitation + {:email-to member-email + :team-id team-id})) ;; Delete any request (only applicable for team invitations) (when team-id @@ -175,22 +196,17 @@ :code :invalid-invitation-token :hint "invitation token contains unexpected data")) - (let [invitation (db/get* conn :team-invitation - (cond-> {:email-to member-email} - team-id (assoc :team-id team-id) - organization-id (assoc :org-id organization-id))) + (let [invitation (if organization-id + (db/exec-one! conn [sql:get-organization-invitation member-email organization-id]) + (db/get* conn :team-invitation + {:email-to member-email + :team-id team-id})) profile (db/get* conn :profile {:id profile-id} {:columns [:id :email :default-team-id]}) registration-disabled? (not (contains? cf/flags :registration)) - org-invitation? (and (contains? cf/flags :nitrate) organization-id) - ;; Membership only makes sense for a logged-in profile; querying it for - ;; an anonymous recipient would call nitrate with a nil profile-id and - ;; mask the clean :invalid-token response with a generic error. - membership (when (and profile org-invitation?) - (nitrate/call cfg :get-org-membership {:profile-id profile-id - :organization-id organization-id}))] + organization-invitation? (and (contains? cf/flags :nitrate) organization-id)] (if profile (do @@ -201,62 +217,130 @@ :reason :email-mismatch :hint "logged-in user does not matches the invitation")) - (when (:is-member membership) - (ex/raise :type :validation - :code :already-an-org-member - :team-id (:default-team-id membership) - :hint "the user is already a member of the organization")) - - (when (and org-invitation? (not (:organization-id membership))) - (ex/raise :type :validation - :code :org-not-found - :team-id (:default-team-id profile) - :hint "the organization doesn't exist")) - (when (nil? invitation) (ex/raise :type :validation - :code :invalid-token - :hint "no invitation associated with the token")) + :code (if organization-id :canceled-invitation :invalid-token) + :hint (if organization-id + "the invitation has been canceled" + "no invitation associated with the token"))) + ;; Membership only makes sense for a logged-in profile with an + ;; existing invitation; querying it when the invitation is absent + ;; would call nitrate needlessly and could mask the clean + ;; :canceled-invitation/:invalid-token response with a generic error. + (let [membership + (when (contains? cf/flags :nitrate) + (cond + organization-id + (nitrate/call cfg :get-organization-membership {:profile-id profile-id + :organization-id organization-id}) - ;; if we have logged-in user and it matches the invitation we proceed - ;; with accepting the invitation and joining the current profile to the - ;; invited team. - (let [props {:team-id (:team-id claims) - :role (:role claims) - :invitation-id (:id invitation)}] + team-id + (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id + :team-id team-id}))) - (audit/submit cfg - (-> (audit/event-from-rpc-params params) - (assoc :name "accept-team-invitation") - (assoc :props props))) + organization-id-on-add + (when (and (:organization-id membership) + (not (:is-member membership))) + (:organization-id membership)) - ;; NOTE: Backward compatibility; old invitations can - ;; have the `created-by` to be nil; so in this case we - ;; don't submit this event to the audit-log - (when-let [created-by (:created-by invitation)] - (audit/submit cfg - (-> (audit/event-from-rpc-params params) - (assoc :profile-id created-by) - (assoc :name "accept-team-invitation-from") - (assoc :props (assoc props - :profile-id (:id profile) - :email (:email profile)))))) + organization-add-source + (when organization-id-on-add + (if organization-id + "direct-organization-invitation" + "team-invitation")) - (let [accepted-team-id (accept-invitation cfg claims invitation profile)] - (cond-> (assoc claims :state :created) - ;; when the invitation is to an org, instead of a team, add the - ;; accepted-team-id as :org-team-id - (:organization-id claims) - (assoc :org-team-id accepted-team-id))))) + organization-event-origin + (when organization-id-on-add + (if organization-id + "organization-invitation-acceptance" + "team-invitation-acceptance")) + + organization-member-count-before + (when organization-id-on-add + (count + (nitrate/call cfg :get-organization-members + {:organization-id organization-id-on-add})))] + + (when (:is-member membership) + (when organization-invitation? + (ex/raise :type :validation + :code :already-an-organization-member + :team-id (:default-team-id membership) + :hint "the user is already a member of the organization"))) + + (when (and organization-invitation? (not (:organization-id membership))) + (ex/raise :type :validation + :code :organization-not-found + :team-id (:default-team-id profile) + :hint "the organization doesn't exist")) + + ;; if we have logged-in user and it matches the invitation we proceed + ;; with accepting the invitation and joining the current profile to the + ;; invited team. + (let [props {:team-id (:team-id claims) + :role (:role claims) + :invitation-id (:id invitation)}] + + (when team-id + (audit/submit cfg + (-> (audit/event-from-rpc-params params) + (assoc :name "accept-team-invitation") + (assoc :props props))) + + ;; NOTE: Backward compatibility; old invitations can + ;; have the `created-by` to be nil; so in this case we + ;; don't submit this event to the audit-log + (when-let [created-by (:created-by invitation)] + (audit/submit cfg + (-> (audit/event-from-rpc-params params) + (assoc :profile-id created-by) + (assoc :name "accept-team-invitation-from") + (assoc :props (assoc props + :profile-id (:id profile) + :email (:email profile))))))) + + (let [accepted-team-id (accept-invitation cfg claims invitation profile)] + (when organization-id-on-add + (audit/submit + cfg + (-> (audit/event-from-rpc-params params) + (assoc :name "accept-organization-invitation") + (assoc :props + (-> props + (assoc :organization-id organization-id-on-add) + (audit/clean-props)))))) + + (cond-> (assoc claims :state :created) + ;; when the invitation is to an organization, instead of a team, add the + ;; accepted-team-id as :organization-team-id + (:organization-id claims) + (assoc :organization-team-id accepted-team-id) + + organization-id-on-add + (assoc :organization-invitation-audit + {:origin organization-event-origin + :props + (-> props + (assoc :organization-id organization-id-on-add + :organization-member-add-source organization-add-source + :belongs-to-team-on-add (boolean team-id) + :organization-member-count-before + organization-member-count-before) + (audit/clean-props))})))))) (do - ;; If the user is not logged-in and the token is invalid we throw the error - ;; Taiga issue #14182 + ;; If the user is not logged-in and the invitation has been canceled + ;; we return a specific error code so the frontend can redirect to + ;; login with an appropriate message instead of showing the error page. + ;; This only applies to organization invitations; team invitations keep the + ;; existing :invalid-token behavior. (when (nil? invitation) (ex/raise :type :validation - :code :invalid-token - :hint "no invitation associated with the token")) + :code (if organization-id :canceled-invitation :invalid-token) + :hint (if organization-id + "the invitation has been canceled" + "no invitation associated with the token"))) ;; If we have not logged-in user, and invitation comes with member-id we ;; redirect user to login, if no member-id is present and in the invitation @@ -272,4 +356,3 @@ [_ _ _] (ex/raise :type :validation :code :invalid-token)) - diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index 570751e48b..9333800af6 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -16,6 +16,7 @@ [app.rpc.commands.teams :as teams] [app.rpc.cond :as-alias cond] [app.rpc.doc :as-alias doc] + [app.rpc.permissions :as perms] [app.util.services :as sv] [cuerdas.core :as str])) @@ -125,8 +126,8 @@ ::sm/params schema:get-view-only-bundle} [system {:keys [::rpc/profile-id file-id share-id] :as params}] (db/run! system - (fn [{:keys [::db/conn] :as system}] - (let [perms (bfc/get-file-permissions conn profile-id file-id share-id) + (fn [system] + (let [perms (perms/get-file-read-permissions system profile-id file-id share-id) params (-> params (assoc ::perms perms) (assoc :profile-id profile-id))] @@ -139,5 +140,3 @@ :hint "object not found")) (get-view-only-bundle system params))))) - - diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 702a9bdd14..33341bb34e 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -172,6 +172,6 @@ ::sm/params schema:get-webhooks} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] - (check-read-permissions! conn profile-id team-id) + (check-read-permissions! cfg profile-id team-id) (->> (db/exec! conn [sql:get-webhooks team-id]) (mapv decode-row)))) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 429205c1c7..adde1bde40 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -8,27 +8,34 @@ "Internal Nitrate HTTP RPC API. Provides authenticated access to organization management and token validation endpoints." (:require + [app.auth :as aauth] + [app.auth.oidc :as oidc] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar]] + [app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] + [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.email :as eml] + [app.http :as-alias http] [app.http.session :as session] [app.loggers.audit :as audit] [app.media :as media] [app.nitrate :as nitrate] [app.rpc :as rpc] + [app.rpc.commands.auth :as auth] [app.rpc.commands.files :as files] [app.rpc.commands.nitrate :as cnit] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] [app.rpc.commands.teams-invitations :as ti] [app.rpc.doc :as doc] + [app.rpc.nitrate.emails-helper :as neh] + [app.rpc.nitrate.organization-helper :as noh] [app.rpc.notifications :as notifications] [app.storage :as sto] [app.util.services :as sv] @@ -40,6 +47,7 @@ {:id (:id profile) :name (:fullname profile) :email (:email profile) + :created-at (:created-at profile) :photo-url (files/resolve-public-uri (get profile :photo-id))}) ;; ---- API: authenticate @@ -48,7 +56,8 @@ "Authenticate the current user" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:profile} + ::sm/result schema:profile + ::nitrate/sso false} [cfg {:keys [::rpc/profile-id] :as params}] (let [profile (profile/get-profile cfg profile-id)] (-> (profile-to-map profile) @@ -99,30 +108,32 @@ "List teams for which current user is owner" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:get-teams-result} + ::sm/result schema:get-teams-result + ::nitrate/sso false} [cfg {:keys [::rpc/profile-id]}] (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (->> (db/exec! cfg [sql:get-teams current-user-id]) (map #(select-keys % [:id :name]))))) -;; ---- API: upload-org-logo +;; ---- API: upload-organization-logo -(def ^:private schema:upload-org-logo +(def ^:private schema:upload-organization-logo [:map [:content media/schema:upload] [:organization-id ::sm/uuid] [:previous-id {:optional true} ::sm/uuid]]) -(def ^:private schema:upload-org-logo-result +(def ^:private schema:upload-organization-logo-result [:map [:id ::sm/uuid]]) -(sv/defmethod ::upload-org-logo +(sv/defmethod ::upload-organization-logo "Store an organization logo in penpot storage and return its ID. Accepts an optional previous-id to mark the old logo for garbage collection when replacing an existing one." {::doc/added "2.17" - ::sm/params schema:upload-org-logo - ::sm/result schema:upload-org-logo-result} + ::sm/params schema:upload-organization-logo + ::sm/result schema:upload-organization-logo-result + ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] (when previous-id (sto/touch-object! storage previous-id)) @@ -156,12 +167,12 @@ [:role ::sm/text]]) (sv/defmethod ::notify-user-added-to-organization - "Notify to Penpot that an user has joined an org from nitrate" + "Notify to Penpot that an user has joined an organization from nitrate" {::doc/added "2.14" ::sm/params schema:notify-user-added-to-organization ::rpc/auth false} [cfg {:keys [profile-id organization-id]}] - (db/tx-run! cfg teams/create-default-org-team profile-id organization-id)) + (db/tx-run! cfg teams/create-default-organization-team profile-id organization-id)) ;; ---- API: get-managed-profiles @@ -190,7 +201,8 @@ "List profiles that belong to teams for which current user is owner" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:managed-profile-result} + ::sm/result schema:managed-profile-result + ::nitrate/sso false} [cfg {:keys [::rpc/profile-id]}] (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id]))) @@ -229,7 +241,8 @@ "Get summary information for a list of teams" {::doc/added "2.15" ::sm/params schema:get-teams-summary-params - ::sm/result schema:get-teams-summary-result} + ::sm/result schema:get-teams-summary-result + ::nitrate/sso false} [cfg {:keys [ids]}] (let [;; Handle one or multiple params ids (cond @@ -301,7 +314,7 @@ RETURNING id, deleted_at;") nil) (defn manage-deleted-organization-teams - "For a deleted organization, preserve org teams unchanged and only prefix or + "For a deleted organization, preserve organization teams unchanged and only prefix or delete member Your Penpot teams depending on whether they still contain files." [cfg {:keys [organization-id organization-name teams]}] (let [all-team-ids (->> teams @@ -316,7 +329,7 @@ RETURNING id, deleted_at;") distinct (into []))] (when (seq all-team-ids) - (let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")] + (let [organization-prefix (str "[" (d/sanitize-string organization-name) "] ")] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] @@ -330,11 +343,11 @@ RETURNING id, deleted_at;") teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into [])) teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))] - ;; Org teams move to the fallback org unchanged. Only imported - ;; Your Penpot teams keep the org prefix when they still have files. + ;; Organization teams move to the fallback organization unchanged. Only imported + ;; Your Penpot teams keep the organization prefix when they still have files. (when (seq teams-to-prefix) (db/exec! conn [sql:prefix-teams-name-and-unset-default - org-prefix + organization-prefix (db/create-array conn "uuid" teams-to-prefix)])) ;; Empty imported Your Penpot teams disappear entirely. @@ -345,16 +358,16 @@ RETURNING id, deleted_at;") (sv/defmethod ::notify-organization-deletion - "For a deleted organization, preserve org teams and only prefix or delete + "For a deleted organization, preserve organization teams and only prefix or delete imported Your Penpot teams before notifying connected users." {::doc/added "2.15" ::sm/params schema:notify-organization-deletion ::rpc/auth false} [cfg {:keys [organization-id]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - teams (:teams org-summary)] - (manage-deleted-organization-teams cfg {:organization-name (:name org-summary) - :organization-id (:id org-summary) + (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id}) + teams (:teams organization-summary)] + (manage-deleted-organization-teams cfg {:organization-name (:name organization-summary) + :organization-id (:id organization-summary) :teams teams}) nil)) @@ -365,17 +378,18 @@ RETURNING id, deleted_at;") [:profile-id ::sm/uuid]]) (sv/defmethod ::notify-user-organizations-deletion - "For a given user, find all owned organizations and apply the deleted-org + "For a given user, find all owned organizations and apply the deleted-organization transfer rules to their imported Your Penpot teams." {::doc/added "2.18" - ::sm/params schema:notify-user-organizations-deletion} + ::sm/params schema:notify-user-organizations-deletion + ::nitrate/sso false} [cfg {:keys [profile-id]}] - (let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})] - (doseq [org owned-orgs] - (let [organization-name (:name org) - teams (:teams org)] + (let [owned-organizations (nitrate/call cfg :get-owned-organizations {:profile-id profile-id})] + (doseq [organization owned-organizations] + (let [organization-name (:name organization) + teams (:teams organization)] (manage-deleted-organization-teams cfg {:organization-name organization-name - :organization-id (:id org) + :organization-id (:id organization) :teams teams})))) nil) @@ -394,7 +408,8 @@ RETURNING id, deleted_at;") "Get profile by email" {::doc/added "2.15" ::sm/params [:map [:email ::sm/email]] - ::sm/result schema:profile} + ::sm/result schema:profile + ::nitrate/sso false} [cfg {:keys [email]}] (let [profile (db/exec-one! cfg [sql:get-profile-by-email email])] (when-not profile @@ -417,7 +432,8 @@ RETURNING id, deleted_at;") "Get profile by email" {::doc/added "2.15" ::sm/params [:map [:id ::sm/uuid]] - ::sm/result schema:profile} + ::sm/result schema:profile + ::nitrate/sso false} [cfg {:keys [id]}] (let [profile (db/exec-one! cfg [sql:get-profile-by-id id])] (when-not profile @@ -428,9 +444,9 @@ RETURNING id, deleted_at;") (profile-to-map profile))) -;; ---- API: get-org-member-team-counts +;; ---- API: get-organization-member-team-counts -(def ^:private sql:get-org-member-team-counts +(def ^:private sql:get-organization-member-team-counts "SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count FROM team_profile_rel AS tpr JOIN team AS t ON t.id = tpr.team_id @@ -439,19 +455,19 @@ RETURNING id, deleted_at;") AND t.is_default IS FALSE GROUP BY tpr.profile_id;") -(def ^:private schema:get-org-member-team-counts-params +(def ^:private schema:get-organization-member-team-counts-params [:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]]) -(def ^:private schema:get-org-member-team-counts-result +(def ^:private schema:get-organization-member-team-counts-result [:vector [:map [:profile-id ::sm/uuid] [:team-count ::sm/int]]]) -(sv/defmethod ::get-org-member-team-counts +(sv/defmethod ::get-organization-member-team-counts "Get the number of non-default teams each profile belongs to within a set of teams." {::doc/added "2.15" - ::sm/params schema:get-org-member-team-counts-params - ::sm/result schema:get-org-member-team-counts-result + ::sm/params schema:get-organization-member-team-counts-params + ::sm/result schema:get-organization-member-team-counts-result ::rpc/auth false} [cfg {:keys [team-ids]}] (let [team-ids (cond @@ -467,46 +483,30 @@ RETURNING id, deleted_at;") [] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] - (db/exec! conn [sql:get-org-member-team-counts ids-array]))))))) + (db/exec! conn [sql:get-organization-member-team-counts ids-array]))))))) -;; API: invite-to-org +;; API: invite-to-organization -(sv/defmethod ::invite-to-org +(sv/defmethod ::invite-to-organization "Invite to organization" {::doc/added "2.15" ::sm/params [:map [:email ::sm/email] - [:organization schema:organization-with-avatar]]} + [:organization schema:organization-with-avatar]] + ::nitrate/sso false} [cfg params] - (db/tx-run! cfg ti/create-org-invitation params) + (db/tx-run! cfg ti/create-organization-invitation params) nil) -;; API: get-org-invitations +;; API: get-organization-invitations -(def ^:private sql:get-org-invitations - "SELECT DISTINCT ON (email_to) - ti.id, - ti.org_id AS organization_id, - ti.email_to AS email, - ti.created_at AS sent_at, - p.fullname AS name, - p.id AS profile_id, - p.photo_id - FROM team_invitation AS ti -LEFT JOIN profile AS p - ON p.email = ti.email_to - AND p.deleted_at IS NULL - WHERE ti.valid_until >= now() - AND (ti.org_id = ? OR ti.team_id = ANY(?)) - ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") - -(def ^:private schema:get-org-invitations-params +(def ^:private schema:get-organization-invitations-params [:map [:organization-id ::sm/uuid]]) -(def ^:private schema:get-org-invitations-result +(def ^:private schema:get-organization-invitations-result [:vector [:map [:id ::sm/uuid] @@ -517,84 +517,75 @@ LEFT JOIN profile AS p [:profile-id {:optional true} [:maybe ::sm/uuid]] [:photo-url {:optional true} ::sm/uri]]]) -(sv/defmethod ::get-org-invitations +(sv/defmethod ::get-organization-invitations "Get valid invitations for an organization, returning at most one invitation per email." {::doc/added "2.16" - ::sm/params schema:get-org-invitations-params - ::sm/result schema:get-org-invitations-result} + ::sm/params schema:get-organization-invitations-params + ::sm/result schema:get-organization-invitations-result + ::nitrate/sso false} [cfg {:keys [organization-id]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - team-ids (->> (:teams org-summary) - (map :id) - (filter uuid?) - (into []))] + (let [team-ids (noh/get-organization-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] - (let [ids-array (db/create-array conn "uuid" team-ids)] - (->> (db/exec! conn [sql:get-org-invitations organization-id ids-array]) - (mapv (fn [{:keys [photo-id] :as invitation}] - (cond-> (dissoc invitation :photo-id) - photo-id - (assoc :photo-url (files/resolve-public-uri photo-id))))))))))) + (->> (noh/get-organization-invitations conn organization-id team-ids) + (mapv (fn [{:keys [photo-id] :as invitation}] + (cond-> (dissoc invitation :photo-id) + photo-id + (assoc :photo-url (files/resolve-public-uri photo-id)))))))))) -;; API: delete-org-invitations +;; API: delete-organization-invitations -(def ^:private sql:delete-org-invitations +(def ^:private sql:delete-organization-invitations "DELETE FROM team_invitation AS ti WHERE ti.email_to = ? AND (ti.org_id = ? OR ti.team_id = ANY(?));") -(def ^:private schema:delete-org-invitations-params +(def ^:private schema:delete-organization-invitations-params [:map [:organization-id ::sm/uuid] [:email ::sm/email]]) -(sv/defmethod ::delete-org-invitations - "Delete all invitations for one email in an organization scope (org + org teams)." +(sv/defmethod ::delete-organization-invitations + "Delete all invitations for one email in an organization scope (organization + organization teams)." {::doc/added "2.16" - ::sm/params schema:delete-org-invitations-params} + ::sm/params schema:delete-organization-invitations-params + ::nitrate/sso false} [cfg {:keys [organization-id email]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - clean-email (profile/clean-email email) - team-ids (->> (:teams org-summary) - (map :id) - (filter uuid?) - (into []))] + (let [clean-email (profile/clean-email email) + team-ids (noh/get-organization-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] - (db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array])))) + (db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array])))) nil)) -;; API: delete-all-org-invitations +;; API: delete-all-organization-invitations -(def ^:private sql:delete-all-org-invitations +(def ^:private sql:delete-all-organization-invitations "DELETE FROM team_invitation AS ti WHERE ti.org_id = ? OR ti.team_id = ANY(?);") -(def ^:private schema:delete-all-org-invitations-params +(def ^:private schema:delete-all-organization-invitations-params [:map [:organization-id ::sm/uuid]]) -(sv/defmethod ::delete-all-org-invitations - "Delete every pending invitation associated with an organization (org-level + team-level). +(sv/defmethod ::delete-all-organization-invitations + "Delete every pending invitation associated with an organization (organization-level + team-level). Called from Nitrate when an organization is about to be deleted, so users that click their invitation token hit the existing invalid-token landing page." {::doc/added "2.18" - ::sm/params schema:delete-all-org-invitations-params + ::sm/params schema:delete-all-organization-invitations-params ::rpc/auth false} [cfg {:keys [organization-id]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - team-ids (->> (:teams org-summary) - (map :id))] + (let [team-ids (noh/get-organization-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] - (db/exec! conn [sql:delete-all-org-invitations organization-id ids-array])))) + (db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array])))) nil)) -;; API: remove-from-org +;; API: remove-from-organization (def ^:private sql:get-reassign-to "SELECT tpr.profile_id @@ -619,7 +610,7 @@ LEFT JOIN profile AS p (assoc team-to-transfer :reassign-to reassign-to))) -(sv/defmethod ::remove-from-org +(sv/defmethod ::remove-from-organization "Remove an user from an organization" {::doc/added "2.17" ::sm/params [:map @@ -627,9 +618,14 @@ LEFT JOIN profile AS p [:organization-id ::sm/uuid] [:organization-name ::sm/text] [:default-team-id ::sm/uuid]] - ::db/transaction true} - [cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}] - (let [{:keys [valid-teams-to-delete-ids + ::db/transaction true + ::nitrate/sso false} + [cfg {actor-profile-id ::rpc/profile-id + :keys [profile-id organization-id organization-name default-team-id] + :as params}] + (let [actor-profile-id (when-not (= actor-profile-id uuid/zero) + actor-profile-id) + {:keys [valid-teams-to-delete-ids valid-teams-to-transfer valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id) add-reassign-to (partial add-reassign-to cfg profile-id) @@ -637,25 +633,28 @@ LEFT JOIN profile AS p valid-teams-to-leave (into valid-teams-to-exit (map add-reassign-to valid-teams-to-transfer))] - (cnit/leave-org cfg (assoc params - :id organization-id - :name organization-name - :teams-to-delete valid-teams-to-delete-ids - :teams-to-leave valid-teams-to-leave - :skip-validation true)) - (notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org") + (cnit/leave-organization cfg (assoc params + :id organization-id + :name organization-name + :teams-to-delete valid-teams-to-delete-ids + :teams-to-leave valid-teams-to-leave + :skip-validation true + :user-who-delete-member actor-profile-id + :deleted-by-role (when actor-profile-id + "organization-owner"))) + (notifications/notify-user-organization-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-organization") nil)) -;; API: get-remove-from-org-summary +;; API: get-remove-from-organization-summary -(def ^:private schema:get-remove-from-org-summary-result +(def ^:private schema:get-remove-from-organization-summary-result [:map [:teams-to-delete ::sm/int] [:teams-to-transfer ::sm/int] [:teams-to-exit ::sm/int] [:teams-to-detach ::sm/int]]) -(sv/defmethod ::get-remove-from-org-summary +(sv/defmethod ::get-remove-from-organization-summary "Get a summary of the teams that would be deleted, transferred, or exited if the user were removed from the organization" {::doc/added "2.17" @@ -663,8 +662,9 @@ LEFT JOIN profile AS p [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] [:default-team-id ::sm/uuid]] - ::sm/result schema:get-remove-from-org-summary-result - ::db/transaction true} + ::sm/result schema:get-remove-from-organization-summary-result + ::db/transaction true + ::nitrate/sso false} [cfg {:keys [profile-id organization-id default-team-id]}] (let [{:keys [valid-teams-to-delete-ids valid-teams-to-transfer @@ -673,11 +673,11 @@ LEFT JOIN profile AS p (when-not valid-default-team (ex/raise :type :validation :code :not-valid-teams)) - (cnit/get-leave-org-summary cfg - default-team-id - valid-teams-to-delete-ids - (count valid-teams-to-transfer) - (count valid-teams-to-exit)))) + (cnit/get-leave-organization-summary cfg + default-team-id + valid-teams-to-delete-ids + (count valid-teams-to-transfer) + (count valid-teams-to-exit)))) ;; API: send-renewal-email @@ -711,8 +711,8 @@ LEFT JOIN profile AS p :organizations organizations})))) nil) -;; API: exists-org-team-invitations-for-non-members / -;; delete-org-team-invitations-for-non-members +;; API: exists-organization-team-invitations-for-non-members / +;; delete-organization-team-invitations-for-non-members (def ^:private sql:get-profile-emails-by-ids "SELECT email @@ -720,7 +720,7 @@ LEFT JOIN profile AS p WHERE id = ANY(?) AND deleted_at IS NULL") -(def ^:private sql:exists-non-member-org-team-invitations +(def ^:private sql:exists-non-member-organization-team-invitations "SELECT EXISTS ( SELECT 1 FROM team_invitation @@ -728,22 +728,22 @@ LEFT JOIN profile AS p AND email_to <> ALL(?) ) AS non_member") -(def ^:private sql:delete-non-member-org-team-invitations +(def ^:private sql:delete-non-member-organization-team-invitations "DELETE FROM team_invitation WHERE team_id = ANY(?) AND email_to <> ALL(?) RETURNING email_to") -(def ^:private schema:org-team-invitations-for-non-members-params +(def ^:private schema:organization-team-invitations-for-non-members-params [:map [:team-ids [:vector ::sm/uuid]] [:member-ids [:vector ::sm/uuid]]]) -(def ^:private schema:exists-org-team-invitations-for-non-members-result +(def ^:private schema:exists-organization-team-invitations-for-non-members-result [:map [:exists ::sm/boolean]]) -(defn- org-team-invitations-for-non-members-arrays - "Member emails and PG arrays used by exists/delete org team invitation endpoints." +(defn- organization-team-invitations-for-non-members-arrays + "Member emails and PG arrays used by exists/delete organization team invitation endpoints." [conn {:keys [team-ids member-ids]}] (let [member-ids-array (db/create-array conn "uuid" member-ids) member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array]) @@ -752,34 +752,36 @@ LEFT JOIN profile AS p {:emails-array (db/create-array conn "text" (vec member-emails)) :teams-array (db/create-array conn "uuid" team-ids)})) -(defn- non-member-org-team-invitations-exist? +(defn- non-member-organization-team-invitations-exist? [conn params] (let [{:keys [emails-array teams-array]} - (org-team-invitations-for-non-members-arrays conn params)] - (-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations + (organization-team-invitations-for-non-members-arrays conn params)] + (-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations teams-array emails-array]) :non-member))) -(sv/defmethod ::exists-org-team-invitations-for-non-members +(sv/defmethod ::exists-organization-team-invitations-for-non-members "Return if there are any team invitations for emails that are not organization members." {::doc/added "2.18" - ::sm/params schema:org-team-invitations-for-non-members-params - ::sm/result schema:exists-org-team-invitations-for-non-members-result} + ::sm/params schema:organization-team-invitations-for-non-members-params + ::sm/result schema:exists-organization-team-invitations-for-non-members-result + ::nitrate/sso false} [cfg params] (db/run! cfg (fn [{:keys [::db/conn]}] - {:exists (boolean (non-member-org-team-invitations-exist? conn params))}))) + {:exists (boolean (non-member-organization-team-invitations-exist? conn params))}))) -(sv/defmethod ::delete-org-team-invitations-for-non-members +(sv/defmethod ::delete-organization-team-invitations-for-non-members "Delete team invitations for emails that are not organization members." {::doc/added "2.18" - ::sm/params schema:org-team-invitations-for-non-members-params - ::db/transaction true} + ::sm/params schema:organization-team-invitations-for-non-members-params + ::db/transaction true + ::nitrate/sso false} [cfg params] (db/run! cfg (fn [{:keys [::db/conn]}] (let [{:keys [emails-array teams-array]} - (org-team-invitations-for-non-members-arrays conn params)] - (db/exec! conn [sql:delete-non-member-org-team-invitations + (organization-team-invitations-for-non-members-arrays conn params)] + (db/exec! conn [sql:delete-non-member-organization-team-invitations teams-array emails-array]) nil)))) @@ -790,53 +792,235 @@ LEFT JOIN profile AS p [:map {:title "NitrateAuditEvent"} [:name [:and [:string {:max 250}] [:re #"[\d\w-]{1,50}"]]] + [:type {:optional true} ::sm/text] [:profile-id ::sm/uuid] - [:props {:optional true} [:map-of :keyword :any]]]) + [:props {:optional true} [:map-of :keyword :any]] + [:context {:optional true} [:map-of :keyword :any]]]) (def ^:private schema:push-audit-events-params [:map {:title "PushAuditEventsParams"} [:events [:vector schema:nitrate-audit-event]]]) -(defn- submit-nitrate-audit-event - [cfg {:keys [name profile-id props]}] - (let [now (ct/now)] - (audit/submit* cfg {:type "action" - :name name - :profile-id profile-id - :props (or props {}) - :context {} - :tracked-at now - :created-at now - :source "nitrate" - :ip-addr "0.0.0.0"}))) - (sv/defmethod ::push-audit-events - "Push audit events from Nitrate to Penpot audit log" + "Push audit events from nitrate (strictly for nitrate backend + events)" + {::doc/added "2.19" + ::audit/skip true ::sm/params schema:push-audit-events-params ::rpc/auth false} - [{:keys [::db/pool] :as cfg} {:keys [events]}] - (let [telemetry? (contains? cf/flags :telemetry) - audit-log? (contains? cf/flags :audit-log) - enabled? (and (not (db/read-only? pool)) - (or audit-log? telemetry?))] - (when (and enabled? (seq events)) - (run! (partial submit-nitrate-audit-event cfg) events)) + [cfg {:keys [::rpc/request-at events] :as params}] + (let [request (-> params meta ::http/request) + context' (-> (audit/prepare-context-from-request request) + (assoc :request-id (::rpc/request-id params))) + + ip-addr (::rpc/ip-addr params)] + + (run! (fn [{:keys [type name profile-id props context] :as event}] + (let [context (-> (merge context (d/without-nils context')) + (d/without-nils))] + (audit/submit cfg {:type (d/nilv type "action") + :name name + :profile-id profile-id + :props (or props {}) + :context context + :tracked-at request-at + :ip-addr ip-addr}))) + events) + nil)) -;; ---- API: notify-org-sso-change +;; ---- API: get-teams-detail -(sv/defmethod ::notify-org-sso-change +(def ^:private sql:get-teams-detail + "SELECT + t.id, + t.name, + t.photo_id, + t.created_at, + (SELECT MAX(activity.modified_at) + FROM ( + SELECT p2.modified_at + FROM project AS p2 + WHERE p2.team_id = t.id + AND p2.deleted_at IS NULL + AND p2.is_default IS FALSE + UNION ALL + SELECT f.modified_at + FROM file AS f + JOIN project AS p ON p.id = f.project_id + WHERE p.team_id = t.id + AND p.deleted_at IS NULL + AND f.deleted_at IS NULL + UNION ALL + SELECT tpr2.created_at + FROM team_profile_rel AS tpr2 + WHERE tpr2.team_id = t.id + AND tpr2.is_owner IS NOT TRUE + UNION ALL + SELECT ti.updated_at + FROM team_invitation AS ti + WHERE ti.team_id = t.id + ) AS activity) AS last_activity_at, + owner_tpr.profile_id AS owner_profile_id, + owner_p.fullname AS owner_name, + owner_p.photo_id AS owner_photo_id, + (SELECT COUNT(*) + FROM project AS p3 + WHERE p3.team_id = t.id + AND p3.deleted_at IS NULL + AND p3.is_default IS FALSE) AS num_projects, + (SELECT COUNT(*) + FROM file AS f + JOIN project AS p4 ON p4.id = f.project_id + WHERE p4.team_id = t.id + AND f.deleted_at IS NULL + AND p4.deleted_at IS NULL) AS num_files, + (SELECT COUNT(*) + FROM team_profile_rel AS tpr + WHERE tpr.team_id = t.id) AS num_members + FROM team AS t + LEFT JOIN team_profile_rel AS owner_tpr + ON owner_tpr.team_id = t.id AND owner_tpr.is_owner IS TRUE + LEFT JOIN profile AS owner_p + ON owner_p.id = owner_tpr.profile_id + WHERE t.id = ANY(?) + AND t.deleted_at IS NULL + AND t.is_default IS FALSE + ORDER BY last_activity_at DESC NULLS LAST") + +(def ^:private schema:get-teams-detail-params + [:map + [:organization-id ::sm/uuid]]) + +(def ^:private schema:get-teams-detail-result + [:vector + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:photo-url {:optional true} ::sm/uri] + [:created-at ::sm/inst] + [:last-activity-at {:optional true} [:maybe ::sm/inst]] + [:owner-profile-id {:optional true} [:maybe ::sm/uuid]] + [:owner-name {:optional true} [:maybe ::sm/text]] + [:owner-photo-url {:optional true} ::sm/uri] + [:num-projects ::sm/int] + [:num-files ::sm/int] + [:num-members ::sm/int]]]) + +(sv/defmethod ::get-teams-detail + "Get detailed information for all non-deleted teams in an organization, + including owner info and project/file/member counts." + {::doc/added "2.20" + ::sm/params schema:get-teams-detail-params + ::sm/result schema:get-teams-detail-result + ::nitrate/sso false} + [cfg {:keys [organization-id]}] + (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id}) + team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams organization-summary))] + (if (empty? team-ids) + [] + (db/run! cfg + (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-teams-detail ids-array]) + (mapv (fn [{:keys [photo-id owner-photo-id] :as row}] + (cond-> (dissoc row :photo-id :owner-photo-id) + photo-id (assoc :photo-url (files/resolve-public-uri photo-id)) + owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id)))))))))))) + +;; ---- API: check-organization-sso + +(def ^:private schema:check-organization-sso-result + [:map + [:valid ::sm/boolean]]) + +(sv/defmethod ::check-organization-sso + "Validate an organization SSO configuration by generating a login redirect URL. + Nitrate calls this while configuring SSO to verify client credentials and OIDC + discovery before saving the settings." + {::doc/added "2.20" + ::sm/params schema:nitrate-sso + ::sm/result schema:check-organization-sso-result + ::rpc/auth false} + [cfg params] + {:valid (oidc/is-organization-sso-config-valid? cfg params)}) + +;; ---- API: notify-organization-sso-change +(sv/defmethod ::notify-organization-sso-change "Nitrate notifies that an organization sso values have changed" {::doc/added "2.19" ::sm/params [:map [:organization-id ::sm/uuid] - [:updated-props ::sm/boolean]] + [:updated-props ::sm/boolean] + [:announce-activation ::sm/boolean]] ::rpc/auth false} - [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}] + [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}] (when updated-props - (rpc/invalidate-org-sso-cache-by-org! organization-id) - (session/clear-org-sso-sessions! pool organization-id)) + (rpc/invalidate-organization-sso-cache-by-organization! organization-id) + (session/clear-organization-sso-sessions! pool organization-id)) (notifications/notify-organization-change-sso cfg organization-id) + (when announce-activation + (neh/send-organization-setup-sso-emails! cfg organization-id)) nil) + +;; ---- API: bulk-create-profiles + +(def ^:private schema:bulk-create-profiles-params + [:map + [:password [::sm/word-string {:max 500}]] + [:emails [:vector ::sm/email]]]) + +(def ^:private schema:bulk-create-profiles-result + [:map + [:created [:vector ::sm/email]] + [:skipped [:vector ::sm/email]]]) + +(defn- create-active-profile! + "Create a single already-active profile (email pre-verified, onboarding + skipped) plus its default team. Returns nil; existence checks happen in the + caller so duplicates are skipped instead of aborting the whole batch." + [cfg email password] + (let [fullname (-> (str/split email "@") first)] + (->> {:email email + :fullname fullname + :password password + :is-active true + :props {:onboarding-viewed true}} + (auth/create-profile cfg) + (auth/create-profile-rels cfg)) + nil)) + +(sv/defmethod ::bulk-create-profiles + "Create multiple already-active profiles that share a single password. The + created users skip email verification and onboarding. Emails that already + belong to an existing profile are skipped. Intended for the Nitrate admin + bulk-creation screen; access is gated by the shared key and, in Nitrate, an + email allow-list. Requires the `nitrate-bulk-create-profiles` flag, disabled + by default so it is only available on test environments." + {::doc/added "2.19" + ::sm/params schema:bulk-create-profiles-params + ::sm/result schema:bulk-create-profiles-result + ::rpc/auth false} + [cfg {:keys [password emails]}] + + (when-not (contains? cf/flags :nitrate-bulk-create-profiles) + (ex/raise :type :restriction + :code :nitrate-bulk-create-profiles-not-allowed + :hint "Bulk profile creation is disabled by config.")) + + (let [derived (aauth/derive-password password)] + (db/tx-run! + cfg + (fn [{:keys [::db/conn] :as cfg}] + (reduce + (fn [acc email] + (let [email (eml/clean email)] + (if (profile/get-profile-by-email conn email) + (update acc :skipped conj email) + (do + (create-active-profile! cfg email derived) + (update acc :created conj email))))) + {:created [] :skipped []} + emails))))) diff --git a/backend/src/app/rpc/nitrate/emails_helper.clj b/backend/src/app/rpc/nitrate/emails_helper.clj new file mode 100644 index 0000000000..3a2286f3f5 --- /dev/null +++ b/backend/src/app/rpc/nitrate/emails_helper.clj @@ -0,0 +1,104 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.rpc.nitrate.emails-helper + "Helpers for organization SSO notification emails triggered by Nitrate integration." + (:require + [app.common.data :as d] + [app.config :as cf] + [app.db :as db] + [app.email :as eml] + [app.nitrate :as nitrate] + [app.rpc.commands.teams :as teams] + [app.rpc.nitrate.organization-helper :as neh] + [cuerdas.core :as str])) + +(def ^:private sql:get-profile-emails-by-ids + "SELECT email + FROM profile + WHERE id = ANY(?) + AND deleted_at IS NULL") + +(def ^:private sql:get-profiles-by-emails + "SELECT id, email, is_muted + FROM profile + WHERE email = ANY(?) + AND deleted_at IS NULL") + +(defn- organization-sso-active? + "Return whether SSO is enabled for the organization." + [cfg organization-id] + (when (contains? cf/flags :nitrate) + (true? (:active (nitrate/call cfg :get-organization-sso {:organization-id organization-id}))))) + +(def ^:private xf:map-email (map :email)) + +(defn- recipients-by-emails + "Build `{:email :profile}` maps for a deduplicated email list." + [conn emails] + (let [profiles (if (seq emails) + (let [emails-array (db/create-array conn "text" emails)] + (db/exec! conn [sql:get-profiles-by-emails emails-array])) + []) + profile-by-email (d/index-by (comp str/lower :email) profiles)] + (map (fn [email] + (let [profile (get profile-by-email (str/lower email))] + {:email email + :profile profile})) + emails))) + +(defn- send-organization-setup-sso-email! + "Send the organization SSO setup email to a single recipient, when allowed." + [conn organization-name {:keys [email profile]}] + (when (or (nil? profile) + (eml/allow-send-emails? conn profile)) + (eml/send! {::eml/conn conn + ::eml/factory eml/organization-setup-sso + :public-uri (cf/get :public-uri) + :to email + :organization-name organization-name}))) + +(defn- get-organization-sso-notify-recipients + "Unique organization members and pending organization/team invitees for SSO activation emails." + [conn cfg organization-id organization-summary] + (let [member-ids (nitrate/call cfg :get-organization-members {:organization-id organization-id}) + team-ids (neh/get-organization-team-ids organization-summary) + member-emails (if (seq member-ids) + (let [ids-array (db/create-array conn "uuid" member-ids)] + (into #{} (map :email (db/exec! conn [sql:get-profile-emails-by-ids ids-array])))) + #{}) + invite-emails (into #{} (map :email + (neh/get-organization-invitations conn organization-id team-ids))) + emails (into #{} (concat member-emails invite-emails))] + (recipients-by-emails conn emails))) + +(defn- get-team-sso-notify-recipients + "Team members who are not in `organization-member-ids`, plus pending team invitations." + [conn team-id organization-member-ids] + (let [team-members (->> (teams/get-team-members conn team-id) + (remove #(contains? organization-member-ids (:id %)))) + invitations (neh/get-team-invitation-emails conn team-id)] + (->> (sequence xf:map-email (concat team-members invitations)) + (recipients-by-emails conn)))) + +(defn send-organization-setup-sso-emails! + "Notify all organization members and pending organization/team invitees that SSO is active." + [cfg organization-id] + (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (doseq [recipient (get-organization-sso-notify-recipients conn cfg organization-id organization-summary)] + (send-organization-setup-sso-email! conn (:name organization-summary) recipient)))))) + +(defn send-organization-setup-sso-emails-for-team! + "Notify team members who are not in `organization-member-ids-before` and pending team invitees." + [cfg organization-id team-id organization-member-ids-before] + (when (organization-sso-active? cfg organization-id) + (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (doseq [recipient (get-team-sso-notify-recipients conn team-id organization-member-ids-before)] + (send-organization-setup-sso-email! conn (:name organization-summary) recipient))))))) diff --git a/backend/src/app/rpc/nitrate/organization_helper.clj b/backend/src/app/rpc/nitrate/organization_helper.clj new file mode 100644 index 0000000000..ed5d918e8b --- /dev/null +++ b/backend/src/app/rpc/nitrate/organization_helper.clj @@ -0,0 +1,60 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.rpc.nitrate.organization-helper + "Shared Nitrate organization query helpers." + (:require + [app.db :as db] + [app.nitrate :as nitrate])) + +(def ^:private sql:get-organization-invitations + "SELECT DISTINCT ON (email_to) + ti.id, + ti.org_id AS organization_id, + ti.email_to AS email, + ti.created_at AS sent_at, + p.fullname AS name, + p.id AS profile_id, + p.photo_id + FROM team_invitation AS ti +LEFT JOIN profile AS p + ON p.email = ti.email_to + AND p.deleted_at IS NULL + WHERE ti.valid_until >= now() + AND (ti.org_id = ? OR ti.team_id = ANY(?)) + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(def ^:private sql:get-team-invitation-emails + "SELECT DISTINCT ON (email_to) + ti.email_to AS email + FROM team_invitation AS ti + WHERE ti.team_id = ? + AND ti.valid_until >= now() + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(defn get-organization-team-ids + "Return team ids for an organization. + + Accepts either `cfg` and `organization-id` (fetches the organization summary from + Nitrate) or an already-resolved organization summary map." + ([cfg organization-id] + (get-organization-team-ids (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))) + ([organization-summary] + (->> (:teams organization-summary) + (map :id) + (filter uuid?) + (vec)))) + +(defn get-organization-invitations + "Fetch valid organization-level and team-level invitations for an organization." + [conn organization-id team-ids] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:get-organization-invitations organization-id ids-array]))) + +(defn get-team-invitation-emails + "Return distinct valid team invitation recipient emails." + [conn team-id] + (db/exec! conn [sql:get-team-invitation-emails team-id])) diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj index d2410092aa..ec3d7c89d9 100644 --- a/backend/src/app/rpc/notifications.clj +++ b/backend/src/app/rpc/notifications.clj @@ -16,17 +16,17 @@ ;;TODO There is a bug on dashboard with teams notifications. ;;For now we send it to uuid/zero instead of team-id :topic uuid/zero - :message {:type :team-org-change + :message {:type :team-organization-change :team team :notification notification}))) -(defn notify-user-org-change +(defn notify-user-organization-change [cfg profile-id organization-id organization-name notification] (let [msgbus (::mbus/msgbus cfg)] (mbus/pub! msgbus :topic profile-id - :message {:type :user-org-change + :message {:type :user-organization-change :topic profile-id :organization-id organization-id :organization-name organization-name diff --git a/backend/src/app/rpc/permissions.clj b/backend/src/app/rpc/permissions.clj index f4107ec235..36ff9b2c23 100644 --- a/backend/src/app/rpc/permissions.clj +++ b/backend/src/app/rpc/permissions.clj @@ -7,8 +7,11 @@ (ns app.rpc.permissions "A permission checking helper factories." (:require + [app.binfile.common :as bfc] [app.common.exceptions :as ex] - [app.common.schema :as sm])) + [app.common.schema :as sm] + [app.db :as db] + [app.nitrate :as nitrate])) (def schema:permissions [:map {:title "Permissions"} @@ -89,3 +92,64 @@ (ex/raise :type :not-found :code :object-not-found :hint "not found")))) + +;; --- Organization owner (Nitrate) viewer access +;; +;; Read-permission helpers that augment normal Penpot membership with +;; Nitrate organization-owner viewer access. Edit/admin permission +;; providers intentionally stay membership-only. + +(def viewer-role-flags + "Role flags granted to a non-member organization owner: read-only. + Shared so callers that build full team/file rows shape permissions the + same way the permission lookups do." + {:is-owner false + :is-admin false + :can-edit false}) + +(def ^:private sql:get-team-id-for-project + "SELECT team_id FROM project WHERE id = ?") + +(def ^:private sql:get-team-id-for-file + "SELECT p.team_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE f.id = ?") + +(defn get-team-id-for-project + [cfg project-id] + (some-> (db/exec-one! cfg [sql:get-team-id-for-project project-id]) + (:team-id))) + +(defn get-team-id-for-file + [cfg file-id] + (some-> (db/exec-one! cfg [sql:get-team-id-for-file file-id]) + (:team-id))) + +(defn resolve-team-id + [cfg {:keys [team-id project-id file-id]}] + (cond + (some? team-id) team-id + (some? project-id) (get-team-id-for-project cfg project-id) + (some? file-id) (get-team-id-for-file cfg file-id))) + +(defn get-organization-owner-permissions + "When `profile-id` is a non-member owner of the organization that owns + the team/project/file referenced by `params`, returns read-only viewer + permissions; otherwise nil." + [cfg profile-id & {:as params}] + (when-let [team-id (resolve-team-id cfg params)] + (when (nitrate/organization-owner-of-team? cfg profile-id team-id) + (assoc viewer-role-flags + :can-read true + :type :membership + :is-logged (some? profile-id))))) + +(defn get-file-read-permissions + ([cfg profile-id file-id] + (or (bfc/get-file-permissions cfg profile-id file-id) + (get-organization-owner-permissions cfg profile-id :file-id file-id))) + + ([cfg profile-id file-id share-id] + (or (bfc/get-file-permissions cfg profile-id file-id share-id) + (get-organization-owner-permissions cfg profile-id :file-id file-id)))) diff --git a/backend/src/app/srepl/binfile.clj b/backend/src/app/srepl/binfile.clj index cd5bec409e..badf02d98c 100644 --- a/backend/src/app/srepl/binfile.clj +++ b/backend/src/app/srepl/binfile.clj @@ -7,6 +7,7 @@ (ns app.srepl.binfile (:require [app.binfile.v2 :as binfile.v2] + [app.common.uuid :as uuid] [app.db :as db] [app.srepl.helpers :as h] [app.system :as sys] @@ -30,7 +31,8 @@ (when owner (db/insert! cfg :team-profile-rel - {:team-id (:id team) + {:id (uuid/next) + :team-id (:id team) :profile-id (:id owner) :is-admin true :is-owner true diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 302def8a7b..22ccb624fe 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -63,6 +63,23 @@ (t/is (= :auto (#'oidc/select-user-info-source :token))) (t/is (= :auto (#'oidc/select-user-info-source :userinfo))))) +(t/deftest token-endpoint-errors-detect-valid-client-credentials + (let [response {:status 403 + :body "{\"error\":\"invalid_grant\",\"error_description\":\"Invalid authorization code\"}"}] + (t/is (#'oidc/token-endpoint-valid-client-error? response)) + (t/is (not (#'oidc/token-endpoint-invalid-client-error? response))))) + +(t/deftest token-endpoint-errors-detect-invalid-client-credentials + (t/is (#'oidc/token-endpoint-invalid-client-error? + {:status 401 + :body "{\"error\":\"access_denied\",\"error_description\":\"Unauthorized\"}"})) + (t/is (#'oidc/token-endpoint-invalid-client-error? + {:status 400 + :body "{\"error\":\"invalid_client\"}"})) + (t/is (not (#'oidc/token-endpoint-valid-client-error? + {:status 400 + :body "{\"error\":\"invalid_client\"}"})))) + (t/deftest int-in-range-checks-range-correctly (t/testing "values within range return true" (t/is (#'oidc/int-in-range? 200 200 300)) diff --git a/backend/test/backend_tests/email_sending_test.clj b/backend/test/backend_tests/email_sending_test.clj index a688814e63..91d2848185 100644 --- a/backend/test/backend_tests/email_sending_test.clj +++ b/backend/test/backend_tests/email_sending_test.clj @@ -6,10 +6,12 @@ (ns backend-tests.email-sending-test (:require + [app.config :as cf] [app.db :as db] [app.email :as emails] [backend-tests.helpers :as th] [clojure.test :as t] + [cuerdas.core :as str] [promesa.core :as p])) (t/use-fixtures :once th/state-init) @@ -23,3 +25,67 @@ (t/is (contains? result :to)) #_(t/is (contains? result :reply-to)) (t/is (map? (:body result))))) + +(def ^:private sso-notice-snippet + "has set up single sign-on (SSO) in Penpot") + +(defn- email-text-body + [result] + (get-in result [:body "text/plain"])) + +(defn- invite-email-params + [organization] + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :user-name "Invitee User" + :token "test-token" + :organization organization}) + +(t/deftest invite-to-organization-includes-sso-notice-when-active + (let [result (emails/render emails/invite-to-organization + (invite-email-params {:name "Acme Inc" + :sso-active true}))] + (t/is (str/includes? (email-text-body result) sso-notice-snippet)) + (t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))) + +(t/deftest invite-to-organization-omits-sso-notice-when-inactive + (let [result (emails/render emails/invite-to-organization + (invite-email-params {:name "Acme Inc" + :sso-active false}))] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) + +(t/deftest invite-to-team-includes-sso-notice-when-active + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token" + :organization {:name "Acme Inc" + :sso-active true}})] + (t/is (str/includes? (email-text-body result) sso-notice-snippet)) + (t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))) + +(t/deftest invite-to-team-omits-sso-notice-when-inactive + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token" + :organization {:name "Acme Inc" + :sso-active false}})] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) + +(t/deftest invite-to-team-omits-sso-notice-without-organization + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token"})] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 247b27a699..f839f222b9 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -79,59 +79,66 @@ :enable-auto-file-snapshot :disable-file-validation]) -(defn state-init +(defn init-config + ([next] + (init-config nil next)) + ([extra-flags next] + (let [flags (into default-flags extra-flags)] + (with-redefs [app.config/flags (flags/parse flags/default flags) + app.config/config config + app.loggers.audit/submit (constantly nil) + app.auth/derive-password identity + app.auth/verify-password (fn [a b] {:valid (= a b)}) + app.common.features/get-enabled-features + (fn [& _] app.common.features/supported-features)] + (cf/validate! :exit-on-error? false) + (fs/create-dir "/tmp/penpot") + (next))))) + +(defn init-system [next] - (with-redefs [app.config/flags (flags/parse flags/default default-flags) - app.config/config config - app.loggers.audit/submit (constantly nil) - app.auth/derive-password identity - app.auth/verify-password (fn [a b] {:valid (= a b)}) - app.common.features/get-enabled-features (fn [& _] app.common.features/supported-features)] + (let [templates [{:id "test" + :name "test" + :file-uri "test" + :thumbnail-uri "test" + :path (-> "backend_tests/test_files/template.penpot" io/resource fs/path)}] + system (-> (merge main/system-config main/worker-config) + (assoc-in [:app.redis/client :app.redis/uri] (:redis-uri config)) + (assoc-in [::db/pool ::db/uri] (:database-uri config)) + (assoc-in [::db/pool ::db/username] (:database-username config)) + (assoc-in [::db/pool ::db/password] (:database-password config)) + (assoc-in [:app.rpc/methods :app.setup/templates] templates) + (assoc-in [:app.rpc/methods :app.setup/templates] templates) + (update :app.rpc/rlimit assoc + :app.loggers.mattermost/reporter nil + :app.loggers.database/reporter nil) + (update :app.rpc/methods assoc + :app.setup/templates templates + :app.loggers.mattermost/reporter nil + :app.loggers.database/reporter nil) + (dissoc :app.srepl/server + :app.http/server + :app.http/route + :app.setup/templates + :app.http.oauth/handler + :app.notifications/handler + :app.loggers.mattermost/reporter + :app.loggers.database/reporter + :app.worker/cron + :app.worker/dispatcher + [:app.main/default :app.worker/runner] + [:app.main/webhook :app.worker/runner])) + _ (ig/load-namespaces system) + system (-> (ig/expand system) (ig/init))] + (try + (binding [*system* system + *pool* (:app.db/pool system)] + (next)) + (finally + (ig/halt! system))))) - (cf/validate! :exit-on-error? false) - - (fs/create-dir "/tmp/penpot") - - (let [templates [{:id "test" - :name "test" - :file-uri "test" - :thumbnail-uri "test" - :path (-> "backend_tests/test_files/template.penpot" io/resource fs/path)}] - system (-> (merge main/system-config main/worker-config) - (assoc-in [:app.redis/client :app.redis/uri] (:redis-uri config)) - (assoc-in [::db/pool ::db/uri] (:database-uri config)) - (assoc-in [::db/pool ::db/username] (:database-username config)) - (assoc-in [::db/pool ::db/password] (:database-password config)) - (assoc-in [:app.rpc/methods :app.setup/templates] templates) - (assoc-in [:app.rpc/methods :app.setup/templates] templates) - (update :app.rpc/rlimit assoc - :app.loggers.mattermost/reporter nil - :app.loggers.database/reporter nil) - (update :app.rpc/methods assoc - :app.setup/templates templates - :app.loggers.mattermost/reporter nil - :app.loggers.database/reporter nil) - (dissoc :app.srepl/server - :app.http/server - :app.http/route - :app.setup/templates - :app.http.oauth/handler - :app.notifications/handler - :app.loggers.mattermost/reporter - :app.loggers.database/reporter - :app.worker/cron - :app.worker/dispatcher - [:app.main/default :app.worker/runner] - [:app.main/webhook :app.worker/runner])) - _ (ig/load-namespaces system) - system (-> (ig/expand system) - (ig/init))] - (try - (binding [*system* system - *pool* (:app.db/pool system)] - (next)) - (finally - (ig/halt! system)))))) +(def state-init + (t/compose-fixtures init-config init-system)) (defn database-reset [next] @@ -386,29 +393,15 @@ (assoc :app.rpc/request-at (ct/now))))))) (defn management-command! - ([data] - (management-command! data nil)) - ([{:keys [::type] :as data} flags-to-add] - (let [flags (reduce conj cf/flags (or flags-to-add [])) - - resolve-management-methods - (requiring-resolve 'app.rpc/resolve-management-methods) - - methods - (with-redefs [cf/flags flags] - (resolve-management-methods *system*)) - - [_ method-fn] - (get methods type)] - - (when-not method-fn - (ex/raise :type :assertion - :code :rpc-method-not-found - :hint (str/ffmt "management rpc method '%' not found" (name type)))) - - (try-on! (method-fn (-> data - (dissoc ::type) - (assoc :app.rpc/request-at (ct/now)))))))) + [{:keys [::type] :as data}] + (let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])] + (when-not method-fn + (ex/raise :type :assertion + :code :rpc-method-not-found + :hint (str/ffmt "management rpc method '%' not found" (name type)))) + (try-on! (method-fn (-> data + (dissoc ::type) + (assoc :app.rpc/request-at (ct/now))))))) (defn run-task! ([name] diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index bc521cb082..796306efe2 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -110,7 +110,8 @@ (doseq [bucket ["file-media-object" "file-object-thumbnail" "team-font-variant" - "file-data-fragment"]] + "file-data-fragment" + "organization"]] (t/testing (str "bucket: " bucket) (let [object (create-storage-object! storage bucket "public data") request {:path-params {:id (str (:id object))}} @@ -120,6 +121,19 @@ (t/is (not= 404 (::yres/status response)) (str "bucket " bucket " object should exist"))))))) +(t/deftest objects-handler-organization-logo-no-auth + ;; Organization logos are embedded in unauthenticated contexts, such as + ;; the invitation email image shown to a not-yet-registered invitee, so + ;; they must be servable without a session or access token. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + object (create-storage-object! storage "organization" "logo data") + request {:path-params {:id (str (:id object))}} + response (assets/objects-handler cfg request)] + (t/is (not= 401 (::yres/status response))) + (t/is (not= 404 (::yres/status response))))) + (t/deftest objects-handler-public-bucket-with-auth ;; Objects in public buckets should also be accessible WITH authentication. (let [storage (-> (:app.storage/storage th/*system*) diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index c164303168..bdb2f20887 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -9,6 +9,7 @@ [app.common.uuid :as uuid] [app.db :as db] [app.http :as http] + [app.http.access-token :as actoken] [app.rpc :as-alias rpc] [app.storage :as sto] [backend-tests.helpers :as th] @@ -205,3 +206,29 @@ (t/is (not (contains? all-ids (:id first-mcp)))) (t/is (not (contains? all-ids (:id second-mcp)))) (t/is (contains? all-ids (:id third-mcp)))))))) + +(t/deftest mcp-tokens-cannot-be-used-as-access-tokens + (let [prof (th/create-profile* 1 {:is-active true}) + cfg th/*system*] + + (t/testing "MCP tokens use different issuer claim" + (let [{:keys [result]} (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "mcp token"}) + mcp-token (:token result) + + ;; Try to decode as access token (should fail) + decoded (actoken/decode-token cfg mcp-token)] + (t/is (nil? decoded)))) + + (t/testing "Regular access tokens use access-token issuer claim" + (let [{:keys [result]} (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :name "regular token"}) + access-token (:token result) + + ;; Should decode successfully + decoded (actoken/decode-token cfg access-token)] + (t/is (some? decoded)) + (t/is (= (:id prof) (:uid decoded))))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index abba0fa7a1..49a6605324 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -6,11 +6,14 @@ (ns backend-tests.rpc-management-nitrate-test (:require + [app.auth.oidc :as oidc] [app.common.data :as d] [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] - [app.db :as-alias db] + [app.db :as db] + [app.email :as eml] + [app.http :as-alias http] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -18,34 +21,77 @@ [backend-tests.helpers :as th] [clojure.set :as set] [clojure.test :as t] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once (t/compose-fixtures + (partial th/init-config [:enable-nitrate]) + th/init-system)) -(t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) -(defn- management-command-with-nitrate! - [data] - (th/management-command! data [:nitrate])) - (t/deftest authenticate-success - (let [profile (th/create-profile* 1 {:is-active true - :fullname "Nitrate User"}) - out (management-command-with-nitrate! {::th/type :authenticate - ::rpc/profile-id (:id profile)})] - (t/is (th/success? out)) - (t/is (= (:id profile) (-> out :result :id))) - (t/is (= "Nitrate User" (-> out :result :name))) - (t/is (= (:email profile) (-> out :result :email))) - (t/is (nil? (-> out :result :photo-url))))) + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true + :fullname "Nitrate User"}) + out (th/management-command! {::th/type :authenticate + ::rpc/profile-id (:id profile)})] + (t/is (th/success? out)) + (t/is (= (:id profile) (-> out :result :id))) + (t/is (= "Nitrate User" (-> out :result :name))) + (t/is (= (:email profile) (-> out :result :email))) + (t/is (= (:created-at profile) (-> out :result :created-at))) + (t/is (nil? (-> out :result :photo-url)))))) (t/deftest authenticate-requires-authentication - (let [out (management-command-with-nitrate! {::th/type :authenticate})] + (let [out (th/management-command! {::th/type :authenticate})] (t/is (not (th/success? out))) (t/is (= :authentication (th/ex-type (:error out)))) (t/is (= :authentication-required (th/ex-code (:error out)))))) +(t/deftest create-and-update-organization-invitations-audit-props + (with-mocks [email-mock {:target 'app.email/send! :return nil} + audit-mock {:target 'app.loggers.audit/submit :return nil} + nitrate-mock {:target 'app.nitrate/call :return nil}] + (binding [cf/flags (conj cf/flags :email-verification)] + (let [owner (th/create-profile* 101 {:is-active true}) + invitee (th/create-profile* 102 {:is-active true}) + organization {:id (uuid/random) + :name "Acme" + :initials "AC" + :logo nil + :avatar-bg-url nil} + params {::th/type :invite-to-organization + ::rpc/profile-id (:id owner) + :email (:email invitee) + :organization organization} + create-out (th/management-command! params) + update-out (th/management-command! params) + external-out (th/management-command! (assoc params :email "external@example.com")) + events (mapv second (:call-args-list @audit-mock)) + create-event (first (filter #(= "create-organization-invitation" (:name %)) events)) + update-event (first (filter #(= "update-organization-invitation" (:name %)) events)) + external-event + (first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))] + (t/is (th/success? create-out)) + (t/is (th/success? update-out)) + (t/is (th/success? external-out)) + + (doseq [event [create-event update-event]] + (t/is (not (contains? (:props event) :event-origin))) + (t/is (= (str (:id owner)) + (get-in event [:props :user-who-send-invitation]))) + (t/is (= (:id organization) + (get-in event [:props :organization-id]))) + (t/is (= (:email invitee) + (get-in event [:props :member-email]))) + (t/is (= (:id invitee) + (get-in event [:props :member-id])))) + + (t/is (not (contains? (:props external-event) :member-id))))))) + (t/deftest get-penpot-version - (let [out (management-command-with-nitrate! {::th/type :get-penpot-version}) + (let [out (th/management-command! {::th/type :get-penpot-version}) version (-> out :result :version)] (t/is (th/success? out)) (t/is (= #{:full :branch :base :main :major :minor :patch :modifier :commit :commit-hash} @@ -56,24 +102,25 @@ (t/is (= cf/version version)))) (t/deftest get-teams-returns-only-owned-non-default-non-deleted - (let [profile (th/create-profile* 1 {:is-active true}) - other (th/create-profile* 2 {:is-active true}) - owned-team (th/create-team* 1 {:profile-id (:id profile)}) - deleted-team (th/create-team* 2 {:profile-id (:id profile)}) - _ (th/db-update! :team - {:deleted-at (ct/now)} - {:id (:id deleted-team)}) - other-team (th/create-team* 3 {:profile-id (:id other)}) - _ (th/create-team-role* {:team-id (:id other-team) - :profile-id (:id profile) - :role :editor}) - out (management-command-with-nitrate! {::th/type :get-teams - ::rpc/profile-id (:id profile)})] - (t/is (th/success? out)) - (t/is (= #{(:id owned-team)} - (->> out :result (map :id) set))) - (t/is (= #{(:name owned-team)} - (->> out :result (map :name) set))))) + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + owned-team (th/create-team* 1 {:profile-id (:id profile)}) + deleted-team (th/create-team* 2 {:profile-id (:id profile)}) + _ (th/db-update! :team + {:deleted-at (ct/now)} + {:id (:id deleted-team)}) + other-team (th/create-team* 3 {:profile-id (:id other)}) + _ (th/create-team-role* {:team-id (:id other-team) + :profile-id (:id profile) + :role :editor}) + out (th/management-command! {::th/type :get-teams + ::rpc/profile-id (:id profile)})] + (t/is (th/success? out)) + (t/is (= #{(:id owned-team)} + (->> out :result (map :id) set))) + (t/is (= #{(:name owned-team)} + (->> out :result (map :name) set)))))) (t/deftest notify-team-change-publishes-event (let [team-id (uuid/random) @@ -87,15 +134,15 @@ out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] (swap! calls conj {:topic topic :message message}))] - (management-command-with-nitrate! {::th/type :notify-team-change - :id team-id - :is-your-penpot false - :organization organization}))] + (th/management-command! {::th/type :notify-team-change + :id team-id + :is-your-penpot false + :organization organization}))] (t/is (th/success? out)) (t/is (= 1 (count @calls))) (t/is (= uuid/zero (-> @calls first :topic))) (let [msg (-> @calls first :message)] - (t/is (= :team-org-change (:type msg))) + (t/is (= :team-organization-change (:type msg))) (t/is (= nil (:notification msg))) (t/is (= team-id (-> msg :team :id))) (t/is (= false (-> msg :team :is-your-penpot))) @@ -105,1096 +152,1673 @@ (t/is (= (:owner-id organization) (-> msg :team :organization :owner-id))) (t/is (= (:avatar-bg-url organization) (str (-> msg :team :organization :avatar-bg-url))))))) -(t/deftest notify-user-added-to-organization-creates-default-org-team - (let [profile (th/create-profile* 1 {:is-active true}) - before-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile) - :is-owner true}) - (map :team-id) - set) - out (management-command-with-nitrate! {::th/type :notify-user-added-to-organization - :profile-id (:id profile) - :organization-id (uuid/random) - :role "owner"}) - after-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile) - :is-owner true}) - (map :team-id) - set) - new-team-id (first (set/difference after-teams before-teams)) - new-team (th/db-get :team {:id new-team-id})] - (t/is (th/success? out)) - (t/is (= 1 (count (set/difference after-teams before-teams)))) - (t/is (= "Your Penpot" (:name new-team))) - (t/is (true? (:is-default new-team))))) +(t/deftest notify-user-added-to-organization-creates-default-organization-team + (with-mocks [nitrate-mock {:target 'app.nitrate/call + :return (fn [_ m _] + (case m + :set-team-organization {:success true} + nil))}] + (let [profile (th/create-profile* 1 {:is-active true}) + before-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile) + :is-owner true}) + (map :team-id) + set) + out (th/management-command! {::th/type :notify-user-added-to-organization + :profile-id (:id profile) + :organization-id (uuid/random) + :role "owner"}) + after-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile) + :is-owner true}) + (map :team-id) + set) + new-team-id (first (set/difference after-teams before-teams)) + new-team (th/db-get :team {:id new-team-id})] + (t/is (th/success? out)) + (t/is (= 1 (count (set/difference after-teams before-teams)))) + (t/is (= "Your Penpot" (:name new-team))) + (t/is (true? (:is-default new-team)))))) (t/deftest get-managed-profiles-returns-unique-members-for-owned-teams - (let [owner (th/create-profile* 1 {:is-active true}) - member1 (th/create-profile* 2 {:is-active true}) - member2 (th/create-profile* 3 {:is-active true}) - team1 (th/create-team* 1 {:profile-id (:id owner)}) - team2 (th/create-team* 2 {:profile-id (:id owner)}) - _ (th/create-team-role* {:team-id (:id team1) - :profile-id (:id member1) - :role :editor}) - _ (th/create-team-role* {:team-id (:id team1) - :profile-id (:id member2) - :role :editor}) - _ (th/create-team-role* {:team-id (:id team2) - :profile-id (:id member1) - :role :editor}) - out (management-command-with-nitrate! {::th/type :get-managed-profiles - ::rpc/profile-id (:id owner)})] - (t/is (th/success? out)) - (t/is (= #{(:id member1) (:id member2)} - (->> out :result (map :id) set))) - (t/is (= #{(:email member1) (:email member2)} - (->> out :result (map :email) set))))) + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + member1 (th/create-profile* 2 {:is-active true}) + member2 (th/create-profile* 3 {:is-active true}) + team1 (th/create-team* 1 {:profile-id (:id owner)}) + team2 (th/create-team* 2 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id member1) + :role :editor}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id member2) + :role :editor}) + _ (th/create-team-role* {:team-id (:id team2) + :profile-id (:id member1) + :role :editor}) + out (th/management-command! {::th/type :get-managed-profiles + ::rpc/profile-id (:id owner)})] + (t/is (th/success? out)) + (t/is (= #{(:id member1) (:id member2)} + (->> out :result (map :id) set))) + (t/is (= #{(:email member1) (:email member2)} + (->> out :result (map :email) set)))))) (t/deftest get-teams-summary-returns-teams-and-files-count - (let [profile (th/create-profile* 1 {:is-active true}) - team1 (th/create-team* 1 {:profile-id (:id profile)}) - team2 (th/create-team* 2 {:profile-id (:id profile)}) - proj1 (th/create-project* 1 {:profile-id (:id profile) - :team-id (:id team1)}) - proj2 (th/create-project* 2 {:profile-id (:id profile) - :team-id (:id team2)}) - _ (th/create-file* 1 {:profile-id (:id profile) - :project-id (:id proj1)}) - _ (th/create-file* 2 {:profile-id (:id profile) - :project-id (:id proj2)}) - out (management-command-with-nitrate! {::th/type :get-teams-summary - ::rpc/profile-id (:id profile) - :ids [(:id team1) (:id team2)]})] - (t/is (th/success? out)) - (t/is (= 2 (-> out :result :num-files))) - (t/is (= #{(:id team1) (:id team2)} - (->> out :result :teams (map :id) set))))) + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true}) + team1 (th/create-team* 1 {:profile-id (:id profile)}) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + proj1 (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team1)}) + proj2 (th/create-project* 2 {:profile-id (:id profile) + :team-id (:id team2)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id proj1)}) + _ (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id proj2)}) + out (th/management-command! {::th/type :get-teams-summary + ::rpc/profile-id (:id profile) + :ids [(:id team1) (:id team2)]})] + (t/is (th/success? out)) + (t/is (= 2 (-> out :result :num-files))) + (t/is (= #{(:id team1) (:id team2)} + (->> out :result :teams (map :id) set)))))) -(t/deftest notify-organization-deletion-prefixes-teams-and-publishes-org-deleted-event - (let [profile (th/create-profile* 1 {:is-active true}) - ;; One team will have files -> it will be kept and renamed. - team-with-files (th/db-get :team {:id (:default-team-id profile)}) - project (th/create-project* 1 {:profile-id (:id profile) - :team-id (:id team-with-files)}) - _ (th/create-file* 1 {:profile-id (:id profile) - :project-id (:id project)}) +(t/deftest get-teams-detail-last-activity-reflects-file-modifications + (let [organization-summary-ref (atom nil)] + (with-mocks [nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-organization-summary @organization-summary-ref + nil))}] + (let [profile (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile)}) + organization-id (uuid/random) + organization-summary {:id organization-id + :teams [{:id (:id team)}]} + _ (reset! organization-summary-ref organization-summary) + params {::th/type :get-teams-detail + ::rpc/profile-id (:id profile) + :organization-id organization-id} + call! (fn [] (th/management-command! params)) - ;; One team will be empty -> it will be soft-deleted. - empty-team (th/create-team* 1 {:profile-id (:id profile)}) + empty-out (call!) + empty-team (-> empty-out :result first) - organization-id (uuid/random) - organization-name "Acme / Design" - expected-start (str "[" (d/sanitize-string organization-name) "] ") - org-summary {:id organization-id - :name organization-name - :teams [{:id (:id team-with-files) - :is-your-penpot true} - {:id (:id empty-team) - :is-your-penpot true}]} - calls (atom []) - submitted (atom []) - out (with-redefs [nitrate/call (fn [_cfg method params] - (t/is (= :get-org-summary method)) - (t/is (= {:organization-id organization-id} params)) - org-summary) - wrk/submit! (fn [task] - (swap! submitted conj task) - nil) - mbus/pub! (fn [_cfg & {:keys [topic message]}] - (swap! calls conj {:topic topic - :message message}))] - (management-command-with-nitrate! {::th/type :notify-organization-deletion - ::rpc/profile-id (:id profile) - :organization-id organization-id})) - updated-with-files (th/db-get :team {:id (:id team-with-files)} {::db/remove-deleted false}) - updated-empty (th/db-get :team {:id (:id empty-team)} {::db/remove-deleted false})] - (t/is (th/success? out)) - (t/is (nil? (:result out))) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + file-after-create (th/db-get :file {:id (:id file)}) + project-after-create (th/db-get :project {:id (:id project)}) + expected-activity-create (if (.isAfter (:modified-at file-after-create) + (:modified-at project-after-create)) + (:modified-at file-after-create) + (:modified-at project-after-create)) - ;; Team with files is kept, unset as default, and renamed with org prefix. - (t/is (false? (:is-default updated-with-files))) - (t/is (str/starts-with? (:name updated-with-files) expected-start)) - (t/is (nil? (:deleted-at updated-with-files))) + with-file-out (call!) + with-file (-> with-file-out :result first) - ;; Empty team is soft-deleted and a delete task is submitted. - (t/is (some? (:deleted-at updated-empty))) - (t/is (= 1 (count @submitted))) + new-activity (ct/in-future "1h") + _ (th/db-update! :file + {:modified-at new-activity} + {:id (:id file)}) + file-after-update (th/db-get :file {:id (:id file)}) - ;; A single organization-deleted event is published. - (t/is (= 1 (count @calls))) - (let [{:keys [topic message]} (first @calls)] - (t/is (= uuid/zero topic)) - (t/is (= :organization-deleted (:type message))) - (t/is (= organization-id (:organization-id message))) - (t/is (= organization-name (:organization-name message))) - (t/is (= #{(:id team-with-files) (:id empty-team)} - (set (:teams message)))) - (t/is (= #{(:id empty-team)} - (set (:deleted-teams message))))))) + updated-out (call!) + updated-team (-> updated-out :result first)] -(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-org-events - (let [profile (th/create-profile* 1 {:is-active true}) - ;; org-1: one team with files, one empty - org-1-team-files (th/db-get :team {:id (:default-team-id profile)}) - org-1-proj (th/create-project* 1 {:profile-id (:id profile) - :team-id (:id org-1-team-files)}) - _ (th/create-file* 1 {:profile-id (:id profile) - :project-id (:id org-1-proj)}) - org-1-team-empty (th/create-team* 1 {:profile-id (:id profile)}) + (t/is (th/success? empty-out)) + (t/is (= (:id team) (:id empty-team))) + (t/is (nil? (:last-activity-at empty-team))) - ;; org-2: one team with files, one empty - org-2-team-files (th/create-team* 2 {:profile-id (:id profile)}) - org-2-proj (th/create-project* 2 {:profile-id (:id profile) - :team-id (:id org-2-team-files)}) - _ (th/create-file* 2 {:profile-id (:id profile) - :project-id (:id org-2-proj)}) - org-2-team-empty (th/create-team* 3 {:profile-id (:id profile)}) + (t/is (th/success? with-file-out)) + (t/is (= (:id team) (:id with-file))) + (t/is (= expected-activity-create (:last-activity-at with-file))) - org-1-id (uuid/random) - org-2-id (uuid/random) - org-1-name "Org One / Design" - org-2-name "Org Two" - org-1-prefix (str "[" (d/sanitize-string org-1-name) "] ") - org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ") - owned-orgs [{:id org-1-id - :name org-1-name - :teams [{:id (:id org-1-team-files) - :is-your-penpot true} - {:id (:id org-1-team-empty) - :is-your-penpot true}]} - {:id org-2-id - :name org-2-name - :teams [{:id (:id org-2-team-files) - :is-your-penpot true} - {:id (:id org-2-team-empty) - :is-your-penpot true}]}] - calls (atom []) - submitted (atom []) - out (with-redefs [nitrate/call (fn [_cfg method params] - (case method - :get-owned-orgs - (do - (t/is (= {:profile-id (:id profile)} params)) - owned-orgs) - nil)) - wrk/submit! (fn [task] - (swap! submitted conj task) - nil) - mbus/pub! (fn [_cfg & {:keys [topic message]}] - (swap! calls conj {:topic topic - :message message}))] - (management-command-with-nitrate! {::th/type :notify-user-organizations-deletion - ::rpc/profile-id (:id profile) - :profile-id (:id profile)})) - org-1-updated-files (th/db-get :team {:id (:id org-1-team-files)} {::db/remove-deleted false}) - org-1-updated-empty (th/db-get :team {:id (:id org-1-team-empty)} {::db/remove-deleted false}) - org-2-updated-files (th/db-get :team {:id (:id org-2-team-files)} {::db/remove-deleted false}) - org-2-updated-empty (th/db-get :team {:id (:id org-2-team-empty)} {::db/remove-deleted false}) - msgs (->> @calls (map :message) vec) - org-msg (fn [org-name] - (first (filter #(= org-name (:organization-name %)) msgs)))] - (t/is (th/success? out)) - (t/is (nil? (:result out))) + (t/is (th/success? updated-out)) + (t/is (= (:id team) (:id updated-team))) + (t/is (= (:modified-at file-after-update) (:last-activity-at updated-team))) + (t/is (not= (:last-activity-at with-file) (:last-activity-at updated-team))))))) - ;; org-1: team with files renamed; empty team deleted - (t/is (false? (:is-default org-1-updated-files))) - (t/is (str/starts-with? (:name org-1-updated-files) org-1-prefix)) - (t/is (nil? (:deleted-at org-1-updated-files))) - (t/is (some? (:deleted-at org-1-updated-empty))) +(t/deftest notify-organization-deletion-prefixes-teams-and-publishes-organization-deleted-event + ;; --- Deferred organization-summary: nil during setup, filled before RPC --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: return nil during profile/team creation, + ;; then serve the computed organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method params] + (if @organization-summary-ref + @organization-summary-ref + nil))} + ;; --- Worker mock: capture delete-task submission --- + wrk-mock {:target 'app.worker/submit! :return nil} + ;; --- Message bus mock: capture published events --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] - ;; org-2: team with files renamed; empty team deleted - (t/is (false? (:is-default org-2-updated-files))) - (t/is (str/starts-with? (:name org-2-updated-files) org-2-prefix)) - (t/is (nil? (:deleted-at org-2-updated-files))) - (t/is (some? (:deleted-at org-2-updated-empty))) + ;; --- Setup: create a profile with two teams --- + (let [profile (th/create-profile* 1 {:is-active true}) + ;; --- Team with files: should be kept and renamed --- + team-with-files (th/db-get :team {:id (:default-team-id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team-with-files)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + ;; --- Empty team: should be soft-deleted --- + empty-team (th/create-team* 1 {:profile-id (:id profile)}) - ;; two delete tasks (one per empty team) - (t/is (= 2 (count @submitted))) + ;; --- Data needed by the RPC call --- + organization-id (uuid/random) + organization-name "Acme / Design" + expected-start (str "[" (d/sanitize-string organization-name) "] ") + ;; --- Org-summary that nitrate would return --- + organization-summary {:id organization-id + :name organization-name + :teams [{:id (:id team-with-files) + :is-your-penpot true} + {:id (:id empty-team) + :is-your-penpot true}]} + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) - ;; one organization-deleted event per org - (t/is (= 2 (count @calls))) - (t/is (every? #(= uuid/zero (:topic %)) @calls)) - (t/is (= #{:organization-deleted} - (set (map (comp :type :message) @calls)))) + ;; --- Exercise: notify Penpot that an organization is deleted --- + out (th/management-command! {::th/type :notify-organization-deletion + ::rpc/profile-id (:id profile) + :organization-id organization-id}) - (let [m1 (org-msg org-1-name) - m2 (org-msg org-2-name)] - (t/is (some? m1)) - (t/is (some? m2)) - (t/is (= org-1-id (:organization-id m1))) - (t/is (= org-2-id (:organization-id m2))) - (t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)} - (set (:teams m1)))) - (t/is (= #{(:id org-1-team-empty)} - (set (:deleted-teams m1)))) - (t/is (= #{(:id org-2-team-files) (:id org-2-team-empty)} - (set (:teams m2)))) - (t/is (= #{(:id org-2-team-empty)} - (set (:deleted-teams m2))))))) + ;; --- Fetch teams post-deletion to verify mutations --- + updated-with-files (th/db-get :team {:id (:id team-with-files)} {::db/remove-deleted false}) + updated-empty (th/db-get :team {:id (:id empty-team)} {::db/remove-deleted false})] + + ;; --- Verify: nitrate was queried for the organization summary --- + (let [[_ method params] (:call-args @nitrate-mock)] + (t/is (= :get-organization-summary method)) + (t/is (= {:organization-id organization-id} params))) + + ;; --- Verify: RPC returns success with no result payload --- + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; --- Verify: team with files is kept, default flag removed, name prefixed --- + (t/is (false? (:is-default updated-with-files))) + (t/is (str/starts-with? (:name updated-with-files) expected-start)) + (t/is (nil? (:deleted-at updated-with-files))) + + ;; --- Verify: empty team is soft-deleted and a background delete task is enqueued --- + (t/is (some? (:deleted-at updated-empty))) + (t/is (:called? @wrk-mock)) + (t/is (= 1 (:call-count @wrk-mock))) + + ;; --- Verify: exactly one organization-deleted event is published on the message bus --- + (t/is (:called? @mbus-mock)) + (let [msg (apply hash-map (rest (:call-args @mbus-mock)))] + (t/is (= uuid/zero (:topic msg))) + (t/is (= :organization-deleted (:type (:message msg)))) + (t/is (= organization-id (:organization-id (:message msg)))) + (t/is (= organization-name (:organization-name (:message msg)))) + (t/is (= #{(:id team-with-files) (:id empty-team)} + (set (:teams (:message msg))))) + (t/is (= #{(:id empty-team)} + (set (:deleted-teams (:message msg)))))))))) + +(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-organization-events + ;; --- Deferred owned-organizations: nil during setup, filled before RPC --- + (let [owned-organizations-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: return nil during profile/team creation, + ;; then serve the computed owned-organizations once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-owned-organizations @owned-organizations-ref + nil))} + ;; --- Worker mock: capture delete-task submissions --- + wrk-mock {:target 'app.worker/submit! :return nil} + ;; --- Message bus mock: capture published events --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] + + ;; --- Setup: create a profile with teams across two organizations --- + (let [profile (th/create-profile* 1 {:is-active true}) + ;; --- Org-1: team with files: kept and renamed --- + organization-1-team-files (th/db-get :team {:id (:default-team-id profile)}) + organization-1-proj (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id organization-1-team-files)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id organization-1-proj)}) + ;; --- Org-1: empty team: soft-deleted --- + organization-1-team-empty (th/create-team* 1 {:profile-id (:id profile)}) + + ;; --- Org-2: team with files: kept and renamed --- + organization-2-team-files (th/create-team* 2 {:profile-id (:id profile)}) + organization-2-proj (th/create-project* 2 {:profile-id (:id profile) + :team-id (:id organization-2-team-files)}) + _ (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id organization-2-proj)}) + ;; --- Org-2: empty team: soft-deleted --- + organization-2-team-empty (th/create-team* 3 {:profile-id (:id profile)}) + + ;; --- Data needed by the RPC call --- + organization-1-id (uuid/random) + organization-2-id (uuid/random) + organization-1-name "Org One / Design" + organization-2-name "Org Two" + organization-1-prefix (str "[" (d/sanitize-string organization-1-name) "] ") + organization-2-prefix (str "[" (d/sanitize-string organization-2-name) "] ") + ;; --- Owned-organizations that nitrate would return --- + owned-organizations [{:id organization-1-id + :name organization-1-name + :teams [{:id (:id organization-1-team-files) + :is-your-penpot true} + {:id (:id organization-1-team-empty) + :is-your-penpot true}]} + {:id organization-2-id + :name organization-2-name + :teams [{:id (:id organization-2-team-files) + :is-your-penpot true} + {:id (:id organization-2-team-empty) + :is-your-penpot true}]}] + ;; --- Publish owned-organizations so the mock can serve it --- + _ (reset! owned-organizations-ref owned-organizations) + + ;; --- Exercise: notify Penpot that the user's organizations are deleted --- + out (th/management-command! {::th/type :notify-user-organizations-deletion + ::rpc/profile-id (:id profile) + :profile-id (:id profile)}) + + ;; --- Fetch teams post-deletion to verify mutations --- + organization-1-updated-files (th/db-get :team {:id (:id organization-1-team-files)} {::db/remove-deleted false}) + organization-1-updated-empty (th/db-get :team {:id (:id organization-1-team-empty)} {::db/remove-deleted false}) + organization-2-updated-files (th/db-get :team {:id (:id organization-2-team-files)} {::db/remove-deleted false}) + organization-2-updated-empty (th/db-get :team {:id (:id organization-2-team-empty)} {::db/remove-deleted false}) + + ;; --- Extract published messages from the message bus mock --- + msgs (->> (:call-args-list @mbus-mock) + (map #(apply hash-map (rest %))) + (map :message) + vec) + organization-msg (fn [organization-name] + (first (filter #(= organization-name (:organization-name %)) msgs)))] + + ;; --- Verify: nitrate was queried for owned organizations with correct params --- + (let [[_ method params] (:call-args @nitrate-mock)] + (t/is (= :get-owned-organizations method)) + (t/is (= {:profile-id (:id profile)} params))) + + ;; --- Verify: RPC returns success with no result payload --- + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; --- Verify: organization-1 team with files kept, renamed, default flag removed --- + (t/is (false? (:is-default organization-1-updated-files))) + (t/is (str/starts-with? (:name organization-1-updated-files) organization-1-prefix)) + (t/is (nil? (:deleted-at organization-1-updated-files))) + ;; --- Verify: organization-1 empty team soft-deleted --- + (t/is (some? (:deleted-at organization-1-updated-empty))) + + ;; --- Verify: organization-2 team with files kept, renamed, default flag removed --- + (t/is (false? (:is-default organization-2-updated-files))) + (t/is (str/starts-with? (:name organization-2-updated-files) organization-2-prefix)) + (t/is (nil? (:deleted-at organization-2-updated-files))) + ;; --- Verify: organization-2 empty team soft-deleted --- + (t/is (some? (:deleted-at organization-2-updated-empty))) + + ;; --- Verify: two delete tasks submitted (one per empty team) --- + (t/is (:called? @wrk-mock)) + (t/is (= 2 (:call-count @wrk-mock))) + + ;; --- Verify: one organization-deleted event per organization, all on correct topic --- + (t/is (= 2 (count msgs))) + (t/is (every? #(= uuid/zero (:topic %)) + (->> (:call-args-list @mbus-mock) + (map #(apply hash-map (rest %)))))) + (t/is (= #{:organization-deleted} (set (map :type msgs)))) + + ;; --- Verify: each organization-deleted event has correct organization-specific payload --- + (let [m1 (organization-msg organization-1-name) + m2 (organization-msg organization-2-name)] + (t/is (some? m1)) + (t/is (some? m2)) + (t/is (= organization-1-id (:organization-id m1))) + (t/is (= organization-2-id (:organization-id m2))) + (t/is (= #{(:id organization-1-team-files) (:id organization-1-team-empty)} + (set (:teams m1)))) + (t/is (= #{(:id organization-1-team-empty)} + (set (:deleted-teams m1)))) + (t/is (= #{(:id organization-2-team-files) (:id organization-2-team-empty)} + (set (:teams m2)))) + (t/is (= #{(:id organization-2-team-empty)} + (set (:deleted-teams m2))))))))) (t/deftest get-profile-by-email-success-and-not-found - (let [profile (th/create-profile* 1 {:is-active true - :fullname "Lookup by Email"}) - ok-out (management-command-with-nitrate! {::th/type :get-profile-by-email - ::rpc/profile-id (:id profile) - :email (:email profile)}) - ko-out (management-command-with-nitrate! {::th/type :get-profile-by-email - ::rpc/profile-id (:id profile) - :email "not-found@example.com"})] - (t/is (th/success? ok-out)) - (t/is (= (:id profile) (-> ok-out :result :id))) - (t/is (= "Lookup by Email" (-> ok-out :result :name))) - (t/is (nil? (-> ok-out :result :photo-url))) + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true + :fullname "Lookup by Email"}) + ok-out (th/management-command! {::th/type :get-profile-by-email + ::rpc/profile-id (:id profile) + :email (:email profile)}) + ko-out (th/management-command! {::th/type :get-profile-by-email + ::rpc/profile-id (:id profile) + :email "not-found@example.com"})] + (t/is (th/success? ok-out)) + (t/is (= (:id profile) (-> ok-out :result :id))) + (t/is (= "Lookup by Email" (-> ok-out :result :name))) + (t/is (nil? (-> ok-out :result :photo-url))) - (t/is (not (th/success? ko-out))) - (t/is (= :not-found (th/ex-type (:error ko-out)))) - (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) + (t/is (not (th/success? ko-out))) + (t/is (= :not-found (th/ex-type (:error ko-out)))) + (t/is (= :profile-not-found (th/ex-code (:error ko-out))))))) (t/deftest get-profile-by-id-success-and-not-found - (let [profile (th/create-profile* 1 {:is-active true - :fullname "Lookup by Id"}) - ok-out (management-command-with-nitrate! {::th/type :get-profile-by-id - ::rpc/profile-id (:id profile) - :id (:id profile)}) - ko-out (management-command-with-nitrate! {::th/type :get-profile-by-id - ::rpc/profile-id (:id profile) - :id (uuid/random)})] - (t/is (th/success? ok-out)) - (t/is (= (:id profile) (-> ok-out :result :id))) - (t/is (= "Lookup by Id" (-> ok-out :result :name))) - (t/is (nil? (-> ok-out :result :photo-url))) + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [profile (th/create-profile* 1 {:is-active true + :fullname "Lookup by Id"}) + ok-out (th/management-command! {::th/type :get-profile-by-id + ::rpc/profile-id (:id profile) + :id (:id profile)}) + ko-out (th/management-command! {::th/type :get-profile-by-id + ::rpc/profile-id (:id profile) + :id (uuid/random)})] + (t/is (th/success? ok-out)) + (t/is (= (:id profile) (-> ok-out :result :id))) + (t/is (= "Lookup by Id" (-> ok-out :result :name))) + (t/is (nil? (-> ok-out :result :photo-url))) - (t/is (not (th/success? ko-out))) - (t/is (= :not-found (th/ex-type (:error ko-out)))) - (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) + (t/is (not (th/success? ko-out))) + (t/is (= :not-found (th/ex-type (:error ko-out)))) + (t/is (= :profile-not-found (th/ex-code (:error ko-out))))))) -(t/deftest get-org-invitations-returns-valid-deduped-by-email - (let [profile (th/create-profile* 1 {:is-active true}) - team-1 (th/create-team* 1 {:profile-id (:id profile)}) - team-2 (th/create-team* 2 {:profile-id (:id profile)}) - org-id (uuid/random) - org-summary {:id org-id - :teams [{:id (:id team-1)} - {:id (:id team-2)}]} - params {::th/type :get-org-invitations - ::rpc/profile-id (:id profile) - :organization-id org-id}] +(t/deftest get-organization-invitations-returns-valid-deduped-by-email + ;; --- Deferred organization-summary: nil during setup, filled before RPC --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: return nil during profile/team creation, + ;; then serve the computed organization-summary when the handler queries it --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-organization-summary @organization-summary-ref + nil))}] - ;; Same email appears in org and team invitations; only one should be returned. - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to "dup@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Setup: create profile, teams, and invitation data --- + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-1) - :org-id nil - :email-to "dup@example.com" - :created-by (:id profile) - :role "admin" - :valid-until (ct/in-future "72h")}) + ;; --- Data needed by the RPC call --- + organization-id (uuid/random) + organization-summary {:id organization-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + params {::th/type :get-organization-invitations + ::rpc/profile-id (:id profile) + :organization-id organization-id} + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-2) - :org-id nil - :email-to "valid@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "48h")}) + ;; --- Insert invitation records --- + ;; Same email appears in organization and team invitations; only one should be returned. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "admin" + :valid-until (ct/in-future "72h")}) + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "valid@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "48h")}) + ;; Expired invitation should be ignored. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to "expired@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) - ;; Expired invitation should be ignored. - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to "expired@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-past "1h")}) + ;; --- Exercise: query organization invitations --- + out (th/management-command! params) - (let [out (with-redefs [nitrate/call (fn [_cfg method _params] - (case method - :get-org-summary org-summary - nil))] - (management-command-with-nitrate! params)) - result (:result out) - emails (->> result (map :email) set) - dedup (->> result - (filter #(= "dup@example.com" (:email %))) - first)] - (t/is (th/success? out)) - (t/is (= #{"dup@example.com" "valid@example.com"} emails)) - (t/is (= 2 (count result))) - (t/is (some? (:id dedup))) - (t/is (some? (:sent-at dedup))) - (t/is (nil? (:organization-id dedup))) - (t/is (nil? (:team-id dedup))) - (t/is (nil? (:role dedup))) - (t/is (nil? (:valid-until dedup)))))) + ;; --- Extract results --- + result (:result out) + emails (->> result (map :email) set) + dedup (->> result + (filter #(= "dup@example.com" (:email %))) + first)] -(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams - (let [profile (th/create-profile* 1 {:is-active true}) - org-id (uuid/random) - org-summary {:id org-id - :teams []} - params {::th/type :get-org-invitations - ::rpc/profile-id (:id profile) - :organization-id org-id}] + ;; --- Verify: nitrate was queried for the organization summary --- + (let [[_ method params'] (:call-args @nitrate-mock)] + (t/is (= :get-organization-summary method)) + (t/is (= {:organization-id organization-id} params'))) - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to "org-only@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Verify: RPC returns success with deduplicated invitations --- + (t/is (th/success? out)) + (t/is (= #{"dup@example.com" "valid@example.com"} emails)) + (t/is (= 2 (count result))) + ;; --- Verify: deduplicated invitation has id/sent-at but no team-specific fields --- + (t/is (some? (:id dedup))) + (t/is (some? (:sent-at dedup))) + (t/is (nil? (:organization-id dedup))) + (t/is (nil? (:team-id dedup))) + (t/is (nil? (:role dedup))) + (t/is (nil? (:valid-until dedup))))))) - (let [out (with-redefs [nitrate/call (fn [_cfg method _params] - (case method - :get-org-summary org-summary - nil))] - (management-command-with-nitrate! params)) - result (:result out)] - (t/is (th/success? out)) - (t/is (= 1 (count result))) - (t/is (= "org-only@example.com" (-> result first :email))) - (t/is (some? (-> result first :sent-at)))))) +(t/deftest get-organization-invitations-includes-organization-level-invitations-when-no-teams + ;; --- Org-summary has no teams — computable before with-mocks, no deferral needed --- + (let [organization-id (uuid/random) + organization-summary {:id organization-id :teams []} + params {::th/type :get-organization-invitations + :organization-id organization-id}] + (with-mocks + [;; --- Nitrate mock: return the organization-summary when the handler queries it --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-organization-summary organization-summary + nil))}] -(t/deftest get-org-invitations-returns-existing-profile-data - (let [profile (th/create-profile* 1 {:is-active true}) - invited (th/create-profile* 2 {:is-active true - :fullname "Invited User"}) - photo-id (uuid/random) - _ (th/db-insert! :storage-object {:id photo-id - :backend "assets-fs"}) - _ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)}) - org-id (uuid/random) - org-summary {:id org-id - :teams []} - params {::th/type :get-org-invitations - ::rpc/profile-id (:id profile) - :organization-id org-id}] + ;; --- Setup: create profile and insert an organization-level invitation --- + (let [profile (th/create-profile* 1 {:is-active true}) + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to "organization-only@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to (:email invited) - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Exercise: query organization invitations --- + out (th/management-command! (assoc params ::rpc/profile-id (:id profile))) + result (:result out)] - (let [out (with-redefs [nitrate/call (fn [_cfg method _params] - (case method - :get-org-summary org-summary - nil))] - (management-command-with-nitrate! params)) - invitation (-> out :result first)] - (t/is (th/success? out)) - (t/is (= "Invited User" (:name invitation))) - (t/is (some? (:sent-at invitation))) - (t/is (str/ends-with? (:photo-url invitation) - (str "/assets/by-id/" photo-id)))))) + ;; --- Verify: nitrate was queried for the organization summary --- + (let [[_ method params'] (:call-args @nitrate-mock)] + (t/is (= :get-organization-summary method)) + (t/is (= {:organization-id organization-id} params'))) -(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email - (let [profile (th/create-profile* 1 {:is-active true}) - team-1 (th/create-team* 1 {:profile-id (:id profile)}) - team-2 (th/create-team* 2 {:profile-id (:id profile)}) - outside-team (th/create-team* 3 {:profile-id (:id profile)}) - org-id (uuid/random) - org-summary {:id org-id - :teams [{:id (:id team-1)} - {:id (:id team-2)}]} - target-email "target@example.com" - params {::th/type :delete-org-invitations - ::rpc/profile-id (:id profile) - :organization-id org-id - :email "TARGET@example.com"}] + ;; --- Verify: the organization-level invitation is returned --- + (t/is (th/success? out)) + (t/is (= 1 (count result))) + (t/is (= "organization-only@example.com" (-> result first :email))) + (t/is (some? (-> result first :sent-at))))))) - ;; Should be deleted: org-level invitation for same org+email. - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to target-email - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) +(t/deftest get-organization-invitations-returns-existing-profile-data + ;; --- Org-summary has no teams — computable before with-mocks, no deferral needed --- + (let [organization-id (uuid/random) + organization-summary {:id organization-id :teams []}] + (with-mocks + [;; --- Nitrate mock: return the organization-summary when the handler queries it --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-organization-summary organization-summary + nil))}] - ;; Should be deleted: team-level invitation for teams belonging to org summary. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-1) - :org-id nil - :email-to target-email - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-past "1h")}) + ;; --- Setup: create profiles, set photo on invited user, insert invitation --- + (let [profile (th/create-profile* 1 {:is-active true}) + invited (th/create-profile* 2 {:is-active true + :fullname "Invited User"}) + photo-id (uuid/random) + _ (th/db-insert! :storage-object {:id photo-id + :backend "assets-fs"}) + _ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)}) + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to (:email invited) + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - ;; Should remain: different email. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-2) - :org-id nil - :email-to "other@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Exercise: query organization invitations --- + out (th/management-command! {::th/type :get-organization-invitations + ::rpc/profile-id (:id profile) + :organization-id organization-id}) + invitation (-> out :result first)] - ;; Should remain: same email but outside org scope. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id outside-team) - :org-id nil - :email-to target-email - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Verify: nitrate was queried for the organization summary --- + (let [[_ method params'] (:call-args @nitrate-mock)] + (t/is (= :get-organization-summary method)) + (t/is (= {:organization-id organization-id} params'))) - (let [out (with-redefs [nitrate/call (fn [_cfg method _params] - (case method - :get-org-summary org-summary - nil))] - (management-command-with-nitrate! params)) - remaining-target (th/db-query :team-invitation {:email-to target-email}) - remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})] - (t/is (th/success? out)) - (t/is (nil? (:result out))) - (t/is (= 1 (count remaining-target))) - (t/is (= (:id outside-team) (:team-id (first remaining-target)))) - (t/is (= 1 (count remaining-other)))))) + ;; --- Verify: invitation includes the invited user's existing profile data --- + (t/is (th/success? out)) + (t/is (= "Invited User" (:name invitation))) + (t/is (some? (:sent-at invitation))) + (t/is (str/ends-with? (:photo-url invitation) + (str "/assets/by-id/" photo-id))))))) -(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations - (let [profile (th/create-profile* 1 {:is-active true}) - team-1 (th/create-team* 1 {:profile-id (:id profile)}) - team-2 (th/create-team* 2 {:profile-id (:id profile)}) - outside-team (th/create-team* 3 {:profile-id (:id profile)}) - org-id (uuid/random) - org-summary {:id org-id - :teams [{:id (:id team-1)} - {:id (:id team-2)}]} - params {::th/type :delete-all-org-invitations - :organization-id org-id}] +(t/deftest delete-organization-invitations-removes-organization-and-organization-team-invitations-for-email + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil) + target-email "target@example.com"] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary when handler queries it --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-organization-summary @organization-summary-ref + nil))}] - ;; Should be deleted: org-level invitation. - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to "alice@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Setup: create profile, teams, and invitation data --- + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + organization-id (uuid/random) + organization-summary {:id organization-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) - ;; Should be deleted: team-level invitation in team-1 (belongs to org). - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-1) - :org-id nil - :email-to "bob@example.com" - :created-by (:id profile) - :role "admin" - :valid-until (ct/in-future "48h")}) + ;; --- Insert invitation records --- + ;; Should be deleted: organization-level invitation for same organization+email. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + ;; Should be deleted: team-level invitation for teams in organization summary. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + ;; Should remain: different email. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "other@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + ;; Should remain: same email but outside organization scope. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - ;; Should be deleted: team-level invitation in team-2 (belongs to org), - ;; even if expired. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-2) - :org-id nil - :email-to "carol@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-past "1h")}) + ;; --- Exercise: delete organization invitations for target email --- + out (th/management-command! {::th/type :delete-organization-invitations + ::rpc/profile-id (:id profile) + :organization-id organization-id + :email "TARGET@example.com"}) + remaining-target (th/db-query :team-invitation {:email-to target-email}) + remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})] - ;; Should remain: invitation to a team outside the org. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id outside-team) - :org-id nil - :email-to "dan@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Verify: nitrate was queried for the organization summary --- + (let [[_ method params'] (:call-args @nitrate-mock)] + (t/is (= :get-organization-summary method)) + (t/is (= {:organization-id organization-id} params'))) - ;; Should remain: invitation to a different organization. - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id (uuid/random) - :team-id nil - :email-to "erin@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Verify: RPC returns success with no result payload --- + (t/is (th/success? out)) + (t/is (nil? (:result out))) + ;; --- Verify: only the outside-team invitation remains for target email --- + (t/is (= 1 (count remaining-target))) + (t/is (= (:id outside-team) (:team-id (first remaining-target)))) + ;; --- Verify: other-email invitation is untouched --- + (t/is (= 1 (count remaining-other))))))) - (let [calls (atom []) - out (with-redefs [nitrate/call (fn [_cfg method params] - (swap! calls conj {:method method :params params}) - (case method - :get-org-summary org-summary - nil))] - (management-command-with-nitrate! params)) - present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))] - (t/is (th/success? out)) - (t/is (nil? (:result out))) +(t/deftest delete-all-organization-invitations-removes-organization-and-organization-team-invitations + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary when handler queries it --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-organization-summary @organization-summary-ref + nil))}] - ;; get-org-summary was called with the right organization-id. - (t/is (= 1 (count @calls))) - (t/is (= :get-org-summary (-> @calls first :method))) - (t/is (= {:organization-id org-id} (-> @calls first :params))) + ;; --- Setup: create profile, teams, and invitation data --- + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + organization-id (uuid/random) + organization-summary {:id organization-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) - ;; Org-level + team-in-org invitations are deleted. - (t/is (not (present? "alice@example.com"))) - (t/is (not (present? "bob@example.com"))) - (t/is (not (present? "carol@example.com"))) + ;; --- Insert invitation records --- + ;; Should be deleted: organization-level invitation. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to "alice@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + ;; Should be deleted: team-level invitation in team-1 (belongs to organization). + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "bob@example.com" + :created-by (:id profile) + :role "admin" + :valid-until (ct/in-future "48h")}) + ;; Should be deleted: team-level invitation in team-2 (belongs to organization), + ;; even if expired. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "carol@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + ;; Should remain: invitation to a team outside the org. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to "dan@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + ;; Should remain: invitation to a different organization. + _ (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id (uuid/random) + :team-id nil + :email-to "erin@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - ;; Invitations outside the org survive. - (t/is (present? "dan@example.com")) - (t/is (present? "erin@example.com"))))) + ;; --- Exercise: delete all invitations in the organization --- + out (th/management-command! {::th/type :delete-all-organization-invitations + ::rpc/profile-id (:id profile) + :organization-id organization-id}) + present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))] -(t/deftest delete-all-org-invitations-handles-org-with-no-teams - (let [profile (th/create-profile* 1 {:is-active true}) - org-id (uuid/random) - params {::th/type :delete-all-org-invitations - :organization-id org-id}] + ;; --- Verify: the handler's nitrate call was :get-organization-summary with correct params --- + ;; (The mock also recorded setup-phase calls from add-profile-to-team!, + ;; so :call-args reflects the LAST call — which is the handler's.) + (let [[_ method params'] (:call-args @nitrate-mock)] + (t/is (= :get-organization-summary method)) + (t/is (= {:organization-id organization-id} params'))) - ;; Org-level invitation should still be deleted. - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to "alice@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; --- Verify: RPC returns success with no result payload --- + (t/is (th/success? out)) + (t/is (nil? (:result out))) - (let [out (with-redefs [nitrate/call (fn [_cfg method _params] - (case method - :get-org-summary {:id org-id :teams []} - nil))] - (management-command-with-nitrate! params)) - remaining (th/db-query :team-invitation {:org-id org-id})] - (t/is (th/success? out)) - (t/is (nil? (:result out))) - (t/is (empty? remaining))))) + ;; --- Verify: organization-level + team-in-organization invitations are deleted --- + (t/is (not (present? "alice@example.com"))) + (t/is (not (present? "bob@example.com"))) + (t/is (not (present? "carol@example.com"))) -(t/deftest exists-org-team-invitations-for-non-members-reports-invitations-to-delete - (let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"}) - profile (th/create-profile* 4 {:is-active true}) - team-1 (th/create-team* 1 {:profile-id (:id profile)}) - team-2 (th/create-team* 2 {:profile-id (:id profile)}) - outside-team (th/create-team* 3 {:profile-id (:id profile)}) - org-id (uuid/random) - base-params {::th/type :exists-org-team-invitations-for-non-members - ::rpc/profile-id (:id profile) - :organization-id org-id - :team-ids [(:id team-1) (:id team-2)] - :member-ids [(:id member1)]} - exist! (fn [] (-> (management-command-with-nitrate! base-params) - :result - :exists))] + ;; --- Verify: invitations outside the organization survive --- + (t/is (present? "dan@example.com")) + (t/is (present? "erin@example.com")))))) - (t/is (false? (exist!))) +(t/deftest delete-all-organization-invitations-handles-organization-with-no-teams + (let [organization-id (uuid/random) + params {::th/type :delete-all-organization-invitations + :organization-id organization-id}] + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil} + nitrate-fn {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (case method + :get-organization-summary {:id organization-id :teams []} + nil))}] + (let [profile (th/create-profile* 1 {:is-active true})] - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-1) - :org-id nil - :email-to "member1@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) - (t/is (false? (exist!))) - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to "pending@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) - (t/is (false? (exist!))) + ;; Org-level invitation should still be deleted. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to "alice@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id outside-team) - :org-id nil - :email-to "outsider@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) - (t/is (false? (exist!))) + (let [out (th/management-command! params) + remaining (th/db-query :team-invitation {:org-id organization-id})] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + (t/is (empty? remaining))))))) - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-2) - :org-id nil - :email-to "orphan@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) - (t/is (true? (exist!))))) +(t/deftest exists-organization-team-invitations-for-non-members-reports-invitations-to-delete + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"}) + profile (th/create-profile* 4 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + organization-id (uuid/random) + base-params {::th/type :exists-organization-team-invitations-for-non-members + ::rpc/profile-id (:id profile) + :organization-id organization-id + :team-ids [(:id team-1) (:id team-2)] + :member-ids [(:id member1)]} + exist! (fn [] (-> (th/management-command! base-params) + :result + :exists))] -(t/deftest delete-org-team-invitations-for-non-members-removes-non-member-invitations - (let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"}) - profile (th/create-profile* 4 {:is-active true}) - team-1 (th/create-team* 1 {:profile-id (:id profile)}) - team-2 (th/create-team* 2 {:profile-id (:id profile)}) - outside-team (th/create-team* 3 {:profile-id (:id profile)}) - org-id (uuid/random) - params {::th/type :delete-org-team-invitations-for-non-members - ::rpc/profile-id (:id profile) - :organization-id org-id - :team-ids [(:id team-1) (:id team-2)] - :member-ids [(:id member1)]}] + (t/is (false? (exist!))) - ;; Should remain: member1 is an org member. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-1) - :org-id nil - :email-to "member1@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "member1@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + (t/is (false? (exist!))) - ;; Org-level invitation remains (out of team cleanup scope). - (th/db-insert! :team-invitation - {:id (uuid/random) - :org-id org-id - :team-id nil - :email-to "pending@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to "pending@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + (t/is (false? (exist!))) - ;; Should be deleted: team invitation for non-member - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-2) - :org-id nil - :email-to "pending@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to "outsider@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + (t/is (false? (exist!))) - ;; Should be deleted: orphaned invitation - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-2) - :org-id nil - :email-to "orphan@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "orphan@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + (t/is (true? (exist!)))))) - ;; Should be deleted: expired invitation. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id team-1) - :org-id nil - :email-to "expired@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-past "1h")}) +(t/deftest delete-organization-team-invitations-for-non-members-removes-non-member-invitations + (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] + (let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"}) + profile (th/create-profile* 4 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + organization-id (uuid/random) + params {::th/type :delete-organization-team-invitations-for-non-members + ::rpc/profile-id (:id profile) + :organization-id organization-id + :team-ids [(:id team-1) (:id team-2)] + :member-ids [(:id member1)]}] - ;; Should remain: outside org scope. - (th/db-insert! :team-invitation - {:id (uuid/random) - :team-id (:id outside-team) - :org-id nil - :email-to "outsider@example.com" - :created-by (:id profile) - :role "editor" - :valid-until (ct/in-future "24h")}) + ;; Should remain: member1 is an organization member. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "member1@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - (let [out (management-command-with-nitrate! params)] + ;; Org-level invitation remains (out of team cleanup scope). + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to "pending@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - (t/is (th/success? out)) - (t/is (nil? (:result out))) + ;; Should be deleted: team invitation for non-member + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "pending@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) - ;; Verify remaining invitations. - (t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"})))) - (t/is (= 1 (count (th/db-query :team-invitation {:email-to "pending@example.com"})))) - (t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"})))) - (t/is (= 0 (count (th/db-query :team-invitation {:email-to "expired@example.com"})))) - (t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"}))))))) + ;; Should be deleted: orphaned invitation + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "orphan@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should be deleted: expired invitation. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "expired@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + ;; Should remain: outside organization scope. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to "outsider@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (th/management-command! params)] + + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; Verify remaining invitations. + (t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"})))) + (t/is (= 1 (count (th/db-query :team-invitation {:email-to "pending@example.com"})))) + (t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"})))) + (t/is (= 0 (count (th/db-query :team-invitation {:email-to "expired@example.com"})))) + (t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"})))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Tests: remove-from-org +;; Tests: remove-from-organization ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- make-org-summary - [& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams] - :or {your-penpot-teams [] org-teams []}}] +(defn- make-organization-summary + [& {:keys [organization-id organization-name owner-id your-penpot-teams organization-teams] + :or {your-penpot-teams [] organization-teams []}}] {:id organization-id :name organization-name :owner-id owner-id :teams (into (mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams) - (mapv (fn [id] {:id id :is-your-penpot false}) org-teams))}) + (mapv (fn [id] {:id id :is-your-penpot false}) organization-teams))}) (defn- nitrate-call-mock - [org-summary] - (fn [_cfg method _params] - (case method - :get-org-summary org-summary - :get-org-membership {:organization-id (:id org-summary) - :is-member true} - :remove-profile-from-org nil - nil))) + ([organization-summary] + (nitrate-call-mock organization-summary nil)) + ([organization-summary remove-profile-params] + (fn [_cfg method params] + (case method + :get-organization-summary organization-summary + :get-organization-membership {:organization-id (:id organization-summary) + :is-member true} + :remove-profile-from-organization (when remove-profile-params + (reset! remove-profile-params params)) + nil)))) -(t/deftest remove-from-org-happy-path-no-extra-teams +(t/deftest remove-from-organization-happy-path-no-extra-teams ;; User is only in its default team (which has files); it should be ;; kept, renamed and unset as default. A notification must be sent. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - org-team (th/create-team* 1 {:profile-id (:id user)}) - project (th/create-project* 1 {:profile-id (:id user) - :team-id (:id org-team)}) - _ (th/create-file* 1 {:profile-id (:id user) - :project-id (:id project)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams []) - calls (atom []) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary) - mbus/pub! (fn [_bus & {:keys [topic message]}] - (swap! calls conj {:topic topic :message message}))] - (management-command-with-nitrate! - {::th/type :remove-from-org - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :organization-name "Acme Org" - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - (t/is (nil? (:result out))) + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil) + remove-profile-params (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref remove-profile-params) args))} + ;; --- Message bus mock: capture published events --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] - ;; default team preserved, renamed and unset as default - (let [team (th/db-get :team {:id (:id org-team)})] - (t/is (false? (:is-default team))) - (t/is (str/starts-with? (:name team) "[Acme Org] "))) + ;; --- Setup: create profiles, team with files, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + organization-team (th/create-team* 1 {:profile-id (:id user)}) + project (th/create-project* 1 {:profile-id (:id user) + :team-id (:id organization-team)}) + _ (th/create-file* 1 {:profile-id (:id user) + :project-id (:id project)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams []) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) - ;; exactly one notification sent to the user - (t/is (= 1 (count @calls))) - (let [msg (-> @calls first :message)] - (t/is (= :user-org-change (:type msg))) - (t/is (= (:id user) (:topic msg))) - (t/is (= organization-id (:organization-id msg))) - (t/is (= "Acme Org" (:organization-name msg))) - (t/is (= "dashboard.user-no-longer-belong-org" (:notification msg)))))) + ;; --- Exercise: remove the user from the organization --- + out (th/management-command! + {::th/type :remove-from-organization + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id organization-team)})] -(t/deftest remove-from-org-deletes-empty-default-team + ;; --- Verify: nitrate was called (via nitrate-call-mock delegating) --- + (t/is (:called? @nitrate-mock)) + + ;; --- Verify: RPC returns success with no result payload --- + (t/is (th/success? out)) + (t/is (nil? (:result out))) + (t/is (= (:id organization-owner) + (:user-who-delete-member @remove-profile-params))) + (t/is (= "organization-owner" + (:deleted-by-role @remove-profile-params))) + + ;; --- Verify: default team preserved, renamed and unset as default --- + (let [team (th/db-get :team {:id (:id organization-team)})] + (t/is (false? (:is-default team))) + (t/is (str/starts-with? (:name team) "[Acme Org] "))) + + ;; --- Verify: exactly one notification sent to the user --- + (t/is (:called? @mbus-mock)) + (let [msg (apply hash-map (rest (:call-args @mbus-mock)))] + (t/is (= :user-organization-change (:type (:message msg)))) + (t/is (= (:id user) (:topic msg))) + (t/is (= organization-id (:organization-id (:message msg)))) + (t/is (= "Acme Org" (:organization-name (:message msg)))) + (t/is (= "dashboard.user-no-longer-belong-organization" (:notification (:message msg))))))))) + +(t/deftest remove-from-organization-deletes-empty-default-team ;; When the default team has no files it should be soft-deleted. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - org-team (th/create-team* 2 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams []) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary) - mbus/pub! (fn [& _] nil)] - (management-command-with-nitrate! - {::th/type :remove-from-org - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :organization-name "Acme Org" - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - (let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})] - (t/is (some? (:deleted-at team)))))) + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil) + remove-profile-params (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref remove-profile-params) args))} + ;; --- Message bus mock: swallow notifications --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] -(t/deftest remove-from-org-deletes-sole-owner-team - ;; When the user is the sole member of an org team it should be deleted. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - extra-team (th/create-team* 3 {:profile-id (:id user)}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary) - mbus/pub! (fn [& _] nil)] - (management-command-with-nitrate! - {::th/type :remove-from-org - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :organization-name "Acme Org" - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - (let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})] - (t/is (some? (:deleted-at team)))))) + ;; --- Setup: create profiles, empty default team, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + organization-team (th/create-team* 2 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams []) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) -(t/deftest remove-from-org-transfers-ownership-of-multi-member-team + ;; --- Exercise: remove the user from the organization --- + out (th/management-command! + {::th/type :remove-from-organization + ::rpc/profile-id uuid/zero + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns success --- + (t/is (th/success? out)) + (t/is (nil? (:user-who-delete-member @remove-profile-params))) + (t/is (nil? (:deleted-by-role @remove-profile-params))) + ;; --- Verify: empty default team is soft-deleted --- + (let [team (th/db-get :team {:id (:id organization-team)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))))) + +(t/deftest remove-from-organization-deletes-sole-owner-team + ;; When the user is the sole member of an organization team it should be deleted. + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))} + ;; --- Message bus mock: swallow notifications --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] + + ;; --- Setup: create profiles, teams, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 3 {:profile-id (:id user)}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) + + ;; --- Exercise: remove the user from the organization --- + out (th/management-command! + {::th/type :remove-from-organization + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns success --- + (t/is (th/success? out)) + ;; --- Verify: extra team (sole-owner team) is deleted --- + (let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))))) + +(t/deftest remove-from-organization-transfers-ownership-of-multi-member-team ;; When the user owns a team that has another non-owner member, ownership ;; is transferred to that member by the endpoint automatically. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - candidate (th/create-profile* 3 {:is-active true}) - extra-team (th/create-team* 4 {:profile-id (:id user)}) - _ (th/create-team-role* {:team-id (:id extra-team) - :profile-id (:id candidate) - :role :editor}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary) - mbus/pub! (fn [& _] nil)] - (management-command-with-nitrate! - {::th/type :remove-from-org - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :organization-name "Acme Org" - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - ;; user no longer in extra-team - (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] - (t/is (nil? rel))) - ;; candidate promoted to owner - (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})] - (t/is (true? (:is-owner rel)))))) + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))} + ;; --- Message bus mock: swallow notifications --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] -(t/deftest remove-from-org-exits-non-owned-team - ;; When the user is a non-owner member of an org team, they simply leave. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - extra-team (th/create-team* 5 {:profile-id (:id org-owner)}) - _ (th/create-team-role* {:team-id (:id extra-team) - :profile-id (:id user) - :role :editor}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary) - mbus/pub! (fn [& _] nil)] - (management-command-with-nitrate! - {::th/type :remove-from-org - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :organization-name "Acme Org" - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - ;; user no longer a member of extra-team - (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] - (t/is (nil? rel))) - ;; team still exists for the owner - (let [team (th/db-get :team {:id (:id extra-team)})] - (t/is (some? team))))) + ;; --- Setup: create profiles, teams, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + candidate (th/create-profile* 3 {:is-active true}) + extra-team (th/create-team* 4 {:profile-id (:id user)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id candidate) + :role :editor}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) -(t/deftest remove-from-org-error-nobody-to-reassign + ;; --- Exercise: remove the user from the organization --- + out (th/management-command! + {::th/type :remove-from-organization + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns success --- + (t/is (th/success? out)) + ;; --- Verify: user no longer a member of extra-team --- + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (nil? rel))) + ;; --- Verify: candidate promoted to owner --- + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})] + (t/is (true? (:is-owner rel)))))))) + +(t/deftest remove-from-organization-exits-non-owned-team + ;; When the user is a non-owner member of an organization team, they simply leave. + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))} + ;; --- Message bus mock: swallow notifications --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] + + ;; --- Setup: create profiles, teams, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 5 {:profile-id (:id organization-owner)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id user) + :role :editor}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) + + ;; --- Exercise: remove the user from the organization --- + out (th/management-command! + {::th/type :remove-from-organization + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns success --- + (t/is (th/success? out)) + ;; --- Verify: user no longer a member of extra-team --- + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (nil? rel))) + ;; --- Verify: team still exists for the owner --- + (let [team (th/db-get :team {:id (:id extra-team)})] + (t/is (some? team))))))) + +(t/deftest remove-from-organization-error-nobody-to-reassign ;; When the user owns a multi-member team but every other member is ;; also an owner, the auto-selection query finds nobody and raises. - (let [other-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - extra-team (th/create-team* 6 {:profile-id (:id user)}) - ;; add other-owner to the team and make them co-owner directly in DB - _ (th/create-team-role* {:team-id (:id extra-team) - :profile-id (:id other-owner) - :role :editor}) - _ (th/db-update! :team-profile-rel - {:is-owner true :is-admin false} - {:team-id (:id extra-team) :profile-id (:id other-owner)}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id other-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary) - mbus/pub! (fn [& _] nil)] - (management-command-with-nitrate! - {::th/type :remove-from-org - ::rpc/profile-id (:id other-owner) - :profile-id (:id user) - :organization-id organization-id - :organization-name "Acme Org" - :default-team-id (:id org-team)}))] - (t/is (not (th/success? out))) - (t/is (= :validation (th/ex-type (:error out)))) - (t/is (= :nobody-to-reassign-team (th/ex-code (:error out)))))) + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))} + ;; --- Message bus mock: swallow notifications --- + mbus-mock {:target 'app.msgbus/pub! :return nil}] -;; Tests: get-remove-from-org-summary + ;; --- Setup: create profiles, teams, organization-summary --- + (let [other-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 6 {:profile-id (:id user)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id other-owner) + :role :editor}) + _ (th/db-update! :team-profile-rel + {:is-owner true :is-admin false} + {:team-id (:id extra-team) :profile-id (:id other-owner)}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id other-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) + + ;; --- Exercise: remove the user from the organization --- + out (th/management-command! + {::th/type :remove-from-organization + ::rpc/profile-id (:id other-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns error --- + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :nobody-to-reassign-team (th/ex-code (:error out)))))))) + +;; Tests: get-remove-from-organization-summary ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(t/deftest get-remove-from-org-summary-no-extra-teams +(t/deftest get-remove-from-organization-summary-no-extra-teams ;; User only has a default team — nothing to delete/transfer/exit. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - org-team (th/create-team* 1 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams []) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - (t/is (= {:teams-to-delete 0 - :teams-to-transfer 0 - :teams-to-exit 0 - :teams-to-detach 0} - (:result out))))) + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))}] -(t/deftest get-remove-from-org-summary-with-teams-to-delete - ;; User owns a sole-member extra org team → 1 to delete. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - extra-team (th/create-team* 3 {:profile-id (:id user)}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - (t/is (= {:teams-to-delete 1 - :teams-to-transfer 0 - :teams-to-exit 0 - :teams-to-detach 0} - (:result out))))) + ;; --- Setup: create profiles, team, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + organization-team (th/create-team* 1 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams []) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) -(t/deftest get-remove-from-org-summary-with-teams-to-transfer - ;; User owns a multi-member extra org team → 1 to transfer. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - candidate (th/create-profile* 3 {:is-active true}) - extra-team (th/create-team* 4 {:profile-id (:id user)}) - _ (th/create-team-role* {:team-id (:id extra-team) - :profile-id (:id candidate) - :role :editor}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - (t/is (= {:teams-to-delete 0 - :teams-to-transfer 1 - :teams-to-exit 0 - :teams-to-detach 0} - (:result out))))) + ;; --- Exercise: get the summary --- + out (th/management-command! + {::th/type :get-remove-from-organization-summary + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id organization-team)})] -(t/deftest get-remove-from-org-summary-with-teams-to-exit - ;; User is a non-owner member of an org team → 1 to exit. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - extra-team (th/create-team* 5 {:profile-id (:id org-owner)}) - _ (th/create-team-role* {:team-id (:id extra-team) - :profile-id (:id user) - :role :editor}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :default-team-id (:id org-team)}))] - (t/is (th/success? out)) - (t/is (= {:teams-to-delete 0 - :teams-to-transfer 0 - :teams-to-exit 1 - :teams-to-detach 0} - (:result out))))) + ;; --- Verify: RPC returns success with all-zero summary --- + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 0 + :teams-to-exit 0 + :teams-to-detach 0} + (:result out))))))) -(t/deftest get-remove-from-org-summary-does-not-mutate +(t/deftest get-remove-from-organization-summary-with-teams-to-delete + ;; User owns a sole-member extra organization team → 1 to delete. + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))}] + + ;; --- Setup: create profiles, teams, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 3 {:profile-id (:id user)}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) + + ;; --- Exercise: get the summary --- + out (th/management-command! + {::th/type :get-remove-from-organization-summary + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns success with 1 team to delete --- + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 1 + :teams-to-transfer 0 + :teams-to-exit 0 + :teams-to-detach 0} + (:result out))))))) + +(t/deftest get-remove-from-organization-summary-with-teams-to-transfer + ;; User owns a multi-member extra organization team → 1 to transfer. + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))}] + + ;; --- Setup: create profiles, teams, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + candidate (th/create-profile* 3 {:is-active true}) + extra-team (th/create-team* 4 {:profile-id (:id user)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id candidate) + :role :editor}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) + + ;; --- Exercise: get the summary --- + out (th/management-command! + {::th/type :get-remove-from-organization-summary + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns success with 1 team to transfer --- + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 1 + :teams-to-exit 0 + :teams-to-detach 0} + (:result out))))))) + +(t/deftest get-remove-from-organization-summary-with-teams-to-exit + ;; User is a non-owner member of an organization team → 1 to exit. + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))}] + + ;; --- Setup: create profiles, teams, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 5 {:profile-id (:id organization-owner)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id user) + :role :editor}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) + + ;; --- Exercise: get the summary --- + out (th/management-command! + {::th/type :get-remove-from-organization-summary + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id organization-team)})] + + ;; --- Verify: RPC returns success with 1 team to exit --- + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 0 + :teams-to-exit 1 + :teams-to-detach 0} + (:result out))))))) + +(t/deftest get-remove-from-organization-summary-does-not-mutate ;; Calling the summary endpoint must not modify any teams. - (let [org-owner (th/create-profile* 1 {:is-active true}) - user (th/create-profile* 2 {:is-active true}) - extra-team (th/create-team* 6 {:profile-id (:id user)}) - org-team (th/create-team* 99 {:profile-id (:id user)}) - organization-id (uuid/random) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Acme Org" - :owner-id (:id org-owner) - :your-penpot-teams [(:id org-team)] - :org-teams [(:id extra-team)]) - _ (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (management-command-with-nitrate! - {::th/type :get-remove-from-org-summary - ::rpc/profile-id (:id org-owner) - :profile-id (:id user) - :organization-id organization-id - :default-team-id (:id org-team)}))] - ;; Both teams must still exist and be undeleted - (let [t1 (th/db-get :team {:id (:id org-team)})] - (t/is (some? t1)) - (t/is (nil? (:deleted-at t1)))) - (let [t2 (th/db-get :team {:id (:id extra-team)})] - (t/is (some? t2)) - (t/is (nil? (:deleted-at t2)))) - ;; User must still be a member of both teams - (let [rel1 (th/db-get :team-profile-rel {:team-id (:id org-team) :profile-id (:id user)})] - (t/is (some? rel1))) - (let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] - (t/is (some? rel2))))) + ;; --- Deferred organization-summary: depends on team IDs from setup --- + (let [organization-summary-ref (atom nil)] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve organization-summary once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [& args] + (apply (nitrate-call-mock @organization-summary-ref) args))}] + + ;; --- Setup: create profiles, teams, organization-summary --- + (let [organization-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 6 {:profile-id (:id user)}) + organization-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id organization-owner) + :your-penpot-teams [(:id organization-team)] + :organization-teams [(:id extra-team)]) + ;; --- Publish organization-summary so the mock can serve it --- + _ (reset! organization-summary-ref organization-summary) + + ;; --- Exercise: call the summary endpoint --- + _ (th/management-command! + {::th/type :get-remove-from-organization-summary + ::rpc/profile-id (:id organization-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id organization-team)})] + + ;; --- Verify: both teams still exist and are undeleted --- + (let [t1 (th/db-get :team {:id (:id organization-team)})] + (t/is (some? t1)) + (t/is (nil? (:deleted-at t1)))) + (let [t2 (th/db-get :team {:id (:id extra-team)})] + (t/is (some? t2)) + (t/is (nil? (:deleted-at t2)))) + ;; --- Verify: user is still a member of both teams --- + (let [rel1 (th/db-get :team-profile-rel {:team-id (:id organization-team) :profile-id (:id user)})] + (t/is (some? rel1))) + (let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (some? rel2))))))) + +(t/deftest notify-organization-sso-change-sends-setup-sso-email-once-per-recipient + ;; --- Deferred mock data: depends on profile/team IDs from setup --- + (let [mock-data-ref (atom nil) + sent (atom [])] + (with-mocks + [;; --- Nitrate mock: nil during setup, serve data once available --- + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (if-let [data @mock-data-ref] + (let [{:keys [owner-id member-id organization-summary]} data] + (case method + :get-organization-members [owner-id member-id] + :get-organization-summary organization-summary + nil)) + nil))} + ;; --- Email mock: capture sent emails --- + email-mock {:target 'app.email/send! + :return (fn [params] (swap! sent conj params) nil)}] + + ;; --- Setup: create profiles, team, organization-summary --- + (let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"}) + member (th/create-profile* 2 {:is-active true + :fullname "Member" + :email "member@example.com"}) + invited (th/create-profile* 3 {:is-active true + :fullname "Invited User" + :email "invited@example.com"}) + organization-id (uuid/random) + organization-name "Acme Inc" + team (th/create-team* 1 {:profile-id (:id owner)}) + organization-summary {:id organization-id + :name organization-name + :teams [{:id (:id team)}]} + params {::th/type :notify-organization-sso-change + :organization-id organization-id + :updated-props false + :announce-activation true}] + + ;; --- Setup: insert invitations --- + ;; Member also has a pending invitation: should still receive only one email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id organization-id + :team-id nil + :email-to (:email member) + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to (:email invited) + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + ;; --- Invite without an existing profile --- + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "72h")}) + + ;; --- Publish mock data so the mock can serve it --- + (reset! mock-data-ref {:owner-id (:id owner) + :member-id (:id member) + :organization-summary organization-summary}) + + ;; --- Exercise: notify SSO change --- + (th/management-command! params) + + ;; --- Verify: 4 emails sent to correct recipients --- + (let [emails (->> @sent (map :to) set)] + (t/is (= 4 (count @sent))) + (t/is (= #{"member@example.com" + (:email owner) + "invited@example.com" + "external@example.com"} + emails)) + (doseq [email-params @sent] + (t/is (= organization-name (:organization-name email-params))) + (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))))) + +(t/deftest notify-organization-sso-change-skips-email-when-not-active + (let [sent (atom []) + params {::th/type :notify-organization-sso-change + :organization-id (uuid/random) + :updated-props false + :announce-activation false}] + (with-redefs [eml/send! (fn [params] (swap! sent conj params))] + (th/management-command! params)) + (t/is (empty? @sent)))) + +(t/deftest check-organization-sso-returns-valid-true + (let [organization-id (uuid/random) + out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)] + (th/management-command! + {::th/type :check-organization-sso + :organization-id organization-id + :client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com"}))] + (t/is (th/success? out)) + (t/is (true? (-> out :result :valid))))) + +(t/deftest check-organization-sso-returns-valid-false-on-invalid-config + (let [out (th/management-command! + {::th/type :check-organization-sso + :organization-id (uuid/random) + :client-id "test-client" + :client-secret "test-secret"})] + (t/is (th/success? out)) + (t/is (false? (-> out :result :valid))))) + +(t/deftest check-organization-sso-passes-issuer-to-validation + (let [organization-id (uuid/random) + out (with-redefs [oidc/is-organization-sso-config-valid? + (fn [_cfg sso] + (and (= "test-client" (:client-id sso)) + (= "https://idp.example.com/" (:issuer sso))))] + (th/management-command! + {::th/type :check-organization-sso + :organization-id organization-id + :client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com/"}))] + (t/is (th/success? out)) + (t/is (true? (-> out :result :valid))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PUSH AUDIT EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private http-request + {:headers {"x-forwarded-for" "192.168.1.10" + "x-real-ip" "192.168.1.10"}}) + +(t/deftest push-audit-events-stores-backend-source + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (binding [cf/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + :events [{:name "test-action" + :profile-id (:id prof) + :props {:key "val"} + :type "action"}]} + params (with-meta params + {::http/request http-request}) + out (th/management-command! params)] + (t/is (nil? (:error out))) + (t/is (:called? @audit-mock)) + (t/is (= 1 (:call-count @audit-mock))) + (let [[_ event] (:call-args @audit-mock)] + (t/is (= "test-action" (:name event))) + (t/is (= "action" (:type event))) + (t/is (= (:id prof) (:profile-id event))) + (t/is (= "val" (get-in event [:props :key]))) + (t/is (some? (:tracked-at event)))))))) + +(t/deftest push-audit-events-type-defaults-to-action + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (binding [cf/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + :events [{:name "no-type-event" + :profile-id (:id prof)}]} + params (with-meta params + {::http/request http-request}) + out (th/management-command! params)] + (t/is (nil? (:error out))) + (let [[_ event] (:call-args @audit-mock)] + (t/is (= "action" (:type event))) + (t/is (= "no-type-event" (:name event)))))))) + +(t/deftest push-audit-events-multiple-events + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (binding [cf/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + :events [{:name "event-a" + :profile-id (:id prof) + :type "action"} + {:name "event-b" + :profile-id (:id prof) + :type "action"}]} + params (with-meta params + {::http/request http-request}) + out (th/management-command! params)] + (t/is (nil? (:error out))) + (t/is (= 2 (:call-count @audit-mock))) + (let [events (mapv second (:call-args-list @audit-mock))] + (t/is (= "event-a" (:name (first events)))) + (t/is (= "event-b" (:name (second events))))))))) + +(t/deftest push-audit-events-merges-context + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (binding [cf/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + :events [{:name "context-test" + :profile-id (:id prof) + :type "action" + :context {:custom-key "custom-val" + :foo "bar"}}]} + params (with-meta params + {::http/request http-request}) + out (th/management-command! params)] + (t/is (nil? (:error out))) + (let [[_ event] (:call-args @audit-mock)] + (t/is (= "custom-val" (get-in event [:context :custom-key]))) + (t/is (= "bar" (get-in event [:context :foo]))) + (t/is (= (:full cf/version) (get-in event [:context :version]))) + (t/is (= "app" (get-in event [:context :initiator])))))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 20e774ec99..4bfed06abb 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -6,13 +6,21 @@ (ns backend-tests.rpc-nitrate-test (:require + [app.auth.oidc :as oidc] + [app.common.json :as json] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as-alias db] + [app.email :as eml] + [app.http :as-alias http] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.nitrate] + [app.rpc.commands.teams :as teams] + [app.rpc.helpers :as rph] [backend-tests.helpers :as th] + [buddy.core.codecs :as bc] [clojure.test :as t] [cuerdas.core :as str])) @@ -23,62 +31,167 @@ ;; Helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- make-org-summary - [& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams] - :or {your-penpot-teams [] org-teams []}}] +(defn- make-organization-summary + [& {:keys [organization-id organization-name owner-id your-penpot-teams organization-teams] + :or {your-penpot-teams [] organization-teams []}}] {:id organization-id :name organization-name :owner-id owner-id :teams (into (mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams) - (mapv (fn [id] {:id id :is-your-penpot false}) org-teams))}) + (mapv (fn [id] {:id id :is-your-penpot false}) organization-teams))}) (defn- nitrate-call-mock - "Creates a mock for nitrate/call that returns the given org-summary for - :get-org-summary, a valid membership for :get-org-membership, and nil for + "Creates a mock for nitrate/call that returns the given organization-summary for + :get-organization-summary, a valid membership for :get-organization-membership, and nil for any other method." - [org-summary] + ([organization-summary] + (nitrate-call-mock organization-summary nil)) + ([organization-summary remove-profile-params] + (fn [_cfg method params] + (case method + :get-organization-summary organization-summary + :get-organization-membership {:is-member true + :organization-id (:id organization-summary)} + :remove-profile-from-organization (when remove-profile-params + (reset! remove-profile-params params)) + nil)))) + +(defn- nitrate-organization-summary-only-mock + [organization-summary] (fn [_cfg method _params] (case method - :get-org-summary org-summary - :get-org-membership {:is-member true - :organization-id (:id org-summary)} + :get-organization-summary organization-summary + :get-organization-membership {:is-member true + :organization-id (:id organization-summary) + :created-at (ct/inst "2026-07-17T12:00:00Z")} + :get-organization-members [(:owner-id organization-summary) + (uuid/random)] nil))) -(defn- nitrate-org-summary-only-mock - [org-summary] - (fn [_cfg method _params] +(defn- active-sso-call-mock + [team-id organization-id organization-owner-id] + (fn [_cfg method params] (case method - :get-org-summary org-summary + :get-team-organization + (when (= team-id (:team-id params)) + {:id team-id + :organization {:id organization-id + :owner-id organization-owner-id}}) + + :get-organization-sso-by-team + {:active true + :issuer "https://idp.example.com" + :organization-id organization-id} + nil))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(t/deftest leave-org-happy-path-no-extra-teams +(t/deftest check-nitrate-sso-skips-gate-without-team-access + (let [team-owner (th/create-profile* 1 {:is-active true}) + external-profile (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/random) + params (with-meta + {::th/type :check-nitrate-sso + ::rpc/profile-id (:id external-profile) + :team-id (:id team) + :url "https://penpot.example.com/#/workspace"} + {::http/request {}})] + (binding [cf/flags (conj cf/flags :nitrate)] + (with-redefs [nitrate/call + (active-sso-call-mock + (:id team) + organization-id + (:id team-owner)) + oidc/build-organization-sso-auth-redirect-uri + (constantly "https://idp.example.com/authorize")] + (let [out (th/command! params)] + (t/is (th/success? out)) + (t/is (= {:authorized true} (:result out)))))))) + +(t/deftest check-nitrate-sso-keeps-gate-for-team-member + (let [team-owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/random) + redirect-uri "https://idp.example.com/authorize" + params (with-meta + {::th/type :check-nitrate-sso + ::rpc/profile-id (:id team-owner) + :team-id (:id team) + :url "https://penpot.example.com/#/workspace"} + {::http/request {}})] + (binding [cf/flags (conj cf/flags :nitrate)] + (with-redefs [nitrate/call + (active-sso-call-mock + (:id team) + organization-id + (:id team-owner)) + oidc/build-organization-sso-auth-redirect-uri + (constantly redirect-uri)] + (let [out (th/command! params)] + (t/is (th/success? out)) + (t/is (= {:authorized false + :redirect-uri redirect-uri} + (:result out)))))))) + +(t/deftest check-nitrate-sso-keeps-gate-for-non-member-organization-owner + (let [team-owner (th/create-profile* 1 {:is-active true}) + organization-owner (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/random) + redirect-uri "https://idp.example.com/authorize" + params (with-meta + {::th/type :check-nitrate-sso + ::rpc/profile-id (:id organization-owner) + :team-id (:id team) + :url "https://penpot.example.com/#/workspace"} + {::http/request {}})] + (binding [cf/flags (conj cf/flags :nitrate)] + (with-redefs [nitrate/organization-owner-of-team? + (fn [_cfg profile-id team-id] + (and (= (:id organization-owner) profile-id) + (= (:id team) team-id))) + nitrate/call + (active-sso-call-mock + (:id team) + organization-id + (:id organization-owner)) + oidc/build-organization-sso-auth-redirect-uri + (constantly redirect-uri)] + (let [out (th/command! params)] + (t/is (th/success? out)) + (t/is (= {:authorized false + :redirect-uri redirect-uri} + (:result out)))))))) + +(t/deftest leave-organization-happy-path-no-extra-teams (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) project (th/create-project* 99 {:profile-id (:id profile-user) - :team-id (:id org-default-team)}) + :team-id (:id organization-default-team)}) _ (th/create-file* 99 {:profile-id (:id profile-user) :project-id (:id project)}) organization-id (uuid/random) - ;; The user's personal penpot team in the org context - your-penpot-id (:id org-default-team) + ;; The user's personal penpot team in the organization context + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams []) + remove-profile-params (atom nil)] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary remove-profile-params)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -91,29 +204,34 @@ (t/is (th/success? out)) (t/is (nil? (:result out))) - ;; The personal team must be renamed with the org prefix and + ;; The personal team must be renamed with the organization prefix and ;; unset as a default team. (let [team (th/db-get :team {:id your-penpot-id})] (t/is (str/starts-with? (:name team) "[Test Org] ")) - (t/is (false? (:is-default team)))))))) + (t/is (false? (:is-default team)))) -(t/deftest leave-org-deletes-org-default-team-when-empty + (t/is (= (:id profile-user) + (:user-who-delete-member @remove-profile-params))) + (t/is (= "organization-member" + (:deleted-by-role @remove-profile-params))))))) + +(t/deftest leave-organization-deletes-organization-default-team-when-empty (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) - org-default-team (th/create-team* 98 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 98 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -124,31 +242,31 @@ (t/is (th/success? out)) - ;; Empty org default team should be soft-deleted. + ;; Empty organization default team should be soft-deleted. (let [team (th/db-get :team {:id your-penpot-id} {::db/remove-deleted false})] (t/is (some? (:deleted-at team)))))))) -(t/deftest leave-org-keeps-and-renames-org-default-team-when-has-files +(t/deftest leave-organization-keeps-and-renames-organization-default-team-when-has-files (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) - org-default-team (th/create-team* 97 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 97 {:profile-id (:id profile-user)}) project (th/create-project* 97 {:profile-id (:id profile-user) - :team-id (:id org-default-team)}) + :team-id (:id organization-default-team)}) _ (th/create-file* 97 {:profile-id (:id profile-user) :project-id (:id project)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -159,31 +277,31 @@ (t/is (th/success? out)) - ;; Non-empty org default team should remain and be renamed. + ;; Non-empty organization default team should remain and be renamed. (let [team (th/db-get :team {:id your-penpot-id})] (t/is (str/starts-with? (:name team) "[Test Org] ")) (t/is (false? (:is-default team))) (t/is (nil? (:deleted-at team)))))))) -(t/deftest leave-org-with-teams-to-delete +(t/deftest leave-organization-with-teams-to-delete (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; profile-user is the sole owner/member of team1 team1 (th/create-team* 1 {:profile-id (:id profile-user)}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -199,7 +317,7 @@ (let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})] (t/is (some? (:deleted-at team)))))))) -(t/deftest leave-org-with-ownership-transfer +(t/deftest leave-organization-with-ownership-transfer (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; profile-user owns team1; profile-owner is also a member @@ -207,20 +325,20 @@ _ (th/create-team-role* {:team-id (:id team1) :profile-id (:id profile-owner) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -244,7 +362,7 @@ :profile-id (:id profile-owner)})] (t/is (true? (:is-owner rel)))))))) -(t/deftest leave-org-exit-as-non-owner +(t/deftest leave-organization-exit-as-non-owner (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; profile-owner owns team1; profile-user is a non-owner member @@ -252,20 +370,20 @@ _ (th/create-team-role* {:team-id (:id team1) :profile-id (:id profile-user) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -287,22 +405,22 @@ (let [team (th/db-get :team {:id (:id team1)})] (t/is (nil? (:deleted-at team)))))))) -(t/deftest get-leave-org-summary-counts-default-team-as-delete-when-empty +(t/deftest get-leave-organization-summary-counts-default-team-as-delete-when-empty (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) - org-default-team (th/create-team* 97 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 97 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [])] + your-penpot-id (:id organization-default-team) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [])] - (with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)] - (let [out (th/command! {::th/type :get-leave-org-summary + (with-redefs [nitrate/call (nitrate-organization-summary-only-mock organization-summary)] + (let [out (th/command! {::th/type :get-leave-organization-summary ::rpc/profile-id (:id profile-user) :id organization-id :default-team-id your-penpot-id})] @@ -310,30 +428,32 @@ (t/is (= {:teams-to-delete 0 :teams-to-transfer 0 :teams-to-exit 0 - :teams-to-detach 0} + :teams-to-detach 0 + :member-added-at (ct/inst "2026-07-17T12:00:00Z") + :organization-member-count-before 2} (:result out))))))) -(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files +(t/deftest get-leave-organization-summary-counts-default-team-as-keep-when-has-files (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) - org-default-team (th/create-team* 96 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 96 {:profile-id (:id profile-user)}) project (th/create-project* 96 {:profile-id (:id profile-user) - :team-id (:id org-default-team)}) + :team-id (:id organization-default-team)}) _ (th/create-file* 96 {:profile-id (:id profile-user) :project-id (:id project)}) extra-team (th/create-team* 95 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id extra-team)])] + your-penpot-id (:id organization-default-team) + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id extra-team)])] - (with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)] - (let [out (th/command! {::th/type :get-leave-org-summary + (with-redefs [nitrate/call (nitrate-organization-summary-only-mock organization-summary)] + (let [out (th/command! {::th/type :get-leave-organization-summary ::rpc/profile-id (:id profile-user) :id organization-id :default-team-id your-penpot-id})] @@ -342,25 +462,27 @@ (t/is (= {:teams-to-delete 1 :teams-to-transfer 0 :teams-to-exit 0 - :teams-to-detach 1} + :teams-to-detach 1 + :member-added-at (ct/inst "2026-07-17T12:00:00Z") + :organization-member-count-before 2} (:result out))))))) -(t/deftest leave-org-error-org-owner-cannot-leave +(t/deftest leave-organization-error-organization-owner-cannot-leave (let [profile-owner (th/create-profile* 1 {:is-active true}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-owner)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - ;; profile-owner IS the org owner in the org-summary - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [])] + ;; profile-owner IS the organization owner in the organization-summary + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-owner) :id organization-id :name "Test Org" @@ -371,25 +493,25 @@ (t/is (not (th/success? out))) (t/is (= :validation (th/ex-type (:error out)))) - (t/is (= :org-owner-cannot-leave (th/ex-code (:error out)))))))) + (t/is (= :organization-owner-cannot-leave (th/ex-code (:error out)))))))) -(t/deftest leave-org-error-invalid-default-team-id +(t/deftest leave-organization-error-invalid-default-team-id (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] ;; Pass a random UUID that is not in the your-penpot-teams list - (let [data {::th/type :leave-org + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -416,7 +538,7 @@ :or {is-owner false num-members 1 member-ids []}}] {:id id :is-owner is-owner :num-members num-members :member-ids member-ids}) -(t/deftest calculate-valid-teams-no-org-teams +(t/deftest calculate-valid-teams-no-organization-teams (let [default-id (uuid/random) default-team (make-team default-id) result (calculate-valid-teams [default-team] default-id)] @@ -429,7 +551,7 @@ (let [default-id (uuid/random) other-id (uuid/random) other-team (make-team other-id) - ;; default-id is not in org-teams at all + ;; default-id is not in organization-teams at all result (calculate-valid-teams [other-team] default-id)] (t/is (nil? (:valid-default-team result))))) @@ -484,7 +606,7 @@ ;; Integration: combined delete + leave ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(t/deftest leave-org-combined-delete-and-leave +(t/deftest leave-organization-combined-delete-and-leave (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; team1: profile-user is sole owner — must delete @@ -499,20 +621,20 @@ _ (th/create-team-role* {:team-id (:id team3) :profile-id (:id profile-user) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1) (:id team2) (:id team3)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1) (:id team2) (:id team3)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] - (let [data {::th/type :leave-org + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -543,27 +665,27 @@ ;; team3 itself should still exist (profile-owner is still there) (let [team (th/db-get :team {:id (:id team3)})] (t/is (some? team))))))) -(t/deftest leave-org-error-teams-to-delete-incomplete +(t/deftest leave-organization-error-teams-to-delete-incomplete (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; profile-user is the sole owner/member of both team1 and team2 team1 (th/create-team* 1 {:profile-id (:id profile-user)}) team2 (th/create-team* 2 {:profile-id (:id profile-user)}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1) (:id team2)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1) (:id team2)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] ;; Only team1 is listed; team2 is also a sole-owner team and must be included - (let [data {::th/type :leave-org + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -576,7 +698,7 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) -(t/deftest leave-org-error-cannot-delete-multi-member-team +(t/deftest leave-organization-error-cannot-delete-multi-member-team (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; team1 has two members: profile-user (owner) and profile-owner (editor) @@ -584,21 +706,21 @@ _ (th/create-team-role* {:team-id (:id team1) :profile-id (:id profile-owner) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] ;; team1 has 2 members so it is not a valid deletion candidate - (let [data {::th/type :leave-org + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -611,7 +733,7 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) -(t/deftest leave-org-error-teams-to-leave-incomplete +(t/deftest leave-organization-error-teams-to-leave-incomplete (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; profile-user owns team1, which also has profile-owner as editor @@ -619,21 +741,21 @@ _ (th/create-team-role* {:team-id (:id team1) :profile-id (:id profile-owner) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] ;; team1 must be transferred (owner + multiple members) but is absent - (let [data {::th/type :leave-org + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -646,28 +768,28 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) -(t/deftest leave-org-error-reassign-to-self +(t/deftest leave-organization-error-reassign-to-self (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) team1 (th/create-team* 1 {:profile-id (:id profile-user)}) _ (th/create-team-role* {:team-id (:id team1) :profile-id (:id profile-owner) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] ;; reassign-to points to the profile that is leaving — not allowed - (let [data {::th/type :leave-org + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -680,7 +802,7 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) -(t/deftest leave-org-error-reassign-to-non-member +(t/deftest leave-organization-error-reassign-to-non-member (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) profile-other (th/create-profile* 3 {:is-active true}) @@ -689,21 +811,21 @@ _ (th/create-team-role* {:team-id (:id team1) :profile-id (:id profile-owner) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] ;; profile-other is not a member of team1 - (let [data {::th/type :leave-org + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -716,7 +838,7 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) -(t/deftest all-team-members-in-orgs-returns-org-id->boolean-map +(t/deftest all-team-members-in-organizations-returns-organization-id->boolean-map (let [profile-user (th/create-profile* 201 {:is-active true}) profile-other (th/create-profile* 202 {:is-active true}) team (th/create-team* 201 {:profile-id (:id profile-user)}) @@ -726,62 +848,62 @@ team-member-ids (->> (th/db-query :team-profile-rel {:team-id (:id team)}) (map :profile-id) (into #{})) - org-id-1 (uuid/random) - org-id-2 (uuid/random) + organization-id-1 (uuid/random) + organization-id-2 (uuid/random) calls (atom [])] (with-redefs [cf/flags (conj cf/flags :nitrate) nitrate/call (fn [_cfg method params] (swap! calls conj [method params]) (case method - :get-org-membership {:is-member true - :organization-id (:organization-id params)} - :get-org-members (get {org-id-1 (vec team-member-ids) - org-id-2 [(:id profile-user)]} - (:organization-id params) - []) + :get-organization-membership {:is-member true + :organization-id (:organization-id params)} + :get-organization-members (get {organization-id-1 (vec team-member-ids) + organization-id-2 [(:id profile-user)]} + (:organization-id params) + []) nil))] - (let [out (th/command! {::th/type :all-team-members-in-orgs + (let [out (th/command! {::th/type :all-team-members-in-organizations ::rpc/profile-id (:id profile-user) :team-id (:id team) - :organization-ids [org-id-1 org-id-2]}) + :organization-ids [organization-id-1 organization-id-2]}) methods (map first @calls) - membership-calls (count (filter #(= :get-org-membership %) methods)) - get-members-calls (count (filter #(= :get-org-members %) methods))] + membership-calls (count (filter #(= :get-organization-membership %) methods)) + get-members-calls (count (filter #(= :get-organization-members %) methods))] (t/is (th/success? out)) - (t/is (= {org-id-1 true - org-id-2 false} + (t/is (= {organization-id-1 true + organization-id-2 false} (:result out))) (t/is (= 2 membership-calls)) (t/is (= 2 get-members-calls)))))) -(t/deftest all-team-members-in-orgs-fails-before-fetching-org-members +(t/deftest all-team-members-in-organizations-fails-before-fetching-organization-members (let [profile-user (th/create-profile* 203 {:is-active true}) team (th/create-team* 203 {:profile-id (:id profile-user)}) - org-id-1 (uuid/random) - org-id-2 (uuid/random) + organization-id-1 (uuid/random) + organization-id-2 (uuid/random) calls (atom [])] (with-redefs [cf/flags (conj cf/flags :nitrate) nitrate/call (fn [_cfg method params] (swap! calls conj [method params]) (case method - :get-org-membership (if (= (:organization-id params) org-id-2) - {:is-member false - :organization-id (:organization-id params)} - {:is-member true - :organization-id (:organization-id params)}) - :get-org-members [] + :get-organization-membership (if (= (:organization-id params) organization-id-2) + {:is-member false + :organization-id (:organization-id params)} + {:is-member true + :organization-id (:organization-id params)}) + :get-organization-members [] nil))] - (let [out (th/command! {::th/type :all-team-members-in-orgs + (let [out (th/command! {::th/type :all-team-members-in-organizations ::rpc/profile-id (:id profile-user) :team-id (:id team) - :organization-ids [org-id-1 org-id-2]}) + :organization-ids [organization-id-1 organization-id-2]}) methods (map first @calls)] (t/is (not (th/success? out))) (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :user-doesnt-belong-organization (th/ex-code (:error out)))) - (t/is (= 0 (count (filter #(= :get-org-members %) methods)))))))) + (t/is (= 0 (count (filter #(= :get-organization-members %) methods)))))))) -(t/deftest leave-org-error-reassign-on-non-owned-team +(t/deftest leave-organization-error-reassign-on-non-owned-team (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) ;; profile-owner owns team1; profile-user is just a non-owner member @@ -789,21 +911,21 @@ _ (th/create-team-role* {:team-id (:id team1) :profile-id (:id profile-user) :role :editor}) - org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) organization-id (uuid/random) - your-penpot-id (:id org-default-team) + your-penpot-id (:id organization-default-team) - org-summary (make-org-summary - :organization-id organization-id - :organization-name "Test Org" - :owner-id (:id profile-owner) - :your-penpot-teams [your-penpot-id] - :org-teams [(:id team1)])] + organization-summary (make-organization-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :organization-teams [(:id team1)])] - (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (with-redefs [nitrate/call (nitrate-call-mock organization-summary)] ;; profile-user is not the owner so providing reassign-to is invalid - (let [data {::th/type :leave-org + (let [data {::th/type :leave-organization ::rpc/profile-id (:id profile-user) :id organization-id :name "Test Org" @@ -815,3 +937,189 @@ (t/is (not (th/success? out))) (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(defn- add-team-to-organization-nitrate-mock + [{:keys [organization-id organization-summary organization-perms owner-id team-id sso-active? set-team-params]}] + (fn [_cfg method params] + (case method + :get-organization-membership (if (= (:profile-id params) owner-id) + {:is-member true :organization-id organization-id} + {:is-member false :organization-id organization-id}) + :get-organization-members [owner-id] + :get-team-organization {:organization nil} + :get-organization-permissions organization-perms + :set-team-organization (do + (when set-team-params + (reset! set-team-params params)) + {:id team-id}) + :get-organization-sso {:active sso-active?} + :get-organization-summary (assoc organization-summary :teams [{:id team-id}]) + :add-profile-to-organization {:is-member true} + nil))) + +(t/deftest add-team-to-organization-sends-sso-emails-to-new-members-and-invitees + (let [owner (th/create-profile* 301 {:is-active true + :fullname "Owner" + :email "owner301@example.com"}) + member (th/create-profile* 302 {:is-active true + :fullname "Member" + :email "member302@example.com"}) + team (th/create-team* 301 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + organization-id (uuid/random) + organization-name "SSO Org" + organization-summary {:id organization-id + :name organization-name + :owner-id (:id owner) + :teams []} + organization-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}} + sent (atom []) + set-team-params (atom nil)] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external301@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (add-team-to-organization-nitrate-mock + {:organization-id organization-id + :organization-summary organization-summary + :organization-perms organization-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? true + :set-team-params set-team-params}) + teams/initialize-user-in-nitrate-organization (fn [& _] nil) + eml/send! (fn [params] (swap! sent conj params))] + (let [out (th/command! {::th/type :add-team-to-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id organization-id})] + (t/is (th/success? out)))) + + (t/is (= {:team-id (:id team) + :organization-id organization-id + :is-default false} + @set-team-params)) + + (let [emails (->> @sent (map :to) set)] + (t/is (= 2 (count @sent))) + (t/is (= #{"member302@example.com" "external301@example.com"} emails)) + (doseq [email-params @sent] + (t/is (= organization-name (:organization-name email-params))) + (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) + +(t/deftest create-team-in-organization-passes-association-to-nitrate + (let [organization-id (uuid/random) + team {:id (uuid/random) + :created-at (ct/now)} + params* (atom nil)] + (with-redefs [nitrate/call (fn [_cfg method params] + (when (= method :set-team-organization) + (reset! params* params)) + {:id (:id team)})] + (nitrate/set-team-organization + {} + team + {:organization-id organization-id + :is-default false})) + + (t/is (= {:team-id (:id team) + :organization-id organization-id + :is-default false} + @params*)))) + +(t/deftest add-team-to-organization-skips-sso-emails-when-sso-inactive + (let [owner (th/create-profile* 303 {:is-active true :email "owner303@example.com"}) + member (th/create-profile* 304 {:is-active true :email "member304@example.com"}) + team (th/create-team* 303 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + organization-id (uuid/random) + organization-summary {:id organization-id + :name "No SSO Org" + :owner-id (:id owner) + :teams []} + organization-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}} + sent (atom [])] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external303@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (add-team-to-organization-nitrate-mock + {:organization-id organization-id + :organization-summary organization-summary + :organization-perms organization-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? false}) + teams/initialize-user-in-nitrate-organization (fn [& _] nil) + eml/send! (fn [params] (swap! sent conj params))] + (let [out (th/command! {::th/type :add-team-to-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id organization-id})] + (t/is (th/success? out)) + (t/is (empty? @sent)))))) + +(t/deftest get-nitrate-activation-code-request + (let [profile (th/create-profile* 1 {:is-active true}) + nitrate-id "nitrate-instance-1" + public-key "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----" + now (ct/now)] + (with-redefs [cf/flags (conj cf/flags :nitrate) + ct/now (constantly now) + nitrate/call (fn [_cfg method _params] + (t/is (= :get-identity method)) + {:nitrate-id nitrate-id + :public-key public-key})] + (let [out (th/command! {::th/type :get-nitrate-activation-code-request + ::rpc/profile-id (:id profile)}) + body (-> (:result out) + (bc/b64->str) + (json/decode :key-fn json/read-kebab-key))] + (t/is (th/success? out)) + (t/is (= {:nitrate-id nitrate-id + :public-key public-key + :email (:email profile) + :iat (ct/seconds now)} + body)) + + (let [[_ method-fn] (get-in th/*system* [:app.rpc/methods :get-nitrate-activation-code-request]) + result (method-fn {::rpc/profile-id (:id profile) + ::rpc/request-at now}) + headers (::http/headers (meta result))] + (t/is (rph/wrapped? result)) + (t/is (= "text/plain" (get headers "content-type"))) + (t/is (= "attachment; filename=\"penpot-activation-code-request.txt\"" + (get headers "content-disposition")))))))) + +(t/deftest get-nitrate-activation-code-request-identity-unavailable + (let [profile (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (fn [_cfg _method _params] nil)] + (let [out (th/command! {::th/type :get-nitrate-activation-code-request + ::rpc/profile-id (:id profile)})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :nitrate-identity-unavailable)))))) diff --git a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj new file mode 100644 index 0000000000..8ac1a8fd95 --- /dev/null +++ b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj @@ -0,0 +1,240 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-organization-owner-permissions-test + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.msgbus :as mbus] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- organization-data + [organization-id owner-id] + {:id organization-id + :name "Acme" + :slug "acme" + :owner-id owner-id + :avatar-bg-url "http://example.com/avatar.png" + :permissions {}}) + +(defn- with-organization-owner-access + [{:keys [organization-owner-id organization-id team-id]} f] + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/organization-owner-of-team? + (fn [_cfg profile-id candidate-team-id] + (and (= organization-owner-id profile-id) + (= team-id candidate-team-id))) + + nitrate/call + (fn [_cfg method params] + (case method + :get-owned-organizations + [{:id organization-id + :name "Acme" + :owner-id organization-owner-id + :teams [{:id team-id :is-your-penpot false}]}] + + :get-team-organization + (if (= team-id (:team-id params)) + {:id team-id + :is-your-penpot false + :organization (organization-data organization-id organization-owner-id)} + {:id (:team-id params) + :is-your-penpot false + :organization nil})))] + (f))) + +(defn- with-captured-messages + "Runs `f` with the msgbus publications collected on `messages`." + [messages f] + (with-redefs [mbus/pub! (fn [_instance & {:keys [topic message]}] + (swap! messages conj {:topic topic :message message}) + nil)] + (f))) + +(defn- messages-for + [messages profile-id] + (->> @messages + (filter #(= profile-id (:topic %))) + (mapv :message))) + +(t/deftest organization-owner-access-disabled-without-nitrate-flag + (let [team-owner (th/create-profile* 1) + organization-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)})] + + (let [out (th/command! {::th/type :get-projects + ::rpc/profile-id (:id organization-owner) + :team-id (:id target-team)}) + error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))) + +(t/deftest non-member-organization-owner-gets-viewer-access-to-organization-team + (let [team-owner (th/create-profile* 1) + organization-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + project (th/create-project* 1 {:profile-id (:id team-owner) + :team-id (:id target-team)}) + file (th/create-file* 1 {:profile-id (:id team-owner) + :project-id (:id project)}) + organization-id (uuid/next)] + + (with-organization-owner-access {:organization-owner-id (:id organization-owner) + :organization-id organization-id + :team-id (:id target-team)} + (fn [] + ;; The team is not listed for a non-member, even though the organization + ;; owner can access it directly. + (let [out (th/command! {::th/type :get-teams + ::rpc/profile-id (:id organization-owner)})] + (t/is (nil? (:error out))) + (t/is (not-any? #(= (:id target-team) (:id %)) (:result out)))) + + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id organization-owner) + :id (:id target-team)}) + team (:result out)] + (t/is (nil? (:error out))) + (t/is (= (:id target-team) (:id team))) + (t/is (false? (get-in team [:permissions :is-owner]))) + (t/is (false? (get-in team [:permissions :is-admin]))) + (t/is (false? (get-in team [:permissions :can-edit]))) + (t/is (= organization-id (get-in team [:organization :id]))) + (t/is (= "Acme" (get-in team [:organization :name])))) + + (let [out (th/command! {::th/type :get-team-members + ::rpc/profile-id (:id organization-owner) + :team-id (:id target-team)}) + members (:result out)] + (t/is (nil? (:error out))) + (t/is (some #(= (:id team-owner) (:id %)) members)) + (t/is (not-any? #(= (:id organization-owner) (:id %)) members))) + + (let [out (th/command! {::th/type :get-projects + ::rpc/profile-id (:id organization-owner) + :team-id (:id target-team)})] + (t/is (nil? (:error out))) + (t/is (some #(= (:id project) (:id %)) (:result out)))) + + (let [out (th/command! {::th/type :get-file + ::rpc/profile-id (:id organization-owner) + :id (:id file)})] + (t/is (nil? (:error out))) + (t/is (= (:id file) (get-in out [:result :id]))) + (t/is (false? (get-in out [:result :permissions :can-edit])))) + + (let [out (th/command! {::th/type :rename-project + ::rpc/profile-id (:id organization-owner) + :id (:id project) + :name "Nope"}) + error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))))) + +(t/deftest organization-owner-member-keeps-team-role + (let [team-owner (th/create-profile* 1) + organization-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/next)] + + (th/create-team-role* {:team-id (:id target-team) + :profile-id (:id organization-owner) + :role :editor}) + + (with-organization-owner-access {:organization-owner-id (:id organization-owner) + :organization-id organization-id + :team-id (:id target-team)} + (fn [] + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id organization-owner) + :id (:id target-team)}) + team (:result out)] + (t/is (nil? (:error out))) + (t/is (false? (get-in team [:permissions :is-owner]))) + (t/is (false? (get-in team [:permissions :is-admin]))) + (t/is (true? (get-in team [:permissions :can-edit])))))))) + +(t/deftest removed-organization-owner-is-degraded-to-viewer + (let [team-owner (th/create-profile* 1) + organization-owner (th/create-profile* 2) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/next) + messages (atom [])] + + (th/create-team-role* {:team-id (:id target-team) + :profile-id (:id organization-owner) + :role :editor}) + + (with-organization-owner-access {:organization-owner-id (:id organization-owner) + :organization-id organization-id + :team-id (:id target-team)} + (fn [] + (let [out (with-captured-messages messages + #(th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id team-owner) + :team-id (:id target-team) + :member-id (:id organization-owner)}))] + (t/is (nil? (:error out)))) + + ;; The organization owner keeps read-only access, so they are notified + ;; with a role change instead of being kicked out of the team. + (let [notified (messages-for messages (:id organization-owner))] + (t/is (= 1 (count notified))) + (t/is (= :team-role-change (:type (first notified)))) + (t/is (= :viewer (:role (first notified)))) + (t/is (= (:id target-team) (:team-id (first notified)))) + (t/is (not-any? #(= :team-membership-change (:type %)) notified))) + + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id organization-owner) + :id (:id target-team)}) + team (:result out)] + (t/is (nil? (:error out))) + (t/is (false? (get-in team [:permissions :is-owner]))) + (t/is (false? (get-in team [:permissions :is-admin]))) + (t/is (false? (get-in team [:permissions :can-edit])))))))) + +(t/deftest removed-regular-member-is-still-kicked-out + (let [team-owner (th/create-profile* 1) + organization-owner (th/create-profile* 2) + member (th/create-profile* 3) + target-team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/next) + messages (atom [])] + + (th/create-team-role* {:team-id (:id target-team) + :profile-id (:id member) + :role :editor}) + + (with-organization-owner-access {:organization-owner-id (:id organization-owner) + :organization-id organization-id + :team-id (:id target-team)} + (fn [] + (let [out (with-captured-messages messages + #(th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id team-owner) + :team-id (:id target-team) + :member-id (:id member)}))] + (t/is (nil? (:error out)))) + + (let [notified (messages-for messages (:id member))] + (t/is (= 1 (count notified))) + (t/is (= :team-membership-change (:type (first notified)))) + (t/is (= :removed (:change (first notified)))) + (t/is (= (:id target-team) (:team-id (first notified))))) + + (let [out (th/command! {::th/type :get-team + ::rpc/profile-id (:id member) + :id (:id target-team)})] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :not-found))))))) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 3fff7c2953..a590f93a32 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -13,7 +13,9 @@ [app.db :as db] [app.email.blacklist :as email.blacklist] [app.http :as http] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.rpc.commands.teams :as teams] [app.storage :as sto] [app.tokens :as tokens] [backend-tests.helpers :as th] @@ -103,6 +105,63 @@ (t/is (= :validation (:type edata))) (t/is (= :member-is-muted (:code edata)))))))) +(t/deftest create-and-update-team-invitations-include-organization-props + (with-mocks [email-mock {:target 'app.email/send! :return nil} + audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [owner (th/create-profile* 101 {:is-active true}) + invitee (th/create-profile* 102 {:is-active true}) + organization-team (th/create-team* 101 {:profile-id (:id owner)}) + plain-team (th/create-team* 102 {:profile-id (:id owner)}) + organization-id (uuid/random) + organization {:id organization-id + :name "Acme" + :slug "acme" + :owner-id (:id owner) + :avatar-bg-url "https://example.com/avatar.svg" + :permissions {:new-team-members "anyone"}} + nitrate-call + (fn [_cfg method params] + (case method + :get-team-organization + (if (= (:team-id params) (:id organization-team)) + {:organization organization :is-your-penpot false} + {:organization nil :is-your-penpot false}) + + :get-organization-members + [(:id invitee)] + + nil)) + invite! (fn [team email] + (th/command! {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails [email]}))] + (with-redefs [cf/flags (conj cf/flags :nitrate :email-verification) + nitrate/call nitrate-call] + (t/is (th/success? (invite! organization-team (:email invitee)))) + (t/is (th/success? (invite! organization-team (:email invitee)))) + (t/is (th/success? (invite! plain-team "external@example.com")))) + + (let [events (mapv second (:call-args-list @audit-mock)) + create-organization (first (filter #(and (= "create-team-invitation" (:name %)) + (= (:email invitee) + (get-in % [:props :member-email]))) + events)) + update-organization (first (filter #(= "update-team-invitation" (:name %)) events)) + create-plain (first (filter #(and (= "create-team-invitation" (:name %)) + (= "external@example.com" + (get-in % [:props :member-email]))) + events))] + (doseq [event [create-organization update-organization]] + (t/is (true? (get-in event [:props :team-belongs-to-organization]))) + (t/is (true? (get-in event [:props :adds-invitee-to-organization]))) + (t/is (true? (get-in event [:props :invitee-already-organization-member])))) + + (t/is (false? (get-in create-plain [:props :team-belongs-to-organization]))) + (t/is (false? (get-in create-plain [:props :adds-invitee-to-organization]))) + (t/is (false? (get-in create-plain [:props :invitee-already-organization-member]))))))) + (t/deftest create-team-invitations-blacklisted-domain (with-mocks [mock {:target 'app.email/send! :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) @@ -378,6 +437,158 @@ (t/is (= :validation (:type edata))) (t/is (= :invalid-token (:code edata))))))))) +(t/deftest accept-organization-invitation-audit-event + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [inviter (th/create-profile* 201 {:is-active true}) + invitee (th/create-profile* 202 {:is-active true}) + team (th/create-team* 201 {:profile-id (:id inviter)}) + organization-id (uuid/random) + default-team-id (uuid/random) + direct-token (tokens/generate + th/*system* + {:iss :team-invitation + :exp (ct/in-future "1h") + :profile-id (:id inviter) + :role :editor + :organization-id organization-id + :member-email (:email invitee) + :member-id (:id invitee)}) + team-token (tokens/generate + th/*system* + {:iss :team-invitation + :exp (ct/in-future "1h") + :profile-id (:id inviter) + :role :editor + :team-id (:id team) + :member-email (:email invitee) + :member-id (:id invitee)}) + verify! (fn [token] + (th/command! {::th/type :verify-token + ::rpc/profile-id (:id invitee) + :token token})) + organization-event + (fn [] + (->> (:call-args-list @audit-mock) + (map second) + (filter #(= "accept-organization-invitation" (:name %))) + first)) + frontend-event (atom nil)] + + (db/insert! (:app.db/pool th/*system*) + :team-invitation + {:org-id organization-id + :email-to (:email invitee) + :created-by (:id inviter) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call + (fn [_cfg method _params] + (case method + :get-organization-membership {:organization-id organization-id + :is-member false} + :get-organization-members [(:id inviter) (uuid/random) (uuid/random)] + nil)) + teams/initialize-user-in-nitrate-organization + (fn [& _] default-team-id)] + (let [out (verify! direct-token)] + (t/is (th/success? out)) + (reset! frontend-event + (get-in out [:result :organization-invitation-audit])))) + + (let [event (organization-event)] + (t/is (= organization-id (get-in event [:props :organization-id]))) + (t/is (not (contains? (:props event) :organization-member-add-source))) + (t/is (not (contains? (:props event) :belongs-to-team-on-add))) + (t/is (not (contains? (:props event) :organization-member-count-before))) + (t/is (= :editor (get-in event [:props :role]))) + (t/is (uuid? (get-in event [:props :invitation-id]))) + (t/is (= "organization-invitation-acceptance" + (:origin @frontend-event))) + (t/is (= organization-id + (get-in @frontend-event [:props :organization-id]))) + (t/is (= "direct-organization-invitation" + (get-in @frontend-event [:props :organization-member-add-source]))) + (t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add]))) + (t/is (= 3 + (get-in @frontend-event [:props :organization-member-count-before]))) + (t/is (not-any? #(contains? #{"accept-team-invitation" + "accept-team-invitation-from"} + (:name (second %))) + (:call-args-list @audit-mock)))) + + (th/reset-mock! audit-mock) + (db/insert! (:app.db/pool th/*system*) + :team-invitation + {:team-id (:id team) + :email-to (:email invitee) + :created-by (:id inviter) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call + (fn [_cfg method _params] + (case method + :get-organization-membership-by-team {:organization-id organization-id + :is-member false} + :get-organization-members (into [(:id inviter)] + (repeatedly 4 uuid/random)) + nil)) + teams/add-profile-to-team! (fn [& _] nil)] + (let [out (verify! team-token)] + (t/is (th/success? out)) + (reset! frontend-event + (get-in out [:result :organization-invitation-audit])))) + + (let [events (mapv second (:call-args-list @audit-mock)) + event (organization-event)] + (t/is (some #(= "accept-team-invitation" (:name %)) events)) + (t/is (some #(= "accept-team-invitation-from" (:name %)) events)) + (t/is (= (:id team) (get-in event [:props :team-id]))) + (t/is (= organization-id (get-in event [:props :organization-id]))) + (t/is (not (contains? (:props event) :organization-member-add-source))) + (t/is (not (contains? (:props event) :belongs-to-team-on-add))) + (t/is (not (contains? (:props event) :organization-member-count-before))) + (t/is (= "team-invitation-acceptance" + (:origin @frontend-event))) + (t/is (= (:id team) (get-in @frontend-event [:props :team-id]))) + (t/is (= organization-id + (get-in @frontend-event [:props :organization-id]))) + (t/is (= "team-invitation" + (get-in @frontend-event [:props :organization-member-add-source]))) + (t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add]))) + (t/is (= 5 + (get-in @frontend-event [:props :organization-member-count-before])))) + + (th/reset-mock! audit-mock) + (db/insert! (:app.db/pool th/*system*) + :team-invitation + {:team-id (:id team) + :email-to (:email invitee) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call + (fn [_cfg method _params] + (case method + :get-organization-membership-by-team {:organization-id organization-id + :is-member true} + :get-organization-members (throw (ex-info "unexpected member count" {})) + nil)) + teams/add-profile-to-team! (fn [& _] nil)] + (let [out (verify! team-token)] + (t/is (th/success? out)) + (reset! frontend-event + (get-in out [:result :organization-invitation-audit])))) + + (let [events (mapv second (:call-args-list @audit-mock))] + (t/is (some #(= "accept-team-invitation" (:name %)) events)) + (t/is (not-any? #(= "accept-organization-invitation" (:name %)) events)) + (t/is (nil? @frontend-event)))))) + (t/deftest create-team-invitations-with-email-verification-disabled (with-mocks [mock {:target 'app.email/send! :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) @@ -845,4 +1056,3 @@ :name "My Valid Team"} out (th/command! data)] (t/is (th/success? out))))) - diff --git a/common/deps.edn b/common/deps.edn index 56714185b7..edffb487f0 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -1,21 +1,21 @@ {:deps {org.clojure/clojure {:mvn/version "1.12.5"} org.clojure/data.json {:mvn/version "2.5.2"} - org.clojure/tools.cli {:mvn/version "1.1.230"} - org.clojure/test.check {:mvn/version "1.1.1"} + org.clojure/tools.cli {:mvn/version "1.4.256"} + org.clojure/test.check {:mvn/version "1.1.3"} org.clojure/data.fressian {:mvn/version "1.1.1"} - org.clojure/clojurescript {:mvn/version "1.12.42"} + org.clojure/clojurescript {:mvn/version "1.12.145"} org.apache.commons/commons-pool2 {:mvn/version "2.13.1"} ;; Logging - org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"} - org.apache.logging.log4j/log4j-core {:mvn/version "2.26.0"} - org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"} - org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"} - org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-api {:mvn/version "2.26.1"} + org.apache.logging.log4j/log4j-core {:mvn/version "2.26.1"} + org.apache.logging.log4j/log4j-web {:mvn/version "2.26.1"} + org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.1"} + org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.1"} org.slf4j/slf4j-api {:mvn/version "2.0.18"} - pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.42"} + pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.43"} selmer/selmer {:mvn/version "1.13.4"} criterium/criterium {:mvn/version "0.4.6"} @@ -23,22 +23,20 @@ metosin/jsonista {:mvn/version "1.0.0" :exclusions [com.fasterxml.jackson.core/jackson-core com.fasterxml.jackson.core/jackson-databind]} - com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.0"} - com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.0"} + com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.1"} + com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.1"} - metosin/malli {:mvn/version "0.19.1"} + metosin/malli {:mvn/version "0.20.1"} expound/expound {:mvn/version "0.9.0"} - com.cognitect/transit-clj {:mvn/version "1.0.333"} + com.cognitect/transit-clj {:mvn/version "1.1.363"} com.cognitect/transit-cljs {:mvn/version "0.8.280"} java-http-clj/java-http-clj {:mvn/version "0.4.3"} integrant/integrant {:mvn/version "1.0.1"} funcool/cuerdas {:mvn/version "2026.415"} - funcool/promesa - {:git/sha "46048fc0d4bf5466a2a4121f5d52aefa6337f2e8" - :git/url "https://github.com/funcool/promesa"} + funcool/promesa {:mvn/version "12.0.1"} funcool/datoteka {:git/tag "4.0.0" @@ -53,7 +51,7 @@ com.sun.mail/jakarta.mail {:mvn/version "2.0.2"} org.la4j/la4j {:mvn/version "0.6.0"} - me.flowthing/pp {:mvn/version "2024-11-13.77"} + me.flowthing/pp {:mvn/version "2026-03-01.107"} io.aviso/pretty {:mvn/version "1.4.4"} environ/environ {:mvn/version "1.2.0"}} @@ -62,7 +60,7 @@ {:dev {:extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.1"} - thheller/shadow-cljs {:mvn/version "3.2.0"} + thheller/shadow-cljs {:mvn/version "3.4.11"} com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} com.bhauman/rebel-readline {:mvn/version "0.1.11"} criterium/criterium {:mvn/version "0.4.6"} diff --git a/common/package.json b/common/package.json index 19257d3ee6..09d48ef2c2 100644 --- a/common/package.json +++ b/common/package.json @@ -4,24 +4,25 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", "type": "module", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "devDependencies": { - "concurrently": "^10.0.3", + "concurrently": "^10.0.4", "nodemon": "^3.1.14", - "prettier": "3.9.4", + "prettier": "3.9.6", "source-map-support": "^0.5.21", - "ws": "^8.21.0" + "ws": "^8.21.1" }, "dependencies": { "date-fns": "^4.4.0" }, "scripts": { - "lint:clj": "clj-kondo --parallel=true --lint src/", + "lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint src/", + "lint:js": "exit 0", "check-fmt:clj": "cljfmt check --parallel=true src/ test/", "check-fmt:js": "prettier -c src/**/*.js", "fmt:clj": "cljfmt fix --parallel=true src/ test/", diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 6f6b1d494c..eeb471366f 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -13,20 +13,20 @@ importers: version: 4.4.0 devDependencies: concurrently: - specifier: ^10.0.3 - version: 10.0.3 + specifier: ^10.0.4 + version: 10.0.4 nodemon: specifier: ^3.1.14 version: 3.1.14 prettier: - specifier: 3.9.4 - version: 3.9.4 + specifier: 3.9.6 + version: 3.9.6 source-map-support: specifier: ^0.5.21 version: 0.5.21 ws: - specifier: ^8.21.0 - version: 8.21.0 + specifier: ^8.21.1 + version: 8.21.1 packages: @@ -73,8 +73,8 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - concurrently@10.0.3: - resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==} + concurrently@10.0.4: + resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} engines: {node: '>=22'} hasBin: true @@ -161,8 +161,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true @@ -181,8 +181,8 @@ packages: engines: {node: '>=10'} hasBin: true - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} engines: {node: '>= 0.4'} simple-update-notifier@2.0.0: @@ -234,8 +234,8 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -303,11 +303,11 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - concurrently@10.0.3: + concurrently@10.0.4: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.8.4 + shell-quote: 1.9.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 @@ -378,7 +378,7 @@ snapshots: picomatch@2.3.2: {} - prettier@3.9.4: {} + prettier@3.9.6: {} pstree.remy@1.1.8: {} @@ -392,7 +392,7 @@ snapshots: semver@7.8.4: {} - shell-quote@1.8.4: {} + shell-quote@1.9.0: {} simple-update-notifier@2.0.0: dependencies: @@ -439,7 +439,7 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.21.0: {} + ws@8.21.1: {} y18n@5.0.8: {} diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 8747099167..7c458aab3f 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -244,6 +244,7 @@ [:page-id {:optional true} ::sm/uuid] [:component-id {:optional true} ::sm/uuid] [:ignore-touched {:optional true} :boolean] + [:allow-altering-copies {:optional true} :boolean] [:parent-id ::sm/uuid] [:shapes ::sm/any]]] @@ -633,22 +634,26 @@ (d/update-in-when data [:components component-id :objects] process-operations change))) (defn- process-children-reordering - [objects {:keys [parent-id shapes] :as change}] + [objects {:keys [parent-id shapes allow-altering-copies] :as change}] (if-let [old-shapes (dm/get-in objects [parent-id :shapes])] - (let [id->idx - (update-vals - (->> (d/enumerate shapes) - (group-by second)) - (comp first first)) + ;; Component sync owns copy child ordering. + (if (and (not allow-altering-copies) + (ctk/in-component-copy? (get objects parent-id))) + objects + (let [id->idx + (update-vals + (->> (d/enumerate shapes) + (group-by second)) + (comp first first)) - new-shapes - (vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))] + new-shapes + (vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))] - (if (not= old-shapes new-shapes) - (do - (some-> *touched-changes* (vswap! conj change)) - (update objects parent-id assoc :shapes new-shapes)) - objects)) + (if (not= old-shapes new-shapes) + (do + (some-> *touched-changes* (vswap! conj change)) + (update objects parent-id assoc :shapes new-shapes)) + objects))) objects)) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index eb7593e992..c0012dfce9 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -198,6 +198,20 @@ ::applied-changes-count (count redo-changes))) changes)) +(defn- without-changes-local + "Append changes through `f` without applying them to the mounted page's + working state." + [changes f] + (if (contains? (meta changes) ::file-data) + (let [changes (-> changes (apply-changes-local) (f))] + (vary-meta changes assoc ::applied-changes-count (count (:redo-changes changes)))) + (f changes))) + +(defn concat-changes-without-local + "Append `other` without applying it to the mounted page's working state." + [changes other] + (without-changes-local changes #(concat-changes % other))) + ;; Page changes (defn add-empty-page @@ -603,68 +617,72 @@ (-> (reduce update-shape changes ids) (apply-changes-local))))) +(defn- add-remove-objects-changes + [changes page-id objects ids {:keys [ignore-touched allow-altering-copies] + :or {ignore-touched false + allow-altering-copies false}}] + (let [add-redo-change + (fn [change-set id] + (conj change-set + (cond-> {:type :del-obj + :page-id page-id + :id id} + ignore-touched + (assoc :ignore-touched true)))) + + add-undo-change-shape + (fn [change-set id] + (let [shape (get objects id)] + (cond-> change-set + (some? shape) + (conj {:type :add-obj + :id id + :page-id page-id + :parent-id (:parent-id shape) + :frame-id (:frame-id shape) + :index (cfh/get-position-on-parent objects id) + :obj (cond-> shape + (contains? shape :shapes) + (assoc :shapes []))})))) + + add-undo-change-parent + (fn [change-set id] + (let [shape (get objects id) + prev-sibling (cfh/get-prev-sibling objects (:id shape))] + (cond-> change-set + (some? shape) + (conj (cond-> {:type :mov-objects + :page-id page-id + :parent-id (:parent-id shape) + :shapes [id] + :after-shape prev-sibling + :index 0 + :ignore-touched true} + allow-altering-copies + (assoc :allow-altering-copies true))))))] + + (-> changes + (update :redo-changes #(reduce add-redo-change % ids)) + (update :undo-changes #(as-> % $ + (reduce add-undo-change-parent $ ids) + (reduce add-undo-change-shape $ ids)))))) + (defn remove-objects ([changes ids] (remove-objects changes ids nil)) - ([changes ids {:keys [ignore-touched] :or {ignore-touched false}}] + ([changes ids options] (assert-page-id! changes) (assert-objects! changes) - (let [page-id (::page-id (meta changes)) - objects (lookup-objects changes) - - add-redo-change - (fn [change-set id] - (conj change-set - (cond-> {:type :del-obj - :page-id page-id - :id id} - ignore-touched - (assoc :ignore-touched true)))) - - add-undo-change-shape - (fn [change-set id] - (let [shape (get objects id)] - (cond-> change-set - (some? shape) - (conj {:type :add-obj - :id id - :page-id page-id - :parent-id (:parent-id shape) - :frame-id (:frame-id shape) - :index (cfh/get-position-on-parent objects id) - :obj (cond-> shape - (contains? shape :shapes) - (assoc :shapes []))})))) - - add-undo-change-parent - (fn [change-set id] - (let [shape (get objects id) - prev-sibling (cfh/get-prev-sibling objects (:id shape))] - (cond-> change-set - (some? shape) - (conj {:type :mov-objects - :page-id page-id - :parent-id (:parent-id shape) - :shapes [id] - :after-shape prev-sibling - :index 0 - :ignore-touched true}))))] - - (-> changes - (update :redo-changes #(reduce add-redo-change % ids)) - (update :undo-changes #(as-> % $ - (reduce add-undo-change-parent $ ids) - (reduce add-undo-change-shape $ ids))) - (apply-changes-local))))) + (-> changes + (add-remove-objects-changes (::page-id (meta changes)) + (lookup-objects changes) + ids + options) + (apply-changes-local)))) ;; FIXME: PERFORMANCE -(defn resize-parents - [changes ids] - (assert-page-id! changes) - (assert-objects! changes) - (let [page-id (::page-id (meta changes)) - - objects (lookup-objects changes) - xform (comp +(defn- add-resize-parents-changes + [changes page-id objects ids] + (let [xform (comp (mapcat #(cons % (cfh/get-parent-ids objects %))) (map (d/getf objects)) (filter #(contains? #{:group :bool} (:type %))) @@ -698,9 +716,8 @@ (update :uops conj {:type :set :attr attr :val old-val :ignore-touched true}))))) resize-parent - (fn [changes parent] - (let [objects (lookup-objects changes) - children (->> parent :shapes (map (d/getf objects))) + (fn [[changes objects] parent] + (let [children (->> parent :shapes (map (d/getf objects))) resized-parent (cond (empty? children) ;; a parent with no children will be deleted, nil ;; so it does not need resize @@ -727,14 +744,24 @@ :id (:id parent)}] (if (seq rops) - (-> changes - (update :redo-changes conj (assoc change :operations rops)) - (update :undo-changes conj (assoc change :operations uops)) - (apply-changes-local)) - changes)) - changes)))] + [(-> changes + (update :redo-changes conj (assoc change :operations rops)) + (update :undo-changes conj (assoc change :operations uops))) + (assoc objects (:id parent) resized-parent)] + [changes objects])) + [changes objects])))] - (reduce resize-parent changes all-parents))) + (first (reduce resize-parent [changes objects] all-parents)))) + +(defn resize-parents + [changes ids] + (assert-page-id! changes) + (assert-objects! changes) + (-> changes + (add-resize-parents-changes (::page-id (meta changes)) + (lookup-objects changes) + ids) + (apply-changes-local))) ;; Library changes @@ -1148,6 +1175,8 @@ (->> ids (map (d/getf objects)) (filter ctl/grid-layout?) + ;; Component sync owns copy child ordering. + (remove ctk/in-component-copy?) (reduce reorder-grid changes))] changes)) diff --git a/common/src/app/common/files/comp_processors.cljc b/common/src/app/common/files/comp_processors.cljc index ed2ca8b51e..9c73a2bba3 100644 --- a/common/src/app/common/files/comp_processors.cljc +++ b/common/src/app/common/files/comp_processors.cljc @@ -63,8 +63,8 @@ file-data))) (defn fix-missing-swap-slots - "Locate shapes that have been swapped (i.e. their shape-ref does not point to the near match) but - they don't have a swap slot. In this case, add one pointing to the near match." + "Locate shapes that have been swapped (see `ctf/swapped-subhead?`) but don't have a swap slot. + In this case, add one pointing to the near match." [file-data libraries] (try (ctf/update-all-shapes @@ -73,7 +73,11 @@ (if (ctk/subcopy-head? shape) (let [container (:container (meta shape)) file {:id (:id file-data) :data file-data} - near-match (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false)] + swapped? (ctf/swapped-subhead? + shape container + #(ctf/find-ref-shape file container libraries % :include-deleted? true)) + near-match (when swapped? + (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false))] (if (and (some? near-match) (not= (:shape-ref shape) (:id near-match)) (nil? (ctk/get-swap-slot shape))) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 147df1d1cc..28174ba84f 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -1874,6 +1874,110 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0025-repair-empty-text-content" + ;; Repair text shapes whose :content tree has empty/missing :children + ;; at any of the three levels: + ;; Level 1: root with no paragraph-set + ;; Level 2: paragraph-set with no paragraph + ;; Level 3: paragraph with no span + ;; Such shapes fail the backend `validate-shape` schema and would also + ;; break the v2 editor's `cljs->dom` roundtrip. Re-seed the canonical + ;; root -> paragraph-set -> paragraph -> span tree, preserving the + ;; original root-level attributes (e.g. :vertical-align) when present. + ;; Idempotent on healthy content. + [data _] + (let [default-span {:text "" :fills types.text/default-text-fills} + default-paragraph {:type "paragraph" :children [default-span]} + default-paragraph-set {:type "paragraph-set" :children [default-paragraph]} + + ;; Level 3: repair paragraph with empty/missing children + repair-span (fn [span] + (if (and (map? span) + (string? (:text span))) + span + default-span)) + + repair-paragraph (fn [paragraph] + (if (and (map? paragraph) + (= "paragraph" (:type paragraph))) + (cond + ;; Children is nil or empty vector - seed with default span + (or (nil? (:children paragraph)) + (and (vector? (:children paragraph)) + (empty? (:children paragraph)))) + (assoc paragraph :children [default-span]) + + ;; Children is a vector - repair any invalid spans + (vector? (:children paragraph)) + (update paragraph :children + (fn [children] + (mapv repair-span children))) + + ;; Children is not a vector - replace with default + :else + (assoc paragraph :children [default-span])) + default-paragraph)) + + ;; Level 2: repair paragraph-set with empty/missing children + repair-paragraph-set (fn [paragraph-set] + (if (and (map? paragraph-set) + (= "paragraph-set" (:type paragraph-set))) + (cond + ;; Children is nil or empty vector - seed with default paragraph + (or (nil? (:children paragraph-set)) + (and (vector? (:children paragraph-set)) + (empty? (:children paragraph-set)))) + (assoc paragraph-set :children [default-paragraph]) + + ;; Children is a vector - repair any invalid paragraphs + (vector? (:children paragraph-set)) + (update paragraph-set :children + (fn [children] + (mapv repair-paragraph children))) + + ;; Children is not a vector - replace with default + :else + (assoc paragraph-set :children [default-paragraph])) + default-paragraph-set)) + + ;; Repair content at all levels, handling all edge cases + repair-content (fn [content] + (cond + ;; Content is not a valid root map - create default + ;; Preserve root-level attrs if content is a map + (or (nil? content) + (not (map? content)) + (not= "root" (:type content))) + (merge types.text/default-root-attrs + {:type "root" + :children [default-paragraph-set]} + (when (map? content) + (select-keys content types.text/root-attrs))) + + ;; Content is a valid root - repair all levels + :else + (let [children (if (and (vector? (:children content)) + (seq (:children content))) + (:children content) + [default-paragraph-set])] + (merge types.text/default-root-attrs + {:type "root"} + (select-keys content types.text/root-attrs) + {:children (mapv repair-paragraph-set children)})))) + + ;; Simplified gatekeeper - just check if it's a text shape + fix-shape (fn [shape] + (if (cfh/text-shape? shape) + (update shape :content repair-content) + shape)) + + update-container (fn [container] + (d/update-when container :objects d/update-vals fix-shape))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -1955,4 +2059,5 @@ "0021-fix-shape-svg-attrs" "0022-normalize-component-root-and-resync" "0023-repair-token-themes-with-inexistent-sets" - "0024b-fix-stroke-cap-placement"])) + "0024b-fix-stroke-cap-placement" + "0025-repair-empty-text-content"])) diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index ca7b469d4b..82aeed3d9e 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -438,15 +438,15 @@ shape file page))) (defn- check-required-swap-slot - "Validate that the shape has swap-slot if it's a subinstance head and the ref shape is not the - matching shape by position in the near main." + "Validate that the shape has a swap slot if it's a subinstance head that has been + swapped (see `ctf/swapped-subhead?`)." [shape file page libraries] ;; Guard first: if the shape already has a swap slot the invariant is satisfied - ;; and we can avoid the expensive `find-near-match` call entirely. - (when (nil? (ctk/get-swap-slot shape)) + ;; and we can avoid the ref-shape lookups entirely. + (when (and (nil? (ctk/get-swap-slot shape)) + (ctf/swapped-subhead? shape page #(find-ref-shape* file page libraries %))) (let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)] - (when (and (some? near-match) - (not= (:shape-ref shape) (:id near-match))) + (when (some? near-match) (report-error :missing-slot "Shape has been swapped, should have swap slot" shape file page diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 62ce48aa9a..27519d487c 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -167,10 +167,18 @@ ;; Activates the nitrate module :nitrate + ;; disabled by default. When enabled, allows the nitrate + ;; `bulk-create-profiles` method to create batches of already + ;; active profiles. Only intended for test environments. + :nitrate-bulk-create-profiles + :mcp :background-blur :available-viewer-wasm - :stroke-path}) + :stroke-path + :stroke-per-side + + :custom-shortcuts}) (def all-flags (set/union email login varia)) @@ -204,6 +212,7 @@ :enable-render-wasm-info :enable-available-viewer-wasm :enable-background-blur + :enable-stroke-path :enable-token-combobox]) (defn parse diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc index 0c96e7c22e..ffed8cc09f 100644 --- a/common/src/app/common/logging.cljc +++ b/common/src/app/common/logging.cljc @@ -253,7 +253,7 @@ (swap! log-record (constantly lrecord))))] (if sync? (logfn) - (px/exec! *default-executor* logfn)))) + (px/exec *default-executor* logfn)))) (defmacro log! "Emit a new log record to the global log-record state (asynchronously). " diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index d18f7019ec..a89aa633ab 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2505,15 +2505,49 @@ (pcb/concat-changes changes new-changes))) (defn- reposition-shape - [shape origin-root dest-root] - (let [shape-pos (fn [shape] - (gpt/point (get-in shape [:selrect :x]) - (get-in shape [:selrect :y]))) + "Expresses the shape (belonging to the origin-root instance) in the frame of the + dest-root instance, making the geometry of both instances directly comparable — + and copyable. - origin-root-pos (shape-pos origin-root) - dest-root-pos (shape-pos dest-root) - delta (gpt/subtract dest-root-pos origin-root-pos)] - (gsh/move shape delta))) + If the dest root's geometry is NOT overridden (touched), the instance follows + the origin's transformation verbatim (including rotation and flips), so a + translation by the roots' (untransformed) position delta suffices — position is + free per-instance placement. + + If the dest root's geometry IS overridden (e.g. the user rotated the copy as a + whole), the instance keeps its own placement transform, so the origin shape is + additionally transformed by the roots' relative transformation (rotation / + flips) around the dest root center — geometric changes then land expressed in + the dest instance's own frame instead of wiping its placement." + [shape origin-root dest-root] + (let [shape-pos (fn [shape] + (gpt/point (dm/get-in shape [:selrect :x]) + (dm/get-in shape [:selrect :y]))) + + origin-root-pos (shape-pos origin-root) + dest-root-pos (shape-pos dest-root) + delta (gpt/subtract dest-root-pos origin-root-pos) + + shape (gsh/move shape delta)] + (if-not (ctk/touched-group? dest-root :geometry-group) + shape + (let [origin-transform (d/nilv (:transform origin-root) (gmt/matrix)) + dest-transform (d/nilv (:transform dest-root) (gmt/matrix)) + rel-transform (gmt/multiply dest-transform (gmt/inverse origin-transform))] + (if ^boolean (gmt/unit? rel-transform) + shape + ;; The roots differ in rotation/flips: rotate the whole (already moved) + ;; shape around the dest root center by the roots' relative transform. + ;; The :rotation attribute delta is fed through the :modifiers path so + ;; apply-transform keeps it consistent with the resulting matrix. + (let [center (grc/rect->center (:selrect dest-root)) + rel-rotation (mod (- (d/nilv (:rotation dest-root) 0) + (d/nilv (:rotation origin-root) 0)) + 360)] + (-> shape + (assoc-in [:modifiers :rotation] rel-rotation) + (gsh/apply-transform (gmt/transform-in center rel-transform)) + (dissoc :modifiers)))))))) (defn- make-change [container change] diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 8f7aad2b49..831fce4076 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -113,6 +113,116 @@ (-> changes (pcb/update-shapes ids update-fn {:attrs #{:blocked :hidden}})))) +;; Deleting shapes inside a surviving component main transitively removes their +;; copies across all pages. Whole-main and component-swap deletions are excluded. + +(defn- mutilates-main? + "Whether deleting `id` takes a shape out of a component main that survives. + `deleted-ids` are all the ids the deletion removes from the page." + [objects deleted-ids id] + (->> (cfh/get-parent-ids objects id) + (some (fn [parent-id] + (let [parent (get objects parent-id)] + (and (:main-instance parent) + (not (contains? deleted-ids parent-id)))))))) + +(defn- build-shape-ref-index + "Index every referencing shape by shape-ref and page." + [pages-index page-objects] + (reduce (fn [index page-id] + (reduce (fn [index shape] + (if-let [shape-ref (:shape-ref shape)] + (update index shape-ref (fnil conj []) [page-id (:id shape)]) + index)) + index + (vals (page-objects page-id)))) + {} + (keys pages-index))) + +(defn- collect-copy-deletions + "Collect dangling copy roots and their subtree ids by page. + `scheduled` prevents duplicate deletions during transitive traversal." + [ref-index descendants-of dangling scheduled] + (loop [dangling dangling + scheduled scheduled + result {}] + (if (empty? dangling) + result + (let [hits + (into #{} + (comp (mapcat ref-index) + (remove (fn [[_ id]] (contains? scheduled id)))) + dangling) + + ;; Ancestor subtrees already contain nested hits. + nested + (into #{} + (mapcat (fn [[page-id id]] (descendants-of page-id id))) + hits) + + subtrees + (into [] + (comp (remove (fn [[_ id]] (contains? nested id))) + (map (fn [[page-id id]] + [page-id id (-> (descendants-of page-id id) + (set) + (conj id))]))) + hits) + + deleted + (into #{} (mapcat #(nth % 2)) subtrees)] + (recur deleted + (into scheduled deleted) + (reduce (fn [result [page-id root-id ids]] + (-> result + (update-in [page-id :roots] + (fnil conj (d/ordered-set)) + root-id) + (update-in [page-id :ids] (fnil into #{}) ids))) + result + subtrees)))))) + +(defn- propagated-copy-deletions + "Collect propagated copy deletions unless component sync owns the operation." + [objects pages-index page-objects deleted-ids allow-altering-copies] + (if (or allow-altering-copies (nil? pages-index)) + {} + (let [dangling (into #{} + (filter #(mutilates-main? objects deleted-ids %)) + deleted-ids)] + (if (empty? dangling) + {} + (collect-copy-deletions (build-shape-ref-index pages-index page-objects) + ;; Cache subtrees shared by multiple references. + (memoize (fn [page-id id] + (cfh/get-children-ids (page-objects page-id) id))) + dangling + deleted-ids))))) + +(declare generate-delete-shapes) + +(defn- generate-copy-deletions + "Delete propagated copy roots through each page's normal deletion workflow." + [changes data page pages-index copy-deletions] + (reduce-kv (fn [changes pid {:keys [roots]}] + (let [options {:ignore-touched true + :allow-altering-copies true} + target-page (if (= pid (:id page)) + page + (get pages-index pid))] + (if (= pid (:id page)) + (second (generate-delete-shapes changes roots options)) + (let [[_ target-changes] + (generate-delete-shapes (pcb/empty-changes nil pid) + data + target-page + (:objects target-page) + roots + options)] + (pcb/concat-changes-without-local changes target-changes))))) + changes + copy-deletions)) + (defn generate-delete-shapes ([changes file page objects ids options] (generate-delete-shapes (-> changes @@ -185,17 +295,19 @@ ids-to-delete) []) - interacting-shapes - (filter (fn [shape] - ;; If any of the deleted shapes is the destination of - ;; some interaction, this must be deleted, too. - (let [interactions (:interactions shape)] - (some #(and (ctsi/has-destination %) - (contains? ids-to-delete (:destination %))) - interactions))) - (vals objects)) - id-to-delete? (set ids-to-delete) + + interacting-shapes + (into [] + (filter (fn [shape] + ;; If any of the deleted shapes is the destination of + ;; some interaction, this must be deleted, too. + (let [interactions (:interactions shape)] + (some #(and (ctsi/has-destination %) + (id-to-delete? (:destination %))) + interactions)))) + (vals objects)) + changes (->> (:flows page) (reduce @@ -261,19 +373,43 @@ [] (into ids-to-delete descendants-to-delete)) + ;; Empty main parents also leave their copies dangling. + all-deleted-ids + (-> (set ids-to-delete) + (into descendants-to-delete) + (into empty-parents)) - ids-set (set ids-to-delete) + pages-index + (when data + (or (:pages-index data) + (dm/get-in data [:data :pages-index]))) + + page-objects + (fn [id] + (if (= id (:id page)) + objects + (dm/get-in pages-index [id :objects]))) + + copy-deletions + (propagated-copy-deletions objects pages-index page-objects + all-deleted-ids allow-altering-copies) + + ;; Propagated copy deletions supersede hiding the same shapes. + ids-to-hide + (if-let [deleted (seq (get-in copy-deletions [(:id page) :ids]))] + (into [] (remove (set deleted)) ids-to-hide) + ids-to-hide) guides-to-delete (->> (:guides page) (vals) - (filter #(contains? ids-set (:frame-id %))) + (filter #(id-to-delete? (:frame-id %))) (map :id)) changes (reduce (fn [changes guide-id] (-> changes (pcb/with-page page) - (pcb/set-flow guide-id nil))) + (pcb/set-guide guide-id nil))) changes guides-to-delete) @@ -289,6 +425,7 @@ (pcb/remove-objects descendants-to-delete {:ignore-touched true}) (pcb/remove-objects ids-to-delete {:ignore-touched ignore-touched}) (pcb/remove-objects empty-parents) + (generate-copy-deletions data page pages-index copy-deletions) (pcb/resize-parents all-parents) (pcb/update-shapes groups-to-unmask (fn [shape] @@ -299,7 +436,7 @@ (fn [interactions] (into [] (remove #(and (ctsi/has-destination %) - (contains? ids-to-delete (:destination %)))) + (id-to-delete? (:destination %)))) interactions))))))] [all-parents changes]))) diff --git a/common/src/app/common/math.cljc b/common/src/app/common/math.cljc index 043559e257..839079efee 100644 --- a/common/src/app/common/math.cljc +++ b/common/src/app/common/math.cljc @@ -34,12 +34,14 @@ #?(:cljs (js/isNaN v) :clj (Double/isNaN v))) -;; NOTE: on cljs we don't need to check for `number?` so we explicitly -;; ommit it for performance reasons. +;; NOTE: we need `number?` guard on cljs because `js/isFinite` coerces +;; strings to numbers, accepting "16" as finite when it shouldn't. +;; This caused a bug where string values from format-number were +;; propagated through the system until Malli rejected them (issue #10638). (defn finite? [v] - #?(:cljs (and (not (nil? v)) (js/isFinite v)) + #?(:cljs (and (not (nil? v)) (number? v) (js/isFinite v)) :clj (and (not (nil? v)) (number? v) (Double/isFinite v)))) (defn finite diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index accda94bc5..8c97f4cce0 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -429,6 +429,17 @@ (with-meta (meta parent-ref-shape))))] near-match)) +(defn swapped-subhead? + "Whether `shape` references outside its near main parent and needs a swap slot. + Same-parent positional differences are synchronized as reorders." + [shape container find-parent-ref-shape] + (let [parent-shape (ctst/get-shape container (:parent-id shape)) + parent-ref-shape (when parent-shape + (find-parent-ref-shape parent-shape))] + (and (some? parent-ref-shape) + (not-any? #(= % (:shape-ref shape)) + (:shapes parent-ref-shape))))) + (defn advance-shape-ref "Get the shape-ref of the near main of the shape, recursively repeated as many times as the given levels." diff --git a/common/src/app/common/types/nitrate_permissions.cljc b/common/src/app/common/types/nitrate_permissions.cljc index a395da6709..fc47f2ed7c 100644 --- a/common/src/app/common/types/nitrate_permissions.cljc +++ b/common/src/app/common/types/nitrate_permissions.cljc @@ -14,29 +14,29 @@ :new-team-members "anyone"}) (defn- can-create-team? - [{:keys [is-org-owner? permission-value]}] - (or is-org-owner? + [{:keys [is-organization-owner? permission-value]}] + (or is-organization-owner? (= permission-value "any"))) (defn- can-delete-team? - [{:keys [is-org-owner? permission-value team-perms]}] + [{:keys [is-organization-owner? permission-value team-perms]}] (cond - ;; Org owners can always delete teams inside their organizations. - is-org-owner? + ;; Organization owners can always delete teams inside their organizations. + is-organization-owner? true (= permission-value "onlyOwners") (boolean (:is-owner team-perms)) :else false)) (defn- can-move-team? - [{:keys [permission-value target-org-same-owner?]}] + [{:keys [permission-value target-organization-same-owner?]}] (cond (= permission-value "never") false (= permission-value "always") true (= permission-value "myOrganizations") - (true? target-org-same-owner?) + (true? target-organization-same-owner?) :else false)) (defn- can-invite-to-team? @@ -67,36 +67,36 @@ :add-anybody-to-team {:permission-key :new-team-members :check-fn can-add-anybody-to-team?}}) -(defn- normalize-org-permissions - [org-perms] - (merge defaults (or (:permissions org-perms) {}))) +(defn- normalize-organization-permissions + [organization-perms] + (merge defaults (or (:permissions organization-perms) {}))) (defn- owner? - [org-perms profile-id] - (= profile-id (:owner-id org-perms))) + [organization-perms profile-id] + (= profile-id (:owner-id organization-perms))) (defn allowed? "Returns true only for explicitly allowed actions (fail-closed)." - [action {:keys [org-perms profile-id team-perms target-org-same-owner?]}] + [action {:keys [organization-perms profile-id team-perms target-organization-same-owner?]}] (let [{:keys [permission-key check-fn] :as rule} (get action-rules action) - permissions (normalize-org-permissions org-perms) - is-org-owner? (owner? org-perms profile-id) + permissions (normalize-organization-permissions organization-perms) + is-organization-owner? (owner? organization-perms profile-id) permission-value (get permissions permission-key)] (cond (nil? rule) false - :else (boolean (check-fn {:is-org-owner? is-org-owner? + :else (boolean (check-fn {:is-organization-owner? is-organization-owner? :permission-value permission-value :team-perms team-perms - :target-org-same-owner? target-org-same-owner?}))))) + :target-organization-same-owner? target-organization-same-owner?}))))) (defn can-send-invitations? [{:keys [nitrate-enabled? organization profile-id team-permissions]}] - (let [in-org? (and nitrate-enabled? organization)] - (if in-org? + (let [in-organization? (and nitrate-enabled? organization)] + (if in-organization? (allowed? :send-invitations - {:org-perms {:owner-id (:owner-id organization) - :permissions (:permissions organization)} + {:organization-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} :profile-id profile-id :team-perms team-permissions}) (or (boolean (:is-owner team-permissions)) diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 62d77ac14c..451c61e6d5 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -61,4 +61,14 @@ [:name ::sm/text] [:initials [:maybe :string]] [:logo [:maybe ::sm/uri]] - [:avatar-bg-url [:maybe ::sm/uri]]]) + [:avatar-bg-url [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe :boolean]]]) + +(def schema:nitrate-sso + [:map {:title "NitrateOrganizationSso"} + [:organization-id ::sm/uuid] + [:active {:optional true} [:maybe :boolean]] + [:provider {:optional true} [:maybe :string]] + [:client-id {:optional true} [:maybe :string]] + [:client-secret {:optional true} [:maybe :string]] + [:issuer {:optional true} [:maybe :string]]]) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index c7334d1717..d645e72ae1 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -139,6 +139,12 @@ [:stroke-style {:optional true} [::sm/one-of #{:solid :dotted :dashed :mixed}]] [:stroke-width {:optional true} ::sm/safe-number] + ;; wasm-render only, backwards compatible + [:stroke-per-side {:optional true} :boolean] + [:stroke-width-top {:optional true} ::sm/safe-number] + [:stroke-width-right {:optional true} ::sm/safe-number] + [:stroke-width-bottom {:optional true} ::sm/safe-number] + [:stroke-width-left {:optional true} ::sm/safe-number] [:stroke-dash {:optional true} ::sm/safe-number] [:stroke-gap {:optional true} ::sm/safe-number] [:stroke-alignment {:optional true} @@ -692,6 +698,31 @@ :r3 :r4}) +(def ^:private text-extract-props + (into #{} cat [txt/root-attrs txt/paragraph-attrs txt/text-node-attrs])) + +(def ^:private layout-extract-props + (set ctsl/layout-attrs)) + +;; Token attrs are not shape attrs (:fill token vs :fills attr, :m1..:m4 vs +;; :layout-item-margin). A token may only travel with the value it resolves to, +;; so its domain is derived from the props that are actually written. The attrs +;; holding a map of edges are patched edge by edge, so only the edges present +;; in the map are written. +(defn- token-attrs + [props] + (reduce-kv (fn [result attr value] + (let [sub-attrs (when (map? value) + (not-empty (set (keys value))))] + (into result + (if (= :layout-gap attr) + (if (some? sub-attrs) + (filter cto/spacing-gap-keys sub-attrs) + cto/spacing-gap-keys) + (cto/shape-attr->token-attrs attr sub-attrs))))) + #{} + props)) + (defn extract-props "Retrieves an object with the 'pasteable' properties for a shape." [shape] @@ -723,7 +754,15 @@ props))) (extract-layout-attrs [props shape] - (d/patch-object props (select-keys shape ctsl/layout-attrs)))] + (d/patch-object props (select-keys shape ctsl/layout-attrs))) + + (extract-token-props [props shape] + (let [tokens (-> (:applied-tokens shape) + (select-keys (token-attrs props)) + (not-empty))] + (cond-> props + (some? tokens) + (assoc :applied-tokens tokens))))] (let [;; For texts we don't extract the fill extract-props @@ -731,7 +770,8 @@ (-> shape (select-keys extract-props) (cond-> (cfh/text-shape? shape) (extract-text-props shape)) - (cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape)))))) + (cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape)) + (extract-token-props shape))))) (defn patch-props "Given the object of `extract-props` applies it to a shape. Adapt the shape if necessary" @@ -759,12 +799,33 @@ (let [shape (d/patch-object shape (select-keys props ctsl/layout-attrs))] (cond-> shape (ctsl/grid-layout? shape) - (ctsl/assign-cells objects))))] + (ctsl/assign-cells objects)))) + + (patched-props [shape props] + (let [text? (cfh/text-shape? shape) + frame? (cfh/frame-shape? shape)] + (select-keys props + (filter (fn [attr] + (or (contains? basic-extract-props attr) + (and text? (contains? text-extract-props attr)) + (and frame? (contains? layout-extract-props attr)))) + (keys props))))) + + (patch-token-props [shape props] + (let [attrs (token-attrs (patched-props shape props)) + tokens (-> (:applied-tokens shape) + (d/without-keys attrs) + (merge (select-keys (:applied-tokens props) attrs)) + (not-empty))] + (if (some? tokens) + (assoc shape :applied-tokens tokens) + (dissoc shape :applied-tokens))))] (-> shape (d/patch-object (select-keys props basic-extract-props)) (cond-> (cfh/text-shape? shape) (patch-text-props props)) - (cond-> (cfh/frame-shape? shape) (patch-layout-props props))))) + (cond-> (cfh/frame-shape? shape) (patch-layout-props props)) + (patch-token-props props)))) diff --git a/common/src/app/common/types/shape/interactions.cljc b/common/src/app/common/types/shape/interactions.cljc index d207f337ca..6b06b68897 100644 --- a/common/src/app/common/types/shape/interactions.cljc +++ b/common/src/app/common/types/shape/interactions.cljc @@ -76,7 +76,10 @@ [:map {:title "AnimationDisolve"} [:animation-type [:= :dissolve]] [:duration ::sm/safe-int] - [:easing [::sm/one-of easing-types]]]) + [:easing [::sm/one-of easing-types]] + [:way {:optional true} [::sm/one-of way-types]] + [:offset-effect {:optional true} :boolean] + [:direction {:optional true} [::sm/one-of direction-types]]]) (def schema:slide-animation [:map {:title "AnimationSlide"} diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 595f99caa0..03532db4ec 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -347,6 +347,22 @@ (+ pad-top pad-top) (+ pad-top pad-bottom)))) +(defn padding-type-for + "`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)." + [{:keys [p1 p2 p3 p4]}] + (if (and (mth/close? (d/nilv p1 0) (d/nilv p3 0)) + (mth/close? (d/nilv p2 0) (d/nilv p4 0))) + :simple + :multiple)) + +(defn margin-type-for + "`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)." + [{:keys [m1 m2 m3 m4]}] + (if (and (mth/close? (d/nilv m1 0) (d/nilv m3 0)) + (mth/close? (d/nilv m2 0) (d/nilv m4 0))) + :simple + :multiple)) + (defn child-min-width [child] (if (and (fill-width? child) @@ -1509,20 +1525,37 @@ (some? target-cell) (add-children-to-cell ids objects [(:row target-cell) (:column target-cell)])))) +(defn- refill-slots + "Fill matching positions in `shapes` from `ordered`, preserving other indices. + `ordered` must contain exactly the ids accepted by `slot?`." + [shapes slot? ordered] + (loop [shapes (seq shapes) + ordered (seq ordered) + result (transient [])] + (if (nil? shapes) + (persistent! result) + (let [id (first shapes)] + (if (slot? id) + (recur (next shapes) (next ordered) (conj! result (first ordered))) + (recur (next shapes) ordered (conj! result id))))))) + (defn reorder-grid-children + "Order cell children by grid position while preserving the indices of + hidden and absolute-positioned children." [parent] - (let [cells (get-cells parent {:sort? true}) + (let [cells (get-cells parent {:sort? true}) child? (set (:shapes parent)) - new-shapes - (into (d/ordered-set) + + in-cell-ids + (into [] (comp (keep (comp first :shapes)) - (filter child?)) - cells) - - ;; Add the children that are not in cells (absolute positioned for example) - new-shapes (into new-shapes (:shapes parent))] - - (assoc parent :shapes (into [] (reverse new-shapes))))) + (filter child?) + (distinct)) + cells)] + ;; :shapes is ordered in reverse relative to the visual cell order + (assoc parent :shapes (refill-slots (:shapes parent) + (set in-cell-ids) + (reverse in-cell-ids))))) (defn cells-by-row ([parent index] diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index c5211e8a4a..8c0595daa6 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -20,44 +20,42 @@ [:type [:= "root"]] [:key {:optional true} :string] [:children - {:optional true} - [:maybe - [:vector {:min 1 :gen/max 2 :gen/min 1} - [:map - [:type [:= "paragraph-set"]] - [:key {:optional true} :string] - [:children - [:vector {:min 1 :gen/max 2 :gen/min 1} - [:map - [:type [:= "paragraph"]] - [:key {:optional true} :string] - [:fills {:optional true} - [:maybe schema:fills]] - [:font-family {:optional true} ::sm/text] - [:font-size {:optional true} ::sm/text] - [:font-style {:optional true} ::sm/text] - [:font-weight {:optional true} ::sm/text] - [:direction {:optional true} ::sm/text] - [:text-decoration {:optional true} ::sm/text] - [:text-transform {:optional true} ::sm/text] - [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] - [:typography-ref-file {:optional true} [:maybe ::sm/uuid]] - [:children - [:vector {:min 1 :gen/max 2 :gen/min 1} - [:map - [:text :string] - [:key {:optional true} :string] - [:fills {:optional true} - [:maybe schema:fills]] - [:font-family {:optional true} ::sm/text] - [:font-size {:optional true} ::sm/text] - [:font-style {:optional true} ::sm/text] - [:font-weight {:optional true} ::sm/text] - [:direction {:optional true} ::sm/text] - [:text-decoration {:optional true} ::sm/text] - [:text-transform {:optional true} ::sm/text] - [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] - [:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]]) + [:vector {:min 1 :gen/max 2 :gen/min 1} + [:map + [:type [:= "paragraph-set"]] + [:key {:optional true} :string] + [:children + [:vector {:min 1 :gen/max 2 :gen/min 1} + [:map + [:type [:= "paragraph"]] + [:key {:optional true} :string] + [:fills {:optional true} + [:maybe schema:fills]] + [:font-family {:optional true} ::sm/text] + [:font-size {:optional true} ::sm/text] + [:font-style {:optional true} ::sm/text] + [:font-weight {:optional true} ::sm/text] + [:direction {:optional true} ::sm/text] + [:text-decoration {:optional true} ::sm/text] + [:text-transform {:optional true} ::sm/text] + [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] + [:typography-ref-file {:optional true} [:maybe ::sm/uuid]] + [:children + [:vector {:min 1 :gen/max 2 :gen/min 1} + [:map + [:text :string] + [:key {:optional true} :string] + [:fills {:optional true} + [:maybe schema:fills]] + [:font-family {:optional true} ::sm/text] + [:font-size {:optional true} ::sm/text] + [:font-style {:optional true} ::sm/text] + [:font-weight {:optional true} ::sm/text] + [:direction {:optional true} ::sm/text] + [:text-decoration {:optional true} ::sm/text] + [:text-transform {:optional true} ::sm/text] + [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] + [:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]) (def valid-content? (sm/lazy-validator schema:content)) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 619bc5e2b8..10cedd5c19 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -235,6 +235,8 @@ [:row-gap {:optional true} schema:token-name] [:column-gap {:optional true} schema:token-name]]) +(def spacing-gap-keys (schema-keys schema:spacing-gap)) + (def ^:private schema:spacing-padding [:map {:title "SpacingPaddingTokenAttrs"} [:p1 {:optional true} schema:token-name] diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc index 3cbc475000..7671f7a787 100644 --- a/common/test/common_tests/files_changes_test.cljc +++ b/common/test/common_tests/files_changes_test.cljc @@ -912,3 +912,4 @@ (nil? (get-in result2 [:pages-index page-id :default-grids]))))) {:num 1000}))) + diff --git a/common/test/common_tests/files_migrations_0025_test.cljc b/common/test/common_tests/files_migrations_0025_test.cljc new file mode 100644 index 0000000000..4d57398953 --- /dev/null +++ b/common/test/common_tests/files_migrations_0025_test.cljc @@ -0,0 +1,833 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.files-migrations-0025-test + (:require + [app.common.files.migrations :as cfm] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;; 0025-repair-empty-text-content +;; Text shapes whose :content is a root with an empty/missing :children +;; vector used to slip past the schema (children was optional). With the +;; schema tightening those shapes must be repaired on next load. +(defn- make-text-shape-with-content + "Build a text shape with arbitrary content structure" + [shape-id content] + (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content content))) + +(defn- make-broken-text-shape + "Build a fully-initialised text shape with a broken :content and the + supplied root-level attrs overlaid on it." + [shape-id root-attrs] + (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content (merge {:type "root"} + (when (seq root-attrs) root-attrs) + {:children []})))) + +(t/deftest migration-0025-repair-empty-text-content-empty-children + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-broken-text-shape shape-id {:vertical-align "top"})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "root type preserved") + (t/is (vector? (:children content)) "children is now a vector") + (t/is (= 1 (count (:children content))) "exactly one paragraph-set seeded") + (t/is (= "paragraph-set" (get-in content [:children 0 :type]))) + (t/is (pos? (count (get-in content [:children 0 :children]))) + "paragraph-set has at least one paragraph") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) + "seeded span has empty text") + (t/is (= "top" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-missing-children + (let [shape-id (uuid/next) + page-id (uuid/next) + ;; A text shape whose :content has no :children key at all. + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "center"}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (:children content)) "missing children becomes a vector") + (t/is (pos? (count (:children content))) "missing children gets a paragraph-set") + (t/is (= "center" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-no-content + (let [shape-id (uuid/next) + page-id (uuid/next) + ;; A text shape with no :content at all. Should be repaired with default content. + data {:pages-index + {page-id + {:objects + {shape-id (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (map? content) "content is now a map") + (t/is (= "root" (:type content)) "content has root type") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-repair-empty-text-content-idempotent + (let [shape-id (uuid/next) + page-id (uuid/next) + ;; A healthy text shape with a proper paragraph-set/paragraph/ + ;; span tree. The migration must leave it untouched. + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "hello"}]}]}]}))}}}} + original (get-in data [:pages-index page-id :objects shape-id]) + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (cts/valid-shape? original) "baseline shape is valid") + (t/is (= original shape') "healthy content is unchanged"))) + +(t/deftest migration-0025-repair-empty-text-content-component + ;; The migration also walks :components, so a broken text inside a + ;; component is also repaired. + (let [shape-id (uuid/next) + comp-id (uuid/next) + data {:components + {comp-id + {:objects + {shape-id (make-broken-text-shape shape-id nil)}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:components comp-id :objects shape-id])] + + (t/is (cts/valid-shape? shape) "repaired component shape is valid") + (t/is (pos? (count (get-in shape [:content :children]))) + "children vector is no longer empty"))) + +(t/deftest migration-0025-repair-empty-text-content-level2 + ;; Level 2: paragraph-set with empty/missing children + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children []}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "paragraph-set preserved") + (t/is (pos? (count (get-in content [:children 0 :children]))) + "paragraph-set now has at least one paragraph") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) + "seeded child is a paragraph") + (t/is (= "top" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-level3 + ;; Level 3: paragraph with empty/missing children + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children []}]}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "paragraph preserved") + (t/is (pos? (count (get-in content [:children 0 :children 0 :children]))) + "paragraph now has at least one span") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) + "seeded span has empty text") + (t/is (= "top" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-mixed-levels + ;; Valid level 1, but broken at levels 2 and 3 in different paragraph-sets + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children []} + {:type "paragraph-set" + :children [{:type "paragraph" + :children []}]}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 2 (count (:children content))) "both paragraph-sets preserved") + ;; First paragraph-set had empty children (level 2 broken) + (t/is (pos? (count (get-in content [:children 0 :children]))) + "first paragraph-set now has paragraphs") + ;; Second paragraph-set had a paragraph with empty children (level 3 broken) + (t/is (pos? (count (get-in content [:children 1 :children 0 :children]))) + "second paragraph's paragraph now has spans"))) + +;; ============================================================================ +;; Category A: Shape-level guards (fix-shape) +;; ============================================================================ + +(t/deftest migration-0025-non-text-shape-untouched + ;; A: Non-text shape should not be processed + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0})}}}} + original (get-in data [:pages-index page-id :objects shape-id]) + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (= original shape') "non-text shape is unchanged"))) + +(t/deftest migration-0025-text-shape-non-map-content-repaired + ;; A: Text shape with non-map content should be repaired + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content "not a map"))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (map? content) "content is now a map") + (t/is (= "root" (:type content)) "content has root type") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-wrong-root-type-repaired + ;; A: Text shape with content :type not "root" should be repaired, preserving root-level attrs + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "paragraph" + :vertical-align "center" + :children []}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "type is now root") + (t/is (= "center" (:vertical-align content)) "root-level attrs preserved") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-nil-content + ;; I: Text shape with :content nil should be repaired with default content + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content nil))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (map? content) "content is now a map") + (t/is (= "root" (:type content)) "content has root type") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-empty-map-content + ;; I: Text shape with :content {} (empty map) should be repaired with default content + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "type is now root") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-wrong-type-with-root-attrs + ;; I: Text shape with wrong type but valid root-level attrs should preserve attrs + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "paragraph" + :vertical-align "bottom" + :children []}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "type is now root") + (t/is (= "bottom" (:vertical-align content)) "root-level attrs preserved") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-partial-salvage-paragraphs-under-root + ;; K: Root has children but they're paragraphs (not paragraph-sets) - should preserve level 1 attrs + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph" + :children [{:text "hello"}]}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "root type preserved") + (t/is (= "top" (:vertical-align content)) "root-level attrs preserved") + (t/is (= 1 (count (:children content))) "has one paragraph-set") + (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "child is paragraph-set") + (t/is (pos? (count (get-in content [:children 0 :children]))) "paragraph-set has paragraphs"))) + +;; ============================================================================ +;; Category B: Level 1 (root) variants +;; ============================================================================ + +(t/deftest migration-0025-root-non-vector-children-map + ;; B: Root with non-vector children (map) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :vertical-align "top" + :children {:invalid "map"}})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "root type preserved") + (t/is (vector? (:children content)) "children is now a vector") + (t/is (= "top" (:vertical-align content)) "preserves vertical-align"))) + +(t/deftest migration-0025-root-non-vector-children-string + ;; B: Root with non-vector children (string) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :vertical-align "center" + :children "not a vector"})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (:children content)) "children is now a vector") + (t/is (= "center" (:vertical-align content)) "preserves vertical-align"))) + +;; ============================================================================ +;; Category C: Level 2 (paragraph-set) variants +;; ============================================================================ + +(t/deftest migration-0025-paragraph-set-nil-children + ;; C: Paragraph-set with nil children key + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set"}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children])) "children is a vector") + (t/is (pos? (count (get-in content [:children 0 :children]))) "has at least one paragraph"))) + +(t/deftest migration-0025-paragraph-set-non-vector-children-map + ;; C: Paragraph-set with non-vector children (map) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children {:invalid "map"}}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children])) "children is now a vector") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "seeded with default paragraph"))) + +(t/deftest migration-0025-paragraph-set-non-vector-children-string + ;; C: Paragraph-set with non-vector children (string) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children "not a vector"}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children])) "children is now a vector"))) + +(t/deftest migration-0025-paragraph-set-item-not-map + ;; C: Paragraph-set with non-map item in children vector + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children ["not a map"]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "non-map item replaced with default paragraph"))) + +(t/deftest migration-0025-paragraph-set-mixed-valid-nil-non-map + ;; C: Paragraph-set with mix of valid paragraphs, nil, and non-map items + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "ok"}]} + nil + "not-a-map" + {:type "paragraph" + :children [{:text "also ok"}]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + paragraphs (get-in shape [:content :children 0 :children])] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 4 (count paragraphs)) "all items preserved as paragraphs") + (t/is (= "paragraph" (:type (nth paragraphs 0))) "valid paragraph preserved") + (t/is (= "ok" (:text (get-in (nth paragraphs 0) [:children 0]))) "valid span text preserved") + (t/is (= "paragraph" (:type (nth paragraphs 1))) "nil replaced with default paragraph") + (t/is (= "paragraph" (:type (nth paragraphs 2))) "non-map replaced with default paragraph") + (t/is (= "paragraph" (:type (nth paragraphs 3))) "valid paragraph preserved") + (t/is (= "also ok" (:text (get-in (nth paragraphs 3) [:children 0]))) "valid span text preserved"))) + +(t/deftest migration-0025-paragraph-set-wrong-type + ;; C: Paragraph-set with wrong :type + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph" + :children []}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "wrong type replaced with default paragraph-set"))) + +;; ============================================================================ +;; Category D: Level 3 (paragraph) variants +;; ============================================================================ + +(t/deftest migration-0025-paragraph-nil-children + ;; D: Paragraph with nil children key + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph"}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is a vector") + (t/is (pos? (count (get-in content [:children 0 :children 0 :children]))) "has at least one span"))) + +(t/deftest migration-0025-paragraph-non-vector-children-map + ;; D: Paragraph with non-vector children (map) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children {:invalid "map"}}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "seeded with default span"))) + +(t/deftest migration-0025-paragraph-non-vector-children-string + ;; D: Paragraph with non-vector children (string) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children "not a vector"}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector"))) + +(t/deftest migration-0025-paragraph-item-not-map + ;; D: Paragraph with non-map item in children vector + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children ["not a map"]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "non-map item replaced with default span"))) + +(t/deftest migration-0025-paragraph-wrong-type + ;; D: Paragraph with wrong :type + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "span" + :text "hello"}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "wrong type replaced with default paragraph"))) + +(t/deftest migration-0025-paragraph-valid-spans-preserved + ;; D: Paragraph with valid spans should be preserved + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "hello"} + {:text "world"}]}]}]})}}}} + original-spans (get-in data [:pages-index page-id :objects shape-id :content :children 0 :children 0 :children]) + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 2 (count (get-in content [:children 0 :children 0 :children]))) "both spans preserved") + (t/is (= original-spans (get-in content [:children 0 :children 0 :children])) "spans unchanged"))) + +;; ============================================================================ +;; Category E: Preservation tests +;; ============================================================================ + +(t/deftest migration-0025-root-attrs-preserved-level2-repair + ;; E: Root-level attrs preserved when level 2 repaired + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :vertical-align "bottom" + :children [{:type "paragraph-set" + :children []}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "bottom" (:vertical-align content)) "root attrs preserved during level 2 repair"))) + +(t/deftest migration-0025-paragraph-set-attrs-preserved + ;; E: Paragraph-set attrs preserved when repaired at level 2 + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :custom-attr "preserve-me" + :children []}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "preserve-me" (get-in content [:children 0 :custom-attr])) "paragraph-set attrs preserved"))) + +(t/deftest migration-0025-paragraph-attrs-preserved + ;; E: Paragraph attrs preserved when repaired at level 3 + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :text-align "center" + :children []}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "center" (get-in content [:children 0 :children 0 :text-align])) "paragraph attrs preserved"))) + +;; ============================================================================ +;; Category F: Multi-item tests +;; ============================================================================ + +(t/deftest migration-0025-multiple-paragraphs-mixed-valid-broken + ;; F: Multiple paragraphs within one paragraph-set, mix of valid and broken + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "valid"}]} + {:type "paragraph" + :children []} + {:type "paragraph" + :children [{:text "also valid"}]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 3 (count (get-in content [:children 0 :children]))) "all three paragraphs preserved") + (t/is (= "valid" (get-in content [:children 0 :children 0 :children 0 :text])) "first paragraph preserved") + (t/is (= "" (get-in content [:children 0 :children 1 :children 0 :text])) "second paragraph repaired") + (t/is (= "also valid" (get-in content [:children 0 :children 2 :children 0 :text])) "third paragraph preserved"))) + +(t/deftest migration-0025-multiple-spans-all-preserved + ;; F: Multiple spans within one paragraph (all should be preserved) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "span1"} + {:text "span2"} + {:text "span3"}]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 3 (count (get-in content [:children 0 :children 0 :children]))) "all spans preserved") + (t/is (= "span1" (get-in content [:children 0 :children 0 :children 0 :text]))) + (t/is (= "span2" (get-in content [:children 0 :children 0 :children 1 :text]))) + (t/is (= "span3" (get-in content [:children 0 :children 0 :children 2 :text]))))) + +;; ============================================================================ +;; Category G: Container coverage +;; ============================================================================ + +(t/deftest migration-0025-multiple-pages-broken-shapes + ;; G: Multiple pages, each with broken shapes + (let [shape-id-1 (uuid/next) + shape-id-2 (uuid/next) + page-id-1 (uuid/next) + page-id-2 (uuid/next) + data {:pages-index + {page-id-1 + {:objects + {shape-id-1 (make-text-shape-with-content + shape-id-1 + {:type "root" :children []})}} + page-id-2 + {:objects + {shape-id-2 (make-text-shape-with-content + shape-id-2 + {:type "root" :children []})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape-1 (get-in data' [:pages-index page-id-1 :objects shape-id-1]) + shape-2 (get-in data' [:pages-index page-id-2 :objects shape-id-2])] + + (t/is (cts/valid-shape? shape-1) "first page shape is valid") + (t/is (cts/valid-shape? shape-2) "second page shape is valid") + (t/is (pos? (count (get-in shape-1 [:content :children]))) "first page shape repaired") + (t/is (pos? (count (get-in shape-2 [:content :children]))) "second page shape repaired"))) + +(t/deftest migration-0025-container-without-objects + ;; G: Container without :objects key should not crash + (let [page-id (uuid/next) + data {:pages-index + {page-id {}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content")] + + (t/is (= data data') "container without objects is unchanged"))) + +;; ============================================================================ +;; Category H: Idempotency +;; ============================================================================ + +(t/deftest migration-0025-already-repaired-unchanged + ;; H: Already-repaired content unchanged (run migration twice) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" :children []})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + data'' (cfm/migrate-data data' "0025-repair-empty-text-content") + shape' (get-in data' [:pages-index page-id :objects shape-id]) + shape'' (get-in data'' [:pages-index page-id :objects shape-id])] + + (t/is (cts/valid-shape? shape') "first repair produces valid shape") + (t/is (cts/valid-shape? shape'') "second repair produces valid shape") + (t/is (= shape' shape'') "migration is idempotent"))) diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 3f6bd635fa..36ff3a09e2 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -8,7 +8,6 @@ (:require [app.common.data :as d] [app.common.files.migrations :as cfm] - [app.common.pprint :as pp] [app.common.types.file :as ctf] [app.common.uuid :as uuid] [clojure.test :as t])) diff --git a/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc new file mode 100644 index 0000000000..03ceb6b8ee --- /dev/null +++ b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc @@ -0,0 +1,221 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.logic.comp-main-edit-breaks-copy-slots-test + (:require + [app.common.files.changes :as cpc] + [app.common.files.changes-builder :as pcb] + [app.common.logic.shapes :as cls] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +;; Main-side reorder and deletion preserve copy reference integrity and support +;; exact undo across pages. +(defn- setup-main + [file] + (-> file + (tho/add-simple-component :icon :icon-main :icon-child) + (tho/add-frame :row-main :name "Row") + (thc/instantiate-component :icon :icon-1 :parent-label :row-main) + (thc/instantiate-component :icon :icon-2 :parent-label :row-main) + (thc/instantiate-component :icon :icon-3 :parent-label :row-main) + (thc/make-component :row :row-main))) + +(defn- setup-file + [] + (-> (thf/sample-file :file1) + (setup-main) + (thc/instantiate-component :row :row-copy :children-labels [:copy-1 :copy-2 :copy-3]))) + +(defn- add-copy-page-metadata + [file] + (let [page-id (thi/id :page2) + copy-id (thi/id :copy-1) + flow-id (thi/new-id! :copy-flow) + guide-id (thi/new-id! :copy-guide)] + (-> file + (ths/add-interaction :copy-2 :copy-1) + (assoc-in [:data :pages-index page-id :flows flow-id] + {:id flow-id :name "Copy flow" :starting-frame copy-id}) + (assoc-in [:data :pages-index page-id :guides guide-id] + {:id guide-id :axis :x :position 10 :frame-id copy-id})))) + +;; Same structure, but with :row-copy on a second page: the propagated +;; deletions then leave the page mounted in the changes builder. +(defn- setup-cross-page-file + [] + (-> (thf/sample-file :file1 :page-label :page1) + (setup-main) + (thf/add-sample-page :page2) + (thc/instantiate-component :row :row-copy :children-labels [:copy-1 :copy-2 :copy-3]) + (add-copy-page-metadata) + (thf/switch-to-page :page1))) + +(defn- delete-changes + [file shape-label] + (let [page (thf/current-page file)] + (second (cls/generate-delete-shapes (pcb/empty-changes nil (:id page)) + file + page + (:objects page) + #{(:id (ths/get-shape file shape-label))} + {})))) + +(defn- deleted-ids + [changes] + (->> (:redo-changes changes) + (filter #(= :del-obj (:type %))) + (map :id))) + +;; Deleting a nested sub-head of a COPY only hides it (deleted-subinstance). +(t/deftest deleting-a-copy-subhead-only-hides-it + (let [file (setup-file) + file' (tho/delete-shape file :copy-1) + copy-1' (ths/get-shape file' :copy-1)] + (t/is (some? copy-1')) + (t/is (true? (:hidden copy-1'))))) + +;; Reordering a nested sub-head within a COPY keeps referential integrity. +(t/deftest reordering-a-copy-subhead-keeps-referential-integrity + (let [file (setup-file) + page (thf/current-page file) + copy-1 (ths/get-shape file :copy-1) + row-copy (ths/get-shape file :row-copy) + changes (cls/generate-relocate (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + (:id row-copy) 2 #{(:id copy-1)}) + file' (thf/apply-changes file changes)] + (t/is (some? (ths/get-shape file' :copy-2))) + (t/is (some? (ths/get-shape file' :copy-3))))) + +;; Main-side reorders remain valid while component sync realigns copy children. +(t/deftest reordering-a-main-subhead-must-not-break-copies + (let [file (setup-file) + page (thf/current-page file) + row-main (ths/get-shape file :row-main) + icon-1 (ths/get-shape file :icon-1) + ;; move the main's first sub-head to the end (index 2) + changes (cls/generate-relocate (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + (:id row-main) 2 #{(:id icon-1)}) + file' (thf/apply-changes file changes)] + (t/is (some? (ths/get-shape file' :copy-1))) + (t/is (some? (ths/get-shape file' :copy-2))) + (t/is (some? (ths/get-shape file' :copy-3))))) + +;; Main-side deletions remove corresponding copy shapes and dangling refs. +(t/deftest deleting-a-main-subhead-must-not-break-copies + (let [file (setup-file) + file' (tho/delete-shape file :icon-1)] + (t/is (nil? (ths/get-shape file' :copy-1))) + (t/is (some? (ths/get-shape file' :copy-2))) + (t/is (some? (ths/get-shape file' :copy-3))))) + +;; Each propagated shape is deleted once so undo restores a valid tree. +(t/deftest propagated-deletions-are-emitted-once + (let [file (setup-file) + changes (delete-changes file :icon-1) + ids (deleted-ids changes)] + (t/is (= (count ids) (count (distinct ids)))))) + +;; Empty main groups propagate deletion to their corresponding copy groups. +(t/deftest emptied-main-groups-propagate-to-copies + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component :icon :icon-main :icon-child) + (tho/add-frame :row-main :name "Row") + (tho/add-group :grp-main :parent-label :row-main) + (thc/instantiate-component :icon :icon-1 :parent-label :grp-main) + (thc/make-component :row :row-main) + (thc/instantiate-component :row :row-copy :children-labels [:grp-copy]) + (tho/delete-shape :icon-1))] + (t/is (nil? (ths/get-shape file :grp-main))) + (t/is (nil? (ths/get-shape file :grp-copy))))) + +;; Propagated deletion supersedes hiding the same selected copy shape. +(t/deftest propagated-deletions-are-not-hidden-first + (let [file (setup-file) + page (thf/current-page file) + icon-1 (ths/get-shape file :icon-1) + copy-1 (ths/get-shape file :copy-1) + [_ changes] (cls/generate-delete-shapes (pcb/empty-changes nil (:id page)) + file page (:objects page) + #{(:id icon-1) (:id copy-1)} + {}) + file' (thf/apply-changes file changes)] + (t/is (nil? (ths/get-shape file' :copy-1))) + (t/is (not (contains? (->> (:redo-changes changes) + (filter #(= :mod-obj (:type %))) + (map :id) + (set)) + (:id copy-1)))))) + +;; Cross-page propagation restores the exact original structure on undo. +(t/deftest deleting-a-main-subhead-propagates-across-pages + (let [file (setup-cross-page-file) + changes (delete-changes file :icon-1) + ids (deleted-ids changes) + file' (thf/apply-changes file changes) + file'' (thf/apply-undo-changes file' changes) + page2-id (thi/id :page2) + copy-2' (ths/get-shape file' :copy-2 :page-label :page2)] + (t/is (= (count ids) (count (distinct ids)))) + (t/is (nil? (ths/get-shape file' :copy-1 :page-label :page2))) + (t/is (some? (ths/get-shape file' :copy-2 :page-label :page2))) + (t/is (some? (ths/get-shape file' :copy-3 :page-label :page2))) + (t/is (empty? (:interactions copy-2'))) + (t/is (nil? (get-in file' [:data :pages-index page2-id :flows (thi/id :copy-flow)]))) + (t/is (nil? (get-in file' [:data :pages-index page2-id :guides (thi/id :copy-guide)]))) + (t/is (= (:pages-index (:data file)) + (:pages-index (:data file'')))))) + +;; A persisted main reorder remains valid before and after a later copy edit. +(t/deftest main-reorder-keeps-copies-valid-for-later-edits + (let [file (setup-file) + page (thf/current-page file) + row-main (ths/get-shape file :row-main) + icon-1 (ths/get-shape file :icon-1) + reorder (cls/generate-relocate (-> (pcb/empty-changes nil) + (pcb/with-page-id (:id page)) + (pcb/with-objects (:objects page))) + (:id row-main) 2 #{(:id icon-1)}) + ;; Apply without validation to model persisted intermediate state. + file' (thf/apply-changes file reorder :validate? false)] + (thf/validate-file! file') + (let [file'' (tho/delete-shape file' :copy-2) + copy-2' (ths/get-shape file'' :copy-2)] + (t/is (some? copy-2')) + (t/is (true? (:hidden copy-2')))))) + +;; Copy ordering rejects local reorders unless component sync owns the operation. +(t/deftest reorder-children-change-cannot-alter-copies + (let [file (setup-file) + page (thf/current-page file) + row-copy (ths/get-shape file :row-copy) + scrambled (vec (reverse (:shapes row-copy))) + change {:type :reorder-children + :page-id (:id page) + :parent-id (:id row-copy) + :shapes scrambled} + get-order (fn [data] + (get-in data [:pages-index (:id page) + :objects (:id row-copy) :shapes]))] + ;; without allow-altering-copies the reorder is rejected + (t/is (= (:shapes row-copy) + (get-order (cpc/process-changes (:data file) [change] false)))) + ;; the sync engine can still restructure copies explicitly + (t/is (= scrambled + (get-order (cpc/process-changes + (:data file) + [(assoc change :allow-altering-copies true)] + false)))))) diff --git a/common/test/common_tests/math_test.cljc b/common/test/common_tests/math_test.cljc new file mode 100644 index 0000000000..e644101895 --- /dev/null +++ b/common/test/common_tests/math_test.cljc @@ -0,0 +1,59 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.math-test + (:require + [app.common.math :as mth] + [clojure.test :as t])) + +(t/deftest finite?-number-test + (t/testing "finite? returns true for positive integer" + (t/is (true? (mth/finite? 16)))) + + (t/testing "finite? returns true for zero" + (t/is (true? (mth/finite? 0)))) + + (t/testing "finite? returns true for negative float" + (t/is (true? (mth/finite? -42.5)))) + + (t/testing "finite? returns true for very large number" + (t/is (true? (mth/finite? 1e308))))) + +(t/deftest finite?-string-test + (t/testing "finite? returns false for numeric string" + (t/is (false? (mth/finite? "16")))) + + (t/testing "finite? returns false for non-numeric string" + (t/is (false? (mth/finite? "abc")))) + + (t/testing "finite? returns false for empty string" + (t/is (false? (mth/finite? "")))) + + (t/testing "finite? returns false for string with spaces" + (t/is (false? (mth/finite? " "))))) + +(t/deftest finite?-nil-test + (t/testing "finite? returns false for nil" + (t/is (false? (mth/finite? nil))))) + +(t/deftest finite?-other-types-test + (t/testing "finite? returns false for keyword" + (t/is (false? (mth/finite? :foo)))) + + (t/testing "finite? returns false for vector" + (t/is (false? (mth/finite? [1 2 3])))) + + (t/testing "finite? returns false for map" + (t/is (false? (mth/finite? {:a 1}))))) + +#_:clj-kondo/ignore +(t/deftest finite?-nan-test + #?(:cljs + (t/testing "finite? returns false for js/NaN (CLJS)" + (t/is (false? (mth/finite? js/NaN)))) + :clj + (t/testing "finite? returns false for Double/NaN (CLJ)" + (t/is (false? (mth/finite? Double/NaN)))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index d3d2f7d48e..84930589d2 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -18,6 +18,7 @@ [common-tests.data-test] [common-tests.files-builder-test] [common-tests.files-changes-test] + [common-tests.files-migrations-0025-test] [common-tests.files-migrations-test] [common-tests.files.shapes-builder-test] [common-tests.files.validate-test] @@ -47,6 +48,7 @@ [common-tests.logic.chained-propagation-test] [common-tests.logic.comp-creation-test] [common-tests.logic.comp-detach-with-nested-test] + [common-tests.logic.comp-main-edit-breaks-copy-slots-test] [common-tests.logic.comp-remove-swap-slots-test] [common-tests.logic.comp-reset-test] [common-tests.logic.comp-sync-test] @@ -59,6 +61,7 @@ [common-tests.logic.swap-as-override-test] [common-tests.logic.token-test] [common-tests.logic.variants-switch-test] + [common-tests.math-test] [common-tests.media-test] [common-tests.path-names-test] [common-tests.record-test] @@ -93,6 +96,7 @@ 'common-tests.data-test 'common-tests.files-changes-test 'common-tests.files-builder-test + 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-test 'common-tests.files.validate-test 'common-tests.geom-align-test @@ -121,6 +125,7 @@ 'common-tests.logic.chained-propagation-test 'common-tests.logic.comp-creation-test 'common-tests.logic.comp-detach-with-nested-test + 'common-tests.logic.comp-main-edit-breaks-copy-slots-test 'common-tests.logic.comp-remove-swap-slots-test 'common-tests.logic.comp-reset-test 'common-tests.logic.comp-sync-test @@ -133,6 +138,7 @@ 'common-tests.logic.swap-as-override-test 'common-tests.logic.token-test 'common-tests.logic.variants-switch-test + 'common-tests.math-test 'common-tests.media-test 'common-tests.path-names-test 'common-tests.record-test diff --git a/common/test/common_tests/types/nitrate_permissions_test.cljc b/common/test/common_tests/types/nitrate_permissions_test.cljc index 795b94cf74..eeab8099ac 100644 --- a/common/test/common_tests/types/nitrate_permissions_test.cljc +++ b/common/test/common_tests/types/nitrate_permissions_test.cljc @@ -9,7 +9,7 @@ [app.common.types.nitrate-permissions :as nitrate-perms] [clojure.test :as t])) -(def org-perms +(def organization-perms {:owner-id :owner :permissions {:create-teams "any" :delete-teams "onlyOwners" @@ -17,162 +17,162 @@ (t/deftest unknown-action-is-denied (t/is (false? (nitrate-perms/allowed? :unknown - {:org-perms org-perms + {:organization-perms organization-perms :profile-id :member :team-perms {:is-admin true}})))) -(t/deftest org-owner-is-allowed-for-create-and-delete +(t/deftest organization-owner-is-allowed-for-create-and-delete (t/is (true? (nitrate-perms/allowed? :create-team - {:org-perms org-perms + {:organization-perms organization-perms :profile-id :owner :team-perms {:is-admin false}}))) (t/is (true? (nitrate-perms/allowed? :delete-team - {:org-perms org-perms + {:organization-perms organization-perms :profile-id :owner :team-perms {:is-admin false}})))) (t/deftest create-team-permission-rules (t/is (true? (nitrate-perms/allowed? :create-team - {:org-perms org-perms + {:organization-perms organization-perms :profile-id :member :team-perms {:is-admin false}}))) (t/is (false? (nitrate-perms/allowed? :create-team - {:org-perms (assoc org-perms :permissions {:create-teams "none" - :delete-teams "onlyOwners"}) + {:organization-perms (assoc organization-perms :permissions {:create-teams "none" + :delete-teams "onlyOwners"}) :profile-id :member :team-perms {:is-admin false}})))) (t/deftest delete-team-onlyowners-allows-only-team-owners (t/is (true? (nitrate-perms/allowed? :delete-team - {:org-perms org-perms + {:organization-perms organization-perms :profile-id :member :team-perms {:is-owner true :is-admin true}}))) (t/is (false? (nitrate-perms/allowed? :delete-team - {:org-perms org-perms + {:organization-perms organization-perms :profile-id :member :team-perms {:is-admin true}}))) (t/is (false? (nitrate-perms/allowed? :delete-team - {:org-perms (assoc org-perms :permissions {:create-teams "any" - :delete-teams "invalid-value"}) + {:organization-perms (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "invalid-value"}) :profile-id :member :team-perms {:is-admin true}})))) -(t/deftest delete-team-onlyme-still-allows-org-owner - (let [only-me-org (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyMe"})] +(t/deftest delete-team-onlyme-still-allows-organization-owner + (let [only-me-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyMe"})] (t/is (true? (nitrate-perms/allowed? :delete-team - {:org-perms only-me-org + {:organization-perms only-me-organization :profile-id :owner :team-perms {:is-owner false :is-admin false}}))) (t/is (false? (nitrate-perms/allowed? :delete-team - {:org-perms only-me-org + {:organization-perms only-me-organization :profile-id :member :team-perms {:is-owner true :is-admin true}}))))) -(t/deftest move-team-always-allows-any-org-owner-or-all-users - (let [always-org (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :move-teams "always"})] - ;; Org owner should always be allowed +(t/deftest move-team-always-allows-any-organization-owner-or-all-users + (let [always-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "always"})] + ;; Organization owner should always be allowed (t/is (true? (nitrate-perms/allowed? :move-team - {:org-perms always-org + {:organization-perms always-organization :profile-id :owner :team-perms {}}))) ;; Regular member should be allowed when move-teams is "always" (t/is (true? (nitrate-perms/allowed? :move-team - {:org-perms always-org + {:organization-perms always-organization :profile-id :member :team-perms {}}))))) (t/deftest move-team-myorganizations-allows-only-within-same-owner - (let [my-orgs (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :move-teams "myOrganizations"})] - ;; Org owner must also stay within same-owner organizations + (let [my-organizations (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "myOrganizations"})] + ;; Organization owner must also stay within same-owner organizations (t/is (false? (nitrate-perms/allowed? :move-team - {:org-perms my-orgs + {:organization-perms my-organizations :profile-id :owner :team-perms {} - :target-org-same-owner? false}))) + :target-organization-same-owner? false}))) (t/is (true? (nitrate-perms/allowed? :move-team - {:org-perms my-orgs + {:organization-perms my-organizations :profile-id :owner :team-perms {} - :target-org-same-owner? true}))) + :target-organization-same-owner? true}))) ;; Regular member should be allowed only if target has same owner (t/is (true? (nitrate-perms/allowed? :move-team - {:org-perms my-orgs + {:organization-perms my-organizations :profile-id :member :team-perms {} - :target-org-same-owner? true}))) + :target-organization-same-owner? true}))) (t/is (false? (nitrate-perms/allowed? :move-team - {:org-perms my-orgs + {:organization-perms my-organizations :profile-id :member :team-perms {} - :target-org-same-owner? false}))))) + :target-organization-same-owner? false}))))) (t/deftest move-team-never-denies-all - (let [never-org (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :move-teams "never"})] - ;; Even org owner should be denied + (let [never-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :move-teams "never"})] + ;; Even organization owner should be denied (t/is (false? (nitrate-perms/allowed? :move-team - {:org-perms never-org + {:organization-perms never-organization :profile-id :owner :team-perms {}}))) ;; Regular member should be denied (t/is (false? (nitrate-perms/allowed? :move-team - {:org-perms never-org + {:organization-perms never-organization :profile-id :member :team-perms {}}))))) (t/deftest move-team-defaults-to-always - (let [default-org (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners"})] + (let [default-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"})] ;; Should default to "always" when not specified (t/is (true? (nitrate-perms/allowed? :move-team - {:org-perms default-org + {:organization-perms default-organization :profile-id :member :team-perms {}}))))) (t/deftest send-invitations-defaults-to-owners-and-admins - (let [default-org (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners"})] + (let [default-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"})] (t/is (true? (nitrate-perms/allowed? :send-invitations - {:org-perms default-org + {:organization-perms default-organization :profile-id :owner :team-perms {:is-owner true :is-admin false}}))) (t/is (true? (nitrate-perms/allowed? :send-invitations - {:org-perms default-org + {:organization-perms default-organization :profile-id :member :team-perms {:is-owner false :is-admin true}}))) (t/is (false? (nitrate-perms/allowed? :send-invitations - {:org-perms default-org + {:organization-perms default-organization :profile-id :member :team-perms {:is-owner false :is-admin false}}))))) (t/deftest send-invitations-owners-allows-only-team-owners - (let [only-owners-org (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :send-invitations "owners"})] + (let [only-owners-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "owners"})] (t/is (true? (nitrate-perms/allowed? :send-invitations - {:org-perms only-owners-org + {:organization-perms only-owners-organization :profile-id :member :team-perms {:is-owner true :is-admin true}}))) (t/is (false? (nitrate-perms/allowed? :send-invitations - {:org-perms only-owners-org + {:organization-perms only-owners-organization :profile-id :owner :team-perms {:is-owner false :is-admin false}}))) (t/is (false? (nitrate-perms/allowed? :send-invitations - {:org-perms only-owners-org + {:organization-perms only-owners-organization :profile-id :member :team-perms {:is-owner false :is-admin true}}))))) (t/deftest send-invitations-invalid-value-is-denied - (let [invalid-org (assoc org-perms :permissions {:create-teams "any" - :delete-teams "onlyOwners" - :send-invitations "invalid-value"})] + (let [invalid-organization (assoc organization-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "invalid-value"})] (t/is (false? (nitrate-perms/allowed? :send-invitations - {:org-perms invalid-org + {:organization-perms invalid-organization :profile-id :member :team-perms {:is-owner true :is-admin true}}))))) diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index 81bec62349..e655c66713 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -1211,7 +1211,29 @@ ;; so shape-id-2 before shape-id-1 in new order; reorder-grid-children reverses (let [result (layout/reorder-grid-children parent)] (t/is (vector? (:shapes result))) - (t/is (= 2 (count (:shapes result)))))))) + (t/is (= [shape-id-1 shape-id-2] (:shapes result))))))) + +(t/deftest reorder-grid-children-keeps-cell-less-children-in-place-test + ;; Cell-less children keep their index while cell children follow grid order. + (let [shape-id-1 (uuid/next) + shape-id-2 (uuid/next) + hidden-id (uuid/next) + ;; Grid storage reverses the visual cell order. + cell-a (make-cell :row 1 :column 1 :shapes [shape-id-2]) + cell-b (make-cell :row 1 :column 2 :shapes [shape-id-1]) + parent {:layout-grid-dir :row + :shapes [shape-id-2 shape-id-1 hidden-id] + :layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b}}] + + (t/testing "cell-less child at the end stays at the end" + (let [result (layout/reorder-grid-children parent)] + (t/is (= [shape-id-1 shape-id-2 hidden-id] (:shapes result))))) + + (t/testing "cell-less child in the middle stays in the middle" + (let [parent (assoc parent :shapes [shape-id-2 hidden-id shape-id-1]) + result (layout/reorder-grid-children parent)] + (t/is (= [shape-id-1 hidden-id shape-id-2] (:shapes result))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; merge-cells diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 0ca56925a4..4aff930e9e 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:26.04 AS base +FROM dhi.io/debian-base:trixie-debian13-dev AS base ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ @@ -13,6 +13,8 @@ RUN set -ex; \ rsync \ wget \ sudo \ + libpam-modules \ + libpam-runtime \ tmux \ vim \ curl \ @@ -24,7 +26,39 @@ RUN set -ex; \ ca-certificates \ \ binutils \ - build-essential autoconf libtool pkg-config + build-essential autoconf libtool pkg-config \ + ; \ + mkdir -p /usr/lib/pam.d; \ + printf '%s\n' \ + '#%PAM-1.0' \ + 'auth required pam_unix.so nullok' \ + 'account required pam_unix.so' \ + 'password required pam_unix.so' \ + 'session required pam_unix.so' \ + 'session required pam_limits.so' \ + | tee /etc/pam.d/sudo /usr/lib/pam.d/sudo > /dev/null; \ + printf '%s\n' \ + '#%PAM-1.0' \ + 'auth required pam_deny.so' \ + 'account required pam_deny.so' \ + 'password required pam_deny.so' \ + 'session required pam_deny.so' \ + | tee /etc/pam.d/other /usr/lib/pam.d/other > /dev/null; \ + if [ ! -s /etc/nsswitch.conf ]; then \ + printf '%s\n' \ + 'passwd: files' \ + 'group: files' \ + 'shadow: files' \ + 'gshadow: files' \ + 'hosts: files dns' \ + 'networks: files' \ + 'protocols: files' \ + 'services: files' \ + 'ethers: files' \ + 'rpc: files' \ + 'netgroup: files' \ + > /etc/nsswitch.conf; \ + fi ################################################################################ ## NODE SETUP @@ -32,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.18.0 \ +ENV NODE_VERSION=v24.18.1 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ @@ -66,7 +100,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.18.4 +ENV OPENCODE_VERSION=1.18.11 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ @@ -126,18 +160,18 @@ RUN set -eux; \ FROM base AS setup-jvm # https://clojure.org/releases/tools -ENV CLOJURE_VERSION=1.12.5.1654 +ENV CLOJURE_VERSION=1.12.5.1664 RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \ + ESUM='5b222fce0b7076a10ac7ae3b1009a6c2caf4f35bc4e81de72010af6750c5e146'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \ + ESUM='4b7c114917aebd0fc6284fc7111245d7747a4d9603bd12d86b384b1abc9d575d'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -208,16 +242,16 @@ RUN set -eux; \ FROM base AS setup-utils -ENV CLJKONDO_VERSION=2026.05.25 \ - BABASHKA_VERSION=1.12.218 \ - CLJFMT_VERSION=0.16.4 \ - PIXI_VERSION=0.67.2 \ - GITHUB_CLI_VERSION=2.96.0 \ - UV_VERSION=0.11.9 \ +ENV CLJKONDO_VERSION=2026.07.24 \ + BABASHKA_VERSION=1.13.219 \ + CLJFMT_VERSION=0.16.5 \ + PIXI_VERSION=0.75.0 \ + GITHUB_CLI_VERSION=2.97.0 \ + UV_VERSION=0.12.1 \ UV_TOOL_DIR=/opt/uv/tools \ UV_TOOL_BIN_DIR=/opt/utils/bin \ UV_PYTHON_INSTALL_DIR=/opt/uv/python \ - SERENA_VERSION=1.5.0 + SERENA_VERSION=1.6.1 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ @@ -374,13 +408,17 @@ RUN set -ex; \ FROM base AS devenv-base RUN set -ex; \ - usermod -l penpot -d /home/penpot -G users -s /bin/bash ubuntu; \ + apt-get -qq update; \ + apt-get -qqy --no-install-recommends install passwd; \ + groupadd -f users; \ + useradd -u 1000 -m -d /home/penpot -G users -s /bin/bash penpot; \ passwd penpot -d; \ echo "penpot ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers RUN set -ex; \ apt-get -qq update; \ apt-get -qq dist-upgrade; \ + apt-get -qqy --no-install-recommends install init-system-helpers; \ apt-get -qqy install --no-install-recommends \ redis-tools \ gnupg2 \ @@ -414,16 +452,16 @@ RUN set -ex; \ fonts-freefont-ttf \ poppler-utils \ \ - libasound2t64 \ - libatk-bridge2.0-0t64 \ - libatk1.0-0t64 \ - libatspi2.0-0t64 \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libatspi2.0-0 \ libcairo2 \ - libcups2t64 \ + libcups2 \ libdbus-1-3 \ libdrm2 \ libgbm1 \ - libglib2.0-0t64 \ + libglib2.0-0 \ libnspr4 \ libnss3 \ libpango-1.0-0 \ @@ -437,14 +475,14 @@ RUN set -ex; \ libxrandr2 \ \ libpng16-16 \ - libjpeg-turbo8 \ + libjpeg62-turbo \ libtiff6 \ libwebp7 \ libopenexr-3-1-30 \ libfreetype6 \ libfontconfig1 \ libglib2.0-0 \ - libxml2-16 \ + libxml2 \ liblcms2-2 \ libheif1 \ libopenjp2-7 \ @@ -455,12 +493,15 @@ RUN set -ex; \ libwebpdemux2 \ libzip5 \ ; \ - rm -rf /var/lib/apt/lists/*; + rm -rf /var/lib/apt/lists/*; \ + mkdir -p /usr/local/bin; \ + ln -sf /usr/bin/fdfind /usr/local/bin/fd; \ + ln -sf /usr/bin/batcat /usr/local/bin/bat; RUN set -ex; \ install -d /usr/share/postgresql-common/pgdg; \ curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc; \ - echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt noble-pgdg main" >> /etc/apt/sources.list.d/postgresql.list; \ + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt trixie-pgdg main" >> /etc/apt/sources.list.d/postgresql.list; \ apt-get -qq update; \ apt-get -qqy install postgresql-client-16; \ apt-get clean; \ @@ -487,7 +528,7 @@ ENV LANG='C.UTF-8' \ SERENA_CONTEXT="claude-code" \ PATH="/opt/jdk/bin:/opt/gh/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" -COPY --from=penpotapp/imagemagick:7.1.2-24 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick COPY --from=setup-jvm /opt/jdk /opt/jdk COPY --from=setup-jvm /opt/clojure /opt/clojure COPY --from=setup-node /opt/node /opt/node @@ -519,3 +560,4 @@ COPY files/init.sh /home/init.sh ENTRYPOINT ["/home/entrypoint.sh"] CMD ["/home/init.sh"] + diff --git a/docker/devenv/docker-compose.main.yml b/docker/devenv/docker-compose.main.yml index 944a36885c..35bac398ca 100644 --- a/docker/devenv/docker-compose.main.yml +++ b/docker/devenv/docker-compose.main.yml @@ -10,7 +10,7 @@ volumes: services: main: privileged: true - image: "penpotapp/devenv:latest" + image: "penpotapp/devenv:${DEVENV_TAG:-latest}" build: context: "." container_name: "${PENPOT_MAIN_CONTAINER_NAME}" @@ -89,9 +89,9 @@ services: - PENPOT_TENANT=${PENPOT_TENANT} - PENPOT_TMUX_ATTACH=${PENPOT_TMUX_ATTACH} - # Agentic devenv: set to a commit/tag to update Serena on startup, + # Agentic devenv: set to a PyPI release version/tag to update Serena on startup, # leave empty to skip update and use the version baked into the image. - - SERENA_UPDATE_VERSION=1.5.0 + - SERENA_UPDATE_VERSION=1.6.1 - SHADOW_SERVER_URL=${SHADOW_SERVER_URL} networks: diff --git a/docker/devenv/files/entrypoint.sh b/docker/devenv/files/entrypoint.sh index 1cae3f8e3f..d7a00a670a 100755 --- a/docker/devenv/files/entrypoint.sh +++ b/docker/devenv/files/entrypoint.sh @@ -19,7 +19,7 @@ chown -R penpot:users ${SERENA_HOME} chown penpot:users /home/penpot # we need to be able to install rust-analyzer and possibly other dependencies with rustup -chown -R penpot:ubuntu /opt/rustup +chown -R penpot:penpot /opt/rustup rsync -ar --chown=penpot:users /opt/cargo/ /home/penpot/.cargo/ diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index e53fb65d8a..92ff766acb 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:26.04 +FROM dhi.io/debian-base:trixie-debian13-dev AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -6,11 +6,12 @@ ENV LANG='C.UTF-8' \ DEBIAN_FRONTEND=noninteractive \ TZ=Etc/UTC -ARG IMAGEMAGICK_VERSION=7.1.2-24 +ARG IMAGEMAGICK_VERSION=7.1.2-27 RUN set -e; \ apt-get -qq update; \ apt-get -qq upgrade; \ + apt-get -qqy --no-install-recommends install init-system-helpers; \ apt-get -qqy --no-install-recommends install \ autoconf \ binutils \ @@ -38,42 +39,24 @@ RUN set -e; \ mkdir -p /tmp/magick; \ cd /tmp/magick; \ tar -xf /tmp/magick.tar.gz --strip-components=1; \ - ./configure --prefix=/opt/imagick; \ + ./configure --prefix=/opt/imagick; \ make -j 2; \ make install; \ rm -rf /opt/imagick/lib/libMagick++*; \ rm -rf /opt/imagick/include; \ - rm -rf /opt/imagick/share; \ - apt-get -qqy --autoremove purge \ - autoconf \ - binutils \ - build-essential \ - ca-certificates \ - curl \ - libfftw3-dev \ - libheif-dev \ - libjpeg-dev \ - libxml2-dev \ - liblcms2-dev \ - libltdl-dev \ - liblzma-dev \ - libopenexr-dev \ - libpng-dev \ - librsvg2-dev \ - libtiff-dev \ - libtool\ - libwebp-dev \ - libzip-dev \ - libzstd-dev \ - pkg-config \ - ;\ + rm -rf /opt/imagick/share; + +# The DHI runtime image ships no apt/dpkg, so `magick`'s ~15 runtime shared +# libs (libheif, libopenjp2, librsvg2, etc.) are vendored here via `ldd` into +# a self-contained bundle instead of relying on system packages at runtime. +RUN set -ex; \ apt-get -qqy --no-install-recommends install \ libfontconfig1 \ libfreetype6 \ libglib2.0-0 \ libgomp1 \ libheif1 \ - libjpeg-turbo8 \ + libjpeg62-turbo \ liblcms2-2 \ libopenexr-3-1-30 \ libopenjp2-7 \ @@ -83,11 +66,25 @@ RUN set -e; \ libwebp7 \ libwebpdemux2 \ libwebpmux3 \ - libxml2-16 \ + libxml2 \ libzip5 \ libzstd1 \ - ;\ + ; \ + mkdir -p /opt/imagick/lib/deps; \ + ldd /opt/imagick/bin/magick | awk '{print $3}' | grep '^/' | sort -u \ + | xargs -I{} cp -L --no-clobber {} /opt/imagick/lib/deps/; \ apt-get -qqy clean; \ - rm -rf /var/lib/apt/lists/*; + rm -rf /var/lib/apt/lists/* + + +FROM dhi.io/debian-base:trixie-debian13 AS image +LABEL maintainer="Penpot " + +ENV LANG='C.UTF-8' \ + LC_ALL='C.UTF-8' \ + TZ=Etc/UTC \ + LD_LIBRARY_PATH=/opt/imagick/lib/deps + +COPY --from=build /opt/imagick /opt/imagick ENTRYPOINT ["/opt/imagick/bin/magick"] diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index 583da3612d..98b3e02ab8 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM ubuntu:26.04 AS build +FROM dhi.io/debian-base:trixie-debian13-dev AS build LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -22,12 +22,12 @@ RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \ + ESUM='5b222fce0b7076a10ac7ae3b1009a6c2caf4f35bc4e81de72010af6750c5e146'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \ + ESUM='4b7c114917aebd0fc6284fc7111245d7747a4d9603bd12d86b384b1abc9d575d'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.32.13-ca-jdk26.0.2-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -47,7 +47,8 @@ RUN set -eux; \ --add-modules java.base,jdk.net,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \ --output /opt/jre; -FROM ubuntu:26.04 AS image + +FROM dhi.io/debian-base:trixie-debian13-dev AS image LABEL maintainer="Penpot " ENV LANG='C.UTF-8' \ @@ -55,11 +56,13 @@ ENV LANG='C.UTF-8' \ JAVA_HOME="/opt/jre" \ PATH=/opt/jre/bin:/opt/imagick/bin:$PATH \ DEBIAN_FRONTEND=noninteractive \ - TZ=Etc/UTC + TZ=Etc/UTC \ + LD_LIBRARY_PATH=/opt/imagick/lib/deps RUN set -ex; \ - useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ apt-get -qq update; \ + apt-get -qqy --no-install-recommends install passwd; \ + useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ apt-get -qq dist-upgrade; \ apt-get -qqy --no-install-recommends install \ ca-certificates \ @@ -71,7 +74,7 @@ RUN set -ex; \ libglib2.0-0 \ libgomp1 \ libheif1 \ - libjpeg-turbo8 \ + libjpeg62-turbo \ liblcms2-2 \ libopenexr-3-1-30 \ libopenjp2-7 \ @@ -81,7 +84,7 @@ RUN set -ex; \ libwebp7 \ libwebpdemux2 \ libwebpmux3 \ - libxml2-16 \ + libxml2 \ libzip5 \ libzstd1 \ python3 \ @@ -100,7 +103,7 @@ RUN set -ex; \ chown -R penpot:penpot /opt/data; COPY --from=build /opt/jre /opt/jre -COPY --from=penpotapp/imagemagick:7.1.2-24 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick COPY files/imagemagick-policy.xml /opt/imagick/etc/ImageMagick-7/policy.xml diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 260e5fda8d..7c0b1a14ff 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,18 +1,16 @@ -FROM ubuntu:26.04 +FROM dhi.io/node:24.18.1-debian13-dev LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v24.18.0 \ DEBIAN_FRONTEND=noninteractive \ - PATH=/opt/node/bin:/opt/imagick/bin:$PATH \ + PATH=/opt/imagick/bin:$PATH \ PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers RUN set -ex; \ - useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ - mkdir -p /etc/resolvconf/resolv.conf.d; \ - echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \ apt-get -qq update; \ + apt-get -qqy --no-install-recommends install passwd; \ + useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ apt-get -qq dist-upgrade; \ apt-get -qqy --no-install-recommends install \ curl \ @@ -44,16 +42,16 @@ RUN set -ex; \ fonts-freefont-ttf \ poppler-utils \ \ - libasound2t64 \ - libatk-bridge2.0-0t64 \ - libatk1.0-0t64 \ - libatspi2.0-0t64 \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libatspi2.0-0 \ libcairo2 \ - libcups2t64 \ + libcups2 \ libdbus-1-3 \ libdrm2 \ libgbm1 \ - libglib2.0-0t64 \ + libglib2.0-0 \ libnspr4 \ libnss3 \ libpango-1.0-0 \ @@ -68,7 +66,7 @@ RUN set -ex; \ \ libgomp1 \ libheif1 \ - libjpeg-turbo8 \ + libjpeg62-turbo \ liblcms2-2 \ libopenexr-3-1-30 \ libopenjp2-7 \ @@ -78,44 +76,26 @@ RUN set -ex; \ libwebp7 \ libwebpdemux2 \ libwebpmux3 \ - libxml2-16 \ + libxml2 \ libzip5 \ libzstd1 \ ; \ apt-get clean; \ - rm -rf /var/lib/apt/lists/*; - -RUN set -eux; \ - ARCH="$(dpkg --print-architecture)"; \ - case "${ARCH}" in \ - aarch64|arm64) \ - BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - ;; \ - amd64|x86_64) \ - BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - ;; \ - *) \ - echo "Unsupported arch: ${ARCH}"; \ - exit 1; \ - ;; \ - esac; \ - curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ - mkdir -p /opt/node; \ - cd /opt/node; \ - tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ - chown -R root /opt/node; \ - rm -rf /tmp/nodejs.tar.gz; \ - corepack enable; \ + rm -rf /var/lib/apt/lists/*; \ mkdir -p /opt/penpot; \ chown -R penpot:penpot /opt/penpot; ARG BUNDLE_PATH="./bundle-exporter/" COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/ -COPY --from=penpotapp/imagemagick:7.1.2-24 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-27 /opt/imagick /opt/imagick WORKDIR /opt/penpot/exporter + +# DHI Node image installs Node at the system level (symlinked into +# /usr/bin), so `./setup`'s internal `corepack enable` needs root to write +# there. Ownership is fixed right after. +RUN ./setup && chown -R penpot:penpot /opt/penpot/exporter + USER penpot:penpot -RUN ./setup - CMD ["node", "app.js"] diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 0772aab00c..306c42467d 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,15 +1,17 @@ -FROM nginxinc/nginx-unprivileged:1.31.1-alpine +FROM dhi.io/nginx:1.31.1-debian13-dev LABEL maintainer="Penpot " +ENV DEBIAN_FRONTEND=noninteractive + USER root RUN set -ex; \ - apk update; \ - apk upgrade; \ - apk add --no-cache bash gettext; \ - rm -rf /var/cache/apk/*; \ - addgroup -g 1001 penpot; \ - adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot; \ + apt-get -qq update; \ + apt-get -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" dist-upgrade; \ + apt-get -qqy -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" --no-install-recommends install bash gettext-base passwd; \ + rm -rf /var/lib/apt/lists/*; \ + groupadd -f -g 1001 penpot; \ + useradd -M -u 1001 -s /usr/sbin/nologin -d /opt/penpot -g penpot penpot; \ mkdir -p /opt/data/assets; \ chown -R penpot:penpot /opt/data; \ mkdir -p /etc/nginx/overrides/main.d/; \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index ee1c9f4399..8d943c7e27 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,61 +1,26 @@ -FROM ubuntu:26.04 +FROM dhi.io/node:24.18.1-debian13-dev AS build +LABEL maintainer="Penpot " + +ENV DEBIAN_FRONTEND=noninteractive + +ARG BUNDLE_PATH="./bundle-mcp/" +COPY $BUNDLE_PATH /opt/penpot/mcp/ + +WORKDIR /opt/penpot/mcp + +RUN ./setup + + +FROM dhi.io/node:24.18.1-debian13 AS image LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v24.18.0 \ - DEBIAN_FRONTEND=noninteractive \ - PATH=/opt/node/bin:$PATH \ PENPOT_MCP_SERVER_HOST=0.0.0.0 -RUN set -ex; \ - useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ - mkdir -p /etc/resolvconf/resolv.conf.d; \ - echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \ - apt-get -qq update; \ - apt-get -qq dist-upgrade; \ - apt-get -qqy --no-install-recommends install \ - curl \ - tzdata \ - locales \ - ca-certificates \ - ; \ - apt-get clean; \ - rm -rf /var/lib/apt/lists/*; \ - echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ - locale-gen; \ - find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete; - -RUN set -eux; \ - ARCH="$(dpkg --print-architecture)"; \ - case "${ARCH}" in \ - aarch64|arm64) \ - BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ - ;; \ - amd64|x86_64) \ - BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ - ;; \ - *) \ - echo "Unsupported arch: ${ARCH}"; \ - exit 1; \ - ;; \ - esac; \ - curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ - mkdir -p /opt/node; \ - cd /opt/node; \ - tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ - chown -R root /opt/node; \ - rm -rf /tmp/nodejs.tar.gz; \ - corepack enable; \ - mkdir -p /opt/penpot; \ - chown -R penpot:penpot /opt/penpot; - -ARG BUNDLE_PATH="./bundle-mcp/" -COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/mcp/ +COPY --from=build --chown=node:node /opt/penpot/mcp /opt/penpot/mcp WORKDIR /opt/penpot/mcp -USER penpot:penpot - -RUN ./setup +USER node CMD ["node", "index.js", "--multi-user"] diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 26bb326383..3904604a25 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,24 +1,11 @@ -FROM nginxinc/nginx-unprivileged:1.31.1-alpine +FROM dhi.io/nginx:1.31.1-alpine3.22 LABEL maintainer="Penpot " -USER root - -RUN set -ex; \ - apk update; \ - apk upgrade; \ - rm -rf /var/cache/apk/*; \ - addgroup -g 1001 penpot; \ - adduser -D -H -u 1001 -s /bin/false -h /opt/penpot -G penpot penpot; - ARG BUNDLE_PATH="./bundle-storybook/" -COPY $BUNDLE_PATH /var/www/ -COPY ./files/nginx.storybook.conf /etc/nginx/conf.d/default.conf -RUN chown -R 1001:0 /var/cache/nginx; \ - chmod -R g+w /var/cache/nginx; \ - chown -R 1001:0 /etc/nginx; \ - chmod -R g+w /etc/nginx; \ - chown -R 1001:0 /var/www; \ - chmod -R g+w /var/www; +COPY --chown=65532:65532 $BUNDLE_PATH /var/www/ +COPY --chown=65532:65532 ./files/nginx.storybook.conf /etc/nginx/conf.d/default.conf -USER penpot:penpot +# Already the default user in the DHI runtime; explicit for clarity. Numeric +# UID, not by name -- this image has no "nonroot" entry in /etc/passwd. +USER 65532:65532 diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 466144b3f1..8509bb7574 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -129,17 +129,18 @@ If you just want to try Penpot AI workflows quickly through the MCP, follow this ![MCP Server in Penpot Integrations, copy server url](/img/mcp/mcp-server-url.webp) 4. #### Add the server to your MCP client - In your MCP-aware IDE/agent (Cursor, Claude Code, etc.), add a new server pointing to that URL. - **Example (generic JSON config):** - ```json - { - "mcpServers": { - "penpot": { - "url": "https:///mcp/stream?userToken=YOUR_MCP_KEY" - } - } - } + We recommend using the [add-mcp](https://github.com/neon-solutions/add-mcp) project for connecting your MCP client to the Penpot MCP server. + With `npx` available, call + + ```shell + npx -y add-mcp -g -n penpot ``` + + and follow the interactive setup. Alternatively, follow your client's instructions for connecting + to a remote MCP server. For Claude Desktop, we recommend adding the Penpot MCP Server as a + custom [connector](http://claude.ai/customize/connectors). + See the section **Connect your MCP client** for more details on how to connect. + 5. #### Open a Penpot file and connect MCP In Penpot, open a design file and use **File → MCP Server → Connect** to connect the plugin to your current file. @@ -147,6 +148,27 @@ If you just want to try Penpot AI workflows quickly through the MCP, follow this Once all five steps are done, your AI client should list Penpot tools. +### Keep the Penpot tab active + +The MCP plugin runs inside your Penpot browser tab. If the browser puts that tab to sleep, freezes it, or unloads it to save memory, the MCP server cannot run tasks in Penpot until the tab wakes up again. + +When this happens, MCP fails fast instead of waiting for a long task timeout: + +* In Chrome and Chromium-based browsers, the plugin can report when the tab is being frozen. +* In Firefox, Safari, and other browsers that do not expose the same freeze event, MCP uses plugin heartbeats. If the browser stops running the plugin JavaScript, the heartbeat becomes stale and the MCP server reports that the Penpot tab appears to be suspended. +* If the browser unloads the tab completely, the plugin disconnects and MCP reports that no Penpot plugin instance is connected. + +To recover, open or focus the Penpot tab again, wait until MCP reconnects, and retry the prompt. + +To reduce the chances of the browser putting Penpot to sleep during long MCP sessions: + +| Browser | Recommended setting | +| --- | --- | +| Chrome | Go to **Settings → Performance → Always keep these sites active** and add your Penpot site. Pinning the Penpot tab also helps prevent Chrome tab deactivation. | +| Edge | Go to **Settings → System and performance** and add your Penpot site to the list of sites that should never be put to sleep, if that option is available in your Edge version. | +| Firefox | Firefox does not provide the same per-site keep-awake control. If tab unloading is a problem, advanced users can disable tab unloading with `browser.tabs.unloadOnLowMemory=false` in `about:config`, but this can increase memory use. | +| Safari | Safari does not provide a comparable per-site keep-awake setting. Keep the Penpot tab open and active during long MCP sessions. | + ### First prompts to try After connecting, start with **read-only prompts** to confirm everything works and to understand what the agent can see: @@ -195,84 +217,21 @@ You can use Penpot MCP server in two main ways: ## Connect your MCP client -Use the same client setup flow for both modes. What changes is the server URL and authentication method. - -### Connection values by mode - * **Remote MCP** - * URL: `https:///mcp/stream?userToken=YOUR_MCP_KEY` - * Auth: MCP key in `userToken` + * URL (copy from your Penpot account overview): `https:///mcp/stream?userToken=YOUR_MCP_KEY` + * Configure your MCP client with `npx -y add-mcp -g -n penpot ` or by following your client's instructions for connecting to a remote MCP server. + For Claude Desktop, the server can be added as a custom [connector](http://claude.ai/customize/connectors). * **Local MCP** - * URL: `http://localhost:4401/mcp` - * Auth: none (uses your active Penpot browser session) + * Configure your MCP client with `npx -y add-mcp -g -n penpot http://localhost:4401/mcp` (adjust the port if you changed `PENPOT_MCP_SERVER_PORT`) or by following your client's instructions for connecting to an HTTP MCP server. -### Cursor - -1. Open Cursor MCP/tool configuration. -2. Add a Penpot MCP server entry: +Note: For clients that do not support HTTP servers directly (like Claude Desktop), the local MCP server can be connected via [mcp-remote](https://github.com/geelen/mcp-remote) as follows: ```json { "mcpServers": { "penpot": { - "url": "REMOTE_OR_LOCAL_URL", - "type": "http" - } - } -} -``` - -Replace `REMOTE_OR_LOCAL_URL` with the URL for your mode. - -### Claude Code - -1. Open MCP configuration in Claude Code. -2. Add a Penpot server with `http` transport and the URL for your mode. -3. Restart Claude Code or reload tools. - -```json -{ - "mcpServers": { - "penpot": { - "transport": "http", - "url": "REMOTE_OR_LOCAL_URL" - } - } -} -``` - - -### VS Code / Copilot - -1. Open external MCP server configuration in your extension/settings. -2. Add Penpot with the URL for your mode. -3. Save and reload tools. - -```json -{ - "mcp.servers": { - "penpot": { - "transport": "http", - "url": "REMOTE_OR_LOCAL_URL" - } - } -} -``` - -### Codex / OpenCode etc - -1. Use your client's "Add MCP server" flow. -2. Set the URL for your mode. -3. Reload tools and verify Penpot tools are available. - -```json -{ - "servers": { - "penpot": { - "url": "REMOTE_OR_LOCAL_URL", - "transport": { - "type": "http" - } + "command": "npx", + "args": ["-y", "mcp-remote", "", "--allow-http"] } } } @@ -305,9 +264,8 @@ Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted ### Connect -For client-specific setup, use the shared section **Connect your MCP client**. - For remote mode, use the URL shown in **Your account → Integrations → MCP Server**, which includes your `userToken`. +See the section **Connect your MCP client** for details on how to connect. ### Setup videos @@ -443,9 +401,7 @@ For advanced or repository-based workflows, see the [MCP README](https://github. ### Connect -For client-specific setup, use the shared section **Connect your MCP client**. - -For local mode, use `http://localhost:4401/mcp` with HTTP transport (no MCP key; authentication uses your active Penpot browser session). +See the section **Connect your MCP client** for details on how to connect. ### Use diff --git a/docs/package.json b/docs/package.json index 299d497e48..c5286a7d57 100644 --- a/docs/package.json +++ b/docs/package.json @@ -35,9 +35,9 @@ "eleventy-plugin-nesting-toc": "^1.3.0", "eleventy-plugin-youtube-embed": "^1.13.2", "luxon": "^3.7.2", - "markdown-it": "^14.2.0", - "markdown-it-anchor": "^9.2.0", + "markdown-it": "^14.3.0", + "markdown-it-anchor": "^9.2.1", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b" + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c" } diff --git a/docs/plugins/deployment.md b/docs/plugins/deployment.md index d18616623f..72af7348ff 100644 --- a/docs/plugins/deployment.md +++ b/docs/plugins/deployment.md @@ -8,15 +8,15 @@ desc: Deploy your free Penpot plugins! Learn about Netlify, Cloudflare, Surge & When it comes to deploying your plugin there are several platforms to choose from. Each platform has its unique features and benefits, so the choice depends on you. -In this guide you will found some options for static sites that have free plans. +In this guide you will find some options for static sites that have free plans. ## 3.1. Building your project The building may vary between frameworks but if you had previously configured your scripts in package.json, npm run build should work. -The resulting build should be located somewhere in the dist/ folder, maybe somewhere else if you have configured so. +The resulting build should be located somewhere in the dist/ folder, or somewhere else if you configured it that way. -Be wary that some framework's builders can add additional folders like apps/project-name/, project-name/ or browser/. +Be wary that some framework builders can add additional folders like apps/project-name/, project-name/ or browser/. Examples: @@ -27,7 +27,7 @@ Examples: ### Create an account -You need a Netlify account if you don't already have one. You can sign up with Github, GItlab, BItbucket or via email and password. +You need a Netlify account if you don't already have one. You can sign up with GitHub, GitLab, Bitbucket or via email and password. ### CORS issues @@ -82,7 +82,7 @@ npm run build 2. Go to Netlify Drop. -3. Drag and drop the build folder into Netlify Sites. Dropping the whole dist may not work, you should drop the folder where the main files are located. +3. Drag and drop the build folder into Netlify Sites. Dropping the whole dist may not work; you should drop the folder where the main files are located. 4. Done! @@ -122,11 +122,11 @@ Cloudflare allows you to import an existing project from GitHub or GitLab. ![Cloudflare git installation](/img/plugins/install_cloudflare.png) -4. Configure your build settings. +3. Configure your build settings. ![Cloudflare git configuration](/img/plugins/cf_build_settings.png) -5. Save and deploy. +4. Save and deploy. ### Direct upload @@ -220,6 +220,6 @@ Success! - Published to example-plugin-penpot.surge.sh ## 3.5. Submitting to Penpot -To make your finished plugin available in our catalog, submit in on the [plugin submission page](https://penpot.app/penpothub/plugins/create-plugin). Once it becomes available any Penpot user will be able to install and use it. +To make your finished plugin available in our catalog, submit it on the [plugin submission page](https://penpot.app/penpothub/plugins/create-plugin). Once it becomes available, any Penpot user will be able to install and use it. diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index d32be57396..2d324a4b2a 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -42,11 +42,11 @@ importers: specifier: ^3.7.2 version: 3.7.2 markdown-it: - specifier: ^14.2.0 - version: 14.2.0 + specifier: ^14.3.0 + version: 14.3.0 markdown-it-anchor: - specifier: ^9.2.0 - version: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.2.0) + specifier: ^9.2.1 + version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0) markdown-it-plantuml: specifier: ^1.4.1 version: 1.4.1 @@ -456,8 +456,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - linkify-it@5.0.1: - resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} liquidjs@10.27.0: resolution: {integrity: sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==} @@ -474,8 +474,8 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} - markdown-it-anchor@9.2.0: - resolution: {integrity: sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==} + markdown-it-anchor@9.2.1: + resolution: {integrity: sha512-p6APiLJDFAW2GEvaavDvhIBn7jrX2jLv77NkBGgNacFTurbORYc4pyYySg/mI6mpR6cHQuAtzKtmqgQr4K8dsQ==} peerDependencies: '@types/markdown-it': '*' markdown-it: '*' @@ -483,12 +483,12 @@ packages: markdown-it-plantuml@1.4.1: resolution: {integrity: sha512-13KgnZaGYTHBp4iUmGofzZSBz+Zj6cyqfR0SXUIc9wgWTto5Xhn7NjaXYxY0z7uBeTUMlc9LMQq5uP4OM5xCHg==} - markdown-it@14.2.0: - resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true - mdurl@2.0.0: - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} meta-generator@0.1.5: resolution: {integrity: sha512-stImEDLa5k2TfIMfpFomJKM9LuYzwIIsxS/ejBop0CifPqc4nHoLdTQkABnjrzzkZgLhmXdN144R7OT4dnbKqw==} @@ -817,7 +817,7 @@ snapshots: kleur: 4.1.5 liquidjs: 10.27.0 luxon: 3.7.2 - markdown-it: 14.2.0 + markdown-it: 14.3.0 minimist: 1.2.8 moo: 0.5.2 node-retrieve-globals: 6.0.1 @@ -1203,7 +1203,7 @@ snapshots: kleur@4.1.5: {} - linkify-it@5.0.1: + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -1217,23 +1217,23 @@ snapshots: luxon@3.7.2: {} - markdown-it-anchor@9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.2.0): + markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0): dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.2.0 + markdown-it: 14.3.0 markdown-it-plantuml@1.4.1: {} - markdown-it@14.2.0: + markdown-it@14.3.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.1 - mdurl: 2.0.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 punycode.js: 2.3.1 uc.micro: 2.1.0 - mdurl@2.0.0: {} + mdurl@2.1.0: {} meta-generator@0.1.5: dependencies: diff --git a/docs/technical-guide/developer/agentic-devenv.md b/docs/technical-guide/developer/agentic-devenv.md index 8d943dcd93..55b03c1492 100644 --- a/docs/technical-guide/developer/agentic-devenv.md +++ b/docs/technical-guide/developer/agentic-devenv.md @@ -55,7 +55,7 @@ Serena, Playwright) and supports a launcher that wires them into your AI client. for Penpot development (see below for details). 6. **Shut down workspaces** with `./manage.sh stop-devenv`, either one by one or all at once. - You cannot shut down `ws0` if any other workspace is still running, since it's the worker-bearer. + Each workspace is independent and can be stopped in any order. Shared infrastructure will be cleaned up when the last workspace is stopped. Optional: watch Serena's activity in its dashboard @@ -153,8 +153,8 @@ automatically, so regular users never run this. Brings one agentic instance up. Errors out if the target is already running. -`--ws N` (N ≥ 1) auto-starts ws0 first if it is not already up - ws0 is the -worker-bearer and must be running whenever any wsN is. Per-instance ports +`--ws N` (N ≥ 1) brings that workspace up independently — workspaces can be +started and stopped in any order. Per-instance ports are offset by `10000 × N` (ws1's MCP at `http://localhost:14401/mcp`, Serena MCP at `http://localhost:24181`, Serena dashboard at `http://localhost:24182`, etc.). `manage.sh` prints the full URL set on diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 5aaab86e7f..922ed24f1d 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -69,13 +69,12 @@ The devenv runs as separate compose projects: (default `~/.penpot/penpot_workspaces/`). You can explicitly sync them with the `--sync` flag (automatic on first start). -Each call to `run-devenv` brings up one instance, and ws0 is always -running whenever any ws1+ is — `--ws N` (N≥1) auto-starts ws0 first if it -isn't already up: +Each call to `run-devenv` brings up one instance. Workspaces are independent +and can be started and stopped in any order: ```bash ./manage.sh run-devenv # main (ws0) -./manage.sh run-devenv --ws 1 # ws0 if needed, then ws1 +./manage.sh run-devenv --ws 1 # ws1 ./manage.sh run-devenv --ws 2 --sync # ws2, re-seeding from the live repo ``` @@ -91,12 +90,12 @@ the frontend's MCP flag) is copied into each workspace on its initial sync only. After that the developer maintains it in each workspace; subsequent `--sync` runs leave the workspace copy alone. -Stopping mirrors the start invariant — ws0 is the last to stop, and shared -infra stops with it: +Stopping is equally flexible — each workspace is independent. Shared infra +stops only when no instances remain running: ```bash ./manage.sh stop-devenv --ws 1 # stops ws1; ws0 + infra stay up -./manage.sh stop-devenv # stops ws0 + infra; errors if ws1+ still running +./manage.sh stop-devenv # stops ws0; infra stays up if ws1+ still running ./manage.sh stop-devenv --all # stops every ws1+ first, then ws0 + infra ``` @@ -150,12 +149,14 @@ file-summary cache, rate-limit counters) isolated. Background workers (`enable-backend-worker`) run only on ws0 — ws1+ overlays disable it. ws1+ RPC handlers still enqueue tasks into the shared Postgres `task` table; ws0's dispatcher claims them via `FOR UPDATE SKIP LOCKED` and -runs them against the shared DB and MinIO. The "ws0 always up when ws1+ is -up" invariant exists for this reason: it keeps a single worker-bearer and -avoids the multi-instance cron-dedup race (the lock on `scheduled_task` is -released when the task body finishes, so two cron timers firing the same -scheduled instant with a gap larger than the body's runtime can both -execute it). +runs them against the shared DB and MinIO. Workers are fire-and-forget: +`wrk/submit!` inserts a row and returns; RPC handlers never wait on +completion. The "ws0 only" policy avoids multi-instance worker races (cron +dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across +submitters). + +Each workspace is independent and can be started and stopped in any order. +Shared infrastructure shuts down only when no instances remain running. ### Upgrading from a pre-parallel devenv diff --git a/docs/user-guide/designing/flexible-layouts.njk b/docs/user-guide/designing/flexible-layouts.njk index 832344841e..15faa6bd39 100644 --- a/docs/user-guide/designing/flexible-layouts.njk +++ b/docs/user-guide/designing/flexible-layouts.njk @@ -24,11 +24,11 @@ desc: Master responsive web design with Penpot's flexible and grid layouts! Lear

Add Flex Layout

-

You can add Flex Layout to any layer, group, board or a selection including any of these. Once Flex Layout Flex is added the selected elements will be contained into a board with the Flex Layout properties. You have several ways to do this:

+

You can add Flex Layout to any layer, group, board or a selection including any of these. Once Flex Layout is added, the selected elements will be contained into a board with the Flex Layout properties. You have several ways to do this:

  • From the Design panel at the right sidebar.
  • From the option at the selection menu (right click button).
  • -
  • Pressing Ctrl/⌘ + A.
  • +
  • Pressing Shift/⇧ + A.
Adding Layouts
diff --git a/exporter/deps.edn b/exporter/deps.edn index 08e3a3cdd8..9495142719 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -1,9 +1,9 @@ {:paths ["src" "vendor" "resources" "test"] :deps {penpot/common {:local/root "../common"} - org.clojure/clojure {:mvn/version "1.12.2"} + org.clojure/clojure {:mvn/version "1.12.5"} binaryage/devtools {:mvn/version "1.0.7"} - metosin/reitit-core {:mvn/version "0.9.1"} + metosin/reitit-core {:mvn/version "0.10.1"} } :aliases {:outdated @@ -14,7 +14,7 @@ :dev {:extra-deps - {thheller/shadow-cljs {:mvn/version "3.2.1"}}} + {thheller/shadow-cljs {:mvn/version "3.4.11"}}} :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"] diff --git a/exporter/package.json b/exporter/package.json index fce5a1fad9..5b67de97f1 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -18,15 +18,15 @@ "generic-pool": "^3.9.0", "inflation": "^2.1.0", "ioredis": "^5.11.1", - "playwright": "^1.61.1", - "raw-body": "^3.0.2", + "playwright": "1.62.1", + "raw-body": "^4.0.0", "source-map-support": "^0.5.21", - "undici": "^8.5.0", + "undici": "^8.9.0", "xml-js": "^1.6.11", "xregexp": "^5.1.2" }, "devDependencies": { - "ws": "^8.21.0" + "ws": "^8.21.1" }, "scripts": { "clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target", diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 6c1947288c..7066611ba6 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.22: ^4.17.23 lodash@>=4.0.0 <=4.17.23: ^4.17.24 + playwright@>=1.61.1 <2.0.0-0: 1.62.1 importers: @@ -35,17 +36,17 @@ importers: specifier: ^5.11.1 version: 5.11.1 playwright: - specifier: ^1.61.1 - version: 1.61.1 + specifier: 1.62.1 + version: 1.62.1 raw-body: - specifier: ^3.0.2 - version: 3.0.2 + specifier: ^4.0.0 + version: 4.0.0 source-map-support: specifier: ^0.5.21 version: 0.5.21 undici: - specifier: ^8.5.0 - version: 8.5.0 + specifier: ^8.9.0 + version: 8.9.0 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -54,8 +55,8 @@ importers: version: 5.1.2 devDependencies: ws: - specifier: ^8.21.0 - version: 8.21.0 + specifier: ^8.21.1 + version: 8.21.1 packages: @@ -273,10 +274,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -330,14 +327,14 @@ packages: resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} engines: {node: '>=20.19.0'} - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true process-nextick-args@2.0.1: @@ -347,9 +344,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} + raw-body@4.0.0: + resolution: {integrity: sha512-TMHtwexrgOt9VJ2E5JF9RO8mRXGNgC5wXvKkc5AVSJUt1L5gxvjg7eiCQl96EqUEpCWrnNEkCnbAplYtyuq1IA==} + engines: {node: '>=22'} readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -376,9 +373,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -430,19 +424,15 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@8.5.0: - resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} engines: {node: '>=22.19.0'} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -662,10 +652,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} inflation@2.1.0: {} @@ -714,11 +700,11 @@ snapshots: dependencies: boolbase: 2.0.0 - playwright-core@1.61.1: {} + playwright-core@1.62.1: {} - playwright@1.61.1: + playwright@1.62.1: dependencies: - playwright-core: 1.61.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -726,12 +712,10 @@ snapshots: process@0.11.10: {} - raw-body@3.0.2: + raw-body@4.0.0: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 readable-stream@2.3.8: dependencies: @@ -765,8 +749,6 @@ snapshots: safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} - sax@1.6.0: {} setprototypeof@1.2.0: {} @@ -829,13 +811,11 @@ snapshots: tsscmp@1.0.6: {} - undici@8.5.0: {} - - unpipe@1.0.0: {} + undici@8.9.0: {} util-deprecate@1.0.2: {} - ws@8.21.0: {} + ws@8.21.1: {} xml-js@1.6.11: dependencies: diff --git a/exporter/pnpm-workspace.yaml b/exporter/pnpm-workspace.yaml index d71aa3ebaf..ef7ae17b58 100644 --- a/exporter/pnpm-workspace.yaml +++ b/exporter/pnpm-workspace.yaml @@ -1,9 +1,11 @@ allowBuilds: core-js-pure: false minimumReleaseAgeExclude: - - lodash@4.17.24 - - lodash@4.17.23 + - lodash@4.17.23 || 4.17.24 + - playwright-core@1.62.1 + - playwright@1.62.1 overrides: lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.22: ^4.17.23 lodash@>=4.0.0 <=4.17.23: ^4.17.24 + playwright@>=1.61.1 <2.0.0-0: "1.62.1" diff --git a/frontend/deps.edn b/frontend/deps.edn index 801a990923..a662b7086d 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -5,7 +5,7 @@ org.clojure/clojure {:mvn/version "1.12.2"} binaryage/devtools {:mvn/version "RELEASE"} - metosin/reitit-core {:mvn/version "0.9.1"} + metosin/reitit-core {:mvn/version "0.10.1"} funcool/okulary {:mvn/version "2022.04.11-16"} funcool/tubax @@ -50,11 +50,10 @@ "--enable-native-access=ALL-UNNAMED"] :extra-deps - {thheller/shadow-cljs {:mvn/version "3.2.2"} + {thheller/shadow-cljs {:mvn/version "3.4.11"} com.bhauman/rebel-readline {:mvn/version "RELEASE"} org.clojure/tools.namespace {:mvn/version "RELEASE"} - criterium/criterium {:mvn/version "0.4.6"} - cider/cider-nrepl {:mvn/version "0.57.0"}}} + criterium/criterium {:mvn/version "0.4.6"}}} :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"] diff --git a/frontend/package.json b/frontend/package.json index 4e97474dcb..54063ed48f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", "browserslist": [ "defaults" ], @@ -15,7 +15,8 @@ }, "scripts": { "build:app:assets": "node ./scripts/build-app-assets.js", - "build:storybook": "pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build", + "build:fonts-preview": "node ./scripts/build-fonts-preview.js", + "build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build", "build:storybook:assets": "node ./scripts/build-storybook-assets.js", "build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook", "build:wasm": "../render-wasm/build", @@ -57,64 +58,63 @@ "@penpot/text-editor": "link:text-editor", "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", - "@penpot/ui": "link:packages/ui", - "@playwright/test": "1.61.1", - "@storybook/addon-docs": "10.4.6", - "@storybook/addon-themes": "10.4.6", - "@storybook/addon-vitest": "10.4.6", - "@storybook/react-vite": "10.4.6", + "@playwright/test": "1.62.1", + "@storybook/addon-docs": "10.5.5", + "@storybook/addon-themes": "10.5.5", + "@storybook/addon-vitest": "10.5.5", + "@storybook/react-vite": "10.5.5", "@tokens-studio/sd-transforms": "2.0.3", - "@types/node": "^26.1.0", - "@vitest/browser": "4.1.9", - "@vitest/browser-playwright": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@zip.js/zip.js": "2.8.26", - "autoprefixer": "^10.5.2", + "@types/node": "^26.1.2", + "@vitest/browser": "4.1.10", + "@vitest/browser-playwright": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@zip.js/zip.js": "2.8.34", + "autoprefixer": "^10.5.4", "compression": "^1.8.1", - "concurrently": "^10.0.3", + "concurrently": "^10.0.4", "date-fns": "^4.4.0", "esbuild": "^0.28.1", "eventsource-parser": "^3.1.0", "express": "^5.1.0", "fancy-log": "^2.0.0", "getopts": "^2.3.0", - "gettext-parser": "^9.0.2", + "gettext-parser": "^9.1.1", "highlight.js": "^11.10.0", "js-beautify": "^2.0.3", - "jsdom": "^29.0.2", + "jsdom": "^30.0.1", "lodash": "^4.18.1", "lodash.debounce": "^4.0.8", "map-stream": "0.0.7", - "marked": "^18.0.5", + "marked": "^18.0.7", "mkdirp": "^3.0.1", "mustache": "^4.2.0", "nodemon": "^3.1.14", "npm-run-all": "^4.1.5", "opentype.js": "^2.0.0", - "p-limit": "^7.3.0", - "playwright": "1.61.1", - "postcss": "^8.5.16", + "p-limit": "^7.3.1", + "playwright": "1.62.1", + "postcss": "^8.5.25", "postcss-clean": "^1.2.2", - "postcss-modules": "^9.0.0", + "postcss-modules": "^9.0.1", "postcss-scss": "^4.0.9", - "prettier": "3.9.4", + "prettier": "3.9.6", "pretty-time": "^1.1.0", "prop-types": "^15.8.1", "randomcolor": "^0.6.2", - "react": "19.2.7", - "react-dom": "19.2.7", + "react": "19.2.8", + "react-dom": "19.2.8", "react-error-boundary": "^6.1.2", "react-virtualized": "^9.22.6", "rimraf": "^6.1.3", "rxjs": "8.0.0-alpha.14", - "sass": "^1.101.0", + "sass": "^1.102.0", "sass-embedded": "^1.100.0", - "sax": "^1.6.0", + "sax": "^1.6.1", "scheduler": "^0.27.0", "source-map-support": "^0.5.21", - "storybook": "10.4.6", - "style-dictionary": "5.4.4", - "stylelint": "^17.14.0", + "storybook": "10.5.5", + "style-dictionary": "5.5.0", + "stylelint": "^17.14.1", "stylelint-config-standard-scss": "^17.0.0", "stylelint-plugin-logical-css": "^2.1.0", "stylelint-scss": "^7.2.0", @@ -122,11 +122,15 @@ "tdigest": "^0.1.2", "tinycolor2": "^1.6.0", "typescript": "^6.0.2", - "vite": "^8.1.2", - "vitest": "^4.1.9", - "wait-on": "^9.0.4", + "vite": "^8.2.0", + "vitest": "^4.1.10", + "wait-on": "^9.1.0", "watcher": "^2.3.1", "workerpool": "^10.0.3", "xregexp": "^5.1.2" + }, + "dependencies": { + "@penpot/ui": "link:packages/ui", + "react-aria-components": "^1.19.0" } } diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index 31e2597bd9..1fca7c11a8 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -7,6 +7,10 @@ ".": { "import": "./dist/index.js" }, + "./modal": { + "import": "./dist/modal.js", + "types": "./dist/modal.d.ts" + }, "./style.css": "./dist/style.css" }, "scripts": { @@ -16,22 +20,25 @@ "devDependencies": { "@babel/core": "^8.0.1", "@babel/preset-react": "^8.0.1", - "@storybook/react": "10.4.6", - "@storybook/react-vite": "10.4.6", + "@storybook/react": "10.5.5", + "@storybook/react-vite": "10.5.5", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^6.0.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", "babel-plugin-react-compiler": "^1.0.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "react-compiler-runtime": "^1.0.0", - "storybook": "10.4.6", + "storybook": "10.5.5", "vite-plugin-dts": "^5.0.3" }, + "dependencies": { + "react-aria-components": "^1.19.0" + }, "peerDependencies": { "react": ">=19.2", "react-dom": ">=19.2" diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 19ed9b5a98..4c5c366053 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -1 +1 @@ -export * from './lib/example/Example'; +export { Modal, useModalClose } from './lib/modal/Modal'; diff --git a/frontend/packages/ui/src/lib/example/Example.module.css b/frontend/packages/ui/src/lib/example/Example.module.css deleted file mode 100644 index bcb65dd60c..0000000000 --- a/frontend/packages/ui/src/lib/example/Example.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.container { - background-color: #f0f0f0; - padding: 16px; - border: 2px solid #000; -} diff --git a/frontend/packages/ui/src/lib/example/Example.spec.tsx b/frontend/packages/ui/src/lib/example/Example.spec.tsx deleted file mode 100644 index e3022639d9..0000000000 --- a/frontend/packages/ui/src/lib/example/Example.spec.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { render } from '@testing-library/react'; - -import Example from './Example'; - -describe('Example', () => { - it('should render successfully', () => { - const { baseElement } = render(); - expect(baseElement).toBeTruthy(); - }); -}); diff --git a/frontend/packages/ui/src/lib/example/Example.stories.ts b/frontend/packages/ui/src/lib/example/Example.stories.ts deleted file mode 100644 index 4dfb60c9e7..0000000000 --- a/frontend/packages/ui/src/lib/example/Example.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Example } from './Example'; -import type { Meta, StoryObj } from '@storybook/react-vite'; - -const meta = { - title: 'UI/Example', - component: Example, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Primary: Story = {}; diff --git a/frontend/packages/ui/src/lib/example/Example.tsx b/frontend/packages/ui/src/lib/example/Example.tsx deleted file mode 100644 index 908a8e3fd9..0000000000 --- a/frontend/packages/ui/src/lib/example/Example.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useState } from 'react'; -import styles from './Example.module.css'; - -export function Example() { - const [count, setCount] = useState(0); - - return ( -
-

Example!

-
-

Counter: {count}

- - - -
-
- - ); -} - -export default Example; diff --git a/frontend/packages/ui/src/lib/modal/Modal.module.scss b/frontend/packages/ui/src/lib/modal/Modal.module.scss new file mode 100644 index 0000000000..b5ab9bf29e --- /dev/null +++ b/frontend/packages/ui/src/lib/modal/Modal.module.scss @@ -0,0 +1,113 @@ +@use "ds/_borders" as *; +@use "ds/_sizes" as *; +@use "ds/_utils" as *; + +.overlay { + position: fixed; + inset: 0; + background: var(--color-overlay-default); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-index-set); + + &[data-entering] { + animation: overlay-fade-in 0.2s ease-out; + } + + &[data-exiting] { + animation: overlay-fade-out 0.15s ease-in; + } +} + +.modal { + position: relative; + inline-size: 100%; + margin: var(--sp-l); + background: var(--color-background-primary); + border-radius: $br-8; + box-shadow: var(--el-shadow-dark); + outline: none; + max-block-size: calc(100dvh - var(--sp-xxxl) * 2); + display: flex; + flex-direction: column; + + &[data-entering] { + animation: modal-zoom-in 0.2s ease-out; + } + + &[data-exiting] { + animation: modal-zoom-out 0.15s ease-in; + } +} + +.modalSmall { + max-inline-size: $sz-400; +} + +.modalMedium { + max-inline-size: $sz-512; +} + +.modalLarge { + max-inline-size: px2rem(640); +} + +.modalXlarge { + max-inline-size: px2rem(960); +} + +.dialog { + position: relative; + outline: none; + display: flex; + flex-direction: column; + min-block-size: 0; + overflow: hidden; + max-block-size: inherit; +} + + +@keyframes overlay-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes overlay-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes modal-zoom-in { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes modal-zoom-out { + from { + opacity: 1; + transform: scale(0.95); + } + + to { + opacity: 0; + transform: scale(1); + } +} diff --git a/frontend/packages/ui/src/lib/modal/Modal.tsx b/frontend/packages/ui/src/lib/modal/Modal.tsx new file mode 100644 index 0000000000..6d236d4d96 --- /dev/null +++ b/frontend/packages/ui/src/lib/modal/Modal.tsx @@ -0,0 +1,66 @@ +import { + DialogTrigger, + ModalOverlay, + Modal as RACModal, + Dialog, +} from 'react-aria-components'; +import { + createContext, + useContext, + type ReactNode, +} from 'react'; +import styles from './Modal.module.scss'; + +const ModalCloseContext = createContext<(() => void) | null>(null); + +export function useModalClose(): (() => void) | null { + return useContext(ModalCloseContext); +} + +interface ModalProps { + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + children: ReactNode; + trigger?: ReactNode; + isDismissable?: boolean; + size?: 'small' | 'medium' | 'large' | 'xlarge'; + className?: string; +} + +export function Modal({ + isOpen, + onOpenChange, + children, + trigger, + isDismissable = true, + size = 'medium', + className, +}: ModalProps) { + const sizeClass = + size === 'small' + ? styles.modalSmall + : size === 'large' + ? styles.modalLarge + : size === 'xlarge' + ? styles.modalXlarge + : styles.modalMedium; + + return ( + + {trigger} + + + + {({ close }) => ( + + {children} + + )} + + + + + ); +} diff --git a/frontend/packages/ui/src/modal.ts b/frontend/packages/ui/src/modal.ts new file mode 100644 index 0000000000..4c5c366053 --- /dev/null +++ b/frontend/packages/ui/src/modal.ts @@ -0,0 +1 @@ +export { Modal, useModalClose } from './lib/modal/Modal'; diff --git a/frontend/packages/ui/vite.config.mts b/frontend/packages/ui/vite.config.mts index a1f91b23c1..5ec50406f4 100644 --- a/frontend/packages/ui/vite.config.mts +++ b/frontend/packages/ui/vite.config.mts @@ -10,7 +10,7 @@ const copyCssPlugin = () => ({ closeBundle: () => { try { copyFileSync( - 'dist/index.css', + 'dist/ui.css', '../../resources/public/css/ui.css', ); } catch (e) { @@ -21,6 +21,15 @@ const copyCssPlugin = () => ({ export default defineConfig(() => ({ root: import.meta.dirname, + css: { + preprocessorOptions: { + scss: { + loadPaths: [ + path.resolve(import.meta.dirname, '../../src/app/main/ui'), + ], + }, + }, + }, plugins: [ react({ babel: { @@ -42,9 +51,11 @@ export default defineConfig(() => ({ transformMixedEsModules: true, }, lib: { - entry: 'src/index.ts', + entry: { + index: 'src/index.ts', + modal: 'src/modal.ts', + }, name: 'ui', - fileName: 'index', formats: ['es' as const], }, rollupOptions: { diff --git a/frontend/playwright/data/design/get-file-fragment-10638.json b/frontend/playwright/data/design/get-file-fragment-10638.json new file mode 100644 index 0000000000..f54380fed7 --- /dev/null +++ b/frontend/playwright/data/design/get-file-fragment-10638.json @@ -0,0 +1 @@ +{"~:id":"~u525a5d8b-028e-80e7-8005-aa705347b678","~:file-id":"~u525a5d8b-028e-80e7-8005-aa6cad42f27d","~:created-at":"~m1738332498199","~:data":{"~:options":{},"~:objects":{"~u00000000-0000-0000-0000-000000000000":{"~#shape":{"~:y":0,"~:hide-fill-on-export":false,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:name":"Root Frame","~:width":0.01,"~:type":"~:frame","~:points":[{"~#point":{"~:x":0.0,"~:y":0.0}},{"~#point":{"~:x":0.01,"~:y":0.0}},{"~#point":{"~:x":0.01,"~:y":0.01}},{"~#point":{"~:x":0.0,"~:y":0.01}}],"~:r2":0,"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:r3":0,"~:r1":0,"~:id":"~u00000000-0000-0000-0000-000000000000","~:parent-id":"~u00000000-0000-0000-0000-000000000000","~:frame-id":"~u00000000-0000-0000-0000-000000000000","~:strokes":[],"~:x":0,"~:proportion":1.0,"~:r4":0,"~:selrect":{"~#rect":{"~:x":0,"~:y":0,"~:width":0.01,"~:height":0.01,"~:x1":0,"~:y1":0,"~:x2":0.01,"~:y2":0.01}},"~:fills":[{"~:fill-color":"#FFFFFF","~:fill-opacity":1}],"~:flip-x":null,"~:height":0.01,"~:flip-y":null,"~:shapes":["~uc42485d8-8a02-80e3-8005-aa6caf76e409","~uc42485d8-8a02-80e3-8005-aa6caf76e40a"]}},"~uc42485d8-8a02-80e3-8005-aa6caf76e409":{"~#shape":{"~:y":0,"~:layout-grid-columns":[{"~:type":"~:flex","~:value":1},{"~:type":"~:flex","~:value":1}],"~:hide-fill-on-export":false,"~:layout-gap-type":"~:multiple","~:layout-padding":{"~:p1":0,"~:p2":0,"~:p3":0,"~:p4":0},"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:grow-type":"~:fixed","~:layout":"~:grid","~:hide-in-viewer":false,"~:name":"Board","~:layout-align-items":"~:start","~:width":200,"~:layout-grid-cells":{"~uc42485d8-8a02-80e3-8005-aa6cb3b09187":{"~:justify-self":"~:auto","~:column":1,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b09187","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":1,"~:row-span":1,"~:shapes":[]},"~uc42485d8-8a02-80e3-8005-aa6cb3b09188":{"~:justify-self":"~:auto","~:column":2,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b09188","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":1,"~:row-span":1,"~:shapes":[]},"~uc42485d8-8a02-80e3-8005-aa6cb3b09189":{"~:justify-self":"~:auto","~:column":1,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b09189","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":2,"~:row-span":1,"~:shapes":[]},"~uc42485d8-8a02-80e3-8005-aa6cb3b0918a":{"~:justify-self":"~:auto","~:column":2,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b0918a","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":2,"~:row-span":1,"~:shapes":[]}},"~:layout-padding-type":"~:simple","~:type":"~:frame","~:points":[{"~#point":{"~:x":0,"~:y":0}},{"~#point":{"~:x":200,"~:y":0}},{"~#point":{"~:x":200,"~:y":200}},{"~#point":{"~:x":0,"~:y":200}}],"~:r2":0,"~:proportion-lock":false,"~:layout-gap":{"~:row-gap":0,"~:column-gap":0},"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:r3":0,"~:layout-justify-content":"~:stretch","~:r1":0,"~:id":"~uc42485d8-8a02-80e3-8005-aa6caf76e409","~:layout-justify-items":"~:start","~:parent-id":"~u00000000-0000-0000-0000-000000000000","~:layout-align-content":"~:stretch","~:frame-id":"~u00000000-0000-0000-0000-000000000000","~:strokes":[],"~:x":0,"~:proportion":1,"~:r4":0,"~:layout-grid-rows":[{"~:type":"~:flex","~:value":1},{"~:type":"~:flex","~:value":1}],"~:selrect":{"~#rect":{"~:x":0,"~:y":0,"~:width":200,"~:height":200,"~:x1":0,"~:y1":0,"~:x2":200,"~:y2":200}},"~:fills":[{"~:fill-color":"#FFFFFF","~:fill-opacity":1}],"~:layout-grid-dir":"~:row","~:flip-x":null,"~:height":200,"~:flip-y":null,"~:shapes":[]}},"~uc42485d8-8a02-80e3-8005-aa6caf76e40a":{"~#shape":{"~:y":0,"~:layout-grid-columns":[{"~:type":"~:flex","~:value":1},{"~:type":"~:flex","~:value":1}],"~:hide-fill-on-export":false,"~:layout-gap-type":"~:multiple","~:layout-padding":{"~:p1":33,"~:p2":33,"~:p3":33,"~:p4":33},"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:grow-type":"~:fixed","~:layout":"~:grid","~:hide-in-viewer":false,"~:name":"Second","~:layout-align-items":"~:start","~:width":200,"~:layout-grid-cells":{"~uc42485d8-8a02-80e3-8005-aa6cb3b09187":{"~:justify-self":"~:auto","~:column":1,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b09187","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":1,"~:row-span":1,"~:shapes":[]},"~uc42485d8-8a02-80e3-8005-aa6cb3b09188":{"~:justify-self":"~:auto","~:column":2,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b09188","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":1,"~:row-span":1,"~:shapes":[]},"~uc42485d8-8a02-80e3-8005-aa6cb3b09189":{"~:justify-self":"~:auto","~:column":1,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b09189","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":2,"~:row-span":1,"~:shapes":[]},"~uc42485d8-8a02-80e3-8005-aa6cb3b0918a":{"~:justify-self":"~:auto","~:column":2,"~:id":"~uc42485d8-8a02-80e3-8005-aa6cb3b0918a","~:position":"~:auto","~:column-span":1,"~:align-self":"~:auto","~:row":2,"~:row-span":1,"~:shapes":[]}},"~:layout-padding-type":"~:simple","~:type":"~:frame","~:points":[{"~#point":{"~:x":0,"~:y":0}},{"~#point":{"~:x":200,"~:y":0}},{"~#point":{"~:x":200,"~:y":200}},{"~#point":{"~:x":0,"~:y":200}}],"~:r2":0,"~:proportion-lock":false,"~:layout-gap":{"~:row-gap":0,"~:column-gap":0},"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:r3":0,"~:layout-justify-content":"~:stretch","~:r1":0,"~:id":"~uc42485d8-8a02-80e3-8005-aa6caf76e40a","~:layout-justify-items":"~:start","~:parent-id":"~u00000000-0000-0000-0000-000000000000","~:layout-align-content":"~:stretch","~:frame-id":"~u00000000-0000-0000-0000-000000000000","~:strokes":[],"~:x":0,"~:proportion":1,"~:r4":0,"~:layout-grid-rows":[{"~:type":"~:flex","~:value":1},{"~:type":"~:flex","~:value":1}],"~:selrect":{"~#rect":{"~:x":0,"~:y":0,"~:width":200,"~:height":200,"~:x1":0,"~:y1":0,"~:x2":200,"~:y2":200}},"~:fills":[{"~:fill-color":"#FFFFFF","~:fill-opacity":1}],"~:layout-grid-dir":"~:row","~:flip-x":null,"~:height":200,"~:flip-y":null,"~:shapes":[]}}},"~:id":"~u525a5d8b-028e-80e7-8005-aa6cad42f27e","~:name":"Page 1"}} diff --git a/frontend/playwright/data/logged-in-user/get-profile-no-shortcuts.json b/frontend/playwright/data/logged-in-user/get-profile-no-shortcuts.json new file mode 100644 index 0000000000..af49dca188 --- /dev/null +++ b/frontend/playwright/data/logged-in-user/get-profile-no-shortcuts.json @@ -0,0 +1,21 @@ +{ + "~:v2-info-shown": true, + "~:newsletter-updates": false, + "~:onboarding-viewed": true, + "~:onboarding-questions": { + "~:role": "ux", + "~:start-with": "wireframing", + "~:expected-use": "personal", + "~:experience-design-tool": "sketch" + }, + "~:onboarding-questions-answered": true, + "~:workspace-visited": true, + "~:renderer": "~:wasm", + "~:viewed-tutorial?": false, + "~:viewed-walkthrough?": false, + "~:release-notes-viewed": "0.0", + "~:nudge": { + "~:big": 10, + "~:small": 1 + } +} diff --git a/frontend/playwright/data/logged-in-user/get-profile-with-shortcuts.json b/frontend/playwright/data/logged-in-user/get-profile-with-shortcuts.json new file mode 100644 index 0000000000..6fedc2bfae --- /dev/null +++ b/frontend/playwright/data/logged-in-user/get-profile-with-shortcuts.json @@ -0,0 +1,27 @@ +{ + "~:v2-info-shown": true, + "~:newsletter-updates": false, + "~:onboarding-viewed": true, + "~:onboarding-questions": { + "~:role": "ux", + "~:start-with": "wireframing", + "~:expected-use": "personal", + "~:experience-design-tool": "sketch" + }, + "~:onboarding-questions-answered": true, + "~:workspace-visited": true, + "~:custom-shortcuts": { + "~:workspace": { + "~:align-bottom": "ctrl+y", + "~:redo": "" + } + }, + "~:renderer": "~:wasm", + "~:viewed-tutorial?": false, + "~:viewed-walkthrough?": false, + "~:release-notes-viewed": "0.0", + "~:nudge": { + "~:big": 10, + "~:small": 1 + } +} diff --git a/frontend/playwright/data/logged-in-user/update-profile-with-shortcuts.json b/frontend/playwright/data/logged-in-user/update-profile-with-shortcuts.json new file mode 100644 index 0000000000..6fedc2bfae --- /dev/null +++ b/frontend/playwright/data/logged-in-user/update-profile-with-shortcuts.json @@ -0,0 +1,27 @@ +{ + "~:v2-info-shown": true, + "~:newsletter-updates": false, + "~:onboarding-viewed": true, + "~:onboarding-questions": { + "~:role": "ux", + "~:start-with": "wireframing", + "~:expected-use": "personal", + "~:experience-design-tool": "sketch" + }, + "~:onboarding-questions-answered": true, + "~:workspace-visited": true, + "~:custom-shortcuts": { + "~:workspace": { + "~:align-bottom": "ctrl+y", + "~:redo": "" + } + }, + "~:renderer": "~:wasm", + "~:viewed-tutorial?": false, + "~:viewed-walkthrough?": false, + "~:release-notes-viewed": "0.0", + "~:nudge": { + "~:big": 10, + "~:small": 1 + } +} diff --git a/frontend/playwright/data/render-wasm/assets/squares-background.png b/frontend/playwright/data/render-wasm/assets/squares-background.png new file mode 100644 index 0000000000..55b49b4480 Binary files /dev/null and b/frontend/playwright/data/render-wasm/assets/squares-background.png differ diff --git a/frontend/playwright/data/render-wasm/get-file-background-blur-strokes.json b/frontend/playwright/data/render-wasm/get-file-background-blur-strokes.json new file mode 100644 index 0000000000..69bb0d9d59 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-background-blur-strokes.json @@ -0,0 +1,176 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "tokens/numeric-input", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 3", + "~:revn": 65, + "~:modified-at": "~m1784199941209", + "~:vern": 0, + "~:id": "~u814272d9-d3f8-812d-8008-54c11cbba219", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node", + "0017-fix-layout-flex-dir", + "0018-remove-unneeded-objects-from-components", + "0019-fix-missing-swap-slots", + "0020-sync-component-id-with-near-main", + "0021-fix-shape-svg-attrs", + "0022-normalize-component-root-and-resync", + "0023-repair-token-themes-with-inexistent-sets", + "0024b-fix-stroke-cap-placement" + ] + }, + "~:version": 67, + "~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0", + "~:created-at": "~m1784121921262", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u814272d9-d3f8-812d-8008-54c11cbba21a", + "~u844a4204-eb8d-80d3-8008-55e83b4f39e0" + ], + "~:pages-index": { + "~u814272d9-d3f8-812d-8008-54c11cbba21a": { + "~:objects": { + "~#penpot/objects-map/v2": { + "~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u9f14b915-410b-8062-8008-55a1fbbd5fa4\",\"~u9f14b915-410b-8062-8008-559878019a9e\",\"~u844a4204-eb8d-80d3-8008-55e83b566c48\"]]]", + "~u9f14b915-410b-8062-8008-559878019a9e": "[\"~#shape\",[\"^ \",\"~:y\",-1863.999482267452,\"~:background-blur\",[\"^ \",\"~:id\",\"~u9f14b915-410b-8062-8008-5598c30393d7\",\"~:type\",\"^1\",\"~:value\",8,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:key\",\"22sle9m74ka\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^=\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^=\",[[\"^ \",\"^?\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"sourcesanspro\",\"^<\",\"1ixh2em79as\",\"~:font-size\",\"200\",\"~:font-weight\",\"900\",\"~:font-variant-id\",\"black\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.12]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"blur\"]],\"^@\",\"none\",\"~:text-align\",\"left\",\"^A\",\"sourcesanspro\",\"^<\",\"1x2xz4p3kcs\",\"^B\",\"200\",\"^C\",\"900\",\"~:text-direction\",\"ltr\",\"^3\",\"paragraph\",\"^D\",\"black\",\"^E\",\"none\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#ffffff\",\"^I\",0.12]],\"^J\",\"sourcesanspro\"]]]],\"~:vertical-align\",\"top\"],\"~:hide-in-viewer\",false,\"~:name\",\"blur\",\"~:width\",375.0000213699236,\"^3\",\"^K\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",95.49998073772147,\"~:y\",-1863.999482267452]],[\"^S\",[\"^ \",\"~:x\",470.50000210764506,\"~:y\",-1863.999482267452]],[\"^S\",[\"^ \",\"~:x\",470.50000210764506,\"~:y\",-1623.9994721448552]],[\"^S\",[\"^ \",\"~:x\",95.49998073772147,\"~:y\",-1623.9994721448552]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u9f14b915-410b-8062-8008-559878019a9e\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",-1602.939453125,\"^>\",\"1.2\",\"^?\",\"normal\",\"~:typography-ref-id\",null,\"^@\",\"none\",\"^L\",\"left\",\"^A\",\"sourcesanspro\",\"^B\",\"200px\",\"^C\",\"900\",\"~:typography-ref-file\",null,\"^M\",\"ltr\",\"^Q\",374.6099853515625,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",95.4999771118164,\"^G\",[[\"^ \",\"^H\",\"#ffffff\",\"^I\",0.12]],\"~:direction\",\"ltr\",\"^J\",\"sourcesanspro\",\"~:height\",282.1201171875,\"^K\",\"blur\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",95.49998073772146,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",95.49998073772146,\"~:y\",-1863.999482267452,\"^Q\",375.0000213699236,\"^Z\",240.0000101225969,\"~:x1\",95.49998073772146,\"~:y1\",-1863.999482267452,\"~:x2\",470.50000210764506,\"~:y2\",-1623.9994721448552]],\"~:flip-x\",null,\"^Z\",240.0000101225969,\"~:flip-y\",null]]", + "~u9f14b915-410b-8062-8008-55a1fbbd5fa4": "[\"~#shape\",[\"^ \",\"~:y\",-2155.0001098481293,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"background_1\",\"~:width\",1004.0000579080496,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",-211.00000682869666,\"~:y\",-2155.0001098481293]],[\"^9\",[\"^ \",\"~:x\",793.0000510793529,\"~:y\",-2155.0001098481293]],[\"^9\",[\"^ \",\"~:x\",793.0000510793529,\"~:y\",-1150.9999568014878]],[\"^9\",[\"^ \",\"~:x\",-211.00000682869666,\"~:y\",-1150.9999568014878]]],\"~:r2\",0,\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u9f14b915-410b-8062-8008-55a1fbbd5fa4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",-211.00000682869666,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",-211.00000682869666,\"~:y\",-2155.0001098481293,\"^5\",1004.0000579080496,\"~:height\",1004.0001530466416,\"~:x1\",-211.00000682869666,\"~:y1\",-2155.0001098481293,\"~:x2\",793.0000510793529,\"~:y2\",-1150.9999568014878]],\"~:fills\",[[\"^ \",\"~:fill-opacity\",1,\"~:fill-image\",[\"^ \",\"^5\",1181,\"^G\",1181,\"~:mtype\",\"image/png\",\"^?\",\"~u814272d9-d3f8-812d-8008-55a1fb78211b\",\"~:keep-aspect-ratio\",true]]],\"~:flip-x\",null,\"^G\",1004.0001530466416,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e83b566c48": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"background\",\"~:width\",1400,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1400,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1400,\"~:y\",759.9999904632568]],[\"^9\",[\"^ \",\"~:x\",0,\"~:y\",759.9999904632568]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u844a4204-eb8d-80d3-8008-55e83b566c48\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",1400,\"~:height\",759.9999904632568,\"~:x1\",0,\"~:y1\",0,\"~:x2\",1400,\"~:y2\",759.9999904632568]],\"~:fills\",[[\"^ \",\"~:fill-opacity\",1,\"~:fill-image\",[\"^ \",\"^?\",\"~u814272d9-d3f8-812d-8008-55a1fb78211b\",\"^5\",1181,\"^G\",1181,\"~:mtype\",\"image/png\",\"~:keep-aspect-ratio\",true]]],\"~:flip-x\",null,\"^G\",759.9999904632568,\"~:flip-y\",null]]" + } + }, + "~:id": "~u814272d9-d3f8-812d-8008-54c11cbba21a", + "~:name": "Page 1" + }, + "~u844a4204-eb8d-80d3-8008-55e83b4f39e0": { + "~:objects": { + "~#penpot/objects-map/v2": { + "~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u844a4204-eb8d-80d3-8008-55e84d2ab9b9\",\"~u844a4204-eb8d-80d3-8008-55e860a88934\",\"~u844a4204-eb8d-80d3-8008-55e860b3775c\",\"~u844a4204-eb8d-80d3-8008-55e860bb5bdc\",\"~u844a4204-eb8d-80d3-8008-55e860c302cd\",\"~u844a4204-eb8d-80d3-8008-55e860ca2b3b\",\"~u844a4204-eb8d-80d3-8008-55e860d3347f\",\"~u844a4204-eb8d-80d3-8008-55e86a816bac\",\"~u844a4204-eb8d-80d3-8008-55e86a8be839\",\"~u844a4204-eb8d-80d3-8008-55e86a92f143\",\"~u844a4204-eb8d-80d3-8008-55e86a99dc08\",\"~u844a4204-eb8d-80d3-8008-55e86aa09dec\",\"~u844a4204-eb8d-80d3-8008-55e86aa7e998\",\"~u844a4204-eb8d-80d3-8008-55e86aafcb00\",\"~u844a4204-eb8d-80d3-8008-55e86ac03a3a\",\"~u844a4204-eb8d-80d3-8008-55e86acc7ab8\",\"~u844a4204-eb8d-80d3-8008-55e86ad794cf\",\"~u844a4204-eb8d-80d3-8008-55e86ae33dff\",\"~u844a4204-eb8d-80d3-8008-55e86af1cbcf\"]]]", + "~u844a4204-eb8d-80d3-8008-55e86ae33dff": "[\"~#shape\",[\"^ \",\"~:y\",560,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351419\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"90\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"bold\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"bold\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[],\"^K\",\"sourcesanspro\"]]]]],\"~:name\",\"text-center-nofill\",\"~:width\",100.0001106262207,\"^3\",\"^L\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800,\"~:y\",560]],[\"^P\",[\"^ \",\"~:x\",900.0001106262207,\"~:y\",560]],[\"^P\",[\"^ \",\"~:x\",900.0001106262207,\"~:y\",668]],[\"^P\",[\"^ \",\"~:x\",800,\"~:y\",668]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86ae33dff\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",675.9400024414062,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90px\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^N\",99.58001708984375,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",800,\"^J\",[],\"~:direction\",\"ltr\",\"^K\",\"sourcesanspro\",\"~:height\",123.8800048828125,\"^L\",\"Aa\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",12]],\"~:x\",800,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800,\"~:y\",560,\"^N\",100.0001106262207,\"^U\",108,\"~:x1\",800,\"~:y1\",560,\"~:x2\",900.0001106262207,\"~:y2\",668]],\"~:flip-x\",null,\"^U\",108,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e860d3347f": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e88835141a\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"rect-outer-opaque\",\"~:width\",120.00000476837158,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",990,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",1110.0000047683716,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",1110.0000047683716,\"~:y\",180.00000476837158]],[\"^=\",[\"^ \",\"~:x\",990,\"~:y\",180.00000476837158]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"^2\",\"~u844a4204-eb8d-80d3-8008-55e860d3347f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",12]],\"~:x\",990,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",990,\"~:y\",60,\"^:\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",990,\"~:y1\",60,\"~:x2\",1110.0000047683716,\"~:y2\",180.00000476837158]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#333333\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^Q\",120.00000476837158,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e860b3775c": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e88835141b\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"rect-center\",\"~:width\",120.00000476837158,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",230,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",350.0000047683716,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",350.0000047683716,\"~:y\",180.00000476837158]],[\"^=\",[\"^ \",\"~:x\",230,\"~:y\",180.00000476837158]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"^2\",\"~u844a4204-eb8d-80d3-8008-55e860b3775c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",12]],\"~:x\",230,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",230,\"~:y\",60,\"^:\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",230,\"~:y1\",60,\"~:x2\",350.0000047683716,\"~:y2\",180.00000476837158]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^Q\",120.00000476837158,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e860bb5bdc": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e88835141c\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"rect-outer\",\"~:width\",120.00000476837158,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",540.0000047683716,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",540.0000047683716,\"~:y\",180.00000476837158]],[\"^=\",[\"^ \",\"~:x\",420,\"~:y\",180.00000476837158]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"^2\",\"~u844a4204-eb8d-80d3-8008-55e860bb5bdc\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",12]],\"~:x\",420,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420,\"~:y\",60,\"^:\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",420,\"~:y1\",60,\"~:x2\",540.0000047683716,\"~:y2\",180.00000476837158]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^Q\",120.00000476837158,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e860ca2b3b": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e88835141d\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"rect-center-nofill\",\"~:width\",120.00000476837158,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",920.0000047683716,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",920.0000047683716,\"~:y\",180.00000476837158]],[\"^=\",[\"^ \",\"~:x\",800,\"~:y\",180.00000476837158]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"^2\",\"~u844a4204-eb8d-80d3-8008-55e860ca2b3b\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",12]],\"~:x\",800,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800,\"~:y\",60,\"^:\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",800,\"~:y1\",60,\"~:x2\",920.0000047683716,\"~:y2\",180.00000476837158]],\"~:fills\",[],\"~:flip-x\",null,\"^Q\",120.00000476837158,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86ac03a3a": "[\"~#shape\",[\"^ \",\"~:y\",560,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e88835141e\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"90\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"bold\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"bold\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"text-center\",\"~:width\",99.99999618530273,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",230,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",329.99999618530273,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",329.99999618530273,\"~:y\",668]],[\"^R\",[\"^ \",\"~:x\",230,\"~:y\",668]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86ac03a3a\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",675.9400024414062,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90px\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^P\",99.58001708984375,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",230,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",123.8800048828125,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",12]],\"~:x\",230,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",230,\"~:y\",560,\"^P\",99.99999618530273,\"^W\",108,\"~:x1\",230,\"~:y1\",560,\"~:x2\",329.99999618530273,\"~:y2\",668]],\"~:flip-x\",null,\"^W\",108,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e84d2ab9b9": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"background\",\"~:width\",1179.9999713897705,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1179.9999713897705,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1179.9999713897705,\"~:y\",759.9999904632568]],[\"^9\",[\"^ \",\"~:x\",0,\"~:y\",759.9999904632568]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u844a4204-eb8d-80d3-8008-55e84d2ab9b9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",1179.9999713897705,\"~:height\",759.9999904632568,\"~:x1\",0,\"~:y1\",0,\"~:x2\",1179.9999713897705,\"~:y2\",759.9999904632568]],\"~:fills\",[[\"^ \",\"~:fill-opacity\",1,\"~:fill-image\",[\"^ \",\"^?\",\"~u814272d9-d3f8-812d-8008-55a1fb78211b\",\"^5\",1181,\"^G\",1181,\"~:mtype\",\"image/png\",\"~:keep-aspect-ratio\",true]]],\"~:flip-x\",null,\"^G\",759.9999904632568,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86a8be839": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e88835141f\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAAGZDAACWQwIAAAAAAAAAAAAAAAAAAAAAAAAAAACvQwAA0kM=\"],\"~:name\",\"path-center\",\"~:width\",null,\"^3\",\"~:path\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",230,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",350.0000047683716,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",350.0000047683716,\"~:y\",420.0000047683716]],[\"^?\",[\"^ \",\"~:x\",230,\"~:y\",420.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86a8be839\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",12]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",230,\"~:y\",300,\"^<\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",230,\"~:y1\",300,\"~:x2\",350.0000047683716,\"~:y2\",420.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^O\",null,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86acc7ab8": "[\"~#shape\",[\"^ \",\"~:y\",560,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351420\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"90\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"bold\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"bold\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"text-outer\",\"~:width\",99.99999618530273,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",519.9999961853027,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",519.9999961853027,\"~:y\",668]],[\"^R\",[\"^ \",\"~:x\",420,\"~:y\",668]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86acc7ab8\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",675.9400024414062,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90px\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^P\",99.58001708984375,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",420,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",123.8800048828125,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",12]],\"~:x\",420,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420,\"~:y\",560,\"^P\",99.99999618530273,\"^W\",108,\"~:x1\",420,\"~:y1\",560,\"~:x2\",519.9999961853027,\"~:y2\",668]],\"~:flip-x\",null,\"^W\",108,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86aa7e998": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351421\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAgHdEAACWQwIAAAAAAAAAAAAAAAAAAAAAAAAAAMCKRAAA0kM=\"],\"~:name\",\"path-outer-opaque\",\"~:width\",null,\"^3\",\"~:path\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",990,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",1110.0000047683716,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",1110.0000047683716,\"~:y\",420.0000047683716]],[\"^?\",[\"^ \",\"~:x\",990,\"~:y\",420.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86aa7e998\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",12]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",990,\"~:y\",300,\"^<\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",990,\"~:y1\",300,\"~:x2\",1110.0000047683716,\"~:y2\",420.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#333333\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^O\",null,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e860a88934": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351422\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"rect-inner\",\"~:width\",120.00000476837158,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",160.00000476837158,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",160.00000476837158,\"~:y\",180.00000476837158]],[\"^=\",[\"^ \",\"~:x\",40,\"~:y\",180.00000476837158]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"^2\",\"~u844a4204-eb8d-80d3-8008-55e860a88934\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",12]],\"~:x\",40,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40,\"~:y\",60,\"^:\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",40,\"~:y1\",60,\"~:x2\",160.00000476837158,\"~:y2\",180.00000476837158]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^Q\",120.00000476837158,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86af1cbcf": "[\"~#shape\",[\"^ \",\"~:y\",560,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351423\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"90\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"bold\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#333333\",\"~:fill-opacity\",1]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"bold\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#333333\",\"^L\",1]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"text-outer-opaque\",\"~:width\",99.99999618530273,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",990,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",1089.9999961853027,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",1089.9999961853027,\"~:y\",668]],[\"^R\",[\"^ \",\"~:x\",990,\"~:y\",668]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86af1cbcf\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",675.9400024414062,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90px\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^P\",99.5799560546875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",990,\"^J\",[[\"^ \",\"^K\",\"#333333\",\"^L\",1]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",123.8800048828125,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",12]],\"~:x\",990,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",990,\"~:y\",560,\"^P\",99.99999618530273,\"^W\",108,\"~:x1\",990,\"~:y1\",560,\"~:x2\",1089.9999961853027,\"~:y2\",668]],\"~:flip-x\",null,\"^W\",108,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86ad794cf": "[\"~#shape\",[\"^ \",\"~:y\",560,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351424\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"90\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"bold\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"bold\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"text-all\",\"~:width\",99.99999618530273,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",610,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",709.9999961853027,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",709.9999961853027,\"~:y\",668]],[\"^R\",[\"^ \",\"~:x\",610,\"~:y\",668]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86ad794cf\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",675.9400024414062,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90px\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^P\",99.58001708984375,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",610,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",123.8800048828125,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",12],[\"^ \",\"^Z\",\"^[\",\"^10\",\"#ffdd88\",\"^11\",0.4,\"^12\",\"~:center\",\"^14\",12],[\"^ \",\"^Z\",\"^[\",\"^10\",\"#88ccff\",\"^11\",0.4,\"^12\",\"~:outer\",\"^14\",12]],\"~:x\",610,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",610,\"~:y\",560,\"^P\",99.99999618530273,\"^W\",108,\"~:x1\",610,\"~:y1\",560,\"~:x2\",709.9999961853027,\"~:y2\",668]],\"~:flip-x\",null,\"^W\",108,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e860c302cd": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351425\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"rect-all\",\"~:width\",120.00000476837158,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",610,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",730.0000047683716,\"~:y\",60]],[\"^=\",[\"^ \",\"~:x\",730.0000047683716,\"~:y\",180.00000476837158]],[\"^=\",[\"^ \",\"~:x\",610,\"~:y\",180.00000476837158]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"^2\",\"~u844a4204-eb8d-80d3-8008-55e860c302cd\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",12],[\"^ \",\"^F\",\"^G\",\"^H\",\"#ffdd88\",\"^I\",0.4,\"^J\",\"~:center\",\"^L\",12],[\"^ \",\"^F\",\"^G\",\"^H\",\"#88ccff\",\"^I\",0.4,\"^J\",\"~:outer\",\"^L\",12]],\"~:x\",610,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",610,\"~:y\",60,\"^:\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",610,\"~:y1\",60,\"~:x2\",730.0000047683716,\"~:y2\",180.00000476837158]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^S\",120.00000476837158,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86a816bac": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351426\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAACBCAACWQwIAAAAAAAAAAAAAAAAAAAAAAAAAAAAgQwAA0kM=\"],\"~:name\",\"path-inner\",\"~:width\",null,\"^3\",\"~:path\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",160.00000476837158,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",160.00000476837158,\"~:y\",420.0000047683716]],[\"^?\",[\"^ \",\"~:x\",40,\"~:y\",420.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86a816bac\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",12]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40,\"~:y\",300,\"^<\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",40,\"~:y1\",300,\"~:x2\",160.00000476837158,\"~:y2\",420.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^O\",null,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86aa09dec": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351427\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAAEhEAACWQwIAAAAAAAAAAAAAAAAAAAAAAAAAAABmRAAA0kM=\"],\"~:name\",\"path-center-nofill\",\"~:width\",null,\"^3\",\"~:path\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",920.0000047683716,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",920.0000047683716,\"~:y\",420.0000047683716]],[\"^?\",[\"^ \",\"~:x\",800,\"~:y\",420.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86aa09dec\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",12]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800,\"~:y\",300,\"^<\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",800,\"~:y1\",300,\"~:x2\",920.0000047683716,\"~:y2\",420.0000047683716]],\"~:fills\",[],\"~:flip-x\",null,\"^O\",null,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86a99dc08": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351428\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAgBhEAACWQwIAAAAAAAAAAAAAAAAAAAAAAAAAAIA2RAAA0kM=\"],\"~:name\",\"path-all\",\"~:width\",null,\"^3\",\"~:path\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",610,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",730.0000047683716,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",730.0000047683716,\"~:y\",420.0000047683716]],[\"^?\",[\"^ \",\"~:x\",610,\"~:y\",420.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86a99dc08\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",12],[\"^ \",\"^E\",\"^F\",\"^G\",\"#ffdd88\",\"^H\",0.4,\"^I\",\"~:center\",\"^K\",12],[\"^ \",\"^E\",\"^F\",\"^G\",\"#88ccff\",\"^H\",0.4,\"^I\",\"~:outer\",\"^K\",12]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",610,\"~:y\",300,\"^<\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",610,\"~:y1\",300,\"~:x2\",730.0000047683716,\"~:y2\",420.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^Q\",null,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86a92f143": "[\"~#shape\",[\"^ \",\"~:y\",null,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e888351429\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:content\",[\"~#penpot/path-data\",\"~bAQAAAAAAAAAAAAAAAAAAAAAAAAAAANJDAACWQwIAAAAAAAAAAAAAAAAAAAAAAAAAAAAHRAAA0kM=\"],\"~:name\",\"path-outer\",\"~:width\",null,\"^3\",\"~:path\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",540.0000047683716,\"~:y\",300]],[\"^?\",[\"^ \",\"~:x\",540.0000047683716,\"~:y\",420.0000047683716]],[\"^?\",[\"^ \",\"~:x\",420,\"~:y\",420.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86a92f143\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:outer\",\"~:stroke-width\",12]],\"~:x\",null,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420,\"~:y\",300,\"^<\",120.00000476837158,\"~:height\",120.00000476837158,\"~:x1\",420,\"~:y1\",300,\"~:x2\",540.0000047683716,\"~:y2\",420.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:flip-x\",null,\"^O\",null,\"~:flip-y\",null]]", + "~u844a4204-eb8d-80d3-8008-55e86aafcb00": "[\"~#shape\",[\"^ \",\"~:y\",560,\"~:background-blur\",[\"^ \",\"~:id\",\"~u814272d9-d3f8-812d-8008-55e88835142a\",\"~:type\",\"^1\",\"~:value\",16,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"90\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"bold\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.3]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"bold\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"text-inner\",\"~:width\",100.00000762939453,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",140.00000762939453,\"~:y\",560]],[\"^R\",[\"^ \",\"~:x\",140.00000762939453,\"~:y\",668]],[\"^R\",[\"^ \",\"~:x\",40,\"~:y\",668]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u844a4204-eb8d-80d3-8008-55e86aafcb00\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",675.9400024414062,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"90px\",\"^D\",\"700\",\"^E\",null,\"^F\",\"ltr\",\"^P\",99.58000183105469,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",40,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.3]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",123.8800048828125,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-style\",\"~:solid\",\"~:stroke-color\",\"#ffffff\",\"~:stroke-opacity\",0.4,\"~:stroke-alignment\",\"~:inner\",\"~:stroke-width\",12]],\"~:x\",40,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40,\"~:y\",560,\"^P\",100.00000762939453,\"^W\",108,\"~:x1\",40,\"~:y1\",560,\"~:x2\",140.00000762939453,\"~:y2\",668]],\"~:flip-x\",null,\"^W\",108,\"~:flip-y\",null]]" + } + }, + "~:id": "~u844a4204-eb8d-80d3-8008-55e83b4f39e0", + "~:name": "bg-blur-strokes" + } + }, + "~:id": "~u814272d9-d3f8-812d-8008-54c11cbba219", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/data/render-wasm/get-file-text-background-blur.json b/frontend/playwright/data/render-wasm/get-file-text-background-blur.json new file mode 100644 index 0000000000..fd18aaa594 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-text-background-blur.json @@ -0,0 +1,146 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "tokens/numeric-input", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "New File 3", + "~:revn": 54, + "~:modified-at": "~m1784183383968", + "~:vern": 0, + "~:id": "~u814272d9-d3f8-812d-8008-54c11cbba219", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node", + "0017-fix-layout-flex-dir", + "0018-remove-unneeded-objects-from-components", + "0019-fix-missing-swap-slots", + "0020-sync-component-id-with-near-main", + "0021-fix-shape-svg-attrs", + "0022-normalize-component-root-and-resync", + "0023-repair-token-themes-with-inexistent-sets", + "0024b-fix-stroke-cap-placement" + ] + }, + "~:version": 67, + "~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0", + "~:created-at": "~m1784121921262", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u814272d9-d3f8-812d-8008-54c11cbba21a" + ], + "~:pages-index": { + "~u814272d9-d3f8-812d-8008-54c11cbba21a": { + "~:objects": { + "~#penpot/objects-map/v2": { + "~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u9f14b915-410b-8062-8008-55a1fbbd5fa4\",\"~u9f14b915-410b-8062-8008-559878019a9e\"]]]", + "~u9f14b915-410b-8062-8008-559878019a9e": "[\"~#shape\",[\"^ \",\"~:y\",-1863.999482267452,\"~:background-blur\",[\"^ \",\"~:id\",\"~u9f14b915-410b-8062-8008-5598c30393d7\",\"~:type\",\"^1\",\"~:value\",8,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"^3\",\"root\",\"~:key\",\"22sle9m74ka\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^=\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^=\",[[\"^ \",\"^?\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"sourcesanspro\",\"^<\",\"1ixh2em79as\",\"~:font-size\",\"200\",\"~:font-weight\",\"900\",\"~:font-variant-id\",\"black\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.12]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"blur\"]],\"^@\",\"none\",\"~:text-align\",\"left\",\"^A\",\"sourcesanspro\",\"^<\",\"1x2xz4p3kcs\",\"^B\",\"200\",\"^C\",\"900\",\"~:text-direction\",\"ltr\",\"^3\",\"paragraph\",\"^D\",\"black\",\"^E\",\"none\",\"^F\",\"0\",\"^G\",[[\"^ \",\"^H\",\"#ffffff\",\"^I\",0.12]],\"^J\",\"sourcesanspro\"]]]],\"~:vertical-align\",\"top\"],\"~:hide-in-viewer\",false,\"~:name\",\"blur\",\"~:width\",375.0000213699236,\"^3\",\"^K\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",95.49998073772147,\"~:y\",-1863.999482267452]],[\"^S\",[\"^ \",\"~:x\",470.50000210764506,\"~:y\",-1863.999482267452]],[\"^S\",[\"^ \",\"~:x\",470.50000210764506,\"~:y\",-1623.9994721448552]],[\"^S\",[\"^ \",\"~:x\",95.49998073772147,\"~:y\",-1623.9994721448552]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"^2\",\"~u9f14b915-410b-8062-8008-559878019a9e\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:position-data\",[[\"^ \",\"~:y\",-1602.939453125,\"^>\",\"1.2\",\"^?\",\"normal\",\"~:typography-ref-id\",null,\"^@\",\"none\",\"^L\",\"left\",\"^A\",\"sourcesanspro\",\"^B\",\"200px\",\"^C\",\"900\",\"~:typography-ref-file\",null,\"^M\",\"ltr\",\"^Q\",374.6099853515625,\"^D\",\"regular\",\"^E\",\"none\",\"^F\",\"0px\",\"~:x\",95.4999771118164,\"^G\",[[\"^ \",\"^H\",\"#ffffff\",\"^I\",0.12]],\"~:direction\",\"ltr\",\"^J\",\"sourcesanspro\",\"~:height\",282.1201171875,\"^K\",\"blur\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",95.49998073772146,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",95.49998073772146,\"~:y\",-1863.999482267452,\"^Q\",375.0000213699236,\"^Z\",240.0000101225969,\"~:x1\",95.49998073772146,\"~:y1\",-1863.999482267452,\"~:x2\",470.50000210764506,\"~:y2\",-1623.9994721448552]],\"~:flip-x\",null,\"^Z\",240.0000101225969,\"~:flip-y\",null]]", + "~u9f14b915-410b-8062-8008-55a1fbbd5fa4": "[\"~#shape\",[\"^ \",\"~:y\",-2043.9994670845845,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"background_1\",\"~:width\",599.9999796086561,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",-16.999998381644758,\"~:y\",-2043.9994670845845]],[\"^9\",[\"^ \",\"~:x\",582.9999812270113,\"~:y\",-2043.9994670845845]],[\"^9\",[\"^ \",\"~:x\",582.9999812270113,\"~:y\",-1443.9994873277228]],[\"^9\",[\"^ \",\"~:x\",-16.999998381644758,\"~:y\",-1443.9994873277228]]],\"~:r2\",0,\"~:proportion-lock\",true,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u9f14b915-410b-8062-8008-55a1fbbd5fa4\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",-16.999998381644787,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",-16.999998381644787,\"~:y\",-2043.9994670845845,\"^5\",599.9999796086561,\"~:height\",599.9999797568616,\"~:x1\",-16.999998381644787,\"~:y1\",-2043.9994670845845,\"~:x2\",582.9999812270113,\"~:y2\",-1443.9994873277228]],\"~:fills\",[[\"^ \",\"~:fill-opacity\",1,\"~:fill-image\",[\"^ \",\"^5\",1181,\"^G\",1181,\"~:mtype\",\"image/png\",\"^?\",\"~u814272d9-d3f8-812d-8008-55a1fb78211b\",\"~:keep-aspect-ratio\",true]]],\"~:flip-x\",null,\"^G\",599.9999797568616,\"~:flip-y\",null]]" + } + }, + "~:id": "~u814272d9-d3f8-812d-8008-54c11cbba21a", + "~:name": "Page 1" + } + }, + "~:id": "~u814272d9-d3f8-812d-8008-54c11cbba219", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/data/text-editor/get-file-10502-mixed-families.json b/frontend/playwright/data/text-editor/get-file-10502-mixed-families.json new file mode 100644 index 0000000000..05a0b07fbe --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-10502-mixed-families.json @@ -0,0 +1,372 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type", + "text-editor-wasm/v1" + ] + }, + "~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "simple-text", + "~:revn": 7, + "~:modified-at": "~m1749629891313", + "~:vern": 0, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content", + "0004-clean-shadow-and-colors", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-opacity" + ] + }, + "~:version": 67, + "~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07", + "~:created-at": "~m1749629823499", + "~:data": { + "~:pages": [ + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73" + ], + "~:pages-index": { + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u7274a6af-66db-8009-8006-52c837bed25d" + ] + } + }, + "~u7274a6af-66db-8009-8006-52c837bed25d": { + "~#shape": { + "~:y": 368.000005463652, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "13hr3ftth2o", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "1qm8gi1rphc", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Hello " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "gfont-sora", + "~:key": "2qm8gi1rphd", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "Sora", + "~:text": "World" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "r8gahivbg7", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "this is a text", + "~:width": 237.0000390021974, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 426.0000039162686 + } + }, + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 426.0000039162686 + } + } + ], + "~:layout-item-h-sizing": "~:fix", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:id": "~u7274a6af-66db-8009-8006-52c837bed25d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 414.9999714372274, + "~:selrect": { + "~#rect": { + "~:x": 414.9999714372274, + "~:y": 368.000005463652, + "~:width": 237.0000390021974, + "~:height": 57.99999845261664, + "~:x1": 414.9999714372274, + "~:y1": 368.000005463652, + "~:x2": 652.0000104394248, + "~:y2": 426.0000039162686 + } + }, + "~:flip-x": null, + "~:height": 57.99999845261664, + "~:flip-y": null + } + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c73", + "~:name": "Page 1" + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/text-editor/get-file-10502-mixed-variants.json b/frontend/playwright/data/text-editor/get-file-10502-mixed-variants.json new file mode 100644 index 0000000000..d26099ef68 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-10502-mixed-variants.json @@ -0,0 +1,372 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type", + "text-editor-wasm/v1" + ] + }, + "~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "simple-text", + "~:revn": 7, + "~:modified-at": "~m1749629891313", + "~:vern": 0, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content", + "0004-clean-shadow-and-colors", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-opacity" + ] + }, + "~:version": 67, + "~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07", + "~:created-at": "~m1749629823499", + "~:data": { + "~:pages": [ + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73" + ], + "~:pages-index": { + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u7274a6af-66db-8009-8006-52c837bed25d" + ] + } + }, + "~u7274a6af-66db-8009-8006-52c837bed25d": { + "~#shape": { + "~:y": 368.000005463652, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "13hr3ftth2o", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "1qm8gi1rphc", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Hello " + }, + { + "~:line-height": "1.2", + "~:font-style": "italic", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "2qm8gi1rphd", + "~:font-size": "48", + "~:font-weight": "600", + "~:typography-ref-file": null, + "~:font-variant-id": "600italic", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "World" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "r8gahivbg7", + "~:font-size": "48", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "this is a text", + "~:width": 237.0000390021974, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 426.0000039162686 + } + }, + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 426.0000039162686 + } + } + ], + "~:layout-item-h-sizing": "~:fix", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:id": "~u7274a6af-66db-8009-8006-52c837bed25d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 414.9999714372274, + "~:selrect": { + "~#rect": { + "~:x": 414.9999714372274, + "~:y": 368.000005463652, + "~:width": 237.0000390021974, + "~:height": 57.99999845261664, + "~:x1": 414.9999714372274, + "~:y1": 368.000005463652, + "~:x2": 652.0000104394248, + "~:y2": 426.0000039162686 + } + }, + "~:flip-x": null, + "~:height": 57.99999845261664, + "~:flip-y": null + } + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c73", + "~:name": "Page 1" + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/pages/ShortcutsPage.js b/frontend/playwright/ui/pages/ShortcutsPage.js new file mode 100644 index 0000000000..578311570e --- /dev/null +++ b/frontend/playwright/ui/pages/ShortcutsPage.js @@ -0,0 +1,335 @@ +import { expect } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { BaseWebSocketPage } from "./BaseWebSocketPage"; + +function decodeKeyword(value) { + if (typeof value === "string" && value.startsWith("~:")) { + return value.slice(2); + } + if (typeof value === "string" && value.startsWith("~$")) { + return value.slice(2); + } + return value; +} + +function decodeTransit(data) { + if (Array.isArray(data)) { + if (data[0] === "^ ") { + const result = {}; + for (let i = 1; i < data.length; i += 2) { + const key = decodeTransit(data[i]); + const value = decodeTransit(data[i + 1]); + result[key] = value; + } + return result; + } + return data.map(decodeTransit); + } + + if (data !== null && typeof data === "object") { + const result = {}; + for (const [key, value] of Object.entries(data)) { + result[decodeKeyword(key)] = decodeTransit(value); + } + return result; + } + + return decodeKeyword(data); +} + +function encodeKeyword(key) { + return `~:${key}`; +} + +function encodeTransit(data) { + if (data === null || typeof data === "boolean" || typeof data === "number") { + return data; + } + + if (typeof data === "string") { + return data; + } + + if (Array.isArray(data)) { + return data.map(encodeTransit); + } + + const result = {}; + for (const [key, value] of Object.entries(data)) { + result[encodeKeyword(key)] = encodeTransit(value); + } + return result; +} + +function decodeRequestBody(body) { + try { + return decodeTransit(JSON.parse(body)); + } catch { + return null; + } +} + +export class ShortcutsPage extends BaseWebSocketPage { + static async init(page) { + await super.init(page); + + const profileText = await readFile( + "playwright/data/logged-in-user/get-profile-logged-in.json", + "utf-8", + ); + const baseProfile = decodeTransit(JSON.parse(profileText)); + let customShortcuts = null; + + await page.route("**/api/main/methods/get-profile", (route) => { + const profile = JSON.parse(JSON.stringify(baseProfile)); + if (customShortcuts) { + profile.props["custom-shortcuts"] = customShortcuts; + } + route.fulfill({ + status: 200, + contentType: "application/transit+json", + body: JSON.stringify(encodeTransit(profile)), + }); + }); + + await page.route( + "**/api/main/methods/update-profile-props", + async (route, request) => { + const decoded = decodeRequestBody(request.postData() ?? "{}"); + if (decoded?.props && "custom-shortcuts" in decoded.props) { + customShortcuts = decoded.props["custom-shortcuts"]; + } + route.fulfill({ + status: 200, + contentType: "application/transit+json", + body: "{}", + }); + }, + ); + + await super.mockRPC( + page, + "get-teams", + "logged-in-user/get-teams-default.json", + ); + } + + static async initWithShortcuts(page) { + await super.init(page); + + await super.mockRPCs(page, { + "get-profile": "logged-in-user/get-profile-logged-in.json", + "get-teams": "logged-in-user/get-teams-default.json", + "update-profile-props": + "logged-in-user/update-profile-with-shortcuts.json", + }); + } + + static async initWithNoShortcuts(page) { + await super.init(page); + + await super.mockRPCs(page, { + "get-profile": "logged-in-user/get-profile-no-shortcuts.json", + "get-teams": "logged-in-user/get-teams-default.json", + "update-profile-props": + "logged-in-user/update-profile-with-shortcuts.json", + }); + } + + constructor(page) { + super(page); + + this.shortcutsSection = page.getByRole("region", { name: /shortcuts/i }); + this.searchInput = page.getByRole("textbox", { name: /shortcuts/i }); + + this.allTab = page.getByRole("tab", { name: "All" }); + this.personalizedTab = page.getByRole("tab", { name: "Personalized" }); + this.disabledTab = page.getByRole("tab", { name: "Disabled" }); + + this.restoreAllButton = page.getByRole("button", { + name: /restore all/i, + }); + + this.importExportButton = page.getByRole("button", { + name: /import\/export/i, + }); + + this.fileInput = page.locator('input[type="file"]'); + } + + async goToShortcuts() { + await this.page.goto("#/settings/shortcuts"); + await expect(this.shortcutsSection).toBeVisible(); + } + + async searchForShortcut(term) { + await this.searchInput.fill(term); + } + + async clearSearch() { + await this.searchInput.clear(); + } + + async clickTab(tabName) { + const tab = this.page.getByRole("tab", { name: tabName }); + await tab.click(); + } + + async getShortcutRow(shortcutName) { + return this.page.getByRole("listitem", { + name: new RegExp(`^${shortcutName}$`, "i"), + includeHidden: true, + }); + } + + async expandSubsection(subsectionName) { + const subsectionButton = this.page.getByRole("button", { + name: subsectionName, + }); + await subsectionButton.click(); + } + + async clickEditShortcut(shortcutName) { + const button = this.page.getByRole("button", { + name: new RegExp(`Edit ${shortcutName}`, "i"), + }); + await button.click(); + const recordingArea = this.page.getByText("Press the key combination"); + await expect(recordingArea).toBeVisible(); + await recordingArea.focus(); + } + + async pressKey(key) { + await this.page.keyboard.press(key); + } + + async pressKeyCombo(keys) { + for (const key of keys) { + await this.page.keyboard.down(key); + } + for (const key of [...keys].reverse()) { + await this.page.keyboard.up(key); + } + } + + async saveShortcut() { + const saveButton = this.page.getByRole("button", { name: /save/i }); + await saveButton.click(); + } + + async cancelEdit() { + const cancelButton = this.page.getByRole("button", { name: /cancel/i }); + await cancelButton.click(); + } + + async resetShortcut(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + const resetButton = row.getByRole("button", { name: /reset/i }); + await resetButton.click(); + } + + async disableShortcut(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + await row + .getByRole("button", { name: new RegExp(`Edit ${shortcutName}`, "i") }) + .click(); + const disableButton = this.page.getByRole("button", { name: /disable/i }); + await disableButton.click(); + } + + async expectShortcutCustomized(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + await expect(row).toHaveAttribute("data-customized", "true"); + } + + async expectShortcutNotCustomized(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + await expect(row).not.toHaveAttribute("data-customized", "true"); + } + + async expectShortcutHasConflict(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + await expect(row).toHaveAttribute("data-conflict", "true"); + } + + async expectShortcutVisible(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + await expect(row).toBeVisible(); + } + + async expectShortcutHidden(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + await expect(row).not.toBeVisible(); + } + + async restoreAllShortcuts() { + await this.restoreAllButton.click(); + const confirmButton = this.page.getByRole("button", { + name: "Restore", + exact: true, + }); + await confirmButton.click(); + } + + async exportShortcuts() { + await this.importExportButton.click(); + const exportButton = this.page.getByRole("menuitem", { name: /export/i }); + await exportButton.click(); + } + + async importShortcuts(jsonData) { + await this.importExportButton.click(); + const importButton = this.page.getByRole("menuitem", { name: /import$/i }); + await importButton.click(); + + const buffer = Buffer.from(JSON.stringify(jsonData), "utf-8"); + await this.fileInput.setInputFiles({ + name: "shortcuts.json", + mimeType: "application/json", + buffer, + }); + } + + async importShortcutsRaw(content) { + await this.importExportButton.click(); + const importButton = this.page.getByRole("menuitem", { name: /import$/i }); + await importButton.click(); + + const data = + typeof content === "string" ? content : JSON.stringify(content); + const buffer = Buffer.from(data, "utf-8"); + await this.fileInput.setInputFiles({ + name: "shortcuts.json", + mimeType: "application/json", + buffer, + }); + } + + async confirmImportApply() { + const applyButton = this.page.getByRole("button", { + name: /apply/i, + }); + await applyButton.click(); + } + + async getExportedJson() { + const [download] = await Promise.all([ + this.page.waitForEvent("download"), + this.exportShortcuts(), + ]); + + expect(download.suggestedFilename()).toBe("penpot-shortcuts.json"); + + const path = await download.path(); + const content = await readFile(path, "utf-8"); + return JSON.parse(content); + } + + async expectShortcutDisabled(shortcutName) { + const row = await this.getShortcutRow(shortcutName); + await expect(row).toHaveAttribute("data-customized", "true"); + await expect(row.locator("use[href*='broken-link']")).toBeVisible(); + } +} + +export default ShortcutsPage; diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index 891b2a4e31..f6b6a5a11a 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -378,6 +378,29 @@ export class WorkspacePage extends BaseWebSocketPage { } } + /** + * Creates a new auto-width Text Shape by single-clicking at the given + * coordinates (as opposed to dragging a fixed-size box) and, optionally, + * types an initial text. + * + * @param {number} x + * @param {number} y + * @param {string} [initialText] + * @param {*} [options] + */ + async createAutoWidthTextShape(x, y, initialText, options) { + const timeToWait = options?.timeToWait ?? 100; + await this.page.keyboard.press("T"); + await this.page.waitForTimeout(timeToWait); + + await this.clickAt(x, y); + + if (initialText) { + await this.waitForSelectedShapeName("Text"); + await this.page.keyboard.type(initialText); + } + } + /** * Copies the selected element into the clipboard, or copy the * content of the locator into the clipboard. @@ -448,6 +471,33 @@ export class WorkspacePage extends BaseWebSocketPage { await pagesToggle.click(); } + async selectToolbarTool(workspacePage, toolName) { + await workspacePage.page + .getByRole("button", { name: toolName }) + .first() + .click(); + } + + async selectToolFromFlyout( + workspacePage, + { triggerToolName, targetToolName }, + ) { + const trigger = workspacePage.page + .getByRole("button", { name: triggerToolName }) + .first(); + + const option = workspacePage.page + .getByRole("menuitemradio", { name: targetToolName }) + .first(); + + await trigger.hover(); + // Flyout opening is delayed by 350ms in the toolbar component. + await workspacePage.page.waitForTimeout(450); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await option.waitFor({ state: "visible" }); + await option.click(); + } + async moveSelectionToShape(name) { await this.page.locator("rect.viewport-selrect").hover(); await this.page.mouse.down(); diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 33304fe5f0..74b83d1940 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -578,5 +578,28 @@ test("Renders background blur on shapes overlapping other shapes", async ({ }); await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot(); +}); + +test("Renders background blur under strokes on rects, paths and texts", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockFileMediaAsset( + "814272d9-d3f8-812d-8008-55a1fb78211b", + "render-wasm/assets/squares-background.png", + ); + await workspace.mockGetFile( + "render-wasm/get-file-background-blur-strokes.json", + ); + + await workspace.goToWorkspace({ + id: "814272d9-d3f8-812d-8008-54c11cbba219", + pageId: "844a4204-eb8d-80d3-8008-55e83b4f39e0", + pageName: "bg-blur-strokes", + }); + await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot(); }); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-under-strokes-on-rects-paths-and-texts-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-under-strokes-on-rects-paths-and-texts-1.png new file mode 100644 index 0000000000..0d2bb88bef Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-under-strokes-on-rects-paths-and-texts-1.png differ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index b474c7ca05..f660356d91 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -609,4 +609,22 @@ test("Renders a file with group with strokes and not 100% opacities", async ({ maxDiffPixelRatio: 0, threshold: 0.01, }); -}); \ No newline at end of file +}); + +test("Renders background blur on text shapes", async ({ page }) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockFileMediaAsset( + "814272d9-d3f8-812d-8008-55a1fb78211b", + "render-wasm/assets/squares-background.png", + ); + await workspace.mockGetFile("render-wasm/get-file-text-background-blur.json"); + + await workspace.goToWorkspace({ + id: "814272d9-d3f8-812d-8008-54c11cbba219", + pageId: "814272d9-d3f8-812d-8008-54c11cbba21a", + }); + + await workspace.waitForFirstRenderWithoutUI(); + await expect(workspace.canvas).toHaveScreenshot(); +}); diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-background-blur-on-text-shapes-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-background-blur-on-text-shapes-1.png new file mode 100644 index 0000000000..4988e566e7 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-background-blur-on-text-shapes-1.png differ diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index cc772aa3a3..ee9dbcd770 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -24,6 +24,35 @@ test("Bug 7549 - User clicks on color swatch to display the color picker next to expect(distance).toBeLessThan(60); }); +test("Bug 10756 - Image fill picker accepts SVG files", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-not-empty.json", + ); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374", + pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375", + }); + await workspacePage.clickLeafLayer("Rectangle"); + await workspacePage.page + .getByRole("button", { name: "#B1B2B5" }) + .click(); + await workspacePage.page.getByText("Solid").click(); + await workspacePage.page.getByText("Image").click(); + + await expect(workspacePage.page.locator("#fill-image-upload")).toHaveAttribute( + "accept", + /(?:^|,)image\/svg\+xml(?:,|$)/, + ); +}); + test("Create a LINEAR gradient", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); diff --git a/frontend/playwright/ui/specs/numeric-input.spec.js b/frontend/playwright/ui/specs/numeric-input.spec.js index 52a4726f91..49d3d1ca6b 100644 --- a/frontend/playwright/ui/specs/numeric-input.spec.js +++ b/frontend/playwright/ui/specs/numeric-input.spec.js @@ -120,7 +120,7 @@ test("BUG 10001: Negative margins are allowed on the numeric input", async ({ await expect(layoutSection).toBeVisible(); await workspacePage.layers.getByTestId("layer-row").nth(6).click(); - await page.waitForTimeout(500); + await page.waitForTimeout(500); const layoutItemSection = workspacePage.rightSidebar.getByRole("region", { name: "Layout item section", @@ -138,3 +138,95 @@ test("BUG 10001: Negative margins are allowed on the numeric input", async ({ await verticalMarginInput.press("Enter"); await expect(verticalMarginInput).toHaveValue("-10"); }); + +test("BUG 10638 - Invalid padding input on multi-selection must not persist strings", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC(/get\-file\?/, "design/get-file-9543.json"); + await workspacePage.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "design/get-file-fragment-10638.json", + ); + await workspacePage.mockRPC( + "update-file?id=*", + "design/update-file-9543.json", + ); + + // Inspect every persisted change: layout padding and gap values must be + // numbers (the backend rejects strings with a Malli validation error). + // In the transit payload, :set operations carry the attr name and its + // value as siblings: {"~:attr": "~:layout-padding", "~:val": {...}}. + const layoutAttrs = ["~:layout-padding", "~:layout-gap"]; + const seenLayoutValues = []; + const badLayoutValues = []; + const collectLayoutValues = (node) => { + if (!node || typeof node !== "object") { + return; + } + const attr = node["~:attr"]; + const val = node["~:val"]; + if (layoutAttrs.includes(attr) && val && typeof val === "object") { + for (const [prop, leaf] of Object.entries(val)) { + seenLayoutValues.push(`${attr} ${prop}`); + if (typeof leaf !== "number") { + badLayoutValues.push(`${attr} ${prop} = ${JSON.stringify(leaf)}`); + } + } + } + for (const value of Object.values(node)) { + collectLayoutValues(value); + } + }; + page.on("request", (request) => { + if (request.url().includes("/api/main/methods/update-file")) { + const body = request.postData(); + if (body) { + collectLayoutValues(JSON.parse(body)); + } + } + }); + + await workspacePage.goToWorkspace({ + fileId: "525a5d8b-028e-80e7-8005-aa6cad42f27d", + pageId: "525a5d8b-028e-80e7-8005-aa6cad42f27e", + }); + + await workspacePage.clickLeafLayer("Board"); + await workspacePage.clickLeafLayer("Second", { modifiers: ["Shift"] }); + + const toggle = workspacePage.page.getByRole("button", { + name: "Show 4 sided padding options", + }); + await toggle.click(); + + // Paddings differ between the boards (0 vs 33), so the expanded inputs + // have no committed value. Committing invalid text must persist nothing. + const topPaddingInput = workspacePage.page.getByRole("textbox", { + name: "Top padding", + }); + await topPaddingInput.click(); + await topPaddingInput.fill("abc"); + await topPaddingInput.press("Enter"); + + // A valid change afterwards guarantees at least one persisted update. + const leftPaddingInput = workspacePage.page.getByRole("textbox", { + name: "Left padding", + }); + const updateRequest = page.waitForRequest( + "**/api/main/methods/update-file?*", + { + timeout: 15000, + }, + ); + await leftPaddingInput.fill("5"); + await leftPaddingInput.press("Enter"); + await updateRequest; + + // Guard against a vacuous pass: the valid change above must have + // persisted at least one layout-padding value. + expect(seenLayoutValues).not.toEqual([]); + expect(badLayoutValues).toEqual([]); +}); diff --git a/frontend/playwright/ui/specs/profile-menu.spec.js b/frontend/playwright/ui/specs/profile-menu.spec.js index 71bdbb4199..3c12fbb151 100644 --- a/frontend/playwright/ui/specs/profile-menu.spec.js +++ b/frontend/playwright/ui/specs/profile-menu.spec.js @@ -28,6 +28,92 @@ test("Navigate to penpot changelog from profile menu", async ({ page }) => { ); }); +test("Submenu closes when hovering a menu option without submenu", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await page.getByText("About Penpot").hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); + + await dashboardPage.userProfileOption.hover(); + await expect(changelogSubmenuItem).toBeHidden(); +}); + +test("Submenu stays open while moving the pointer into it", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + const aboutPenpotItem = page.getByText("About Penpot"); + await aboutPenpotItem.hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); + + // Walk the pointer from the parent option into the submenu the way a + // real user does — gradually, crossing the gap between the two menus. + const from = await aboutPenpotItem.boundingBox(); + const to = await changelogSubmenuItem.boundingBox(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { + steps: 20, + }); + + await expect(changelogSubmenuItem).toBeVisible(); +}); + +test("Submenu closes when the pointer leaves the menu entirely", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await page.getByText("About Penpot").hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); + + await dashboardPage.mainHeading.hover(); + await expect(changelogSubmenuItem).toBeHidden(); +}); + +test("Hovering another expandable option switches submenus", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await page.getByText("Help & Learning").hover(); + + const helpCenterSubmenuItem = page.getByText("Help Center"); + await expect(helpCenterSubmenuItem).toBeVisible(); + + await page.getByText("About Penpot").hover(); + await expect(page.getByText("Penpot Changelog")).toBeVisible(); + await expect(helpCenterSubmenuItem).toBeHidden(); +}); + +test("Submenu opens with keyboard navigation", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await dashboardPage.goToDashboard(); + + await dashboardPage.openProfileMenu(); + await dashboardPage.sidebarMenu + .getByRole("menuitem", { name: "Help & Learning" }) + .press("Enter"); + + await expect(page.getByText("Help Center")).toBeVisible(); +}); + test("Opens release notes from current version from profile menu", async ({ page, }) => { diff --git a/frontend/playwright/ui/specs/shortcuts.spec.js b/frontend/playwright/ui/specs/shortcuts.spec.js new file mode 100644 index 0000000000..c0b487b883 --- /dev/null +++ b/frontend/playwright/ui/specs/shortcuts.spec.js @@ -0,0 +1,357 @@ +import { test, expect } from "@playwright/test"; +import ShortcutsPage from "../pages/ShortcutsPage"; + +const customShortcutsFlag = "enable-custom-shortcuts"; + +test.beforeEach(async ({ page }) => { + await ShortcutsPage.init(page); + await ShortcutsPage.mockConfigFlags(page, [customShortcutsFlag]); +}); + +test.describe("Shortcuts Settings Page", () => { + test("Shortcuts page loads correctly", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await expect(shortcutsPage.shortcutsSection).toBeVisible(); + await expect(shortcutsPage.searchInput).toBeVisible(); + }); + + test("Tabs are visible and clickable", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await expect(shortcutsPage.allTab).toBeVisible(); + await expect(shortcutsPage.personalizedTab).toBeVisible(); + await expect(shortcutsPage.disabledTab).toBeVisible(); + + await shortcutsPage.clickTab("Personalized"); + await expect(shortcutsPage.personalizedTab).toHaveAttribute( + "aria-selected", + "true", + ); + const personalizedPlacehonder = page.getByText(/Head to All to start/i); + await expect(personalizedPlacehonder).toBeVisible(); + + await shortcutsPage.clickTab("Disabled"); + await expect(shortcutsPage.disabledTab).toHaveAttribute( + "aria-selected", + "true", + ); + const disabledPlaceholder = page.getByText(/There are not disabled/i); + await expect(disabledPlaceholder).toBeVisible(); + + await shortcutsPage.clickTab("All"); + await expect(shortcutsPage.allTab).toHaveAttribute("aria-selected", "true"); + }); +}); + +test.describe("Shortcut Customization", () => { + test("User can edit a shortcut", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + // Expand subsection + await shortcutsPage.expandSubsection("Alignment"); + + // Start edition of the shortcut + await shortcutsPage.clickEditShortcut("Align bottom"); + + // Press a new key combination + await shortcutsPage.pressKey("Control+y"); + + // Save the shortcut + await shortcutsPage.saveShortcut(); + + // Verify the shortcut is now customized + await shortcutsPage.expectShortcutCustomized("Align bottom"); + }); +}); + +test.describe("Shortcut Import", () => { + test("Import valid custom shortcuts", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcuts({ + workspace: { "align-bottom": "ctrl+y" }, + }); + + await shortcutsPage.confirmImportApply(); + + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + await shortcutsPage.clickTab("Personalized"); + await shortcutsPage.searchForShortcut("Align bottom"); + await shortcutsPage.expectShortcutVisible("Align bottom"); + }); + + test("Import invalid JSON syntax", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcutsRaw("{invalid"); + + await expect( + page.getByRole("alert").filter({ hasText: /Invalid data/i }), + ).toBeVisible(); + }); + + test("Import valid JSON with invalid schema", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcuts({ + workspace: { "unknown-shortcut": "ctrl+y" }, + }); + + await expect( + page.getByRole("alert").filter({ hasText: /Invalid data/i }), + ).toBeVisible(); + }); + + test("Import JSON without workspace context accepted", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcuts({ + dashboard: { "toggle-theme": "alt+m" }, + }); + + await shortcutsPage.confirmImportApply(); + + await expect( + page.getByRole("alert").filter({ hasText: /Invalid data/i }), + ).not.toBeVisible(); + }); + + test("Import JSON with unsupported shortcut format", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcuts({ + workspace: { escape: 123 }, + }); + + await expect( + page.getByRole("alert").filter({ hasText: /Invalid data/i }), + ).toBeVisible(); + }); + + test("Import conflicting shortcuts", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcuts({ + workspace: { + "align-bottom": "alt+a", + }, + }); + + await shortcutsPage.confirmImportApply(); + + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + await shortcutsPage.expandSubsection("Alignment"); + await shortcutsPage.expectShortcutDisabled("Align left"); + + const exported = await shortcutsPage.getExportedJson(); + expect(exported.workspace).toMatchObject({ + "align-bottom": "alt+a", + "align-left": "", + }); + }); +}); + +test.describe("Shortcut Export", () => { + test("Export customized shortcuts", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.expandSubsection("Alignment"); + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.pressKey("Control+y"); + await shortcutsPage.saveShortcut(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + const exported = await shortcutsPage.getExportedJson(); + expect(exported).toHaveProperty("workspace"); + expect(exported.workspace).toMatchObject({ + "align-bottom": "ctrl+y", + }); + }); + + test("Export includes disabled shortcuts", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcuts({ + workspace: { "align-bottom": "" }, + }); + + await shortcutsPage.confirmImportApply(); + + await shortcutsPage.searchForShortcut("Align bottom"); + await shortcutsPage.expectShortcutDisabled("Align bottom"); + + const exported = await shortcutsPage.getExportedJson(); + expect(exported.workspace).toMatchObject({ + "align-bottom": "", + }); + }); +}); + +test.describe("Shortcut Conflict Detection", () => { + test("Detects conflict and disables old shortcut on save", async ({ + page, + }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.expandSubsection("Alignment"); + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.pressKey("Alt+a"); + + await expect( + page.getByRole("alert").filter({ hasText: /Combination assigned to/i }), + ).toBeVisible(); + + await shortcutsPage.saveShortcut(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + await shortcutsPage.expectShortcutDisabled("Align left"); + + const exported = await shortcutsPage.getExportedJson(); + expect(exported.workspace).toMatchObject({ + "align-bottom": "alt+a", + "align-left": "", + }); + }); +}); + +test.describe("Shortcut Reset", () => { + test("Reset after import restores defaults", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.importShortcuts({ + workspace: { "align-bottom": "ctrl+y" }, + }); + await shortcutsPage.confirmImportApply(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + await shortcutsPage.restoreAllShortcuts(); + await shortcutsPage.expectShortcutNotCustomized("Align bottom"); + }); + + test("Reset button shows pending message and requires save to apply", async ({ + page, + }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.expandSubsection("Alignment"); + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.pressKey("Control+y"); + await shortcutsPage.saveShortcut(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.resetShortcut("Align bottom"); + + await expect( + page.getByText(/If you save, this shortcut will return/i), + ).toBeVisible(); + + await shortcutsPage.saveShortcut(); + + await shortcutsPage.expectShortcutNotCustomized("Align bottom"); + }); +}); + +test.describe("Shortcut Persistence", () => { + test("Custom shortcuts persist after reload", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.expandSubsection("Alignment"); + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.pressKey("Control+y"); + await shortcutsPage.saveShortcut(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + await page.reload(); + await shortcutsPage.goToShortcuts(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + }); +}); + +test.describe("Cancel Shortcut Editing", () => { + test("Cancel editing does not save changes", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.expandSubsection("Alignment"); + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.pressKey("Control+y"); + await shortcutsPage.cancelEdit(); + + await shortcutsPage.expectShortcutNotCustomized("Align bottom"); + }); +}); + +test.describe("Duplicate Shortcut Prevention", () => { + test("Assigning same shortcut to two actions shows conflict and disables first", async ({ + page, + }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.expandSubsection("Alignment"); + + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.pressKey("Control+y"); + await shortcutsPage.saveShortcut(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + await shortcutsPage.clickEditShortcut("Align left"); + await shortcutsPage.pressKey("Control+y"); + + await expect( + page.getByRole("alert").filter({ hasText: /Combination assigned to/i }), + ).toBeVisible(); + + await shortcutsPage.saveShortcut(); + await shortcutsPage.expectShortcutCustomized("Align left"); + + const exported = await shortcutsPage.getExportedJson(); + expect(exported.workspace).toMatchObject({ + "align-left": "ctrl+y", + "align-bottom": "", + }); + }); +}); + +test.describe("Shortcut Round-Trip", () => { + test("Export, reset, import restores configuration", async ({ page }) => { + const shortcutsPage = new ShortcutsPage(page); + await shortcutsPage.goToShortcuts(); + + await shortcutsPage.expandSubsection("Alignment"); + await shortcutsPage.clickEditShortcut("Align bottom"); + await shortcutsPage.pressKey("Control+y"); + await shortcutsPage.saveShortcut(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + const exported = await shortcutsPage.getExportedJson(); + + await shortcutsPage.restoreAllShortcuts(); + await shortcutsPage.expectShortcutNotCustomized("Align bottom"); + + await shortcutsPage.importShortcuts(exported); + await shortcutsPage.confirmImportApply(); + await shortcutsPage.expectShortcutCustomized("Align bottom"); + + const reExported = await shortcutsPage.getExportedJson(); + expect(reExported).toEqual(exported); + }); +}); diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js new file mode 100644 index 0000000000..53b439ab19 --- /dev/null +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -0,0 +1,155 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; + +const FILE = { + id: "3b0d758a-8c9d-8013-8006-52c8337e5c72", + pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73", +}; + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); + // WASM_FLAGS already enables render-wasm; add the WASM text editor on top. + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]); +}); + +async function openEditorAndSelectAll(workspace) { + await workspace.clickLeafLayer("this is a text"); + // Enter edit mode (waits until the typography controls are ready) and then + // select every character so the sidebar reflects the combined styles of the + // whole text via the WASM editor path. + await workspace.textEditor.startEditing(); + await workspace.page.keyboard.press("ControlOrMeta+a"); +} + +test.describe("BUG 10502 - Mixed families and variants", () => { + test("Multiple variants of the same font family", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-10502-mixed-variants.json"); + + await workspace.goToWorkspace(FILE); + await workspace.waitForFirstRender(); + + await openEditorAndSelectAll(workspace); + + // The whole selection shares a single font family, so it must be shown even + // though the variants differ. + const fontFamily = workspace.rightSidebar.getByTitle("Font Family"); + await expect(fontFamily).toContainText("Source Sans Pro"); + + // The variants differ across the selection, so the variant dropdown shows the + // "mixed" placeholder. + const fontVariant = workspace.rightSidebar + .getByTitle("Font Style") + .getByRole("combobox"); + await expect(fontVariant).toHaveText("--"); + }); + + test("Mixed font families appear as such in the dropdown", async ({ page }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-10502-mixed-families.json"); + // Serve a stand-in TTF for Sora so the render doesn't wait on a real fetch. + // Glyphs are irrelevant here: the assertion only inspects the sidebar. + await workspace.mockGoogleFont("sora", "render-wasm/assets/ebgaramond.ttf"); + + await workspace.goToWorkspace(FILE); + await workspace.waitForFirstRender(); + + await openEditorAndSelectAll(workspace); + + // The selection mixes two different font families (Source Sans Pro and Sora), + // so the font family dropdown reports it as mixed. + const fontFamily = workspace.rightSidebar.getByTitle("Font Family"); + await expect(fontFamily).toContainText("Mixed Font Families"); + }); +}); + +test.describe("BUG 10530 - Empty text box left behind when leaving the editor", () => { + test("An empty text box is removed when leaving the editor by clicking outside", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const layerRows = workspace.layers.getByTestId("layer-row"); + await expect(layerRows).toHaveCount(0); + + // Draw an empty text box + await workspace.createTextShape(200, 150, 320, 210); + // The shape exists while it is being edited + await expect(layerRows).toHaveCount(1); + + // Leave the editor by clicking outside + await workspace.clickAt(500, 400); + + await expect(layerRows).toHaveCount(0); + }); + + test("A non-empty text box is kept when leaving the editor by clicking outside", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const layerRows = workspace.layers.getByTestId("layer-row"); + + // A text box with content must survive leaving the editor by clicking outside. + await workspace.createTextShape(200, 150, 320, 210, "hello"); + await workspace.clickAt(500, 400); + + await expect(layerRows).toHaveCount(1); + }); +}); + +test("BUG 10467 - Auto-width text captures every typed character", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const layerRows = workspace.layers.getByTestId("layer-row"); + + // A single click with the text tool creates an auto-width text box by default + await workspace.createAutoWidthTextShape(200, 150, "hello world"); + + // Leave the editor to finalize the content + await workspace.textEditor.stopEditing(); + + // Assert the whole typed text made it into the shape + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("hello world"); +}); + +test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + // Select the existing text shape and enter edit mode via Enter + await workspace.clickLeafLayer("Lorem ipsum"); + await workspace.textEditor.startEditing(); + + // Copying while editing exports only the selected text as raw text. + // Since we just entered the editor, the whole text should be selected. + await workspace.copy("keyboard"); + + // Assert the text was copied correctly + const copiedText = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(copiedText).toBe("Lorem ipsum"); +}); + diff --git a/frontend/playwright/ui/specs/toolbar.spec.js b/frontend/playwright/ui/specs/toolbar.spec.js new file mode 100644 index 0000000000..2bd9cef5c4 --- /dev/null +++ b/frontend/playwright/ui/specs/toolbar.spec.js @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; +import WorkspacePage from "../pages/WorkspacePage"; + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); +}); + +const expectLayerNamed = async (workspacePage, name) => { + await expect(workspacePage.layers.getByText(name).last()).toBeVisible(); +}; + +test("User creates a frame with the toolbar frame tool", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Board (B)"); + await workspacePage.clickWithDragViewportAt(100, 100, 180, 120); + await expectLayerNamed(workspacePage, "Board"); +}); + +test("User creates a rectangle with the toolbar rect tool", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Rectangle (R)"); + await workspacePage.clickWithDragViewportAt(350, 100, 120, 80); + await expectLayerNamed(workspacePage, "Rectangle"); +}); + +test("User creates an ellipse from the shapes flyout", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); + await workspacePage.clickWithDragViewportAt(520, 100, 100, 100); + await expectLayerNamed(workspacePage, "Ellipse"); +}); + +test("User creates a text shape with the toolbar text tool", async ({ + page, +}) => { + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Text (T)"); + await workspacePage.clickAndMove(120, 320, 300, 380); + await workspacePage.waitForSelectedShapeName("Text"); + await workspacePage.page.keyboard.type("toolbar test"); + await workspacePage.page.keyboard.press("Escape"); + await expectLayerNamed(workspacePage, "Text"); +}); + +test.skip("User creates a path with the toolbar path tool", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolbarTool(workspacePage, "Path (P)"); + await workspacePage.clickAndMove(120, 320, 300, 380); + await workspacePage.page.keyboard.press("Enter"); + await expectLayerNamed(workspacePage, "Path"); +}); + +test("User creates a curve from the path flyout", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.goToWorkspace(); + + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Path (P)", + targetToolName: "Curve (Shift+C)", + }); + await workspacePage.clickAndMove(120, 320, 300, 380); + await workspacePage.page.keyboard.press("Enter"); + await expectLayerNamed(workspacePage, "Path"); +}); diff --git a/frontend/playwright/ui/specs/variants.spec.js b/frontend/playwright/ui/specs/variants.spec.js index 0c50f7dc8b..d9dbb687e5 100644 --- a/frontend/playwright/ui/specs/variants.spec.js +++ b/frontend/playwright/ui/specs/variants.spec.js @@ -343,7 +343,9 @@ test("User drag and drop a variant outside the container", async ({ page }) => { // and use it to calculate the target position await workspacePage.clickWithDragViewportAt(600, 500, 0, 300); - await expect(workspacePage.layers.getByText("Rectangle / Value 1")).toBeVisible(); + await expect( + workspacePage.layers.getByText("Rectangle / Value 1"), + ).toBeVisible(); }); test("User cut paste a component inside a variant", async ({ page }) => { @@ -353,7 +355,10 @@ test("User cut paste a component inside a variant", async ({ page }) => { const variant = await findVariant(workspacePage, 0); //Create a component - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); @@ -384,7 +389,10 @@ test("User cut paste a component with path inside a variant", async ({ const variant = await findVariant(workspacePage, 0); // Create a component - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); @@ -425,7 +433,10 @@ test("User drag and drop a component with path inside a variant", async ({ const variant = findVariantNoWait(workspacePage, 0); //Create a component - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); @@ -457,7 +468,10 @@ test("User cut paste a variant into another container", async ({ page }) => { await setupVariantsFileWithVariant(workspacePage); // Create anothe variant - await workspacePage.ellipseShapeButton.click(); + await workspacePage.selectToolFromFlyout(workspacePage, { + triggerToolName: "Rectangle (R)", + targetToolName: "Ellipse (E)", + }); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 95fb9d9b89..6459be6e82 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,14 +17,21 @@ overrides: postcss@<8.4.31: ^8.4.31 postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 - playwright: 1.61.1 + playwright@>=1.61.1 <2.0.0-0: 1.62.1 patchedDependencies: - '@zip.js/zip.js@2.8.26': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95 + '@zip.js/zip.js@2.8.34': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95 importers: .: + dependencies: + '@penpot/ui': + specifier: link:packages/ui + version: link:packages/ui + react-aria-components: + specifier: ^1.19.0 + version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@penpot/draft-js': specifier: link:packages/draft-js @@ -47,51 +54,48 @@ importers: '@penpot/ua-parser': specifier: penpot/ua-parser#1.0.0 version: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4 - '@penpot/ui': - specifier: link:packages/ui - version: link:packages/ui '@playwright/test': - specifier: 1.61.1 - version: 1.61.1 + specifier: 1.62.1 + version: 1.62.1 '@storybook/addon-docs': - specifier: 10.4.6 - version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: 10.5.5 + version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@storybook/addon-themes': - specifier: 10.4.6 - version: 10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + specifier: 10.5.5 + version: 10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-vitest': - specifier: 10.4.6 - version: 10.4.6(@vitest/browser-playwright@4.1.9)(@vitest/browser@4.1.9)(@vitest/runner@4.1.9)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.9) + specifier: 10.5.5 + version: 10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10) '@storybook/react-vite': - specifier: 10.4.6 - version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: 10.5.5 + version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@tokens-studio/sd-transforms': specifier: 2.0.3 - version: 2.0.3(style-dictionary@5.4.4(tslib@2.8.1)) + version: 2.0.3(style-dictionary@5.5.0(tslib@2.8.1)) '@types/node': - specifier: ^26.1.0 - version: 26.1.0 + specifier: ^26.1.2 + version: 26.1.2 '@vitest/browser': - specifier: 4.1.9 - version: 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) + specifier: 4.1.10 + version: 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) '@vitest/browser-playwright': - specifier: 4.1.9 - version: 4.1.9(playwright@1.61.1)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) + specifier: 4.1.10 + version: 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) '@vitest/coverage-v8': - specifier: 4.1.9 - version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) + specifier: 4.1.10 + version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) '@zip.js/zip.js': - specifier: 2.8.26 - version: 2.8.26(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) + specifier: 2.8.34 + version: 2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) autoprefixer: - specifier: ^10.5.2 - version: 10.5.2(postcss@8.5.16) + specifier: ^10.5.4 + version: 10.5.4(postcss@8.5.25) compression: specifier: ^1.8.1 - version: 1.8.1 + version: 1.8.1(supports-color@5.5.0) concurrently: - specifier: ^10.0.3 - version: 10.0.3 + specifier: ^10.0.4 + version: 10.0.4 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -111,8 +115,8 @@ importers: specifier: ^2.3.0 version: 2.3.0 gettext-parser: - specifier: ^9.0.2 - version: 9.0.2 + specifier: ^9.1.1 + version: 9.1.1 highlight.js: specifier: ^11.10.0 version: 11.11.1 @@ -120,8 +124,8 @@ importers: specifier: ^2.0.3 version: 2.0.3 jsdom: - specifier: ^29.0.2 - version: 29.1.1(canvas@3.2.3) + specifier: ^30.0.1 + version: 30.0.1(canvas@3.2.3) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -132,8 +136,8 @@ importers: specifier: 0.0.7 version: 0.0.7 marked: - specifier: ^18.0.5 - version: 18.0.5 + specifier: ^18.0.7 + version: 18.0.7 mkdirp: specifier: ^3.0.1 version: 3.0.1 @@ -150,26 +154,26 @@ importers: specifier: ^2.0.0 version: 2.0.0 p-limit: - specifier: ^7.3.0 - version: 7.3.0 + specifier: ^7.3.1 + version: 7.3.1 playwright: - specifier: 1.61.1 - version: 1.61.1 + specifier: 1.62.1 + version: 1.62.1 postcss: - specifier: ^8.5.16 - version: 8.5.16 + specifier: ^8.5.25 + version: 8.5.25 postcss-clean: specifier: ^1.2.2 version: 1.2.2 postcss-modules: - specifier: ^9.0.0 - version: 9.0.0(postcss@8.5.16) + specifier: ^9.0.1 + version: 9.0.1(postcss@8.5.25) postcss-scss: specifier: ^4.0.9 - version: 4.0.9(postcss@8.5.16) + version: 4.0.9(postcss@8.5.25) prettier: - specifier: 3.9.4 - version: 3.9.4 + specifier: 3.9.6 + version: 3.9.6 pretty-time: specifier: ^1.1.0 version: 1.1.0 @@ -180,17 +184,17 @@ importers: specifier: ^0.6.2 version: 0.6.2 react: - specifier: 19.2.7 - version: 19.2.7 + specifier: 19.2.8 + version: 19.2.8 react-dom: - specifier: 19.2.7 - version: 19.2.7(react@19.2.7) + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) react-error-boundary: specifier: ^6.1.2 - version: 6.1.2(react@19.2.7) + version: 6.1.2(react@19.2.8) react-virtualized: specifier: ^9.22.6 - version: 9.22.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.22.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) rimraf: specifier: ^6.1.3 version: 6.1.3 @@ -198,14 +202,14 @@ importers: specifier: 8.0.0-alpha.14 version: 8.0.0-alpha.14 sass: - specifier: ^1.101.0 - version: 1.101.0 + specifier: ^1.102.0 + version: 1.102.0 sass-embedded: specifier: ^1.100.0 version: 1.100.0 sax: - specifier: ^1.6.0 - version: 1.6.0 + specifier: ^1.6.1 + version: 1.6.1 scheduler: specifier: ^0.27.0 version: 0.27.0 @@ -213,23 +217,23 @@ importers: specifier: ^0.5.21 version: 0.5.21 storybook: - specifier: 10.4.6 - version: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 10.5.5 + version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) style-dictionary: - specifier: 5.4.4 - version: 5.4.4(tslib@2.8.1) + specifier: 5.5.0 + version: 5.5.0(tslib@2.8.1) stylelint: - specifier: ^17.14.0 - version: 17.14.0(supports-color@5.5.0)(typescript@6.0.3) + specifier: ^17.14.1 + version: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) stylelint-config-standard-scss: specifier: ^17.0.0 - version: 17.0.0(postcss@8.5.16)(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) + version: 17.0.0(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-plugin-logical-css: specifier: ^2.1.0 - version: 2.1.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) + version: 2.1.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-scss: specifier: ^7.2.0 - version: 7.2.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) + version: 7.2.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) svg-sprite: specifier: ^2.0.4 version: 2.0.4 @@ -243,14 +247,14 @@ importers: specifier: ^6.0.2 version: 6.0.3 vite: - specifier: ^8.1.2 - version: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) wait-on: - specifier: ^9.0.4 - version: 9.0.10(supports-color@5.5.0) + specifier: ^9.1.0 + version: 9.1.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0) watcher: specifier: ^2.3.1 version: 2.3.1 @@ -265,16 +269,16 @@ importers: dependencies: draft-js: specifier: penpot/draft-js.git#ba3b26ed63a01227a3560e440531b69d79c03f35 - version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) immutable: specifier: ^5.1.9 version: 5.1.9 react: specifier: '>=0.17.0' - version: 19.2.7 + version: 19.2.8 react-dom: specifier: '>=0.17.0' - version: 19.2.7(react@19.2.7) + version: 19.2.8(react@19.2.8) devDependencies: esbuild: specifier: ^0.28.1 @@ -292,10 +296,13 @@ importers: dependencies: react: specifier: '>=19.2' - version: 19.2.7 + version: 19.2.8 + react-aria-components: + specifier: ^1.19.0 + version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-dom: specifier: '>=19.2' - version: 19.2.7(react@19.2.7) + version: 19.2.8(react@19.2.8) devDependencies: '@babel/core': specifier: ^8.0.1 @@ -304,68 +311,68 @@ importers: specifier: ^8.0.1 version: 8.0.1(@babel/core@8.0.1) '@storybook/react': - specifier: 10.4.6 - version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3) + specifier: 10.5.5 + version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) '@storybook/react-vite': - specifier: 10.4.6 - version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: 10.5.5 + version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/react': - specifier: ^19.2.17 - version: 19.2.17 + specifier: ^19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.17) + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': - specifier: ^6.0.3 - version: 6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: ^6.0.5 + version: 6.0.5(babel-plugin-react-compiler@1.0.0)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(eslint@9.39.2) + version: 2.32.0(eslint@9.39.2(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-jsx-a11y: specifier: 6.10.2 - version: 6.10.2(eslint@9.39.2) + version: 6.10.2(eslint@9.39.2(supports-color@10.2.2)) eslint-plugin-react: specifier: 7.37.5 - version: 7.37.5(eslint@9.39.2) + version: 7.37.5(eslint@9.39.2(supports-color@10.2.2)) eslint-plugin-react-hooks: specifier: 7.1.1 - version: 7.1.1(eslint@9.39.2) + version: 7.1.1(eslint@9.39.2(supports-color@10.2.2))(supports-color@10.2.2) react-compiler-runtime: specifier: ^1.0.0 - version: 1.0.0(react@19.2.7) + version: 1.0.0(react@19.2.8) storybook: - specifier: 10.4.6 - version: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 10.5.5 + version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) vite-plugin-dts: specifier: ^5.0.3 - version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.0))(esbuild@0.28.1)(rolldown@1.1.3)(rollup@4.61.1)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) text-editor: devDependencies: '@playwright/test': - specifier: 1.61.1 - version: 1.61.1 + specifier: 1.62.1 + version: 1.62.1 '@types/node': - specifier: ^26.1.0 - version: 26.1.0 + specifier: ^26.1.2 + version: 26.1.2 '@vitest/browser': - specifier: ^4.1.9 - version: 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) + specifier: ^4.1.10 + version: 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) '@vitest/coverage-v8': - specifier: ^4.1.9 - version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) + specifier: ^4.1.10 + version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) '@vitest/ui': - specifier: ^4.1.9 - version: 4.1.9(vitest@4.1.9) + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) canvas: specifier: ^3.2.3 version: 3.2.3 @@ -373,20 +380,20 @@ importers: specifier: ^0.28.0 version: 0.28.1 jsdom: - specifier: ^29.1.1 - version: 29.1.1(canvas@3.2.3) + specifier: ^30.0.1 + version: 30.0.1(canvas@3.2.3) playwright: - specifier: 1.61.1 - version: 1.61.1 + specifier: 1.62.1 + version: 1.62.1 prettier: - specifier: ^3.9.4 - version: 3.9.4 + specifier: ^3.9.6 + version: 3.9.6 vite: - specifier: ^8.1.2 - version: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) packages: @@ -399,20 +406,13 @@ packages: '@ark/util@0.56.0': resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} - '@asamuzakjp/css-color@5.1.11': - resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@asamuzakjp/css-color@6.0.5': + resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + engines: {node: ^22.13.0 || >=24.0.0} - '@asamuzakjp/dom-selector@7.1.1': - resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/generational-cache@1.0.1': - resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asamuzakjp/dom-selector@8.3.0': + resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} + engines: {node: ^22.13.0 || >=24.0.0} '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} @@ -622,29 +622,29 @@ packages: '@bundled-es-modules/postcss-calc-ast-parser@0.1.6': resolution: {integrity: sha512-y65TM5zF+uaxo9OeekJ3rxwTINlQvrkbZLogYvQYVoLtxm4xEiHfZ7e/MyiWbStYyWZVZkVqsaVU6F4SUK5XUA==} - '@cacheable/memory@2.0.8': - resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} - '@cacheable/utils@2.4.1': - resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} - '@csstools/color-helpers@6.0.2': - resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} - '@csstools/css-calc@3.2.1': - resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.1.1': - resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -656,16 +656,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.4': - resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - - '@csstools/css-syntax-patches-for-csstree@1.1.6': - resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -683,8 +675,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/selector-resolve-nested@4.0.0': - resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + '@csstools/selector-resolve-nested@4.0.1': + resolution: {integrity: sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==} engines: {node: '>=20.19.0'} peerDependencies: postcss-selector-parser: ^7.1.1 @@ -698,30 +690,33 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -916,8 +911,8 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@exodus/bytes@1.15.0': - resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: '@noble/hashes': ^1.8.0 || ^2.0.0 @@ -938,8 +933,8 @@ packages: '@hapi/pinpoint@2.0.1': resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} - '@hapi/tlds@1.1.4': - resolution: {integrity: sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==} + '@hapi/tlds@1.1.7': + resolution: {integrity: sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==} engines: {node: '>=14.0.0'} '@hapi/topo@6.0.2': @@ -965,6 +960,15 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@internationalized/string@3.2.9': + resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==} + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} peerDependencies: @@ -1026,50 +1030,50 @@ packages: peerDependencies: tslib: '2' - '@jsonjoy.com/fs-core@4.57.6': - resolution: {integrity: sha512-uI++Wx6VkBJqVmkb4ZeExwAVpZiA2Do5NrEtXoDk0Pdvce3ytFXJoviT1sLOj16+qDIMnD5nWPfOhVpnDmRJKg==} + '@jsonjoy.com/fs-core@4.64.0': + resolution: {integrity: sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-fsa@4.57.6': - resolution: {integrity: sha512-pKkw/yC5CzSZKhIIUIsH1przOa+K5jGmZIg1sWaSF24JojyrUFbjcQv7QrcGAudriei6HQ6R0BFj+V8NbQinJw==} + '@jsonjoy.com/fs-fsa@4.64.0': + resolution: {integrity: sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node-builtins@4.57.6': - resolution: {integrity: sha512-V4DgEFT3Cg5S9fCMOZSCVdTxdJWWLBO0WnAazV7hnCM96u5zXHyW/ubDAfcSVwqjkMJ50W1Y44IXtxRoIwaCVg==} + '@jsonjoy.com/fs-node-builtins@4.64.0': + resolution: {integrity: sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node-to-fsa@4.57.6': - resolution: {integrity: sha512-+JptNw3iifihxH2rEXrninDzX4FFVW8JD/wPR8GbJPAeL9CQUSblrlumOPB5gZuS7tYRX+PJPLtT7XzKoRhv/Q==} + '@jsonjoy.com/fs-node-to-fsa@4.64.0': + resolution: {integrity: sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node-utils@4.57.6': - resolution: {integrity: sha512-foyUrfS7WmYEUzqYXSNxmJBcSj04TABrkpFabwO9SCDCpVCfJ+qG+2sk5FjfiflG2n0SDFZDCJ6vYlJAEpxJFg==} + '@jsonjoy.com/fs-node-utils@4.64.0': + resolution: {integrity: sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-node@4.57.6': - resolution: {integrity: sha512-Kbn1jdkvDN4F2+BhoB6mMu7NCbhP0bgA5NcI1aJj/Q5UcU+I1JLLW+dEQean33iV4tXv35AzBVKPICnDltBpxw==} + '@jsonjoy.com/fs-node@4.64.0': + resolution: {integrity: sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-print@4.57.6': - resolution: {integrity: sha512-96eAn4Dudtt67LTeuU47yUD+pg9/G/oKpI10zei9ljk3X3WK4lYKc+n3cpaPCAbKPzoyfxl0mXm8f8Y7BOSFXw==} + '@jsonjoy.com/fs-print@4.64.0': + resolution: {integrity: sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' - '@jsonjoy.com/fs-snapshot@4.57.6': - resolution: {integrity: sha512-V57CMzbOgTzUWGOWQ8GzHQdpJP6JnrYVNCtTBNxVYEnlVRvo4uEJqHhtAT8vhDFrIuJOXLrTL1Fki4h5oI7xxg==} + '@jsonjoy.com/fs-snapshot@4.64.0': + resolution: {integrity: sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q==} engines: {node: '>=10.0'} peerDependencies: tslib: '2' @@ -1138,17 +1142,12 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1295,198 +1294,192 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.137.0': - resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} - '@oxc-resolver/binding-android-arm-eabi@11.20.0': - resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.20.0': - resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.20.0': - resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.20.0': - resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.20.0': - resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': - resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': - resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': - resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.20.0': - resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': - resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': - resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': - resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': - resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.20.0': - resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.20.0': - resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.20.0': - resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.20.0': - resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': - resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.20.0': - resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} cpu: [x64] os: [win32] - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [musl] - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': @@ -1497,14 +1490,19 @@ packages: resolution: {gitHosted: true, integrity: sha512-BxcjiWGtCbGBT+dsOnEODk1jASZLNYp27BuGQaJR7fxU4gLws3251r90Sp9seubcpRhGrfRdsA5WU0ExRdPOgg==, tarball: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4} version: 1.0.0 - '@playwright/test@1.61.1': - resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} - engines: {node: '>=18'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} hasBin: true '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@react-types/shared@3.36.0': + resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@resvg/resvg-js-android-arm-eabi@2.6.2': resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} engines: {node: '>= 10'} @@ -1585,97 +1583,96 @@ packages: resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} engines: {node: '>= 10'} - '@rolldown/binding-android-arm64@1.1.3': - resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.3': - resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.3': - resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.3': - resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.3': - resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.3': - resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.3': - resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.3': - resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.3': - resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.3': - resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.3': - resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - '@rolldown/binding-win32-arm64-msvc@1.1.3': - resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.3': - resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1873,27 +1870,27 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.4.6': - resolution: {integrity: sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==} + '@storybook/addon-docs@10.5.5': + resolution: {integrity: sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.6 + storybook: ^10.5.5 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-themes@10.4.6': - resolution: {integrity: sha512-80d622oB9xWZs3VH4uywkLOA5L2DAx04lVouvCM4XH+pLnJElidoylOLm3i3ByvlGkRjCbB27OUVsW94IgyDrw==} + '@storybook/addon-themes@10.5.5': + resolution: {integrity: sha512-ENZCJkvTdGYBRuaE3tEE6jRilMRdGgfYUhnFNEUXAg4II2iVYg9mnrq6tuQfwSVjGuEOTAUen/3YV+l7U4oOOA==} peerDependencies: - storybook: ^10.4.6 + storybook: ^10.5.5 - '@storybook/addon-vitest@10.4.6': - resolution: {integrity: sha512-VvskHge0GZy86LG6kcY5Ww34z8rDV8JBxqSdUpcJVsWfIvyX6MfAbqI76LlereSyBIJGZJZsqaLwRXsQoVY+0Q==} + '@storybook/addon-vitest@10.5.5': + resolution: {integrity: sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.4.6 + storybook: ^10.5.5 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -1905,18 +1902,18 @@ packages: vitest: optional: true - '@storybook/builder-vite@10.4.6': - resolution: {integrity: sha512-BHBtD81HiXUiDQz/CaFynLtWmm7AFUQn8VnXuHipZ8KlnUANopa4yqdVuy/Gwz8ub254uFI5NMZsW/KlgWNgNg==} + '@storybook/builder-vite@10.5.5': + resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==} peerDependencies: - storybook: ^10.4.6 + storybook: ^10.5.5 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.4.6': - resolution: {integrity: sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==} + '@storybook/csf-plugin@10.5.5': + resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.4.6 + storybook: ^10.5.5 vite: '*' webpack: '*' peerDependenciesMeta: @@ -1932,42 +1929,45 @@ packages: '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} - '@storybook/icons@2.0.2': - resolution: {integrity: sha512-KZBCpXsshAIjczYNXR/rlxEtCUX/eAbpFNwKi8bcOomrLA4t/SyPz5RF+lVPO2oZBUE4sAkt43mfJUevQDSEEw==} + '@storybook/icons@2.1.0': + resolution: {integrity: sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.4.6': - resolution: {integrity: sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==} + '@storybook/react-dom-shim@10.5.5': + resolution: {integrity: sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.6 + storybook: ^10.5.5 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.4.6': - resolution: {integrity: sha512-0arEQtybqGYXHbXpTot+Wv9YtG+V5Vp43QayXavPKQ20M8mpEzhyCPKd0EhqMGSC1Z1UEt0hm365WUBhI9LfKA==} + '@storybook/react-vite@10.5.5': + resolution: {integrity: sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.6 + storybook: ^10.5.5 + typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true - '@storybook/react@10.4.6': - resolution: {integrity: sha512-9Y7YecrVFe1/01KYjfOLxVqTg2Aq+IO6TEv6sC2U0PfD0AWCSCmQ91QqgBpN/XW4aFFWoiZNinyXMUlU8zxy2w==} + '@storybook/react@10.5.5': + resolution: {integrity: sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.4.6 + storybook: ^10.5.5 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -1977,6 +1977,9 @@ packages: typescript: optional: true + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2020,9 +2023,6 @@ packages: '@tokens-studio/types@0.5.2': resolution: {integrity: sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -2071,16 +2071,16 @@ packages: '@types/mdx@2.0.14': resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} - '@types/node@26.1.0': - resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -2088,8 +2088,8 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - '@vitejs/plugin-react@6.0.3': - resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 @@ -2101,22 +2101,22 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/browser-playwright@4.1.9': - resolution: {integrity: sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==} + '@vitest/browser-playwright@4.1.10': + resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} peerDependencies: - playwright: 1.61.1 - vitest: 4.1.9 + playwright: 1.62.1 + vitest: 4.1.10 - '@vitest/browser@4.1.9': - resolution: {integrity: sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==} + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} peerDependencies: - vitest: 4.1.9 + vitest: 4.1.10 - '@vitest/coverage-v8@4.1.9': - resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: - '@vitest/browser': 4.1.9 - vitest: 4.1.9 + '@vitest/browser': 4.1.10 + vitest: 4.1.10 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2124,11 +2124,11 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2141,31 +2141,31 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/ui@4.1.9': - resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} + '@vitest/ui@4.1.10': + resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==} peerDependencies: - vitest: 4.1.9 + vitest: 4.1.10 '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2183,8 +2183,8 @@ packages: resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - '@zip.js/zip.js@2.8.26': - resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} + '@zip.js/zip.js@2.8.34': + resolution: {integrity: sha512-+6a3lyqq69rpseLbvDPiVIWsZ/HdTGAAD6afFtug6ECPDGttb2dHnPC6cJgdPofYkzL9OvXizegq+DQVfL2rnA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} abbrev@5.0.0: @@ -2200,8 +2200,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -2268,6 +2268,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -2330,8 +2334,8 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - ast-v8-to-istanbul@1.0.3: - resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} @@ -2347,8 +2351,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.5.2: - resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==} + autoprefixer@10.5.4: + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -2362,8 +2366,8 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2382,8 +2386,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.40: - resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + baseline-browser-mapping@2.11.8: + resolution: {integrity: sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -2422,8 +2426,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.4: - resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2444,8 +2448,8 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - cacheable@2.3.5: - resolution: {integrity: sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==} + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -2463,11 +2467,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} - - caniuse-lite@1.0.30001800: - resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} canvas@3.2.3: resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} @@ -2515,6 +2516,9 @@ packages: resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==} engines: {node: '>= 4.0'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2541,6 +2545,10 @@ packages: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2614,8 +2622,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.3: - resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==} + concurrently@10.0.4: + resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} engines: {node: '>=22'} hasBin: true @@ -2906,8 +2914,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.382: - resolution: {integrity: sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==} + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2965,8 +2973,8 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -3125,8 +3133,8 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} expr-eval-fork@3.0.3: @@ -3157,8 +3165,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.3: - resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} @@ -3188,8 +3196,8 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} - file-entry-cache@11.1.3: - resolution: {integrity: sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==} + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -3211,11 +3219,11 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flat-cache@6.1.22: - resolution: {integrity: sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==} + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -3312,8 +3320,8 @@ packages: getopts@2.3.0: resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} - gettext-parser@9.0.2: - resolution: {integrity: sha512-dGvq3S1gpS6e9KzNkwgPED5xxfWk7mNYzzdi/fPdJF5qS7B+yo8El2ZQyyhJ79PyzTtHbwiqYOFsqBdzbQ0GPg==} + gettext-parser@9.1.1: + resolution: {integrity: sha512-ZLeqWPz9OMNrTgMuww0C22kkcNqis+e4059R94t7L7ERlZ2rUNpiDbaAUus+esBC6uBAQWbS9N+R5vJIJg//lw==} engines: {node: '>=20'} github-from-package@0.0.0: @@ -3357,8 +3365,8 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - globby@16.2.0: - resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + globby@16.2.2: + resolution: {integrity: sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==} engines: {node: '>=20'} globjoin@0.1.4: @@ -3476,8 +3484,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} immutable@3.8.3: @@ -3713,8 +3721,8 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - joi@18.2.1: - resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} + joi@18.2.3: + resolution: {integrity: sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==} engines: {node: '>= 20'} js-beautify@2.0.3: @@ -3731,19 +3739,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} - hasBin: true - js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true - jsdom@29.1.1: - resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: - canvas: ^3.0.0 + canvas: ^3.2.3 peerDependenciesMeta: canvas: optional: true @@ -3780,6 +3784,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -3817,78 +3824,78 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: @@ -3939,8 +3946,8 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - lru-cache@11.5.1: - resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -3957,8 +3964,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -3967,8 +3974,8 @@ packages: map-stream@0.0.7: resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} - marked@18.0.5: - resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + marked@18.0.7: + resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==} engines: {node: '>= 20'} hasBin: true @@ -3992,8 +3999,8 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - memfs@4.57.6: - resolution: {integrity: sha512-WQK+DGjKCnPdpSyJUXphz+COF2uEhhsxQ3VIWBSbzpbbXuch3h4FePMqXrXGdLjsTgo4JFzBFsP6AWd9pVazGw==} + memfs@4.64.0: + resolution: {integrity: sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw==} peerDependencies: tslib: '2' @@ -4080,8 +4087,8 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4118,8 +4125,8 @@ packages: encoding: optional: true - node-releases@2.0.50: - resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} nodemon@3.1.14: @@ -4187,8 +4194,8 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} on-finished@2.4.1: @@ -4225,15 +4232,15 @@ packages: resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.20.0: - resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-limit@7.3.0: - resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + p-limit@7.3.1: + resolution: {integrity: sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==} engines: {node: '>=20'} p-locate@5.0.0: @@ -4318,8 +4325,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pidtree@0.3.1: @@ -4337,14 +4344,14 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true pngjs@7.0.0: @@ -4390,8 +4397,8 @@ packages: peerDependencies: postcss: ^8.5.10 - postcss-modules@9.0.0: - resolution: {integrity: sha512-nyZGaOkHFRvXy10ryKSE3yfh1EhwSrEX2mIhwZblTTdHbKOfH5fNhAC6eZmXB2uCy7x6Bb1Zib5KS2Yw6gSzHQ==} + postcss-modules@9.0.1: + resolution: {integrity: sha512-BrSXxWSls23TzqMuplpeMRL5VHnDOLh2H9EiHNTMIdLBFumJcurDIi47TBuvkn9GsoTLAoPjv2wLzAt1wdQ2aQ==} engines: {node: '>=20.6'} peerDependencies: postcss: ^8.5.10 @@ -4411,10 +4418,6 @@ packages: peerDependencies: postcss: ^8.5.10 - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} - engines: {node: '>=4'} - postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -4425,8 +4428,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -4439,8 +4442,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true @@ -4502,8 +4505,8 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -4527,6 +4530,18 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-aria-components@1.19.0: + resolution: {integrity: sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.50.0: + resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-compiler-runtime@1.0.0: resolution: {integrity: sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==} peerDependencies: @@ -4541,10 +4556,10 @@ packages: resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} engines: {node: ^20.9.0 || >=22} - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: - react: ^19.2.7 + react: ^19.2.8 react-error-boundary@6.1.2: resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==} @@ -4560,14 +4575,19 @@ packages: react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + react-stately@3.48.0: + resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-virtualized@9.22.6: resolution: {integrity: sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==} peerDependencies: react: ^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} read-pkg@3.0.0: @@ -4593,8 +4613,8 @@ packages: resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} engines: {node: '>= 0.8.0'} - recast@0.23.11: - resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + recast@0.23.19: + resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} engines: {node: '>= 4'} redent@3.0.0: @@ -4646,8 +4666,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown@1.1.3: - resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4820,13 +4840,13 @@ packages: engines: {node: '>=20.19.0'} hasBin: true - sass@1.101.0: - resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==} + sass@1.102.0: + resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==} engines: {node: '>=20.19.0'} hasBin: true - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} saxes@6.0.0: @@ -4849,11 +4869,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -4901,12 +4916,12 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} engines: {node: '>= 0.4'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -4917,8 +4932,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -4990,20 +5005,20 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@10.4.6: - resolution: {integrity: sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==} + storybook@10.5.5: + resolution: {integrity: sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 prettier: ^2 || ^3 - vite-plus: ^0.1.15 + vite-plus: ^0.1.15 || ^0.2.0 peerDependenciesMeta: '@types/react': optional: true @@ -5030,8 +5045,8 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} string.prototype.includes@2.0.1: @@ -5098,8 +5113,8 @@ packages: stubborn-fs@1.2.5: resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} - style-dictionary@5.4.4: - resolution: {integrity: sha512-Sd7dkEOLn33EpvlMyS8ykUu0us2EIFUqsysf4DSwZQ46nqQkZ2Q9o5mozu8cSizj3t7E220HaPk7EV1O7LjvDQ==} + style-dictionary@5.5.0: + resolution: {integrity: sha512-AGkOZtAc3OTz99wlzstrmj5OM5BWOW2IbmXD74sf0MXFPi271TGBdywokgd7bS3L0tKOk9M0FR+R9gnbXRSSfg==} engines: {node: '>=22.0.0'} hasBin: true @@ -5146,8 +5161,8 @@ packages: peerDependencies: stylelint: ^16.8.2 || ^17.0.0 - stylelint@17.14.0: - resolution: {integrity: sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==} + stylelint@17.14.1: + resolution: {integrity: sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==} engines: {node: '>=20.19.0'} hasBin: true @@ -5167,8 +5182,8 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - supports-hyperlinks@4.4.0: - resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + supports-hyperlinks@4.5.0: + resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} engines: {node: '>=20'} supports-preserve-symlinks-flag@1.0.0: @@ -5216,8 +5231,8 @@ packages: text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} - thingies@2.6.0: - resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} + thingies@2.6.1: + resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==} engines: {node: '>=10.18'} peerDependencies: tslib: ^2 @@ -5234,8 +5249,8 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -5246,19 +5261,19 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tldts-core@7.0.22: - resolution: {integrity: sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==} + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} - tldts@7.0.22: - resolution: {integrity: sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==} + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true to-regex-range@5.0.1: @@ -5277,8 +5292,8 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} tr46@0.0.3: @@ -5370,9 +5385,9 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} unicorn-magic@0.4.0: resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} @@ -5475,13 +5490,13 @@ packages: vite: optional: true - vite@8.1.2: - resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5518,20 +5533,20 @@ packages: yaml: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5566,8 +5581,8 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - wait-on@9.0.10: - resolution: {integrity: sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==} + wait-on@9.1.0: + resolution: {integrity: sha512-PymrLXHLBM1Ju/Xspb2ADUhbPSMvbnuNvy/mN2hWtpbJ3da0h3Ky1LqwKPG5QSVR57liyO0iUpfipYl/s5qNvA==} engines: {node: '>=20.0.0'} hasBin: true @@ -5592,6 +5607,10 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5655,8 +5674,8 @@ packages: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5742,25 +5761,20 @@ snapshots: '@ark/util@0.56.0': {} - '@asamuzakjp/css-color@5.1.11': + '@asamuzakjp/css-color@6.0.5': dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 - '@asamuzakjp/dom-selector@7.1.1': + '@asamuzakjp/dom-selector@8.3.0': dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - - '@asamuzakjp/generational-cache@1.0.1': {} - - '@asamuzakjp/nwsapi@2.3.9': {} + lru-cache: 11.5.2 '@babel/code-frame@7.29.7': dependencies: @@ -5777,16 +5791,36 @@ snapshots: '@babel/compat-data@8.0.0': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@10.2.2)': 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/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/core@7.29.7(supports-color@5.5.0)': + 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/core@7.29.7(supports-color@5.5.0))(supports-color@5.5.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -5813,7 +5847,7 @@ snapshots: gensync: 1.0.0-beta.2 import-meta-resolve: 4.2.0 json5: 2.2.3 - obug: 2.1.3 + obug: 2.1.4 semver: 7.8.5 '@babel/generator@7.29.7': @@ -5841,7 +5875,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.4 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -5849,17 +5883,24 @@ snapshots: dependencies: '@babel/compat-data': 8.0.0 '@babel/helper-validator-option': 8.0.0 - browserslist: 4.28.4 - lru-cache: 11.5.1 + browserslist: 4.28.7 + lru-cache: 11.5.2 semver: 7.8.5 '@babel/helper-globals@7.29.7': {} '@babel/helper-globals@8.0.0': {} - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7(supports-color@5.5.0)': + dependencies: + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -5869,12 +5910,21 @@ snapshots: '@babel/traverse': 8.0.0 '@babel/types': 8.0.0 - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@5.5.0))(supports-color@5.5.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -5970,7 +6020,19 @@ snapshots: '@babel/parser': 8.0.0 '@babel/types': 8.0.0 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@10.2.2)': + 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.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -5990,7 +6052,7 @@ snapshots: '@babel/parser': 8.0.0 '@babel/template': 8.0.0 '@babel/types': 8.0.0 - obug: 2.1.3 + obug: 2.1.4 '@babel/types@7.29.7': dependencies: @@ -6031,7 +6093,7 @@ snapshots: assert: 2.1.0 buffer: 6.0.3 events: 3.3.0 - memfs: 4.57.6(tslib@2.8.1) + memfs: 4.64.0(tslib@2.8.1) path: 0.12.7 stream: 0.0.3 util: 0.12.5 @@ -6042,31 +6104,31 @@ snapshots: dependencies: postcss-calc-ast-parser: 0.1.4 - '@cacheable/memory@2.0.8': + '@cacheable/memory@2.2.0': dependencies: - '@cacheable/utils': 2.4.1 + '@cacheable/utils': 2.5.0 '@keyv/bigmap': 1.3.1(keyv@5.6.0) hookified: 1.15.1 keyv: 5.6.0 - '@cacheable/utils@2.4.1': + '@cacheable/utils@2.5.0': dependencies: hashery: 1.5.1 keyv: 5.6.0 '@colors/colors@1.6.0': {} - '@csstools/color-helpers@6.0.2': {} + '@csstools/color-helpers@6.1.0': {} - '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -6074,11 +6136,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - - '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -6089,7 +6147,7 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-resolve-nested@4.0.1(postcss-selector-parser@7.1.4)': dependencies: postcss-selector-parser: 7.1.4 @@ -6103,13 +6161,7 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/core@1.11.1': + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 @@ -6121,12 +6173,13 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.10.0': + '@emnapi/core@2.0.0-alpha.3': dependencies: + '@emnapi/wasi-threads': 2.0.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 optional: true @@ -6136,6 +6189,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -6146,6 +6204,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -6224,17 +6287,17 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(supports-color@10.2.2))': dependencies: - eslint: 9.39.2 + eslint: 9.39.2(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -6247,10 +6310,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.5(supports-color@10.2.2)': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -6270,7 +6333,7 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.0': {} + '@exodus/bytes@1.15.1': {} '@hapi/address@5.1.1': dependencies: @@ -6282,7 +6345,7 @@ snapshots: '@hapi/pinpoint@2.0.1': {} - '@hapi/tlds@1.1.4': {} + '@hapi/tlds@1.1.7': {} '@hapi/topo@6.0.2': dependencies: @@ -6304,11 +6367,23 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/string@3.2.9': + dependencies: + '@swc/helpers': 0.5.23 + + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) optionalDependencies: typescript: 6.0.3 @@ -6355,58 +6430,59 @@ snapshots: dependencies: tslib: 2.8.1 - '@jsonjoy.com/fs-core@4.57.6(tslib@2.8.1)': + '@jsonjoy.com/fs-core@4.64.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-node-builtins': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.6(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-fsa@4.57.6(tslib@2.8.1)': + '@jsonjoy.com/fs-fsa@4.64.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-core': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.6(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) + '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-node-builtins@4.57.6(tslib@2.8.1)': + '@jsonjoy.com/fs-node-builtins@4.64.0(tslib@2.8.1)': dependencies: tslib: 2.8.1 - '@jsonjoy.com/fs-node-to-fsa@4.57.6(tslib@2.8.1)': + '@jsonjoy.com/fs-node-to-fsa@4.64.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-fsa': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.6(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-node-utils@4.57.6(tslib@2.8.1)': + '@jsonjoy.com/fs-node-utils@4.64.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-node-builtins': 4.57.6(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-node@4.57.6(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-core': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.57.6(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-print@4.57.6(tslib@2.8.1)': + '@jsonjoy.com/fs-node@4.64.0(tslib@2.8.1)': dependencies: - '@jsonjoy.com/fs-node-utils': 4.57.6(tslib@2.8.1) + '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.64.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 - '@jsonjoy.com/fs-snapshot@4.57.6(tslib@2.8.1)': + '@jsonjoy.com/fs-snapshot@4.64.0(tslib@2.8.1)': dependencies: '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.6(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) tslib: 2.8.1 @@ -6419,7 +6495,7 @@ snapshots: '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) hyperdyperid: 1.2.0 - thingies: 2.6.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 @@ -6431,7 +6507,7 @@ snapshots: '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) hyperdyperid: 1.2.0 - thingies: 2.6.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 @@ -6466,30 +6542,30 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7)': + '@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)': dependencies: '@types/mdx': 2.0.14 - '@types/react': 19.2.17 - react: 19.2.7 + '@types/react': 19.2.18 + react: 19.2.8 - '@microsoft/api-extractor-model@7.32.2(@types/node@26.1.0)': + '@microsoft/api-extractor-model@7.32.2(@types/node@26.1.2)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.0) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) transitivePeerDependencies: - '@types/node' optional: true - '@microsoft/api-extractor@7.56.2(@types/node@26.1.0)': + '@microsoft/api-extractor@7.56.2(@types/node@26.1.2)': dependencies: - '@microsoft/api-extractor-model': 7.32.2(@types/node@26.1.0) + '@microsoft/api-extractor-model': 7.32.2(@types/node@26.1.2) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.0) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.21.0(@types/node@26.1.0) - '@rushstack/ts-command-line': 5.2.0(@types/node@26.1.0) + '@rushstack/terminal': 0.21.0(@types/node@26.1.2) + '@rushstack/ts-command-line': 5.2.0(@types/node@26.1.2) diff: 8.0.4 lodash: 4.18.1 minimatch: 10.2.5 @@ -6512,24 +6588,24 @@ snapshots: '@microsoft/tsdoc@0.16.0': optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 '@tybys/wasm-util': 0.10.3 optional: true @@ -6599,7 +6675,7 @@ snapshots: dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true '@oxc-parser/binding-win32-arm64-msvc@0.127.0': @@ -6613,128 +6689,124 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.137.0': {} + '@oxc-project/types@0.142.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.20.0': + '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true - '@oxc-resolver/binding-android-arm64@11.20.0': + '@oxc-resolver/binding-android-arm64@11.24.2': optional: true - '@oxc-resolver/binding-darwin-arm64@11.20.0': + '@oxc-resolver/binding-darwin-arm64@11.24.2': optional: true - '@oxc-resolver/binding-darwin-x64@11.20.0': + '@oxc-resolver/binding-darwin-x64@11.24.2': optional: true - '@oxc-resolver/binding-freebsd-x64@11.20.0': + '@oxc-resolver/binding-freebsd-x64@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.20.0': + '@oxc-resolver/binding-linux-x64-musl@11.24.2': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.20.0': + '@oxc-resolver/binding-openharmony-arm64@11.24.2': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.20.0': + '@oxc-resolver/binding-wasm32-wasi@11.24.2': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true - '@parcel/watcher-android-arm64@2.5.6': + '@parcel/watcher-android-arm64@2.6.0': optional: true - '@parcel/watcher-darwin-arm64@2.5.6': + '@parcel/watcher-darwin-arm64@2.6.0': optional: true - '@parcel/watcher-darwin-x64@2.5.6': + '@parcel/watcher-darwin-x64@2.6.0': optional: true - '@parcel/watcher-freebsd-x64@2.5.6': + '@parcel/watcher-freebsd-x64@2.6.0': optional: true - '@parcel/watcher-linux-arm-glibc@2.5.6': + '@parcel/watcher-linux-arm-glibc@2.6.0': optional: true - '@parcel/watcher-linux-arm-musl@2.5.6': + '@parcel/watcher-linux-arm-musl@2.6.0': optional: true - '@parcel/watcher-linux-arm64-glibc@2.5.6': + '@parcel/watcher-linux-arm64-glibc@2.6.0': optional: true - '@parcel/watcher-linux-arm64-musl@2.5.6': + '@parcel/watcher-linux-arm64-musl@2.6.0': optional: true - '@parcel/watcher-linux-x64-glibc@2.5.6': + '@parcel/watcher-linux-x64-glibc@2.6.0': optional: true - '@parcel/watcher-linux-x64-musl@2.5.6': + '@parcel/watcher-linux-x64-musl@2.6.0': optional: true - '@parcel/watcher-win32-arm64@2.5.6': + '@parcel/watcher-win32-arm64@2.6.0': optional: true - '@parcel/watcher-win32-ia32@2.5.6': + '@parcel/watcher-win32-x64@2.6.0': optional: true - '@parcel/watcher-win32-x64@2.5.6': - optional: true - - '@parcel/watcher@2.5.6': + '@parcel/watcher@2.6.0': dependencies: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 optional: true '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': @@ -6743,16 +6815,20 @@ snapshots: css-tree: 3.2.1 csso: 5.0.5 lodash: 4.18.1 - sax: 1.6.0 + sax: 1.6.1 '@penpot/ua-parser@https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4': {} - '@playwright/test@1.61.1': + '@playwright/test@1.62.1': dependencies: - playwright: 1.61.1 + playwright: 1.62.1 '@polka/url@1.0.0-next.29': {} + '@react-types/shared@3.36.0(react@19.2.8)': + dependencies: + react: 19.2.8 + '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true @@ -6804,53 +6880,53 @@ snapshots: '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 '@resvg/resvg-js-win32-x64-msvc': 2.6.2 - '@rolldown/binding-android-arm64@1.1.3': + '@rolldown/binding-android-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-arm64@1.1.3': + '@rolldown/binding-darwin-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-x64@1.1.3': + '@rolldown/binding-darwin-x64@1.2.1': optional: true - '@rolldown/binding-freebsd-x64@1.1.3': + '@rolldown/binding-freebsd-x64@1.2.1': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.3': + '@rolldown/binding-linux-arm64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.3': + '@rolldown/binding-linux-arm64-musl@1.2.1': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.3': + '@rolldown/binding-linux-ppc64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.3': + '@rolldown/binding-linux-s390x-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.3': + '@rolldown/binding-linux-x64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-musl@1.1.3': + '@rolldown/binding-linux-x64-musl@1.2.1': optional: true - '@rolldown/binding-openharmony-arm64@1.1.3': + '@rolldown/binding-openharmony-arm64@1.2.1': optional: true - '@rolldown/binding-wasm32-wasi@1.1.3': + '@rolldown/binding-wasm32-wasi@1.2.1': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.3': + '@rolldown/binding-win32-arm64-msvc@1.2.1': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.3': + '@rolldown/binding-win32-x64-msvc@1.2.1': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -6859,7 +6935,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: rollup: 4.61.1 @@ -6940,7 +7016,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.19.1(@types/node@26.1.0)': + '@rushstack/node-core-library@5.19.1(@types/node@26.1.2)': dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) @@ -6951,12 +7027,12 @@ snapshots: resolve: 1.22.12 semver: 7.5.4 optionalDependencies: - '@types/node': 26.1.0 + '@types/node': 26.1.2 optional: true - '@rushstack/problem-matcher@0.1.1(@types/node@26.1.0)': + '@rushstack/problem-matcher@0.1.1(@types/node@26.1.2)': optionalDependencies: - '@types/node': 26.1.0 + '@types/node': 26.1.2 optional: true '@rushstack/rig-package@0.6.0': @@ -6965,18 +7041,18 @@ snapshots: strip-json-comments: 3.1.1 optional: true - '@rushstack/terminal@0.21.0(@types/node@26.1.0)': + '@rushstack/terminal@0.21.0(@types/node@26.1.2)': dependencies: - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.0) - '@rushstack/problem-matcher': 0.1.1(@types/node@26.1.0) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) + '@rushstack/problem-matcher': 0.1.1(@types/node@26.1.2) supports-color: 8.1.1 optionalDependencies: - '@types/node': 26.1.0 + '@types/node': 26.1.2 optional: true - '@rushstack/ts-command-line@5.2.0(@types/node@26.1.0)': + '@rushstack/ts-command-line@5.2.0(@types/node@26.1.2)': dependencies: - '@rushstack/terminal': 0.21.0(@types/node@26.1.0) + '@rushstack/terminal': 0.21.0(@types/node@26.1.2) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -6993,18 +7069,18 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@storybook/addon-docs@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) - '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) - '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@storybook/react-dom-shim': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) + '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/icons': 2.1.0(react@19.2.8) + '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 transitivePeerDependencies: - '@types/react-dom' - esbuild @@ -7012,101 +7088,145 @@ snapshots: - vite - webpack - '@storybook/addon-themes@10.4.6(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': + '@storybook/addon-themes@10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - '@storybook/addon-vitest@10.4.6(@vitest/browser-playwright@4.1.9)(@vitest/browser@4.1.9)(@vitest/runner@4.1.9)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.9)': + '@storybook/addon-vitest@10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)': dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@storybook/icons': 2.1.0(react@19.2.8) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: - '@vitest/browser': 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) - '@vitest/browser-playwright': 4.1.9(playwright@1.61.1)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) - '@vitest/runner': 4.1.9 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/runner': 4.1.10 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) transitivePeerDependencies: - react - - react-dom - '@storybook/builder-vite@10.4.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: - '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.4.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 rollup: 4.61.1 - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) '@storybook/global@5.0.0': {} - '@storybook/icons@2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@storybook/icons@2.1.0(react@19.2.8)': dependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.8 - '@storybook/react-dom-shim@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': + '@storybook/react-dom-shim@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@storybook/react-vite@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@storybook/builder-vite': 10.4.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) - '@storybook/react': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3) + '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 - react: 19.2.7 - react-docgen: 8.0.3 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.8 + react-docgen: 8.0.3(supports-color@10.2.2) + react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + optionalDependencies: + typescript: 6.0.3 transitivePeerDependencies: - '@types/react' - '@types/react-dom' - esbuild - rollup - supports-color - - typescript - webpack - '@storybook/react@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)': + '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@rollup/pluginutils': 5.4.0(rollup@4.61.1) + '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3) + empathic: 2.0.1 + magic-string: 0.30.21 + react: 19.2.8 + react-docgen: 8.0.3(supports-color@5.5.0) + react-dom: 19.2.8(react@19.2.8) + resolve: 1.22.12 + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + tsconfig-paths: 4.2.0 + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - esbuild + - rollup + - supports-color + - webpack + + '@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) - react: 19.2.7 - react-docgen: 8.0.3 + '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + react: 19.2.8 + react-docgen: 8.0.3(supports-color@10.2.2) react-docgen-typescript: 2.4.0(typescript@6.0.3) - react-dom: 19.2.7(react@19.2.7) - storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) typescript: 6.0.3 transitivePeerDependencies: - supports-color + '@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + react: 19.2.8 + react-docgen: 8.0.3(supports-color@5.5.0) + react-docgen-typescript: 2.4.0(typescript@6.0.3) + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -7127,21 +7247,21 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 - '@tokens-studio/sd-transforms@2.0.3(style-dictionary@5.4.4(tslib@2.8.1))': + '@tokens-studio/sd-transforms@2.0.3(style-dictionary@5.5.0(tslib@2.8.1))': dependencies: '@bundled-es-modules/deepmerge': 4.3.2 '@bundled-es-modules/postcss-calc-ast-parser': 0.1.6 @@ -7149,7 +7269,7 @@ snapshots: colorjs.io: 0.5.2 expr-eval-fork: 3.0.3 is-mergeable-object: 1.1.1 - style-dictionary: 5.4.4(tslib@2.8.1) + style-dictionary: 5.5.0(tslib@2.8.1) '@tokens-studio/tokenscript-interpreter@0.26.0': dependencies: @@ -7160,11 +7280,6 @@ snapshots: '@tokens-studio/types@0.5.2': {} - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -7217,15 +7332,15 @@ snapshots: '@types/mdx@2.0.14': {} - '@types/node@26.1.0': + '@types/node@26.1.2': dependencies: undici-types: 8.3.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -7233,58 +7348,58 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@vitejs/plugin-react@6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@vitejs/plugin-react@6.0.5(babel-plugin-react-compiler@1.0.0)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) optionalDependencies: babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-playwright@4.1.9(playwright@1.61.1)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9)': + '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)': dependencies: - '@vitest/browser': 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) - '@vitest/mocker': 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) - playwright: 1.61.1 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9)': + '@vitest/browser@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) - '@vitest/utils': 4.1.9 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) - ws: 8.21.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + ws: 8.21.1 transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/coverage-v8@4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9)': + '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.9 - ast-v8-to-istanbul: 1.0.3 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.3 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) optionalDependencies: - '@vitest/browser': 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) + '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) '@vitest/expect@3.2.4': dependencies: @@ -7294,40 +7409,40 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.10': dependencies: - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 @@ -7335,18 +7450,18 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.10': {} - '@vitest/ui@4.1.9(vitest@4.1.9)': + '@vitest/ui@4.1.10(vitest@4.1.10)': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.10 fflate: 0.8.3 - flatted: 3.4.2 + flatted: 3.4.4 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@vitest/utils@3.2.4': dependencies: @@ -7354,11 +7469,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 '@volar/language-core@2.4.28': dependencies: @@ -7376,7 +7491,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@zip.js/zip.js@2.8.26(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95)': {} + '@zip.js/zip.js@2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95)': {} abbrev@5.0.0: {} @@ -7385,11 +7500,11 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} agent-base@6.0.2(supports-color@5.5.0): dependencies: @@ -7417,7 +7532,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 optional: true @@ -7425,7 +7540,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -7457,6 +7572,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -7558,7 +7677,7 @@ snapshots: dependencies: tslib: 2.8.1 - ast-v8-to-istanbul@1.0.3: + ast-v8-to-istanbul@1.0.5: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -7572,13 +7691,13 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.5.2(postcss@8.5.16): + autoprefixer@10.5.4(postcss@8.5.25): dependencies: - browserslist: 4.28.4 - caniuse-lite: 1.0.30001799 + browserslist: 4.28.7 + caniuse-lite: 1.0.30001806 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.16 + postcss: 8.5.25 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -7587,9 +7706,9 @@ snapshots: axe-core@4.11.1: {} - axios@1.16.1(supports-color@5.5.0): + axios@1.19.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0): dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@5.5.0)) form-data: 4.0.6 https-proxy-agent: 5.0.1(supports-color@5.5.0) proxy-from-env: 2.1.0 @@ -7609,7 +7728,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.40: {} + baseline-browser-mapping@2.11.8: {} bidi-js@1.0.3: dependencies: @@ -7633,7 +7752,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.15.3 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -7656,13 +7775,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.4: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.10.40 - caniuse-lite: 1.0.30001800 - electron-to-chromium: 1.5.382 - node-releases: 2.0.50 - update-browserslist-db: 1.2.3(browserslist@4.28.4) + baseline-browser-mapping: 2.11.8 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.399 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) buffer-from@1.1.2: {} @@ -7682,10 +7801,10 @@ snapshots: bytes@3.1.2: {} - cacheable@2.3.5: + cacheable@2.5.0: dependencies: - '@cacheable/memory': 2.0.8 - '@cacheable/utils': 2.4.1 + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 hookified: 1.15.1 keyv: 5.6.0 qified: 0.10.1 @@ -7709,9 +7828,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001799: {} - - caniuse-lite@1.0.30001800: {} + caniuse-lite@1.0.30001806: {} canvas@3.2.3: dependencies: @@ -7767,6 +7884,8 @@ snapshots: dependencies: source-map: 0.6.1 + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7793,6 +7912,8 @@ snapshots: clsx@1.2.1: {} + clsx@2.1.1: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -7844,11 +7965,11 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.1(supports-color@5.5.0): dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@5.5.0) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -7858,11 +7979,11 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.3: + concurrently@10.0.4: dependencies: chalk: 5.6.2 rxjs: 7.8.2 - shell-quote: 1.8.4 + shell-quote: 1.9.0 supports-color: 10.2.2 tree-kill: 1.2.2 yargs: 18.0.0 @@ -8003,13 +8124,23 @@ snapshots: date-fns@4.4.0: {} - debug@2.6.9: + debug@2.6.9(supports-color@5.5.0): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 5.5.0 - debug@3.2.7: + debug@3.2.7(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 debug@4.4.3(supports-color@5.5.0): dependencies: @@ -8118,13 +8249,13 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 - draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/ba3b26ed63a01227a3560e440531b69d79c03f35(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: fbjs: 3.0.5(encoding@0.1.13) immutable: 3.8.3 object-assign: 4.1.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - encoding @@ -8143,7 +8274,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.382: {} + electron-to-chromium@1.5.399: {} emoji-regex@10.6.0: {} @@ -8255,7 +8386,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@2.1.0: {} + es-module-lexer@2.3.1: {} es-object-atoms@1.1.1: dependencies: @@ -8315,35 +8446,35 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.9(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) is-core-module: 2.16.2 resolve: 1.22.12 transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint@9.39.2): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9(supports-color@10.2.2))(eslint@9.39.2(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) optionalDependencies: - eslint: 9.39.2 - eslint-import-resolver-node: 0.3.9 + eslint: 9.39.2(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.9(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint@9.39.2): + eslint-plugin-import@2.32.0(eslint@9.39.2(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) doctrine: 2.1.0 - eslint: 9.39.2 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint@9.39.2) + eslint: 9.39.2(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.9(supports-color@10.2.2) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9(supports-color@10.2.2))(eslint@9.39.2(supports-color@10.2.2))(supports-color@10.2.2) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -8359,7 +8490,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(supports-color@10.2.2)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -8369,7 +8500,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2 + eslint: 9.39.2(supports-color@10.2.2) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -8378,18 +8509,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@9.39.2): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.2(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 - eslint: 9.39.2 + eslint: 9.39.2(supports-color@10.2.2) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.2): + eslint-plugin-react@7.37.5(eslint@9.39.2(supports-color@10.2.2)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -8397,7 +8528,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 9.39.2 + eslint: 9.39.2(supports-color@10.2.2) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -8420,14 +8551,14 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2: + eslint@9.39.2(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@10.2.2) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 + '@eslint/eslintrc': 3.3.5(supports-color@10.2.2) '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -8437,7 +8568,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -8461,8 +8592,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -8493,7 +8624,7 @@ snapshots: expand-template@2.0.3: {} - expect-type@1.3.0: {} + expect-type@1.4.0: {} expr-eval-fork@3.0.3: {} @@ -8519,11 +8650,11 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.2 + qs: 6.15.3 range-parser: 1.2.1 router: 2.2.0(supports-color@5.5.0) send: 1.2.1(supports-color@5.5.0) - serve-static: 2.2.1 + serve-static: 2.2.1(supports-color@5.5.0) statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 @@ -8550,7 +8681,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.3: {} + fast-uri@3.1.4: {} fastest-levenshtein@1.0.16: {} @@ -8572,17 +8703,17 @@ snapshots: transitivePeerDependencies: - encoding - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 fecha@4.2.3: {} fflate@0.8.3: {} - file-entry-cache@11.1.3: + file-entry-cache@11.1.5: dependencies: - flat-cache: 6.1.22 + flat-cache: 6.1.23 file-entry-cache@8.0.0: dependencies: @@ -8610,20 +8741,22 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 - flat-cache@6.1.22: + flat-cache@6.1.23: dependencies: - cacheable: 2.3.5 - flatted: 3.4.2 + cacheable: 2.5.0 + flatted: 3.4.4 hookified: 1.15.1 - flatted@3.4.2: {} + flatted@3.4.4: {} fn.name@1.1.0: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@5.5.0)): + optionalDependencies: + debug: 4.4.3(supports-color@5.5.0) for-each@0.3.5: dependencies: @@ -8711,7 +8844,7 @@ snapshots: getopts@2.3.0: {} - gettext-parser@9.0.2: + gettext-parser@9.1.1: dependencies: content-type: 1.0.5 encoding: 0.1.13 @@ -8762,11 +8895,11 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 - globby@16.2.0: + globby@16.2.2: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 - ignore: 7.0.5 + ignore: 7.0.6 is-path-inside: 4.0.0 slash: 5.1.0 unicorn-magic: 0.4.0 @@ -8823,7 +8956,7 @@ snapshots: html-encoding-sniffer@6.0.0: dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.1 transitivePeerDependencies: - '@noble/hashes' @@ -8856,9 +8989,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.16): + icss-utils@5.1.0(postcss@8.5.25): dependencies: - postcss: 8.5.16 + postcss: 8.5.25 ieee754@1.2.1: {} @@ -8866,7 +8999,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.6: {} immutable@3.8.3: {} @@ -8901,7 +9034,7 @@ snapshots: dependencies: es-errors: 1.3.0 hasown: 2.0.4 - side-channel: 1.1.0 + side-channel: 1.1.1 ipaddr.js@1.9.1: {} @@ -9086,13 +9219,13 @@ snapshots: jju@1.4.0: optional: true - joi@18.2.1: + joi@18.2.3: dependencies: '@hapi/address': 5.1.1 '@hapi/formula': 3.0.2 '@hapi/hoek': 11.0.7 '@hapi/pinpoint': 2.0.1 - '@hapi/tlds': 1.1.4 + '@hapi/tlds': 1.1.7 '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 @@ -9110,36 +9243,32 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: - dependencies: - argparse: 2.0.1 - js-yaml@4.3.0: dependencies: argparse: 2.0.1 - jsdom@29.1.1(canvas@3.2.3): + jsdom@30.0.1(canvas@3.2.3): dependencies: - '@asamuzakjp/css-color': 5.1.11 - '@asamuzakjp/dom-selector': 7.1.1 + '@asamuzakjp/css-color': 6.0.5 + '@asamuzakjp/dom-selector': 8.3.0 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) - '@exodus/bytes': 1.15.0 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 css-tree: 3.2.1 data-urls: 7.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.1 + lru-cache: 11.5.2 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.1 - undici: 7.28.0 + tough-cookie: 6.0.2 + undici: 8.9.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 17.1.0 xml-name-validator: 5.0.0 optionalDependencies: canvas: 3.2.3 @@ -9166,6 +9295,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -9207,54 +9338,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 lines-and-columns@1.2.4: {} @@ -9304,7 +9435,7 @@ snapshots: loupe@3.2.1: {} - lru-cache@11.5.1: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: @@ -9321,7 +9452,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.3: + magicast@0.5.4: dependencies: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 @@ -9333,7 +9464,7 @@ snapshots: map-stream@0.0.7: {} - marked@18.0.5: {} + marked@18.0.7: {} math-intrinsics@1.1.0: {} @@ -9347,20 +9478,20 @@ snapshots: media-typer@1.1.0: {} - memfs@4.57.6(tslib@2.8.1): + memfs@4.64.0(tslib@2.8.1): dependencies: - '@jsonjoy.com/fs-core': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-fsa': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-to-fsa': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.57.6(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.57.6(tslib@2.8.1) + '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.64.0(tslib@2.8.1) '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) tree-dump: 1.1.0(tslib@2.8.1) tslib: 2.8.1 @@ -9411,7 +9542,7 @@ snapshots: mlly@1.8.2: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.4 @@ -9424,7 +9555,7 @@ snapshots: mustache@4.2.0: {} - nanoid@3.3.12: {} + nanoid@3.3.16: {} napi-build-utils@2.0.0: {} @@ -9448,7 +9579,7 @@ snapshots: optionalDependencies: encoding: 0.1.13 - node-releases@2.0.50: {} + node-releases@2.0.51: {} nodemon@3.1.14: dependencies: @@ -9457,7 +9588,7 @@ snapshots: ignore-by-default: 1.0.1 minimatch: 10.2.5 pstree.remy: 1.1.8 - semver: 7.8.4 + semver: 7.8.5 simple-update-notifier: 2.0.0 supports-color: 5.5.0 touch: 3.1.1 @@ -9485,7 +9616,7 @@ snapshots: minimatch: 3.1.5 pidtree: 0.3.1 read-pkg: 3.0.0 - shell-quote: 1.8.4 + shell-quote: 1.9.0 string.prototype.padend: 3.1.6 nth-check@2.1.1: @@ -9543,7 +9674,7 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - obug@2.1.3: {} + obug@2.1.4: {} on-finished@2.4.1: dependencies: @@ -9608,33 +9739,33 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 '@oxc-parser/binding-win32-x64-msvc': 0.127.0 - oxc-resolver@11.20.0: + oxc-resolver@11.24.2: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.20.0 - '@oxc-resolver/binding-android-arm64': 11.20.0 - '@oxc-resolver/binding-darwin-arm64': 11.20.0 - '@oxc-resolver/binding-darwin-x64': 11.20.0 - '@oxc-resolver/binding-freebsd-x64': 11.20.0 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 - '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 - '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 - '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-x64-musl': 11.20.0 - '@oxc-resolver/binding-openharmony-arm64': 11.20.0 - '@oxc-resolver/binding-wasm32-wasi': 11.20.0 - '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 - '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-limit@7.3.0: + p-limit@7.3.1: dependencies: yocto-queue: 1.2.2 @@ -9680,7 +9811,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.1 + lru-cache: 11.5.2 minipass: 7.1.3 path-to-regexp@8.4.2: {} @@ -9706,7 +9837,7 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} pidtree@0.3.1: {} @@ -9724,11 +9855,11 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 - playwright-core@1.61.1: {} + playwright-core@1.62.1: {} - playwright@1.61.1: + playwright@1.62.1: dependencies: - playwright-core: 1.61.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -9743,57 +9874,52 @@ snapshots: postcss-clean@1.2.2: dependencies: clean-css: 4.2.4 - postcss: 8.5.16 + postcss: 8.5.25 postcss-media-query-parser@0.2.3: {} - postcss-modules-extract-imports@3.1.0(postcss@8.5.16): + postcss-modules-extract-imports@3.1.0(postcss@8.5.25): dependencies: - postcss: 8.5.16 + postcss: 8.5.25 - postcss-modules-local-by-default@4.2.0(postcss@8.5.16): + postcss-modules-local-by-default@4.2.0(postcss@8.5.25): dependencies: - icss-utils: 5.1.0(postcss@8.5.16) - postcss: 8.5.16 + icss-utils: 5.1.0(postcss@8.5.25) + postcss: 8.5.25 postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.16): + postcss-modules-scope@3.2.1(postcss@8.5.25): dependencies: - postcss: 8.5.16 + postcss: 8.5.25 postcss-selector-parser: 7.1.4 - postcss-modules-values@4.0.0(postcss@8.5.16): + postcss-modules-values@4.0.0(postcss@8.5.25): dependencies: - icss-utils: 5.1.0(postcss@8.5.16) - postcss: 8.5.16 + icss-utils: 5.1.0(postcss@8.5.25) + postcss: 8.5.25 - postcss-modules@9.0.0(postcss@8.5.16): + postcss-modules@9.0.1(postcss@8.5.25): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.16) + icss-utils: 5.1.0(postcss@8.5.25) lodash.camelcase: 4.3.0 - postcss: 8.5.16 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.16) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.16) - postcss-modules-scope: 3.2.1(postcss@8.5.16) - postcss-modules-values: 4.0.0(postcss@8.5.16) + postcss: 8.5.25 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.25) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.25) + postcss-modules-scope: 3.2.1(postcss@8.5.25) + postcss-modules-values: 4.0.0(postcss@8.5.25) string-hash: 1.1.3 postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@7.0.1(postcss@8.5.16): + postcss-safe-parser@7.0.1(postcss@8.5.25): dependencies: - postcss: 8.5.16 + postcss: 8.5.25 - postcss-scss@4.0.9(postcss@8.5.16): + postcss-scss@4.0.9(postcss@8.5.25): dependencies: - postcss: 8.5.16 - - postcss-selector-parser@7.1.1: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 + postcss: 8.5.25 postcss-selector-parser@7.1.4: dependencies: @@ -9804,9 +9930,9 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.16: + postcss@8.5.25: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9827,7 +9953,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.4: {} + prettier@3.9.6: {} pretty-format@27.5.1: dependencies: @@ -9883,9 +10009,10 @@ snapshots: dependencies: hookified: 2.2.0 - qs@6.15.2: + qs@6.15.3: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 quansync@0.2.11: {} @@ -9909,18 +10036,43 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-compiler-runtime@1.0.0(react@19.2.7): + react-aria-components@1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - react: 19.2.7 + '@internationalized/date': 3.12.2 + '@react-types/shared': 3.36.0(react@19.2.8) + '@swc/helpers': 0.5.23 + client-only: 0.0.1 + react: 19.2.8 + react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-dom: 19.2.8(react@19.2.8) + react-stately: 3.48.0(react@19.2.8) + + react-aria@3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.8) + '@swc/helpers': 0.5.23 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-stately: 3.48.0(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + react-compiler-runtime@1.0.0(react@19.2.8): + dependencies: + react: 19.2.8 react-docgen-typescript@2.4.0(typescript@6.0.3): dependencies: typescript: 6.0.3 - react-docgen@8.0.3: + react-docgen@8.0.3(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 @@ -9932,14 +10084,29 @@ snapshots: transitivePeerDependencies: - supports-color - react-dom@19.2.7(react@19.2.7): + react-docgen@8.0.3(supports-color@5.5.0): dependencies: - react: 19.2.7 + '@babel/core': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/types': 7.29.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + '@types/doctrine': 0.0.9 + '@types/resolve': 1.20.6 + doctrine: 3.0.0 + resolve: 1.22.12 + strip-indent: 4.1.1 + transitivePeerDependencies: + - supports-color + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 scheduler: 0.27.0 - react-error-boundary@6.1.2(react@19.2.7): + react-error-boundary@6.1.2(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 react-is@16.13.1: {} @@ -9947,18 +10114,28 @@ snapshots: react-lifecycles-compat@3.0.4: {} - react-virtualized@9.22.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-stately@3.48.0(react@19.2.8): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.8) + '@swc/helpers': 0.5.23 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + + react-virtualized@9.22.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@babel/runtime': 7.29.7 clsx: 1.2.1 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) react-lifecycles-compat: 3.0.4 - react@19.2.7: {} + react@19.2.8: {} read-pkg@3.0.0: dependencies: @@ -9990,7 +10167,7 @@ snapshots: readline-sync@1.4.10: {} - recast@0.23.11: + recast@0.23.19: dependencies: ast-types: 0.16.1 esprima: 4.0.1 @@ -10053,26 +10230,26 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown@1.1.3: + rolldown@1.2.1: dependencies: - '@oxc-project/types': 0.137.0 + '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.3 - '@rolldown/binding-darwin-arm64': 1.1.3 - '@rolldown/binding-darwin-x64': 1.1.3 - '@rolldown/binding-freebsd-x64': 1.1.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 - '@rolldown/binding-linux-arm64-gnu': 1.1.3 - '@rolldown/binding-linux-arm64-musl': 1.1.3 - '@rolldown/binding-linux-ppc64-gnu': 1.1.3 - '@rolldown/binding-linux-s390x-gnu': 1.1.3 - '@rolldown/binding-linux-x64-gnu': 1.1.3 - '@rolldown/binding-linux-x64-musl': 1.1.3 - '@rolldown/binding-openharmony-arm64': 1.1.3 - '@rolldown/binding-wasm32-wasi': 1.1.3 - '@rolldown/binding-win32-arm64-msvc': 1.1.3 - '@rolldown/binding-win32-x64-msvc': 1.1.3 + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 rollup@4.61.1: dependencies: @@ -10248,18 +10425,18 @@ snapshots: immutable: 5.1.9 source-map-js: 1.2.1 optionalDependencies: - '@parcel/watcher': 2.5.6 + '@parcel/watcher': 2.6.0 optional: true - sass@1.101.0: + sass@1.102.0: dependencies: chokidar: 5.0.0 immutable: 5.1.9 source-map-js: 1.2.1 optionalDependencies: - '@parcel/watcher': 2.5.6 + '@parcel/watcher': 2.6.0 - sax@1.6.0: {} + sax@1.6.1: {} saxes@6.0.0: dependencies: @@ -10276,8 +10453,6 @@ snapshots: lru-cache: 6.0.0 optional: true - semver@7.8.4: {} - semver@7.8.5: {} send@1.2.1(supports-color@5.5.0): @@ -10296,7 +10471,7 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@5.5.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 @@ -10343,9 +10518,9 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.9.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -10365,11 +10540,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -10387,7 +10562,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.8.4 + semver: 7.8.5 sirv@3.0.2: dependencies: @@ -10437,38 +10612,38 @@ snapshots: statuses@2.0.2: {} - std-env@4.1.0: {} + std-env@4.2.0: {} stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@storybook/icons': 2.1.0(react@19.2.8) + '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 '@webcontainer/env': 1.1.1 esbuild: 0.28.1 + jsonc-parser: 3.3.1 open: 10.2.0 oxc-parser: 0.127.0 - oxc-resolver: 11.20.0 - recast: 0.23.11 - semver: 7.8.4 - use-sync-external-store: 1.6.0(react@19.2.7) - ws: 8.21.0 + oxc-resolver: 11.24.2 + recast: 0.23.19 + semver: 7.8.5 + use-sync-external-store: 1.6.0(react@19.2.8) + ws: 8.21.1 optionalDependencies: - '@types/react': 19.2.17 - prettier: 3.9.4 + '@types/react': 19.2.18 + prettier: 3.9.6 transitivePeerDependencies: - - '@testing-library/dom' - bufferutil - react - - react-dom - utf-8-validate stream@0.0.3: @@ -10492,7 +10667,7 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string-width@8.2.1: + string-width@8.2.2: dependencies: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 @@ -10517,7 +10692,7 @@ snapshots: internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.0 + side-channel: 1.1.1 string.prototype.padend@3.1.6: dependencies: @@ -10584,12 +10759,12 @@ snapshots: stubborn-fs@1.2.5: {} - style-dictionary@5.4.4(tslib@2.8.1): + style-dictionary@5.5.0(tslib@2.8.1): dependencies: '@bundled-es-modules/deepmerge': 4.3.2 '@bundled-es-modules/glob': 13.0.6 '@bundled-es-modules/memfs': 4.17.0(tslib@2.8.1) - '@zip.js/zip.js': 2.8.26(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) + '@zip.js/zip.js': 2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) chalk: 5.6.2 change-case: 5.4.4 colorjs.io: 0.5.2 @@ -10597,64 +10772,64 @@ snapshots: is-plain-obj: 4.1.0 json5: 2.2.3 path-unified: 0.2.0 - prettier: 3.9.4 + prettier: 3.9.6 tinycolor2: 1.6.0 transitivePeerDependencies: - tslib - stylelint-config-recommended-scss@17.0.1(postcss@8.5.16)(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-recommended-scss@17.0.1(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.16) - stylelint: 17.14.0(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-recommended: 18.0.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) - stylelint-scss: 7.2.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) + postcss-scss: 4.0.9(postcss@8.5.25) + stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) + stylelint-scss: 7.2.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.16 + postcss: 8.5.25 - stylelint-config-recommended@18.0.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-recommended@18.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.14.0(supports-color@5.5.0)(typescript@6.0.3) + stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.16)(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.14.0(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-recommended-scss: 17.0.1(postcss@8.5.16)(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) - stylelint-config-standard: 40.0.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) + stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) + stylelint-config-recommended-scss: 17.0.1(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) + stylelint-config-standard: 40.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.16 + postcss: 8.5.25 - stylelint-config-standard@40.0.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-standard@40.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.14.0(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-recommended: 18.0.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)) + stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) - stylelint-plugin-logical-css@2.1.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-plugin-logical-css@2.1.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - stylelint: 17.14.0(supports-color@5.5.0)(typescript@6.0.3) + stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) - stylelint-scss@7.2.0(stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-scss@7.2.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) '@csstools/css-tokenizer': 4.0.0 css-tree: 3.2.1 is-plain-object: 5.0.0 known-css-properties: 0.37.0 postcss-media-query-parser: 0.2.3 postcss-resolve-nested-selector: 0.1.6 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 - stylelint: 17.14.0(supports-color@5.5.0)(typescript@6.0.3) + stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) - stylelint@17.14.0(supports-color@5.5.0)(typescript@6.0.3): + stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3): dependencies: - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.4) + '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.4) '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.4) colord: 2.9.3 cosmiconfig: 9.0.2(typescript@6.0.3) @@ -10663,24 +10838,24 @@ snapshots: debug: 4.4.3(supports-color@5.5.0) fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 - file-entry-cache: 11.1.3 + file-entry-cache: 11.1.5 global-modules: 2.0.0 - globby: 16.2.0 + globby: 16.2.2 globjoin: 0.1.4 html-tags: 5.1.0 - ignore: 7.0.5 + ignore: 7.0.6 import-meta-resolve: 4.2.0 mathml-tag-names: 4.0.0 meow: 14.1.0 micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.16 - postcss-safe-parser: 7.0.1(postcss@8.5.16) + postcss: 8.5.25 + postcss-safe-parser: 7.0.1(postcss@8.5.25) postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 - string-width: 8.2.1 - supports-hyperlinks: 4.4.0 + string-width: 8.2.2 + supports-hyperlinks: 4.5.0 svg-tags: 1.0.0 table: 6.9.0 write-file-atomic: 7.0.1 @@ -10702,7 +10877,7 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-hyperlinks@4.4.0: + supports-hyperlinks@4.5.0: dependencies: has-flag: 5.0.1 supports-color: 10.2.2 @@ -10718,7 +10893,7 @@ snapshots: csso: 4.2.0 cssom: 0.5.0 glob: 7.2.3 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash.escape: 4.0.1 lodash.merge: 4.6.2 mustache: 4.2.0 @@ -10738,7 +10913,7 @@ snapshots: css-tree: 1.1.3 csso: 4.2.0 picocolors: 1.1.1 - sax: 1.6.0 + sax: 1.6.1 stable: 0.1.8 symbol-tree@3.2.4: {} @@ -10778,7 +10953,7 @@ snapshots: text-hex@1.0.0: {} - thingies@2.6.0(tslib@2.8.1): + thingies@2.6.1(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -10792,24 +10967,24 @@ snapshots: tinycolor2@1.6.0: {} - tinyexec@1.0.2: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyrainbow@2.0.0: {} - tinyrainbow@3.1.0: {} + tinyrainbow@3.1.1: {} tinyspy@4.0.4: {} - tldts-core@7.0.22: {} + tldts-core@7.4.10: {} - tldts@7.0.22: + tldts@7.4.10: dependencies: - tldts-core: 7.0.22 + tldts-core: 7.4.10 to-regex-range@5.0.1: dependencies: @@ -10821,9 +10996,9 @@ snapshots: touch@3.1.1: {} - tough-cookie@6.0.1: + tough-cookie@6.0.2: dependencies: - tldts: 7.0.22 + tldts: 7.4.10 tr46@0.0.3: {} @@ -10923,7 +11098,7 @@ snapshots: undici-types@8.3.0: {} - undici@7.28.0: {} + undici@8.9.0: {} unicorn-magic@0.4.0: {} @@ -10932,36 +11107,36 @@ snapshots: unpipe@1.0.0: {} - unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.0))(esbuild@0.28.1)(rolldown@1.1.3)(rollup@4.61.1)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)): + unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.61.1) '@volar/typescript': 2.4.28 compare-versions: 6.1.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@10.2.2) kolorist: 1.8.0 local-pkg: 1.2.1 magic-string: 0.30.21 typescript: 6.0.3 unplugin: 2.3.11 optionalDependencies: - '@microsoft/api-extractor': 7.56.2(@types/node@26.1.0) + '@microsoft/api-extractor': 7.56.2(@types/node@26.1.2) esbuild: 0.28.1 - rolldown: 1.1.3 + rolldown: 1.2.1 rollup: 4.61.1 - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) transitivePeerDependencies: - supports-color unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.17.0 - picomatch: 4.0.4 + acorn: 8.18.0 + picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 - update-browserslist-db@1.2.3(browserslist@4.28.4): + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: - browserslist: 4.28.4 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 @@ -10972,11 +11147,11 @@ snapshots: url@0.11.4: dependencies: punycode: 1.4.1 - qs: 6.15.2 + qs: 6.15.3 - use-sync-external-store@1.6.0(react@19.2.7): + use-sync-external-store@1.6.0(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 util-deprecate@1.0.2: {} @@ -11010,13 +11185,13 @@ snapshots: remove-trailing-separator: 1.1.0 replace-ext: 1.0.1 - vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.0))(esbuild@0.28.1)(rolldown@1.1.3)(rollup@4.61.1)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)): + vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): dependencies: - unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.0))(esbuild@0.28.1)(rolldown@1.1.3)(rollup@4.61.1)(supports-color@5.5.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) + unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) optionalDependencies: - '@microsoft/api-extractor': 7.56.2(@types/node@26.1.0) + '@microsoft/api-extractor': 7.56.2(@types/node@26.1.2) rollup: 4.61.1 - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) transitivePeerDependencies: - '@rspack/core' - '@vue/language-core' @@ -11026,48 +11201,48 @@ snapshots: - typescript - webpack - vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0): + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.16 - rolldown: 1.1.3 + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.0 + '@types/node': 26.1.2 esbuild: 0.28.1 fsevents: 2.3.3 - sass: 1.101.0 + sass: 1.102.0 sass-embedded: 1.100.0 - vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(canvas@3.2.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)): + vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 + picomatch: 4.0.5 + std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 1.2.4 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0) + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.0 - '@vitest/browser-playwright': 4.1.9(playwright@1.61.1)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.101.0))(vitest@4.1.9) - '@vitest/coverage-v8': 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) - '@vitest/ui': 4.1.9(vitest@4.1.9) - jsdom: 29.1.1(canvas@3.2.3) + '@types/node': 26.1.2 + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + '@vitest/ui': 4.1.10(vitest@4.1.10) + jsdom: 30.0.1(canvas@3.2.3) transitivePeerDependencies: - msw @@ -11077,10 +11252,10 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wait-on@9.0.10(supports-color@5.5.0): + wait-on@9.1.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0): dependencies: - axios: 1.16.1(supports-color@5.5.0) - joi: 18.2.1 + axios: 1.19.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0) + joi: 18.2.3 lodash: 4.18.1 minimist: 1.2.8 rxjs: 7.8.2 @@ -11104,7 +11279,15 @@ snapshots: whatwg-url@16.0.1: dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -11211,7 +11394,7 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.21.0: {} + ws@8.21.1: {} wsl-utils@0.1.0: dependencies: diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 5fb7f03614..2c3a2c026f 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -6,7 +6,7 @@ packages: - "packages/ui" patchedDependencies: - '@zip.js/zip.js@2.8.26': patches/@zip.js__zip.js@2.8.26.patch + '@zip.js/zip.js@2.8.34': patches/@zip.js__zip.js@2.8.26.patch shamefullyHoist: true @@ -31,4 +31,4 @@ overrides: postcss@<8.4.31: ^8.4.31 postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 - playwright: 1.61.1 + playwright@>=1.61.1 <2.0.0-0: "1.62.1" diff --git a/frontend/resources/images/assets/nitrate-welcome-light.svg b/frontend/resources/images/assets/nitrate-welcome-light.svg new file mode 100644 index 0000000000..2ee4fbd363 --- /dev/null +++ b/frontend/resources/images/assets/nitrate-welcome-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/images/assets/nitrate-welcome.svg b/frontend/resources/images/assets/nitrate-welcome.svg index 18ced86fa1..2956031d64 100644 --- a/frontend/resources/images/assets/nitrate-welcome.svg +++ b/frontend/resources/images/assets/nitrate-welcome.svg @@ -1,52 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/frontend/resources/images/form/slide-final-team.svg b/frontend/resources/images/form/slide-final-team.svg index 48f2611188..63f817d00b 100644 --- a/frontend/resources/images/form/slide-final-team.svg +++ b/frontend/resources/images/form/slide-final-team.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/resources/images/icons/stroke-bottom.svg b/frontend/resources/images/icons/stroke-bottom.svg new file mode 100644 index 0000000000..3a6e044748 --- /dev/null +++ b/frontend/resources/images/icons/stroke-bottom.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/images/icons/stroke-extended.svg b/frontend/resources/images/icons/stroke-extended.svg new file mode 100644 index 0000000000..1326c5718c --- /dev/null +++ b/frontend/resources/images/icons/stroke-extended.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/images/icons/stroke-left.svg b/frontend/resources/images/icons/stroke-left.svg new file mode 100644 index 0000000000..24c05ddf03 --- /dev/null +++ b/frontend/resources/images/icons/stroke-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/images/icons/stroke-right.svg b/frontend/resources/images/icons/stroke-right.svg new file mode 100644 index 0000000000..248417c737 --- /dev/null +++ b/frontend/resources/images/icons/stroke-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/images/icons/stroke-top.svg b/frontend/resources/images/icons/stroke-top.svg new file mode 100644 index 0000000000..170ac1e2ab --- /dev/null +++ b/frontend/resources/images/icons/stroke-top.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index bbbb16adb7..35d74a25e8 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -976,7 +976,7 @@ margin: 0; margin-top: $s-1; border-radius: $br-8; - z-index: $z-index-4; + z-index: var(--z-index-dropdown); overflow: hidden auto; background-color: var(--menu-background-color); color: var(--menu-foreground-color); diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index 289d6f9e84..453197068e 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -18,6 +18,7 @@ + {{#isDebug}} {{/isDebug}} diff --git a/frontend/resources/templates/preview-head.mustache b/frontend/resources/templates/preview-head.mustache index 962b214f3b..a27a7bed95 100644 --- a/frontend/resources/templates/preview-head.mustache +++ b/frontend/resources/templates/preview-head.mustache @@ -1,4 +1,5 @@ +