Merge remote-tracking branch 'origin/develop' into staging

This commit is contained in:
Andrey Antukh 2026-08-03 09:16:26 +02:00
commit eacdca3c0a
665 changed files with 49835 additions and 16722 deletions

View File

@ -3,7 +3,7 @@ name: Auto Label and Add to Project
on: on:
issues: issues:
types: [opened] types: [opened]
pull_request: pull_request_target:
types: [opened] types: [opened]
jobs: jobs:

View File

@ -9,16 +9,6 @@ on:
type: string type: string
required: true required: true
default: 'develop' 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: workflow_call:
inputs: inputs:
gh_ref: gh_ref:
@ -26,29 +16,21 @@ on:
type: string type: string
required: true required: true
default: 'develop' 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: concurrency:
group: ${{ github.workflow }}-${{ inputs.gh_ref }} group: ${{ github.workflow }}-${{ inputs.gh_ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build-bundle: # ── 1. Decide whether there is anything to build ───────────────────────
name: Build and Upload Penpot Bundle check:
name: Check current bundle
runs-on: penpot-runner-01 runs-on: penpot-runner-01
env: timeout-minutes: 10
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} outputs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} gh_ref: ${{ steps.vars.outputs.gh_ref }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} bundle_version: ${{ steps.vars.outputs.bundle_version }}
exists: ${{ steps.check.outputs.exists }}
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -63,10 +45,52 @@ jobs:
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
echo "bundle_version=$(git describe --tags --always)" >> $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 - name: Build bundle
env: env:
BUILD_WASM: ${{ inputs.build_wasm }} BUILD_WASM: 'yes'
BUILD_STORYBOOK: ${{ inputs.build_storybook }} BUILD_STORYBOOK: 'yes'
run: ./manage.sh build-bundle run: ./manage.sh build-bundle
- name: Prepare directories for zipping - name: Prepare directories for zipping
@ -80,18 +104,32 @@ jobs:
zip -r zips/penpot.zip penpot zip -r zips/penpot.zip penpot
- name: Upload Penpot bundle to S3 - 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: | 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 - name: Notify Mattermost
if: failure() uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: | TEXT: |
❌ 📦 *[PENPOT] Error building penpot bundles.* ❌ 📦 *[PENPOT] Error building penpot bundles.*
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}` 📄 Triggered from ref: `${{ needs.check.outputs.gh_ref || inputs.gh_ref }}`
Bundle version: `${{ steps.vars.outputs.bundle_version }}` Bundle version: `${{ needs.check.outputs.bundle_version || 'n/a' }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra @infra

View File

@ -11,8 +11,6 @@ jobs:
secrets: inherit secrets: inherit
with: with:
gh_ref: "develop" gh_ref: "develop"
build_wasm: "yes"
build_storybook: "yes"
build-docker: build-docker:
needs: build-bundle needs: build-bundle
@ -20,3 +18,9 @@ jobs:
secrets: inherit secrets: inherit
with: with:
gh_ref: "develop" gh_ref: "develop"
build-admin-console-docker:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "develop"

View File

@ -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"

View File

@ -20,12 +20,19 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Login to Docker Registry - name: Login to Docker Registry (push destination)
uses: docker/login-action@v4 uses: docker/login-action@v4
with: with:
username: ${{ secrets.PUB_DOCKER_USERNAME }} username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }} 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 - name: Build and push DevEnv Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
env: env:
@ -35,12 +42,14 @@ jobs:
file: ./docker/devenv/Dockerfile file: ./docker/devenv/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
provenance: mode=max
sbom: true
tags: ${{ env.DOCKER_IMAGE }}:latest tags: ${{ env.DOCKER_IMAGE }}:latest
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost - name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -20,55 +20,117 @@ concurrency:
group: ${{ github.workflow }}-${{ inputs.gh_ref }} group: ${{ github.workflow }}-${{ inputs.gh_ref }}
cancel-in-progress: true 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: jobs:
build-and-push: # ── 1. Resolve the build key and check the whole set at once ───────────
name: Build and Push Penpot Docker Images prepare:
name: Prepare
runs-on: penpot-runner-02 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: steps:
- name: Set common environment variables - name: Set common environment variables
run: | run: |
# Each job execution will use its own docker configuration. # 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 - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0
ref: ${{ inputs.gh_ref }} 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 - name: Login to Docker Registry
uses: docker/login-action@v4 uses: docker/login-action@v4
with: with:
@ -85,103 +147,140 @@ jobs:
username: ${{ secrets.PUB_DOCKER_USERNAME }} username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }} 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) - name: Extract metadata (tags, labels)
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v6
with: with:
images: images: ${{ matrix.image }}
frontend
backend
exporter
storybook
mcp
labels: | 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 uses: docker/build-push-action@v7
env:
DOCKER_IMAGE: 'backend'
BUNDLE_PATH: './bundle-backend'
with: with:
context: ./docker/images/ context: ./docker/images/
file: ./docker/images/Dockerfile.backend file: ./docker/images/Dockerfile.${{ matrix.image }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true 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 }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
- name: Build and push Frontend Docker image # ── 3. Move the branch tags of ALL images together ─────────────────────
uses: docker/build-push-action@v7 # Runs only when every build succeeded (default `needs` semantics); if
env: # the set was already complete, `build` is skipped and so is this job —
DOCKER_IMAGE: 'frontend' # the S3 marker guarantees the branch tags were already moved.
BUNDLE_PATH: './bundle-frontend' 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: with:
context: ./docker/images/ registry: ${{ secrets.DOCKER_REGISTRY }}
file: ./docker/images/Dockerfile.frontend username: ${{ secrets.DOCKER_USERNAME }}
platforms: linux/amd64,linux/arm64 password: ${{ secrets.DOCKER_PASSWORD }}
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 Exporter Docker image - name: Point branch tags to the new build key
uses: docker/build-push-action@v7 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: env:
DOCKER_IMAGE: 'exporter' AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
BUNDLE_PATH: './bundle-exporter' AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
with: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
context: ./docker/images/ run: |
file: ./docker/images/Dockerfile.exporter echo "${{ github.run_id }}" | aws s3 cp - \
platforms: linux/amd64,linux/arm64 "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
push: true {
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }} echo "### ✅ Image set promoted"
labels: ${{ steps.meta.outputs.labels }} echo ""
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max } >> "$GITHUB_STEP_SUMMARY"
- name: Build and push Storybook Docker image # ── 4. Single failure notification for the whole workflow ─────────────
uses: docker/build-push-action@v7 notify:
env: name: Notify failure
DOCKER_IMAGE: 'storybook' runs-on: penpot-runner-02
BUNDLE_PATH: './bundle-storybook' timeout-minutes: 5
with: needs: [prepare, build, promote]
context: ./docker/images/ if: failure()
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
steps:
- name: Notify Mattermost - name: Notify Mattermost
if: failure() uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
uses: mattermost/action-mattermost-notify@master
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: | TEXT: |
❌ 🐳 *[PENPOT] Error building penpot docker images.* ❌ 🐳 *[PENPOT] Error building/promoting the penpot docker image set.*
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}` 📄 Triggered from ref: `${{ needs.prepare.outputs.gh_ref || inputs.gh_ref }}`
📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}` 📦 Bundle: `${{ needs.prepare.outputs.bundle_version || 'n/a' }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra @infra

View File

@ -11,8 +11,6 @@ jobs:
secrets: inherit secrets: inherit
with: with:
gh_ref: "staging" gh_ref: "staging"
build_wasm: "yes"
build_storybook: "yes"
build-docker: build-docker:
needs: build-bundle needs: build-bundle
@ -20,3 +18,9 @@ jobs:
secrets: inherit secrets: inherit
with: with:
gh_ref: "staging" gh_ref: "staging"
build-admin-console-docker:
uses: ./.github/workflows/build-docker-admin-console.yml
secrets: inherit
with:
gh_ref: "staging"

View File

@ -12,8 +12,6 @@ jobs:
secrets: inherit secrets: inherit
with: with:
gh_ref: ${{ github.ref_name }} gh_ref: ${{ github.ref_name }}
build_wasm: "yes"
build_storybook: "yes"
build-docker: build-docker:
needs: build-bundle needs: build-bundle
@ -26,10 +24,9 @@ jobs:
name: Notifications name: Notifications
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: build-docker needs: build-docker
steps: steps:
- name: Notify Mattermost - name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -131,7 +131,7 @@ jobs:
- name: Notify Mattermost - name: Notify Mattermost
if: failure() if: failure()
uses: mattermost/action-mattermost-notify@master uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -114,7 +114,7 @@ jobs:
- name: Notify Mattermost - name: Notify Mattermost
if: failure() if: failure()
uses: mattermost/action-mattermost-notify@master uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -129,7 +129,7 @@ jobs:
- name: Notify Mattermost - name: Notify Mattermost
if: failure() if: failure()
uses: mattermost/action-mattermost-notify@master uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -103,7 +103,7 @@ jobs:
- name: Notify Mattermost - name: Notify Mattermost
if: failure() if: failure()
uses: mattermost/action-mattermost-notify@master uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with: with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }} MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd MATTERMOST_CHANNEL: bot-alerts-cicd

View File

@ -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

2
.nvmrc
View File

@ -1 +1 @@
v24.18.0 v24.18.1

View File

@ -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

View File

@ -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. IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
* **Linting:** `clj-kondo --lint ../common/src/ src/`. * **Linting:** `pnpm run lint:clj`.
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs. * **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 **Before linting:** if delimiter errors are suspected (after LLM edits), run
`scripts/paren-repair` on the affected files first. Delimiter errors produce `scripts/paren-repair` on the affected files first. Delimiter errors produce

View File

@ -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. 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-<uuid>`) 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 ## 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. `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.

View File

@ -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. - `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. - 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. - `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 ## Shape tree edits
@ -19,6 +21,7 @@
- Full referential/semantic validation currently runs only when file features contain `"components/v2"`. - 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. - 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. - `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 ## Migrations

View File

@ -8,6 +8,9 @@
## Grid assignment ## 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. - 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. - 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. - 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. - `fix-overlaps` is marked dev-only and removes one overlapping cell, preferring empty cells first. Avoid depending on it as normal production repair.

View File

@ -25,7 +25,9 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## Worker policy ## 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 ## Port layout
@ -63,8 +65,8 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
## CLI surface ## 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. - `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` (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. - `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. - `run-devenv`: legacy alias, ws0 non-agentic attached.
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing. - `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`.) - `run-devenv-shell [--instance 0|wsN|N] [cmd...]`: bash in target instance. (`--instance` flag not yet renamed to `--ws`.)

View File

@ -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<Identifier>.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`.

View File

@ -23,7 +23,7 @@ From `frontend/`:
- JS lint currently no-ops via `pnpm run lint:js`. - JS lint currently no-ops via `pnpm run lint:js`.
- SCSS lint: `pnpm run lint:scss`. - SCSS lint: `pnpm run lint:scss`.
- Format checks: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt: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`. - Translation formatting after i18n edits: `pnpm run translations`.
**Before linting:** if delimiter errors are suspected (after LLM edits, or **Before linting:** if delimiter errors are suspected (after LLM edits, or

View File

@ -42,15 +42,15 @@ PR descriptions follow this structure:
## What ## What
<one paragraph: the problem or feature, user-facing impact> <the problem or feature and its user-facing impact short bullet items where there is more than one point>
## Why ## Why
<root cause or motivation, why this change was necessary> <root cause or motivation a short paragraph or bullets>
## How ## How
<high-level approach, key technical decisions> <high-level approach and key decisions bullet items, grouped by area (bold lead-ins) for larger PRs>
``` ```
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR. 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. - **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? - **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. - **Skip the obvious.** Don't explain what `git diff` already shows.
### What NOT to Include ### What NOT to Include

View File

@ -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" project_name: "penpot"
# list of languages for which language servers are started (LSP backend only); choose from:
# list of languages for which language servers are started; choose from: # ada al angular ansible bash
# al ansible bash clojure cpp # bsl clojure cpp cpp_ccls crystal
# cpp_ccls crystal csharp csharp_omnisharp dart # csharp csharp_omnisharp cue dart elixir
# elixir elm erlang fortran fsharp # elm erlang fortran fsharp gdscript
# go groovy haskell haxe hlsl # go groovy haskell haxe hlsl
# java json julia kotlin lean4 # html java json julia kotlin
# lua luau markdown matlab msl # latex lean4 lua luau markdown
# nix ocaml pascal perl php # matlab msl nix ocaml pascal
# php_phpactor powershell python python_jedi python_ty # perl php php_phpactor php_phpantom powershell
# r rego ruby ruby_solargraph rust # python python_jedi python_pyrefly python_ty r
# scala solidity swift systemverilog terraform # rego ruby ruby_solargraph rust scala
# toml typescript typescript_vts vue yaml # scss solidity svelte swift systemverilog
# zig # terraform toml typescript typescript_vts vue
# (This list may be outdated. For the current list, see values of Language enum here: # yaml zig
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # (This list may be outdated; generated with scripts/print_language_list.py;
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # 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: # Note:
# - For C, use cpp # - For C, use cpp
# - For JavaScript, use typescript # - 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 # - For Free Pascal/Lazarus, use pascal
# Special requirements: # Special requirements:
# Some languages require additional setup/installations. # 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. # advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the 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. # The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# No documentation on options means no options are available. # See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {} ls_specific_settings: {}
# list of additional paths to ignore in this project. # list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **. # 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. # Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: [] ignored_paths: []
@ -130,13 +142,38 @@ ignored_memory_patterns: []
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes # See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_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. # Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover # Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries. # symbols and references across package boundaries, but these folders are not indexed by Serena,
# Currently supported for: TypeScript. # i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example: # Example:
# additional_workspace_folders: # additional_workspace_folders:
# - ../sibling-package # - ../sibling-package
# - ../shared-lib # - ../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

View File

@ -8,6 +8,9 @@
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS. 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. - **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. 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**: - **Read the workflow memory BEFORE the corresponding action**:
- Before `git commit``mem:workflow/creating-commits` (commit format, AI-assisted-by trailer) - 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) - Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, Issue Type)

View File

@ -1,5 +1,29 @@
# CHANGELOG # 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) ## 2.17.1 (Unreleased)
### :bug: Bugs fixed ### :bug: Bugs fixed

View File

@ -6,7 +6,7 @@
org.clojure/clojure {:mvn/version "1.12.5"} org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/tools.namespace {:mvn/version "1.5.1"} 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 {:mvn/version "0.16.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
@ -34,15 +34,15 @@
:exclusions [org.slf4j/slf4j-api]} :exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc com.github.seancorfield/next.jdbc
{:mvn/version "1.3.1108"} {:mvn/version "1.3.1118"}
metosin/reitit-core {:mvn/version "0.10.1"} metosin/reitit-core {:mvn/version "0.10.1"}
nrepl/nrepl {:mvn/version "1.7.0"} nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.12"} org.postgresql/postgresql {:mvn/version "42.7.13"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"} 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"} io.whitfin/siphash {:mvn/version "2.0.0"}
@ -51,10 +51,10 @@
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} 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 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"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
@ -63,8 +63,8 @@
;; Pretty Print specs ;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"} pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.46.18"} software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
software.amazon.awssdk/sts {:mvn/version "2.46.18"}} software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
:paths ["src" "resources" "target/classes"] :paths ["src" "resources" "target/classes"]
:aliases :aliases

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL", "author": "Kaleidos INC Sucursal en España SL",
"private": true, "private": true,
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/penpot/penpot" "url": "https://github.com/penpot/penpot"
@ -19,8 +19,8 @@
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
"scripts": { "scripts": {
"lint": "clj-kondo --parallel --lint ../common/src src/", "lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
"check-fmt": "cljfmt check --parallel=true src/ test/", "check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"fmt": "cljfmt fix --parallel=true src/ test/" "fmt:clj": "cljfmt fix --parallel=true src/ test/"
} }
} }

View File

@ -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.

View File

@ -195,21 +195,45 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;"> style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20"
style="display:inline-block;vertical-align:middle;">
<tr> <tr>
<td width="20" height="20" align="center" valign="middle" <td width="20" height="20" align="center" valign="middle"
background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}" 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"> 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 %} {% if organization.initials %}{{organization.initials}}{% endif %}
</td> </td>
</tr> </tr>
</table> </table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;"> <span
{{ organization.name|abbreviate:50 }} style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
</span> </span>
</div> </div>
</td> </td>
</tr> </tr>
{% if organization.sso-active %}
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ 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.
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
If you can't get in, your account probably isn't in the directory yet.
To get access, contact the organization owner.
</div>
</td>
</tr>
{% endif %}
<tr> <tr>
<td align="center" vertical-align="middle" <td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;"> style="font-size:0px;padding:10px 25px;word-break:break-word;">

View File

@ -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.

View File

@ -186,10 +186,31 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div <div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;"> style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %} {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:50 }}”{% if
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div> organization %}
part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}.</div>
</td> </td>
</tr> </tr>
{% if organization.sso-active %}
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ 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.
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
If you can't get in, your account probably isn't in the directory yet.
To get access, contact the organization owner.
</div>
</td>
</tr>
{% endif %}
<tr> <tr>
<td align="center" vertical-align="middle" <td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;"> style="font-size:0px;padding:10px 25px;word-break:break-word;">

View File

@ -1,6 +1,13 @@
Hello! 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: Accept invitation using this link:

View File

@ -0,0 +1,231 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Hi,
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ 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.
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
If you can't get in, your account probably isn't in the directory yet. To get access, contact the
organization owner.
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1 @@
“{{ organization-name|abbreviate:25 }}” uses single sign-on

View File

@ -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.

View File

@ -459,9 +459,10 @@
(let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})] (let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
(if (= status 200) (if (= status 200)
(let [data (json/decode body) (let [data (json/decode body)
data {:token/access (get data :access_token) data {:token/access (get data :access_token)
:token/id (get data :id_token) :token/id (get data :id_token)
:token/type (get data :token_type)}] :token/type (get data :token_type)
:token/expires-in (get data :expires_in)}]
(l/trc :hint "access token fetched" (l/trc :hint "access token fetched"
:token-id (:token/id data) :token-id (:token/id data)
:token-type (:token/type data) :token-type (:token/type data)
@ -619,6 +620,9 @@
(some? (:external-session-id state)) (some? (:external-session-id state))
(assoc :external-session-id (: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 ;; If state token comes with props, merge them. The state token
;; props can contain pm_ and utm_ prefixed query params. ;; props can contain pm_ and utm_ prefixed query params.
(map? (:props state)) (map? (:props state))
@ -761,21 +765,112 @@
;; ORG SSO HELPERS ;; ORG SSO HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn prepare-org-sso-provider (defn- non-blank-uri
"Build an OIDC provider map dynamically from the Nitrate org SSO config. [value]
Uses OIDC discovery via :base-url (or :issuer as fallback) when (when-not (str/blank? value) value))
token/auth/user URIs are absent."
[cfg {:keys [client-id client-secret base-url issuer scopes]}] (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 (prepare-oidc-provider cfg
{:type "oidc" {:type "oidc"
:client-id client-id :client-id client-id
:client-secret client-secret :client-secret client-secret
:base-uri (some-> (or base-url issuer) :base-uri (some-> (non-blank-uri issuer)
(str/rtrim "/") (str/rtrim "/")
(str "/")) (str "/"))
:scopes (into default-oidc-scopes (or scopes #{})) :scopes default-oidc-scopes
:skip-ssrf-check? true})) :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 (defn- auth-handler
[cfg {:keys [params] :as request}] [cfg {:keys [params] :as request}]
(let [provider (resolve-provider cfg params) (let [provider (resolve-provider cfg params)
@ -802,17 +897,15 @@
state (get params :state) state (get params :state)
state (tokens/verify cfg {:token state :iss "oidc"})] 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. ;; code with the OIDC provider to verify authentication actually occurred.
(if-let [dest-url (:dest-url state)] (if-let [dest-url (:dest-url state)]
(let [team-id (:team-id state) (let [organization-id (:organization-id state)
organization-id (:organization-id state) sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
sso (nitrate/call cfg :get-org-sso-by-team {:team-id team-id}) provider (prepare-organization-sso-provider cfg sso)
provider (prepare-org-sso-provider cfg sso) info (get-info cfg provider state code)
;; verify token or throw error
_info (get-info cfg provider state code)
session (session/get-session request) 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) (when (and session organization-id)
(let [props (-> (or (:props session) {}) (let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))] (update :sso assoc organization-id exp))]

View File

@ -419,10 +419,19 @@
:id ::change-email :id ::change-email
:schema schema: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 (def ^:private schema:invite-to-team
[:map [:map
[:invited-by ::sm/text] [:invited-by ::sm/text]
[:team ::sm/text] [:team ::sm/text]
[:organization {:optional true} [:maybe schema:organization-data]]
[:token ::sm/text]]) [:token ::sm/text]])
(def invite-to-team (def invite-to-team
@ -431,27 +440,28 @@
:id ::invite-to-team :id ::invite-to-team
:schema schema:invite-to-team)) :schema schema:invite-to-team))
(def ^:private schema:organization-data (def ^:private schema:invite-to-organization
[:map
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]]
[:avatar-bg-url [:maybe ::sm/uri]]])
(def ^:private schema:invite-to-org
[:map [:map
[:invited-by ::sm/text] [:invited-by ::sm/text]
[:user-name [:maybe ::sm/text]] [:user-name [:maybe ::sm/text]]
[:token ::sm/text] [:token ::sm/text]
[:organization schema:organization-data]]) [:organization schema:organization-data]])
(def invite-to-org (def invite-to-organization
"Org member invitation email." "Organization member invitation email."
(template-factory (template-factory
:id ::invite-to-org :id ::invite-to-organization
:schema schema:invite-to-org)) :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 (def ^:private schema:renewal-notice
[:map [:map

View File

@ -31,7 +31,8 @@
#{"file-media-object" #{"file-media-object"
"file-object-thumbnail" "file-object-thumbnail"
"team-font-variant" "team-font-variant"
"file-data-fragment"}) "file-data-fragment"
"organization"})
(defn get-id (defn get-id
[{:keys [path-params]}] [{:keys [path-params]}]

View File

@ -226,19 +226,19 @@
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)]) (-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count)))) (db/get-update-count))))
(def ^:private sql:clear-org-sso-sessions (def ^:private sql:clear-organization-sso-sessions
(str "UPDATE http_session_v2 " (str "UPDATE http_session_v2 "
"SET props = props #- ARRAY['~:sso', ?]::text[] " "SET props = props #- ARRAY['~:sso', ?]::text[] "
"WHERE props IS NOT NULL " "WHERE props IS NOT NULL "
"AND jsonb_exists(props -> '~:sso', ?)")) "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 "Remove the SSO entry for organization-id from the props of every
session that currently holds it. The key is transit-encoded as the session that currently holds it. The key is transit-encoded as the
string '~u<uuid>' under the '~:sso' path." string '~u<uuid>' under the '~:sso' path."
[pool organization-id] [pool organization-id]
(let [org-key (str "~u" organization-id)] (let [organization-key (str "~u" organization-id)]
(db/exec! pool [sql:clear-org-sso-sessions org-key org-key]))) (db/exec! pool [sql:clear-organization-sso-sessions organization-key organization-key])))
(defn- renew-session? (defn- renew-session?
[{:keys [id modified-at] :as session}] [{:keys [id modified-at] :as session}]

View File

@ -88,7 +88,8 @@
#{:session-id #{:session-id
:password :password
:old-password :old-password
:token}) :token
:client-secret})
(defn extract-utm-params (defn extract-utm-params
"Extracts additional data from params and namespace them under "Extracts additional data from params and namespace them under
@ -153,7 +154,7 @@
;; COLLECTOR API ;; COLLECTOR API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private prepare-context-from-request) (declare prepare-context-from-request)
;; Defines a service that collects the audit/activity log using ;; Defines a service that collects the audit/activity log using
;; internal database. Later this audit log can be transferred to ;; internal database. Later this audit log can be transferred to
@ -182,7 +183,7 @@
(def valid-event? (def valid-event?
(sm/validator schema:event)) (sm/validator schema:event))
(defn- prepare-context-from-request (defn prepare-context-from-request
"Prepare backend event context from request" "Prepare backend event context from request"
[request] [request]
(let [client-event-origin (get-client-event-origin request) (let [client-event-origin (get-client-event-origin request)
@ -413,7 +414,7 @@
(update :ip-addr d/nilv "0.0.0.0") (update :ip-addr d/nilv "0.0.0.0")
(update :props d/nilv {}) (update :props d/nilv {})
(update :context d/nilv {}) (update :context d/nilv {})
(assoc :source "backend") (update :source d/nilv "backend")
(d/without-nils))] (d/without-nils))]
(submit* cfg event))) (submit* cfg event)))
@ -430,7 +431,7 @@
(update :profile-id d/nilv uuid/zero) (update :profile-id d/nilv uuid/zero)
(update :props d/nilv {}) (update :props d/nilv {})
(update :context d/nilv {}) (update :context d/nilv {})
(assoc :source "backend") (update :source d/nilv "backend")
(select-keys event-keys) (select-keys event-keys)
(check-event))] (check-event))]
(db/run! cfg append-audit-entry event)))) (db/run! cfg append-audit-entry event))))

View File

@ -495,6 +495,9 @@
{:name "0151-mod-file-tagged-object-thumbnail-table" {:name "0151-mod-file-tagged-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")} :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" {: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")}]) :fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])

View File

@ -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();

View File

@ -14,19 +14,49 @@
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.schema.generators :as sg] [app.common.schema.generators :as sg]
[app.common.time :as ct] [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.config :as cf]
[app.http.client :as http] [app.http.client :as http]
[app.http.session :as session] [app.http.session :as session]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.util.cache :as cache]
[clojure.core :as c] [clojure.core :as c]
[clojure.string :as str]
[integrant.core :as ig])) [integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; 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 (defn- request-builder
[cfg method uri shared-key profile-id request-params] [cfg method uri shared-key profile-id request-params]
(fn [] (fn []
@ -132,7 +162,7 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:org-summary (def ^:private schema:organization-summary
[:map [:map
[:id ::sm/uuid] [:id ::sm/uuid]
[:name ::sm/text] [:name ::sm/text]
@ -143,13 +173,6 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:is-your-penpot :boolean]]]]]) [: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 ;; TODO Unify with schemas on backend/src/app/http/management.clj
(def ^:private schema:timestamp (def ^:private schema:timestamp
(sm/type-schema (sm/type-schema
@ -166,6 +189,13 @@
:decode/json ct/inst :decode/json ct/inst
:encode/json inst-ms}})) :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 (def ^:private schema:subscription
[:map {:title "Subscription"} [:map {:title "Subscription"}
[:id ::sm/text] [:id ::sm/text]
@ -223,60 +253,52 @@
[:map [:map
[:licenses ::sm/boolean]]) [:licenses ::sm/boolean]])
(defn- get-team-org-api (defn- get-team-organization-api
[cfg {:keys [team-id] :as params}] [cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri "api/teams/" team-id)
(str baseuri cto/schema:team-with-organization params))
"/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}] [cfg {:keys [profile-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri
(str baseuri "api/organizations/"
"/api/organizations/" organization-id
organization-id "members/"
"/members/" profile-id)
profile-id) schema:profile-organization params))
schema:profile-org params)))
(defn- get-org-membership-by-team-api (defn- get-organization-membership-by-team-api
[cfg {:keys [profile-id team-id] :as params}] [cfg {:keys [profile-id team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri
(str baseuri "api/teams/"
"/api/teams/" team-id
team-id "users/"
"/users/" profile-id)
profile-id) schema:profile-organization params))
schema:profile-org params)))
(defn- get-organization-summary-api
(defn- get-org-summary-api
[cfg {:keys [organization-id] :as params}] [cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri
(str baseuri "api/organizations/"
"/api/organizations/" organization-id
organization-id "summary")
"/summary") schema:organization-summary params))
schema:org-summary params)))
(defn- get-owned-orgs-api (defn- get-owned-organizations-api
[cfg {:keys [profile-id] :as params}] [cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri
(str baseuri "api/users/"
"/api/users/" profile-id
profile-id "owned-organizations")
"/owned-organizations") [:vector schema:organization-summary]
[:vector schema:org-summary] params))
params)))
(def ^:private schema:org-summary-counts (def ^:private schema:organization-summary-counts
[:map [:map
[:id ::sm/uuid] [:id ::sm/uuid]
[:name ::sm/text] [:name ::sm/text]
@ -286,101 +308,94 @@
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]] [:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]]) [: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}] [cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [organizations (request-to-nitrate cfg :get
orgs (request-to-nitrate cfg :get (generate-nitrate-uri
(str baseuri "api/users/"
"/api/users/" profile-id
profile-id "owned-organizations-summary")
"/owned-organizations-summary") [:vector schema:organization-summary-counts]
[:vector schema:org-summary-counts] params)]
params)] (mapv (fn [organization]
(mapv (fn [org] (if-let [logo-id (:logo-id organization)]
(if-let [logo-id (:logo-id org)] (assoc organization :custom-photo (generate-public-uri "assets/by-id/" logo-id))
(assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id)) organization))
org)) organizations)))
orgs)))
(defn- cleanup-deleted-penpot-user-api (defn- cleanup-deleted-penpot-user-api
[cfg {:keys [profile-id] :as params}] [cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :post
(request-to-nitrate cfg :post (generate-nitrate-uri
(str baseuri "api/users/"
"/api/users/" profile-id
profile-id "cleanup-after-deletion")
"/cleanup-after-deletion") nil params))
nil params)))
(defn- set-team-org-api (defn- set-team-organization-api
[cfg {:keys [organization-id team-id is-default] :as params}] [cfg {:keys [organization-id team-id is-default] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [params (assoc params :request-params {:team-id team-id
params (assoc params :request-params {:team-id team-id
:is-your-penpot (true? is-default)}) :is-your-penpot (true? is-default)})
team (request-to-nitrate cfg :post team (request-to-nitrate cfg :post
(str baseuri (generate-nitrate-uri
"/api/organizations/" "api/organizations/"
organization-id organization-id
"/add-team") "add-team")
cto/schema:team-with-organization params) cto/schema:team-with-organization params)
custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])] 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 (cond-> team
custom-photo custom-photo
(assoc-in [:organization :custom-photo] 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}] [cfg {:keys [profile-id organization-id team-id email] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [request-params (cond-> {:user-id profile-id :team-id team-id}
request-params (cond-> {:user-id profile-id :team-id team-id}
(some? email) (assoc :email email)) (some? email) (assoc :email email))
params (assoc params :request-params request-params)] params (assoc params :request-params request-params)]
(request-to-nitrate cfg :post (request-to-nitrate cfg :post
(str baseuri (generate-nitrate-uri
"/api/organizations/" "api/organizations/"
organization-id organization-id
"/add-user") "add-user")
schema:profile-org params))) schema:profile-organization params)))
(defn- remove-profile-from-org-api (defn- remove-profile-from-organization-api
[cfg {:keys [profile-id organization-id] :as params}] [cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [request-params (cond-> {:user-id profile-id}
params (assoc params :request-params {: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 (request-to-nitrate cfg :post
(str baseuri (generate-nitrate-uri
"/api/organizations/" "api/organizations/"
organization-id organization-id
"/remove-user") "remove-user")
nil params))) nil params)))
(defn- remove-team-from-org-api (defn- remove-team-from-organization-api
[cfg {:keys [team-id organization-id] :as params}] [cfg {:keys [team-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [params (assoc params :request-params {:team-id team-id})]
params (assoc params :request-params {:team-id team-id})]
(request-to-nitrate cfg :post (request-to-nitrate cfg :post
(str baseuri (generate-nitrate-uri
"/api/organizations/" "api/organizations/"
organization-id organization-id
"/remove-team") "remove-team")
nil params))) nil params)))
(defn- delete-team-api (defn- delete-team-api
[cfg {:keys [team-id] :as params}] [cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :delete
(request-to-nitrate cfg :delete (generate-nitrate-uri "api/teams/" team-id)
(str baseuri nil params))
"/api/teams/"
team-id)
nil params)))
(defn- get-subscription-api (defn- get-subscription-api
[cfg {:keys [profile-id] :as params}] [cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri "api/subscriptions/" profile-id)
(str baseuri schema:subscription params))
"/api/subscriptions/"
profile-id)
schema:subscription params)))
(def ^:private schema:subscription-warning (def ^:private schema:subscription-warning
[:maybe [:maybe
@ -392,80 +407,79 @@
(defn- get-subscription-warning-api (defn- get-subscription-warning-api
[cfg {:keys [penpot-id profile-id] :as params}] [cfg {:keys [penpot-id profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [penpot-id (or penpot-id profile-id)]
penpot-id (or penpot-id profile-id)]
(request-to-nitrate cfg :get (request-to-nitrate cfg :get
(str baseuri (generate-nitrate-uri "api/subscription-warning/" penpot-id)
"/api/subscription-warning/"
penpot-id)
schema:subscription-warning params))) schema:subscription-warning params)))
(defn- get-connectivity-api (defn- get-connectivity-api
[cfg params] [cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri "api/connectivity")
(str baseuri schema:connectivity params))
"/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 (def ^:private schema:redeem-result
[:map [:map
[:cancel-at [:maybe schema:timestamp]]]) [:cancel-at [:maybe schema:timestamp]]])
(defn- get-org-permissions-api (defn- get-organization-permissions-api
[cfg {:keys [organization-id] :as params}] [cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri
(str baseuri "api/organizations/"
"/api/organizations/" organization-id
organization-id "permissions")
"/permissions") [:map
[:map [:organization-id ::sm/uuid]
[:organization-id ::sm/uuid] [:owner-id ::sm/uuid]
[:owner-id ::sm/uuid] [:permissions [:map-of :keyword :string]]]
[:permissions [:map-of :keyword :string]]] params))
params)))
(def ^:private schema:nitrate-sso (defn- get-organization-sso-api
[:map "Fetches the SSO configuration for an organization from Nitrate."
[:organization-id ::sm/uuid] [cfg {:keys [organization-id] :as params}]
[:active [:maybe :boolean]] (request-to-nitrate cfg :get
[:provider [:maybe :string]] (generate-nitrate-uri
[:client-id [:maybe :string]] "api/organizations/"
[:base-url [:maybe :string]] organization-id
[:client-secret [:maybe :string]] "sso")
[:issuer [:maybe :string]] schema:nitrate-sso
[:scopes [:maybe [::sm/set ::sm/text]]]]) params))
(defn- get-org-sso-by-team-api (defn- get-organization-sso-by-team-api
[cfg {:keys [team-id] :as params}] [cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri "api/teams/" team-id "sso")
(str baseuri schema:nitrate-sso
"/api/teams/" params))
team-id
"/sso")
schema:nitrate-sso
params)))
(defn- get-org-members-api (defn- get-organization-members-api
[cfg {:keys [organization-id] :as params}] [cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :get
(request-to-nitrate cfg :get (generate-nitrate-uri
(str baseuri "api/organizations/"
"/api/organizations/" organization-id
organization-id "members-list")
"/members-list") [:vector ::sm/uuid]
[:vector ::sm/uuid] params))
params)))
(defn- redeem-activation-code-api (defn- redeem-activation-code-api
[cfg params] [cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)] (request-to-nitrate cfg :post
(request-to-nitrate cfg :post (generate-nitrate-uri "api/activation-codes/redeem")
(str baseuri "/api/activation-codes/redeem") schema:redeem-result
schema:redeem-result (assoc params :throw-on-error? true)))
(assoc params :throw-on-error? true))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION ;; INITIALIZATION
@ -474,39 +488,85 @@
(defmethod ig/init-key ::client (defmethod ig/init-key ::client
[_ cfg] [_ cfg]
(when (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org-api cfg) {:get-team-organization (partial get-team-organization-api cfg)
:set-team-org (partial set-team-org-api cfg) :set-team-organization (partial set-team-organization-api cfg)
:get-org-membership (partial get-org-membership-api cfg) :get-organization-membership (partial get-organization-membership-api cfg)
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg) :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
:get-org-summary (partial get-org-summary-api cfg) :get-organization-summary (partial get-organization-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-api cfg) :get-owned-organizations (partial get-owned-organizations-api cfg)
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg) :get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg)
:get-org-members (partial get-org-members-api cfg) :get-organization-members (partial get-organization-members-api cfg)
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg) :cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg) :add-profile-to-organization (partial add-profile-to-organization-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg) :remove-profile-from-organization (partial remove-profile-from-organization-api cfg)
:get-org-permissions (partial get-org-permissions-api cfg) :get-organization-permissions (partial get-organization-permissions-api cfg)
:get-org-sso-by-team (partial get-org-sso-by-team-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) :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 (partial get-subscription-api cfg)
:get-subscription-warning (partial get-subscription-warning-api cfg) :get-subscription-warning (partial get-subscription-warning-api cfg)
:connectivity (partial get-connectivity-api cfg) :connectivity (partial get-connectivity-api cfg)
:get-identity (partial get-identity-api cfg)
:redeem-activation-code (partial redeem-activation-code-api cfg)})) :redeem-activation-code (partial redeem-activation-code-api cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS ;; 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? (defn sso-session-authorized?
"Fetches the org-SSO config for the given team and checks whether "Fetches the organization-SSO config for the given organization or team and checks
the HTTP request has a valid session entry for it. Returns a map whether the HTTP request has a valid session entry for it. Returns a map
with :authorized and :sso keys." with :authorized and :sso keys."
[cfg team-id request] [cfg organization-id team-id request]
(let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})] (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) (if-not (:active sso)
{:authorized true :sso sso} {:authorized true :sso sso}
(if (or (:issuer sso) (:base-url sso)) (if-not (str/blank? (:issuer sso))
(let [props (:props session) (let [props (:props session)
sso-map (get props :sso {}) sso-map (get props :sso {})
organization-id (:organization-id sso) organization-id (:organization-id sso)
@ -536,21 +596,21 @@
:cause cause) :cause cause)
profile))))) profile)))))
(defn add-org-info-to-team (defn add-organization-info-to-team
"Enriches a team map with organization information from Nitrate. "Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. 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." Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
[cfg team params] [cfg team params]
(try (try
(let [params (assoc (or params {}) :team-id (:id team)) (let [params (assoc (or params {}) :team-id (:id team))
team-with-org (call cfg :get-team-org params) team-with-organization (call cfg :get-team-organization params)
org (:organization team-with-org)] organization (:organization team-with-organization)]
(if (some? org) (if (some? organization)
(-> (cto/apply-organization team (assoc org :custom-photo (-> (cto/apply-organization team (assoc organization :custom-photo
(when-let [logo-id (:logo-id org)] (when-let [logo-id (:logo-id organization)]
(str (cf/get :public-uri) "/assets/by-id/" logo-id)))) (generate-public-uri "assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org))))) (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
team)) team))
(catch Throwable cause (catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type)) (if (= :nitrate-unavailable (-> cause ex-data :type))
@ -570,10 +630,10 @@
:team-id (:id team) :team-id (:id team)
:organization-id (:organization-id params) :organization-id (:organization-id params)
:is-default (:is-default params)) :is-default (:is-default params))
result (call cfg :set-team-org params)] result (call cfg :set-team-organization params)]
(when (nil? result) (when (nil? result)
(ex/raise :type :internal (ex/raise :type :internal
:code :failed-to-set-team-org :code :failed-to-set-team-organization
:context {:team-id (:id team) :context {:team-id (:id team)
:organization-id (:organization-id params)})) :organization-id (:organization-id params)}))
team)) team))

View File

@ -250,64 +250,71 @@
f)) f))
(defonce ^:private org-sso-auth-cache (defonce ^:private organization-sso-auth-cache
(cache/create :expire "15m" :max-size 1024)) (cache/create :expire "15m" :max-size 1024))
(defn invalidate-org-sso-cache-by-org! (defn invalidate-organization-sso-cache-by-organization!
"Invalidates all org-SSO authorization cache entries for the given organization-id." "Invalidates all organization-SSO authorization cache entries for the given organization-id."
[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 (defn- wrap-nitrate-sso
"Enforce Nitrate organization SSO authentication for RPC handlers. "Enforce Nitrate organization SSO authentication for RPC handlers.
Resolves the team context from request params using priority order: Resolves the organization/team context from request params using priority order:
1. Explicit :team-id param 1. Explicit :organization-id param
2. Explicit :project-id param lookup project.team_id 2. Explicit :team-id param
3. Explicit :file-id param lookup file's team via join 3. Explicit :project-id param -> lookup project.team_id
4. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file) 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 Once the context is resolved, checks if the user is authorized within that organization's
session using nitrate/sso-session-authorized?. Results are cached by [profile-id cache-ref] SSO session using nitrate/sso-session-authorized?. Authorized results are cached
for 15 minutes to avoid repeated lookups. by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
Only activates when: Only activates when:
- Nitrate flag is enabled - Nitrate flag is enabled
- Endpoint requires authentication (::auth true by default) - 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] [_ f mdata]
(if (and (contains? cf/flags :nitrate) (if (and (contains? cf/flags :nitrate)
(::auth mdata true) ;; only for endpoints that needs auth (::auth mdata true) ;; only for endpoints that needs auth
(::nitrate/sso mdata true)) (::nitrate/sso mdata true))
(fn [cfg params] (fn [cfg params]
;; Resolve team/project/file from explicit keys or from :id via metadata ;; Resolve team/project/file from explicit keys or from :id via metadata
(let [id-type (::id-type mdata) (let [profile-id (::profile-id params)
id (uuid/coerce (:id params)) organization-id (uuid/coerce (:organization-id params))
team-id (or (uuid/coerce (:team-id params)) id-type (::id-type mdata)
(when (= id-type :team) id)) id (uuid/coerce (:id params))
project-id (or (uuid/coerce (:project-id params)) team-id (or (uuid/coerce (:team-id params))
(when (= id-type :project) id)) (when (= id-type :team) id))
file-id (or (uuid/coerce (:file-id params)) project-id (or (uuid/coerce (:project-id params))
(when (= id-type :file) id))] (when (= id-type :project) id))
(if (or team-id project-id file-id) file-id (or (uuid/coerce (:file-id params))
(let [cache-ref (or team-id project-id file-id) (when (= id-type :file) id))]
profile-id (::profile-id params) (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] 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) result (if (some? cached)
cached cached
(let [team-id (or team-id (let [team-id (when-not organization-id
(when project-id (or team-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) (when project-id
(:id (teams/get-team-for-file cfg file-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)) 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 entry {:authorized authorized
:organization-id (:organization-id sso)}] :organization-id (:organization-id sso)}]
(when authorized (when authorized
(cache/get org-sso-auth-cache cache-key (constantly entry))) (cache/get organization-sso-auth-cache cache-key (constantly entry)))
entry))] entry))]
(if (:authorized result) (if (:authorized result)
(f cfg params) (f cfg params)

View File

@ -37,7 +37,8 @@
(let [token-id (uuid/next) (let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future)) expires-at (some-> expiration (ct/in-future))
created-at (ct/now) 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 :uid profile-id
:iat created-at :iat created-at
:tid token-id}) :tid token-id})

View File

@ -74,8 +74,8 @@
::doc/changes [["2.12" "Remove version parameter, only one version is supported"]] ::doc/changes [["2.12" "Remove version parameter, only one version is supported"]]
::webhooks/event? true ::webhooks/event? true
::sm/params schema:export-binfile} ::sm/params schema:export-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] [cfg {:keys [::rpc/profile-id file-id] :as params}]
(files/check-read-permissions! pool profile-id file-id) (files/check-read-permissions! cfg profile-id file-id)
(sse/response (partial export-binfile cfg params))) (sse/response (partial export-binfile cfg params)))
;; --- Command: import-binfile ;; --- Command: import-binfile

View File

@ -230,8 +230,8 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:get-comment-threads} ::sm/params schema:get-comment-threads}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}] [cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! conn profile-id file-id share-id) (files/check-comment-permissions! cfg profile-id file-id share-id)
(get-comment-threads conn profile-id file-id)))) (get-comment-threads conn profile-id file-id))))
(defn- get-comment-threads-sql (defn- get-comment-threads-sql
@ -328,8 +328,8 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:get-comment-thread} ::sm/params schema:get-comment-thread}
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}] [cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! conn profile-id file-id share-id) (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]) (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
(decode-row))))) (decode-row)))))
@ -347,9 +347,9 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:get-comments} ::sm/params schema:get-comments}
[cfg {:keys [::rpc/profile-id thread-id share-id]}] [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)] (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))))) (get-comments conn thread-id)))))
(def sql:get-comments (def sql:get-comments
@ -406,8 +406,8 @@
::doc/changes ["1.15" "Imported from queries and renamed."] ::doc/changes ["1.15" "Imported from queries and renamed."]
::sm/params schema:get-profiles-for-file-comments} ::sm/params schema:get-profiles-for-file-comments}
[cfg {:keys [::rpc/profile-id file-id share-id]}] [cfg {:keys [::rpc/profile-id file-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! conn profile-id file-id share-id) (files/check-comment-permissions! cfg profile-id file-id share-id)
(get-file-comments-users conn file-id profile-id)))) (get-file-comments-users conn file-id profile-id))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -534,9 +534,9 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:update-comment-thread-status ::sm/params schema:update-comment-thread-status
::db/transaction true} ::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)] (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))) (upsert-comment-thread-status! conn profile-id id)))
;; --- COMMAND: Update Comment Thread ;; --- COMMAND: Update Comment Thread
@ -552,9 +552,9 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:update-comment-thread ::sm/params schema:update-comment-thread
::db/transaction true} ::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)] (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 (db/update! conn :comment-thread
{:is-resolved is-resolved} {:is-resolved is-resolved}
{:id id}) {:id id})
@ -582,7 +582,7 @@
{:keys [team-id project-id] :as file} {:keys [team-id project-id] :as file}
(get-file cfg file-id page-id)] (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/check! cfg {::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id ::quotes/profile-id profile-id
@ -653,7 +653,7 @@
{:keys [file-id page-id] :as thread} {:keys [file-id page-id] :as thread}
(get-comment-thread conn thread-id ::sql/for-update true)] (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 ;; Don't allow edit comments to not owners
(when-not (= owner-id profile-id) (when-not (= owner-id profile-id)
@ -690,9 +690,9 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:delete-comment-thread ::sm/params schema:delete-comment-thread
::db/transaction true} ::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)] (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) (when-not (= owner-id profile-id)
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed)) :code :not-allowed))
@ -713,14 +713,14 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:delete-comment ::sm/params schema:delete-comment
::db/transaction true} ::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} (let [{:keys [owner-id thread-id] :as comment}
(get-comment conn id ::sql/for-update true) (get-comment conn id ::sql/for-update true)
{:keys [file-id]} {:keys [file-id]}
(get-comment-thread conn thread-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) (when-not (= owner-id profile-id)
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed)) :code :not-allowed))
@ -743,9 +743,9 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:update-comment-thread-position ::sm/params schema:update-comment-thread-position
::db/transaction true} ::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)] (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 (db/update! conn :comment-thread
{:modified-at request-at {:modified-at request-at
:position (db/pgpoint position) :position (db/pgpoint position)
@ -767,9 +767,9 @@
{::doc/added "1.15" {::doc/added "1.15"
::sm/params schema:update-comment-thread-frame ::sm/params schema:update-comment-thread-frame
::db/transaction true} ::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)] (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 (db/update! conn :comment-thread
{:modified-at request-at {:modified-at request-at
:frame-id frame-id} :frame-id frame-id}

View File

@ -84,10 +84,10 @@
(perms/make-edition-predicate-fn bfc/get-file-permissions)) (perms/make-edition-predicate-fn bfc/get-file-permissions))
(def has-read-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? (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! (def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?)) (perms/make-check-fn has-edit-permissions?))
@ -99,8 +99,8 @@
;; explicit comment permissions through the share-id ;; explicit comment permissions through the share-id
(defn check-comment-permissions! (defn check-comment-permissions!
[conn profile-id file-id share-id] [cfg profile-id file-id share-id]
(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)
can-read (has-read-permissions? perms) can-read (has-read-permissions? perms)
can-comment (has-comment-permissions? perms)] can-comment (has-comment-permissions? perms)]
(when-not (or can-read can-comment) (when-not (or can-read can-comment)
@ -152,7 +152,7 @@
(defn- get-minimal-file-with-perms (defn- get-minimal-file-with-perms
[cfg {:keys [:id ::rpc/profile-id]}] [cfg {:keys [:id ::rpc/profile-id]}]
(let [mfile (get-minimal-file cfg 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))) (assoc mfile :permissions perms)))
(defn get-file-etag (defn get-file-etag
@ -173,7 +173,7 @@
::sm/params schema:get-file ::sm/params schema:get-file
::sm/result schema:file-with-permissions ::sm/result schema:file-with-permissions
::db/transaction true} ::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 ;; The COND middleware makes initial request for a file and
;; permissions when the incoming request comes with an ;; permissions when the incoming request comes with an
;; ETAG. When ETAG does not matches, the request is resolved ;; ETAG. When ETAG does not matches, the request is resolved
@ -181,10 +181,10 @@
;; will be already prefetched and we just reuse them instead ;; will be already prefetched and we just reuse them instead
;; of making an additional database queries. ;; of making an additional database queries.
(let [perms (or (:permissions (::cond/object params)) (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) (check-read-permissions! perms)
(let [team (teams/get-team conn (let [team (teams/get-team cfg
:profile-id profile-id :profile-id profile-id
:project-id project-id :project-id project-id
:file-id id) :file-id id)
@ -244,7 +244,7 @@
::sm/result schema:file-fragment} ::sm/result schema:file-fragment}
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
(db/run! cfg (fn [cfg] (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) (check-read-permissions! perms)
(-> (get-file-fragment cfg file-id fragment-id) (-> (get-file-fragment cfg file-id fragment-id)
(rph/with-http-cache long-cache-duration)))))) (rph/with-http-cache long-cache-duration))))))
@ -288,7 +288,7 @@
::sm/params schema:get-project-files ::sm/params schema:get-project-files
::sm/result schema:files} ::sm/result schema:files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}] [{: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)) (get-project-files pool project-id))
;; --- COMMAND QUERY: has-file-libraries ;; --- COMMAND QUERY: has-file-libraries
@ -306,7 +306,7 @@
::sm/result ::sm/boolean} ::sm/result ::sm/boolean}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)] (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))) (get-has-file-libraries conn file-id)))
(def ^:private sql:has-file-libraries (def ^:private sql:has-file-libraries
@ -339,7 +339,7 @@
::sm/result ::sm/int} ::sm/result ::sm/int}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
(dm/with-open [conn (db/open pool)] (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))) (get-library-usage conn file-id)))
(def ^:private sql:get-library-usage (def ^:private sql:get-library-usage
@ -389,7 +389,7 @@
:code :params-validation :code :params-validation
:hint "page-id is required when object-id is provided")) :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) file (bfc/get-file cfg file-id :read-only? true)
proj (db/get conn :project {:id (:project-id file)}) proj (db/get conn :project {:id (:project-id file)})
@ -440,8 +440,8 @@
::sm/params schema:get-page} ::sm/params schema:get-page}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}] [cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/tx-run! cfg (db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}] (fn [cfg]
(check-read-permissions! conn profile-id file-id share-id) (check-read-permissions! cfg profile-id file-id share-id)
(get-page cfg (assoc params :profile-id profile-id))))) (get-page cfg (assoc params :profile-id profile-id)))))
;; --- COMMAND QUERY: get-team-shared-files ;; --- COMMAND QUERY: get-team-shared-files
@ -564,7 +564,7 @@
(defn- get-team-shared-files (defn- get-team-shared-files
[{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}] [{: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 (let [process-row
(fn [{:keys [id library-file-ids]}] (fn [{:keys [id library-file-ids]}]
@ -677,8 +677,8 @@
::sm/params schema:get-file-stats ::sm/params schema:get-file-stats
::sm/result schema:get-file-stats-result ::sm/result schema:get-file-stats-result
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}] [cfg {:keys [::rpc/profile-id id]}]
(check-read-permissions! conn profile-id id) (check-read-permissions! cfg profile-id id)
(get-file-stats cfg id)) (get-file-stats cfg id))
@ -721,7 +721,7 @@
::sm/params schema:get-library-file-references} ::sm/params schema:get-library-file-references}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(dm/with-open [conn (db/open pool)] (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))) (get-library-file-references conn file-id)))
;; --- COMMAND QUERY: get-team-recent-files ;; --- COMMAND QUERY: get-team-recent-files
@ -765,7 +765,7 @@
::sm/params schema:get-team-recent-files} ::sm/params schema:get-team-recent-files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)] (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))) (get-team-recent-files conn team-id)))
@ -810,8 +810,8 @@
{::doc/added "2.12" {::doc/added "2.12"
::sm/params schema:get-team-deleted-files} ::sm/params schema:get-team-deleted-files}
[cfg {:keys [::rpc/profile-id team-id]}] [cfg {:keys [::rpc/profile-id team-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(teams/check-read-permissions! conn profile-id team-id) (teams/check-read-permissions! cfg profile-id team-id)
(get-team-deleted-files conn team-id)))) (get-team-deleted-files conn team-id))))
;; --- COMMAND QUERY: get-file-info ;; --- COMMAND QUERY: get-file-info

View File

@ -22,6 +22,7 @@
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes] [app.rpc.quotes :as quotes]
[app.util.services :as sv])) [app.util.services :as sv]))
@ -33,8 +34,8 @@
{::doc/added "1.20" {::doc/added "1.20"
::sm/params schema:get-file-snapshots} ::sm/params schema:get-file-snapshots}
[cfg {:keys [::rpc/profile-id file-id] :as params}] [cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-read-permissions! conn profile-id file-id) (files/check-read-permissions! cfg profile-id file-id)
(fsnap/get-visible-snapshots conn file-id)))) (fsnap/get-visible-snapshots conn file-id))))
;; --- COMMAND QUERY: get-file-snapshot ;; --- COMMAND QUERY: get-file-snapshot
@ -52,8 +53,8 @@
::sm/params schema:get-file-snapshot ::sm/params schema:get-file-snapshot
::sm/result files/schema:file-with-permissions ::sm/result files/schema:file-with-permissions
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] [cfg {:keys [::rpc/profile-id file-id id] :as params}]
(let [perms (bfc/get-file-permissions conn profile-id file-id)] (let [perms (perms/get-file-read-permissions cfg profile-id file-id)]
(files/check-read-permissions! perms) (files/check-read-permissions! perms)
(let [snapshot (fsnap/get-snapshot cfg file-id id)] (let [snapshot (fsnap/get-snapshot cfg file-id id)]
(when-not snapshot (when-not snapshot

View File

@ -85,7 +85,7 @@
::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]} ::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}]
(dm/with-open [conn (db/open pool)] (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 (if tag
(get-object-thumbnails-by-tag conn file-id tag) (get-object-thumbnails-by-tag conn file-id tag)
(get-object-thumbnails conn file-id)))) (get-object-thumbnails conn file-id))))
@ -197,9 +197,9 @@
::sm/params schema:get-file-data-for-thumbnail ::sm/params schema:get-file-data-for-thumbnail
::sm/result schema:partial-file} ::sm/result schema:partial-file}
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}] [cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/run! cfg (fn [cfg]
(files/check-read-permissions! conn profile-id file-id) (files/check-read-permissions! cfg profile-id file-id)
(let [team (teams/get-team conn (let [team (teams/get-team cfg
:profile-id profile-id :profile-id profile-id
:file-id file-id) :file-id file-id)
file (bfc/get-file cfg file-id file (bfc/get-file cfg file-id

View File

@ -6,7 +6,6 @@
(ns app.rpc.commands.fonts (ns app.rpc.commands.fonts
(:require (:require
[app.binfile.common :as bfc]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
@ -30,6 +29,7 @@
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph] [app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.rpc.quotes :as quotes] [app.rpc.quotes :as quotes]
[app.storage :as sto] [app.storage :as sto]
[app.storage.tmp :as tmp] [app.storage.tmp :as tmp]
@ -71,14 +71,14 @@
(cond (cond
(uuid? team-id) (uuid? team-id)
(do (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 (db/query conn :team-font-variant
{:team-id team-id {:team-id team-id
:deleted-at nil})) :deleted-at nil}))
(uuid? project-id) (uuid? project-id)
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-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 (db/query conn :team-font-variant
{:team-id (:team-id project) {:team-id (:team-id project)
:deleted-at nil})) :deleted-at nil}))
@ -86,7 +86,7 @@
(uuid? file-id) (uuid? file-id)
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-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]}) 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) (files/check-read-permissions! perms)
(db/query conn :team-font-variant (db/query conn :team-font-variant
{:team-id (:team-id project) {:team-id (:team-id project)
@ -400,7 +400,7 @@
::sm/params schema:download-font} ::sm/params schema:download-font}
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(let [variant (db/get pool :team-font-variant {: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). ;; Try to get the best available font format (prefer TTF for broader compatibility).
(let [media-id (or (:ttf-file-id variant) (let [media-id (or (:ttf-file-id variant)
@ -432,7 +432,7 @@
(ex/raise :type :not-found (ex/raise :type :not-found
:code :object-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") (let [tempfile (tmp/tempfile :suffix ".zip")
ffamily (-> variants first :font-family)] ffamily (-> variants first :font-family)]

View File

@ -176,7 +176,7 @@
;; profile-id is present; it can be ommited if this function is ;; profile-id is present; it can be ommited if this function is
;; called from SREPL helpers where no profile is available ;; called from SREPL helpers where no profile is available
(when (uuid? profile-id) (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)}})] (binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})]
(let [projs (bfc/get-team-projects cfg team-id) (let [projs (bfc/get-team-projects cfg team-id)

View File

@ -11,6 +11,7 @@
[app.auth.oidc :as oidc] [app.auth.oidc :as oidc]
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms] [app.common.types.nitrate-permissions :as nitrate-perms]
@ -21,9 +22,11 @@
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph] [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.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] (defn assert-is-owner [cfg profile-id team-id]
@ -39,11 +42,11 @@
:code :cant-move-default-team)))) :code :cant-move-default-team))))
(defn assert-membership [cfg profile-id organization-id] (defn assert-membership [cfg profile-id organization-id]
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id (let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})] :organization-id organization-id})]
(when-not (:organization-id membership) (when-not (:organization-id membership)
(ex/raise :type :validation (ex/raise :type :validation
:code :organization-doesnt-exists)) :code :organization-does-not-exist))
(when-not (:is-member membership) (when-not (:is-member membership)
(ex/raise :type :validation (ex/raise :type :validation
@ -113,6 +116,35 @@
:cause cause) :cause cause)
(throw 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 (def ^:private sql:prefix-team-name-and-unset-default
"UPDATE team "UPDATE team
SET name = ? || name, 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?] [{: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)) (let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
files-counts (get-team-files-counts conn all-teams) files-counts (get-team-files-counts conn all-teams)
@ -162,18 +194,18 @@
{:deletable-team-ids deletable {:deletable-team-ids deletable
:keep-default-team? keep-default? :keep-default-team? keep-default?
:delete-default-team? (boolean (and default-team-id (not 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] [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]} (let [{:keys [deletable-team-ids detach-from-organization-team-ids]}
(build-leave-org-plan cfg default-team-id teams-to-delete nil)] (build-leave-organization-plan cfg default-team-id teams-to-delete nil)]
{:teams-to-delete (count deletable-team-ids) {:teams-to-delete (count deletable-team-ids)
:teams-to-transfer teams-to-transfer-count :teams-to-transfer teams-to-transfer-count
:teams-to-exit teams-to-exit-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 [:map
[:id ::sm/uuid] [:id ::sm/uuid]
[:name ::sm/text] [:name ::sm/text]
@ -186,47 +218,49 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:reassign-to {:optional true} ::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 [:map
[:teams-to-delete ::sm/int] [:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int] [:teams-to-transfer ::sm/int]
[:teams-to-exit ::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 [:map
[:id ::sm/uuid] [:id ::sm/uuid]
[:default-team-id ::sm/uuid]]) [:default-team-id ::sm/uuid]])
(defn- get-organization-teams-for-user (defn- get-organization-teams-for-user
[{:keys [::db/conn] :as cfg} org-summary profile-id] [{:keys [::db/conn] :as cfg} organization-summary profile-id]
(let [org-team-ids (->> (:teams org-summary) (let [organization-team-ids (->> (:teams organization-summary)
(map :id)) (map :id))
ids-array (db/create-array conn "uuid" org-team-ids)] ids-array (db/create-array conn "uuid" organization-team-ids)]
(db/exec! conn [sql:get-member-teams-info profile-id ids-array]))) (db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
(defn- calculate-valid-teams (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 (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 ;; 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 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 %) (filter #(and (:is-owner %)
(= (:num-members %) 1))) (= (:num-members %) 1)))
(map :id) (map :id)
(into #{})) (into #{}))
;; valid teams to transfer are those that the user is owner, and have more than one member ;; 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 %) (filter #(and (:is-owner %)
(> (:num-members %) 1)))) (> (: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 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 %)) (filter #(and (not (:is-owner %))
(> (:num-members %) 1))))] (> (:num-members %) 1))))]
{:valid-teams-to-delete-ids valid-teams-to-delete-ids {:valid-teams-to-delete-ids valid-teams-to-delete-ids
@ -235,17 +269,17 @@
:valid-default-team valid-default-team}))) :valid-default-team valid-default-team})))
(defn get-valid-teams [cfg organization-id profile-id default-team-id] (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}) (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
org-teams (get-organization-teams-for-user cfg org-summary profile-id)] organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)]
(calculate-valid-teams org-teams default-team-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] (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}) (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
org-teams (get-organization-teams-for-user cfg org-summary profile-id) organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)
{:keys [valid-teams-to-delete-ids {:keys [valid-teams-to-delete-ids
valid-teams-to-transfer valid-teams-to-transfer
valid-teams-to-exit 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 ;; - 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; ;; 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 ;; - 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? (and
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{}))) (= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
(every? (fn [{:keys [id reassign-to]}] (every? (fn [{:keys [id reassign-to]}]
@ -274,10 +308,10 @@
(contains? members reassign-to))) (contains? members reassign-to)))
(contains? valid-teams-to-exit-ids id))) (contains? valid-teams-to-exit-ids id)))
teams-to-leave))] teams-to-leave))]
;; the org owner cannot leave ;; the organization owner cannot leave
(when (= (:owner-id org-summary) profile-id) (when (= (:owner-id organization-summary) profile-id)
(ex/raise :type :validation (ex/raise :type :validation
:code :org-owner-cannot-leave)) :code :organization-owner-cannot-leave))
(when (or (when (or
(not valid-teams-to-delete?) (not valid-teams-to-delete?)
@ -288,13 +322,14 @@
(defn leave-org (defn leave-organization
[{:keys [::db/conn] :as cfg} [{: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?]}] {: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) "] ") user-who-delete-member deleted-by-role]}]
(let [organization-prefix (str "[" (d/sanitize-string name) "] ")
{:keys [deletable-team-ids {:keys [deletable-team-ids
keep-default-team? 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 ;; assert that the received teams are valid, checking the different constraints
(when-not skip-validation (when-not skip-validation
@ -311,62 +346,77 @@
(doseq [{:keys [id reassign-to]} teams-to-leave] (doseq [{:keys [id reassign-to]} teams-to-leave]
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to})) (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 (when default-team-id
(if keep-default-team? (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 (teams/delete-team cfg {:profile-id profile-id
:team-id default-team-id}))) :team-id default-team-id})))
;; Detach retained owned teams from the organization in Nitrate. ;; Detach retained owned teams from the organization in Nitrate.
;; Nitrate will rehome them to its fallback/default org. ;; Nitrate will rehome them to its fallback/default organization.
(doseq [team-id detach-from-org-team-ids] (doseq [team-id detach-from-organization-team-ids]
(nitrate/call cfg :remove-team-from-org {:team-id team-id (nitrate/call cfg :remove-team-from-organization {:team-id team-id
:organization-id id})) :organization-id id}))
;; Api call to nitrate ;; 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)) nil))
(sv/defmethod ::leave-org (sv/defmethod ::leave-organization
{::rpc/auth true {::rpc/auth true
::doc/added "2.15" ::doc/added "2.15"
::sm/params schema:leave-org ::sm/params schema:leave-organization
::db/transaction true} ::db/transaction true}
[cfg {:keys [::rpc/profile-id] :as params}] [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 {::rpc/auth true
::doc/added "2.18" ::doc/added "2.18"
::sm/params schema:get-leave-org-summary ::sm/params schema:get-leave-organization-summary
::sm/result schema:get-leave-org-summary-result ::sm/result schema:get-leave-organization-summary-result
::db/transaction true} ::db/transaction true}
[cfg {:keys [::rpc/profile-id id default-team-id]}] [cfg {:keys [::rpc/profile-id id default-team-id]}]
(let [{:keys [valid-teams-to-delete-ids (let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer valid-teams-to-transfer
valid-teams-to-exit valid-teams-to-exit
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id) 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-transfer-count (count valid-teams-to-transfer)
teams-to-exit-count (count valid-teams-to-exit)] teams-to-exit-count (count valid-teams-to-exit)]
(when-not valid-default-team (when-not valid-default-team
(ex/raise :type :validation (ex/raise :type :validation
:code :not-valid-teams)) :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 [:map
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
[:organization-id ::sm/uuid] [:organization-id ::sm/uuid]
[:organization-name ::sm/text]]) [:organization-name ::sm/text]])
(sv/defmethod ::remove-team-from-org (sv/defmethod ::remove-team-from-organization
{::doc/added "2.17" {::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]}] [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
(assert-is-owner cfg profile-id team-id) (assert-is-owner cfg profile-id team-id)
@ -374,32 +424,26 @@
(assert-membership cfg profile-id organization-id) (assert-membership cfg profile-id organization-id)
;; Check moveTeams permission on the source organization ;; Check moveTeams permission on the source organization
(when (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
(let [org-perms (nitrate/call cfg :get-org-permissions (let [organization-perms (nitrate/call cfg :get-organization-permissions
{:organization-id organization-id})] {:organization-id organization-id})]
(if (nil? org-perms) (if (nil? organization-perms)
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :code :not-allowed
:hint "Unable to verify organization permissions") :hint "Unable to verify organization permissions")
(when-not (nitrate-perms/allowed? :move-team (when-not (nitrate-perms/allowed? :move-team
{:org-perms org-perms {:organization-perms organization-perms
:profile-id profile-id}) :profile-id profile-id})
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :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."))))) :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 ;; 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 ;; 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) 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 (def ^:private sql:delete-team-external-invitations
"DELETE FROM team_invitation "DELETE FROM team_invitation
WHERE team_id = ? WHERE team_id = ?
@ -413,23 +457,22 @@
AND deleted_at IS NULL") AND deleted_at IS NULL")
(defn- get-external-invitation-info (defn- get-external-invitation-info
"Returns info about external (non-org-member) invitations pending for a team. "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 org. External invitations are those sent to users who are not members of the given organization.
Returns {:allows-anybody bool :external-emails [...]}" Returns {:allows-anybody bool :external-emails [...]}"
[{:keys [::db/conn] :as cfg} team-id organization-id] [{:keys [::db/conn] :as cfg} team-id organization-id]
(let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id}) (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})] allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization-perms})]
(if allows-anybody (if allows-anybody
{:allows-anybody true :external-emails []} {:allows-anybody true :external-emails []}
(let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id]) (let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
emails (map :email-to invitation-emails)]
(if (empty? emails) (if (empty? emails)
{:allows-anybody false :external-emails []} {:allows-anybody false :external-emails []}
(let [emails-array (db/create-array conn "text" (vec emails)) (let [emails-array (db/create-array conn "text" (vec emails))
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) 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 external-emails (->> profiles
(remove #(contains? org-member-ids (:id %))) (remove #(contains? organization-member-ids (:id %)))
(map :email) (map :email)
(vec))] (vec))]
{:allows-anybody false :external-emails external-emails})))))) {:allows-anybody false :external-emails external-emails}))))))
@ -444,81 +487,90 @@
::doc/added "2.17" ::doc/added "2.17"
::sm/params schema:add-team-to-organization ::sm/params schema:add-team-to-organization
::db/transaction true} ::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-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id) (assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id) (assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
(let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) (let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
source-org-id (get-in team-with-org [:organization :id]) team-with-organization (nitrate/call cfg :get-team-organization {:team-id team-id})
source-org-perms (when source-org-id source-organization-id (get-in team-with-organization [:organization :id])
(nitrate/call cfg :get-org-permissions source-organization-perms (when source-organization-id
{:organization-id source-org-id})) (nitrate/call cfg :get-organization-permissions
target-org-perms (nitrate/call cfg :get-org-permissions {:organization-id source-organization-id}))
{:organization-id organization-id}) target-organization-perms (nitrate/call cfg :get-organization-permissions
target-org-same-owner? (and (some? source-org-perms) {:organization-id organization-id})
(some? target-org-perms) target-organization-same-owner? (and (some? source-organization-perms)
(= (:owner-id source-org-perms) (some? target-organization-perms)
(:owner-id target-org-perms)))] (= (:owner-id source-organization-perms)
(when (nil? target-org-perms) (:owner-id target-organization-perms)))]
(when (nil? target-organization-perms)
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :code :not-allowed
:hint "Unable to verify organization permissions")) :hint "Unable to verify organization permissions"))
;; Team already belongs to an organization: check move-teams on source org. ;; Team already belongs to an organization: check move-teams on the source organization.
(when (some? source-org-id) (when (some? source-organization-id)
(when (nil? source-org-perms) (when (nil? source-organization-perms)
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :code :not-allowed
:hint "Unable to verify organization permissions")) :hint "Unable to verify organization permissions"))
(when-not (nitrate-perms/allowed? :move-team (when-not (nitrate-perms/allowed? :move-team
{:org-perms source-org-perms {:organization-perms source-organization-perms
:profile-id profile-id :profile-id profile-id
:target-org-same-owner? target-org-same-owner?}) :target-organization-same-owner? target-organization-same-owner?})
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :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."))) :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). ;; Always check target create-teams permission (new/add and move flows).
(when-not (nitrate-perms/allowed? :create-team (when-not (nitrate-perms/allowed? :create-team
{:org-perms target-org-perms {:organization-perms target-organization-perms
:profile-id profile-id}) :profile-id profile-id})
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :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 organization if needed
;; Add teammates to the org if needed (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
(doseq [{member-id :profile-id} team-members new-member-ids (->> team-members
:when (not= member-id profile-id)] (map :profile-id)
(teams/initialize-user-in-nitrate-org cfg member-id organization-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 ;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] (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 ;; Delete pending invitations for users who are not members of the target organization
(notifications/notify-team-change cfg team "dashboard.team-belong-org")) (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 ;; Send warnings via email if the organization has sso
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] (neh/send-organization-setup-sso-emails-for-team!
(when (and (not allows-anybody) (seq external-emails)) cfg organization-id team-id organization-member-ids-before)))
(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])))))
nil) nil)
(def ^:private schema:check-org-members-params (def ^:private schema:check-organization-members-params
[:map {:title "CheckOrgMembersParams"} [:map {:title "CheckOrganizationMembersParams"}
[:organization-id ::sm/uuid] [:organization-id ::sm/uuid]
[:emails [:vector ::sm/email]]]) [:emails [:vector ::sm/email]]])
(sv/defmethod ::check-org-members (sv/defmethod ::check-organization-members
{::rpc/auth true {::rpc/auth true
::doc/added "2.17" ::doc/added "2.17"
::sm/params schema:check-org-members-params ::sm/params schema:check-organization-members-params
::sm/result [:map-of :string :boolean] ::sm/result [:map-of :string :boolean]
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}] [{: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) (let [emails-array (db/create-array conn "text" emails)
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles) 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 {} (into {}
(map (fn [email] (map (fn [email]
(let [pid (get email->id 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))) emails)))
{})) {}))
(def ^:private schema:all-org-members-in-team-params (def ^:private schema:all-organization-members-in-team-params
[:map {:title "CheckOrgMembersInTeamParams"} [:map {:title "CheckOrganizationMembersInTeamParams"}
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
[:organization-id ::sm/uuid]]) [:organization-id ::sm/uuid]])
(sv/defmethod ::all-org-members-in-team (sv/defmethod ::all-organization-members-in-team
{::rpc/auth true {::rpc/auth true
::doc/added "2.17" ::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} ::sm/result ::sm/boolean}
[cfg {:keys [::rpc/profile-id team-id organization-id]}] [cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :nitrate) (if (contains? cf/flags :nitrate)
@ -552,22 +604,22 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :insufficient-permissions)) :code :insufficient-permissions))
(assert-membership cfg profile-id organization-id) (assert-membership cfg profile-id organization-id)
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id}) (let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
org-member-ids (into #{} org-members) organization-member-ids (into #{} organization-members)
team-members (db/query cfg :team-profile-rel {:team-id team-id}) team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))] 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)) false))
(def ^:private schema:all-team-members-in-orgs-params (def ^:private schema:all-team-members-in-organizations-params
[:map {:title "CheckTeamMembersInOrgsParams"} [:map {:title "CheckTeamMembersInOrganizationsParams"}
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
[:organization-ids [:vector ::sm/uuid]]]) [:organization-ids [:vector ::sm/uuid]]])
(sv/defmethod ::all-team-members-in-orgs (sv/defmethod ::all-team-members-in-organizations
{::rpc/auth true {::rpc/auth true
::doc/added "2.17" ::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]} ::sm/result [:map-of ::sm/uuid ::sm/boolean]}
[cfg {:keys [::rpc/profile-id team-id organization-ids]}] [cfg {:keys [::rpc/profile-id team-id organization-ids]}]
(if (contains? cf/flags :nitrate) (if (contains? cf/flags :nitrate)
@ -578,15 +630,15 @@
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))] 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) (run! #(assert-membership cfg profile-id %) organization-ids)
(into {} (into {}
(map (fn [organization-id] (map (fn [organization-id]
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id}) (let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
org-member-ids (into #{} org-members)] organization-member-ids (into #{} organization-members)]
[organization-id [organization-id
(every? #(contains? org-member-ids %) team-member-ids)]))) (every? #(contains? organization-member-ids %) team-member-ids)])))
organization-ids))) organization-ids)))
{})) {}))
@ -621,13 +673,17 @@
(def ^:private schema:check-nitrate-sso (def ^:private schema:check-nitrate-sso
[:map {:title "AuthSsoParams"} [:and
[:team-id ::sm/uuid] [:map {:title "CheckNitrateSsoParams"}
[:url ::sm/uri]]) [: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 (sv/defmethod ::check-nitrate-sso
"Check if a user needs to login into the organization 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 <url>} when SSO is active; Returns {:authorized false :redirect-uri <url>} when SSO is active;
the client must redirect there. The OIDC provider itself handles the client must redirect there. The OIDC provider itself handles
re-authentication transparently if the user already has an active SSO session." re-authentication transparently if the user already has an active SSO session."
@ -635,24 +691,22 @@
::doc/added "2.19" ::doc/added "2.19"
::sm/params schema:check-nitrate-sso ::sm/params schema:check-nitrate-sso
::nitrate/sso false} ::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) (if (contains? cf/flags :nitrate)
(let [request (rph/get-request params) (if (and team-id
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)] (not (teams/has-read-permissions? cfg profile-id team-id)))
(if authorized ;; Let the destination RPC enforce its own permissions. Starting SSO before
{:authorized true} ;; access is established sends unrelated users through the organization's IdP.
(if-let [issuer (or (:issuer sso) (:base-url sso))] {:authorized true}
(let [oidc-provider (oidc/prepare-org-sso-provider cfg sso) (let [request (rph/get-request params)
organization-id (:organization-id sso) {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
state-token (tokens/generate cfg {:iss "oidc" (if authorized
:dest-url url {:authorized true}
:team-id team-id (if (oidc/organization-sso-discovery-uri sso)
:organization-id organization-id
:issuer issuer
:exp (ct/in-future "4h")})
redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)]
{:authorized false {:authorized false
:redirect-uri redirect-uri}) :redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
{:authorized false :dest-url url
:redirect-uri nil}))) :organization-id organization-id)}
{:authorized false
:redirect-uri nil}))))
{:authorized true})) {:authorized true}))

View File

@ -54,18 +54,23 @@
[:newsletter-news {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid] [:onboarding-team-id {:optional true} ::sm/uuid]
[:onboarding-viewed {:optional true} ::sm/boolean] [:onboarding-viewed {:optional true} ::sm/boolean]
[:nitrate-onboarding-viewed {:optional true} ::sm/boolean]
[:v2-info-shown {:optional true} ::sm/boolean] [:v2-info-shown {:optional true} ::sm/boolean]
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]] [:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
[:release-notes-viewed {:optional true} [:release-notes-viewed {:optional true}
[::sm/text {:max 100}]] [::sm/text {:max 100}]]
[:notifications {:optional true} schema:props-notifications] [: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 (def schema:profile
[:map {:title "Profile"} [:map {:title "Profile"}
[:id ::sm/uuid] [:id ::sm/uuid]
[:fullname [::sm/word-string {:max 250}]] [:fullname [::sm/word-string {:max 250}]]
[:email ::sm/email] [:email ::sm/email]
[:theme {:optional true} :string]
[:is-admin {:optional true} ::sm/boolean]
[:is-active {:optional true} ::sm/boolean] [:is-active {:optional true} ::sm/boolean]
[:is-blocked {:optional true} ::sm/boolean] [:is-blocked {:optional true} ::sm/boolean]
[:is-demo {:optional true} ::sm/boolean] [:is-demo {:optional true} ::sm/boolean]
@ -491,10 +496,10 @@
{:id profile-id}) {:id profile-id})
;; Delete owned organizations on the fly (no grace period). ;; 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 ;; Penpot back through two paths: ::notify-user-organizations-deletion
;; (during delete-owned-orgs) and ::notify-organization-deletion. ;; (during delete-owned-organizations) and ::notify-organization-deletion.
;; Both preserve org teams unchanged and only prefix or delete ;; Both preserve organization teams unchanged and only prefix or delete
;; imported "Your Penpot" teams according to whether they still have files. ;; imported "Your Penpot" teams according to whether they still have files.
;; Let Nitrate clean up the data associated with the deleted Penpot user: ;; Let Nitrate clean up the data associated with the deleted Penpot user:
;; owned organizations, remaining memberships, and subscription cancellation. ;; owned organizations, remaining memberships, and subscription cancellation.
@ -558,7 +563,7 @@
::sm/result schema:get-owned-organizations-summary-result} ::sm/result schema:get-owned-organizations-summary-result}
[cfg {:keys [::rpc/profile-id]}] [cfg {:keys [::rpc/profile-id]}]
(if (contains? cf/flags :nitrate) (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 ;; --- HELPERS

View File

@ -10,6 +10,7 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.db.sql :as-alias sql] [app.db.sql :as-alias sql]
[app.features.logical-deletion :as ldel] [app.features.logical-deletion :as ldel]
@ -56,11 +57,16 @@
:can-edit (or is-owner is-admin can-edit) :can-edit (or is-owner is-admin can-edit)
:can-read true}))) :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? (def has-edit-permissions?
(perms/make-edition-predicate-fn get-permissions)) (perms/make-edition-predicate-fn get-permissions))
(def has-read-permissions? (def has-read-permissions?
(perms/make-read-predicate-fn get-permissions)) (perms/make-read-predicate-fn get-read-permissions))
(def check-edition-permissions! (def check-edition-permissions!
(perms/make-check-fn has-edit-permissions?)) (perms/make-check-fn has-edit-permissions?))
@ -159,10 +165,10 @@
{::doc/added "1.18" {::doc/added "1.18"
::rpc/id-type :project ::rpc/id-type :project
::sm/params schema:get-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)] (dm/with-open [conn (db/open pool)]
(let [project (db/get-by-id conn :project id)] (let [project (db/get-by-id conn :project id)]
(check-read-permissions! conn profile-id id) (check-read-permissions! cfg profile-id id)
project))) project)))
@ -179,7 +185,8 @@
timestamp (::rpc/request-at params)] timestamp (::rpc/request-at params)]
(teams/create-project-role conn profile-id (:id project) :owner) (teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel (db/insert! conn :team-project-profile-rel
{:project-id (:id project) {:id (uuid/next)
:project-id (:id project)
:profile-id profile-id :profile-id profile-id
:created-at timestamp :created-at timestamp
:modified-at timestamp :modified-at timestamp
@ -230,8 +237,8 @@
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id) ::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
::webhooks/event? true ::webhooks/event? true
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}] [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
(check-read-permissions! conn profile-id id) (check-read-permissions! cfg profile-id id)
(db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned]) (db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned])
nil) nil)

View File

@ -60,6 +60,11 @@
:can-edit (or is-owner is-admin can-edit) :can-edit (or is-owner is-admin can-edit)
:can-read true}))) :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? (def has-admin-permissions?
(perms/make-admin-predicate-fn get-permissions)) (perms/make-admin-predicate-fn get-permissions))
@ -67,7 +72,7 @@
(perms/make-edition-predicate-fn get-permissions)) (perms/make-edition-predicate-fn get-permissions))
(def has-read-permissions? (def has-read-permissions?
(perms/make-read-predicate-fn get-permissions)) (perms/make-read-predicate-fn get-read-permissions))
(def check-admin-permissions! (def check-admin-permissions!
(perms/make-check-fn has-admin-permissions?)) (perms/make-check-fn has-admin-permissions?))
@ -180,7 +185,6 @@
sql (if (contains? cf/flags :subscriptions) sql (if (contains? cf/flags :subscriptions)
sql:get-teams-with-permissions-and-subscription sql:get-teams-with-permissions-and-subscription
sql:get-teams-with-permissions)] sql:get-teams-with-permissions)]
(->> (db/exec! conn [sql (:default-team-id profile) profile-id]) (->> (db/exec! conn [sql (:default-team-id profile) profile-id])
(into [] xform:process-teams)))) (into [] xform:process-teams))))
@ -194,7 +198,7 @@
(dm/with-open [conn (db/open pool)] (dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id) (cond->> (get-teams conn profile-id)
(contains? cf/flags :nitrate) (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) (contains? cf/flags :nitrate)
(remove #(get-in % [:organization :expired-license]))))) (remove #(get-in % [:organization :expired-license])))))
@ -238,19 +242,34 @@
{::doc/added "1.17" {::doc/added "1.17"
::rpc/id-type :team ::rpc/id-type :team
::sm/params schema:get-team} ::sm/params schema:get-team}
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}] [cfg {:keys [::rpc/profile-id id file-id] :as params}]
(get-team pool :profile-id profile-id :team-id id :file-id file-id)) (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 (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 (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} (let [{:keys [default-team-id] :as profile}
(profile/get-profile conn profile-id) (profile/get-profile cfg profile-id)
sql sql
(if (contains? cf/flags :subscriptions) (if (contains? cf/flags :subscriptions)
@ -262,14 +281,14 @@
(some? team-id) (some? team-id)
(let [sql (str "WITH teams AS (" sql ") " (let [sql (str "WITH teams AS (" sql ") "
"SELECT * FROM teams WHERE id=?")] "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) (some? project-id)
(let [sql (str "WITH teams AS (" sql ") " (let [sql (str "WITH teams AS (" sql ") "
"SELECT t.* FROM teams AS t " "SELECT t.* FROM teams AS t "
" JOIN project AS p ON (p.team_id = t.id) " " JOIN project AS p ON (p.team_id = t.id) "
" WHERE p.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) (some? file-id)
(let [sql (str "WITH teams AS (" sql ") " (let [sql (str "WITH teams AS (" sql ") "
@ -277,17 +296,18 @@
" JOIN project AS p ON (p.team_id = t.id) " " JOIN project AS p ON (p.team_id = t.id) "
" JOIN file AS f ON (f.project_id = p.id) " " JOIN file AS f ON (f.project_id = p.id) "
" WHERE f.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 :else
(throw (IllegalArgumentException. "invalid arguments")))] (throw (IllegalArgumentException. "invalid arguments")))]
(when-not result (if result
(ex/raise :type :not-found (-> result
:code :team-does-not-exist)) (decode-row)
(-> result (process-permissions))
(decode-row) (or (get-organization-owner-viewer-team cfg profile-id default-team-id params)
(process-permissions)))) (ex/raise :type :not-found
:code :team-does-not-exist)))))
;; --- Query: Team Members ;; --- Query: Team Members
@ -316,7 +336,7 @@
::sm/params schema:get-team-memebrs} ::sm/params schema:get-team-memebrs}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)] (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))) (get-team-members conn team-id)))
;; --- Query: Team Users ;; --- Query: Team Users
@ -342,10 +362,10 @@
(dm/with-open [conn (db/open pool)] (dm/with-open [conn (db/open pool)]
(if team-id (if team-id
(do (do
(check-read-permissions! conn profile-id team-id) (check-read-permissions! cfg profile-id team-id)
(get-users conn team-id)) (get-users conn team-id))
(let [{team-id :id} (get-team-for-file conn file-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))))) (get-users conn team-id)))))
;; This is a similar query to team members but can contain more data ;; This is a similar query to team members but can contain more data
@ -432,7 +452,7 @@
::sm/params schema:get-team-stats} ::sm/params schema:get-team-stats}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)] (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))) (get-team-stats conn team-id)))
(def sql:team-stats (def sql:team-stats
@ -468,7 +488,7 @@
::sm/params schema:get-team-invitations} ::sm/params schema:get-team-invitations}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)] (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))) (get-team-invitations conn team-id)))
@ -515,17 +535,17 @@
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id}) ::quotes/profile-id profile-id})
;; When creating inside an org, verify the user has permission to do so. ;; When creating inside an organization, verify the user has permission to do so.
;; Fail closed: if org permissions cannot be fetched, deny the operation. ;; Fail closed: if organization permissions cannot be fetched, deny the operation.
(when (and organization-id (contains? cf/flags :nitrate)) (when (and organization-id (contains? cf/flags :nitrate))
(let [org-perms (nitrate/call cfg :get-org-permissions (let [organization-perms (nitrate/call cfg :get-organization-permissions
{:organization-id organization-id})] {:organization-id organization-id})]
(if (nil? org-perms) (if (nil? organization-perms)
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :code :not-allowed
:hint "Unable to verify organization permissions") :hint "Unable to verify organization permissions")
(when-not (nitrate-perms/allowed? :create-team (when-not (nitrate-perms/allowed? :create-team
{:org-perms org-perms {:organization-perms organization-perms
:profile-id profile-id}) :profile-id profile-id})
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed :code :not-allowed
@ -543,7 +563,7 @@
{::audit/props {:id (:id team)}}))) {::audit/props {:id (:id team)}})))
(defn create-default-org-team (defn create-default-organization-team
[cfg profile-id organization-id] [cfg profile-id organization-id]
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id}) ::quotes/profile-id profile-id})
@ -559,11 +579,11 @@
team (create-team cfg params)] team (create-team cfg params)]
(select-keys team [:id]))) (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, "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] ([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] ([cfg profile-id organization-id email]
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
@ -572,24 +592,24 @@
cfg cfg
(fn [{:keys [::db/conn] :as tx-cfg}] (fn [{:keys [::db/conn] :as tx-cfg}]
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id (let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})] :organization-id organization-id})]
;; Only when the user doesn't belong to the organization yet ;; Only when the user doesn't belong to the organization yet
(when (and (when (and
(some? (:organization-id membership)) ;; the organization exists (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 (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) default-team-id (:id default-team)
result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id result (nitrate/call tx-cfg :add-profile-to-organization (cond-> {:profile-id profile-id
:team-id default-team-id :team-id default-team-id
:organization-id organization-id} :organization-id organization-id}
(some? email) (assoc :email email)))] (some? email) (assoc :email email)))]
(when (not (:is-member result)) (when (not (:is-member result))
(ex/raise :type :internal (ex/raise :type :internal
:code :failed-add-profile-org-nitrate :code :failed-add-profile-organization-nitrate
:context {:profile-id profile-id :context {:profile-id profile-id
:organization-id organization-id :organization-id organization-id
:default-team-id default-team-id})) :default-team-id default-team-id}))
@ -602,13 +622,13 @@
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
(when (contains? cf/flags :nitrate) (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 ;; Only when the team belong to an organization and the user is not a member
(when (and (when (and
(some? (:organization-id membership)) ;; the team do belong to an organization (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 (not (:is-member membership))) ;; the user is not a member of the organization yet
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership))))) (initialize-user-in-nitrate-organization cfg profile-id (:organization-id membership)))))
(db/insert! conn :team-profile-rel params options))) (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
(defn create-team (defn create-team
"This is a complete team creation process, it creates the team "This is a complete team creation process, it creates the team
@ -679,7 +699,8 @@
(defn create-project-role (defn create-project-role
[conn profile-id project-id role] [conn profile-id project-id role]
(let [params {:project-id project-id (let [params {:project-id project-id
:profile-id profile-id}] :profile-id profile-id
:id (uuid/next)}]
(->> (perms/assign-role-flags params role) (->> (perms/assign-role-flags params role)
(db/insert! conn :project-profile-rel)))) (db/insert! conn :project-profile-rel))))
@ -783,16 +804,16 @@
(let [team (get-team conn :profile-id profile-id :team-id team-id) (let [team (get-team conn :profile-id profile-id :team-id team-id)
team (if (contains? cf/flags :nitrate) 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) team)
perms (get team :permissions) perms (get team :permissions)
org (:organization team) organization (:organization team)
in-org? (and (contains? cf/flags :nitrate) org) in-organization? (and (contains? cf/flags :nitrate) organization)
can-delete? can-delete?
(if in-org? (if in-organization?
(nitrate-perms/allowed? :delete-team (nitrate-perms/allowed? :delete-team
{:org-perms {:owner-id (dm/get-in team [:organization :owner-id]) {:organization-perms {:owner-id (dm/get-in team [:organization :owner-id])
:permissions (dm/get-in team [:organization :permissions])} :permissions (dm/get-in team [:organization :permissions])}
:profile-id profile-id :profile-id profile-id
:team-perms perms}) :team-perms perms})
(boolean (:is-owner perms)))] (boolean (:is-owner perms)))]
@ -802,8 +823,8 @@
:code :only-owner-can-delete-team)) :code :only-owner-can-delete-team))
;; Protect the user's personal default team from deletion. ;; 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. ;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
(when (and (:is-default team) (not in-org?)) (when (and (:is-default team) (not in-organization?))
(ex/raise :type :validation (ex/raise :type :validation
:code :non-deletable-team :code :non-deletable-team
:hint "impossible to delete default team")) :hint "impossible to delete default team"))
@ -930,12 +951,23 @@
(db/delete! conn :team-profile-rel {:profile-id member-id (db/delete! conn :team-profile-rel {:profile-id member-id
:team-id team-id}) :team-id team-id})
(mbus/pub! msgbus
:topic member-id ;; A removed member that owns the organization of this team keeps
:message {:type :team-membership-change ;; read-only access to it, so instead of kicking them out we degrade
:change :removed ;; their session to viewer, same as any other role change.
:team-id team-id (if (nitrate/organization-owner-of-team? cfg member-id team-id)
:team-name (:name team)}) (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)) nil))

View File

@ -44,7 +44,7 @@
update set role = ?, valid_until = ?, updated_at = now() update set role = ?, valid_until = ?, updated_at = now()
returning *") 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) "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, null, ?, ?, ?, ?, ?) values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do on conflict(org_id, email_to) where team_id is null do
@ -86,15 +86,17 @@
[:role types.team/schema:role] [:role types.team/schema:role]
[:email ::sm/email]]) [:email ::sm/email]])
(def ^:private schema:create-org-invitation (def ^:private schema:create-organization-invitation
[:map {:title "params:create-org-invitation"} [:map {:title "params:create-organization-invitation"}
[::rpc/profile-id ::sm/uuid] [::rpc/profile-id ::sm/uuid]
[:organization [:organization
[:map [:map
[:id ::sm/uuid] [:id ::sm/uuid]
[:name :string] [:name :string]
[:initials [:maybe :string]] [:initials [:maybe :string]]
[:logo ::sm/uri]]] [:logo ::sm/uri]
[:avatar-bg-url [:maybe ::sm/uri]]
[:sso-active [:maybe ::sm/boolean]]]]
[:profile [:profile
[:map [:map
[:id ::sm/uuid] [:id ::sm/uuid]
@ -105,8 +107,8 @@
(def ^:private check-create-invitation-params (def ^:private check-create-invitation-params
(sm/check-fn schema:create-invitation)) (sm/check-fn schema:create-invitation))
(def ^:private check-create-org-invitation-params (def ^:private check-create-organization-invitation-params
(sm/check-fn schema:create-org-invitation)) (sm/check-fn schema:create-organization-invitation))
(defn- allow-invitation-emails? (defn- allow-invitation-emails?
[member] [member]
@ -114,23 +116,24 @@
(not= :none (:email-invites notifications)))) (not= :none (:email-invites notifications))))
(defn- assert-email-can-be-invited (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." restricts who can be added to teams."
[member org-member-ids] [member organization-member-ids]
(when (some? org-member-ids) (when (some? organization-member-ids)
(let [is-member? (and (some? member) (contains? org-member-ids (:id member)))] (let [is-member? (and (some? member) (contains? organization-member-ids (:id member)))]
(when-not is-member? (when-not is-member?
(ex/raise :type :validation (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"))))) :hint "The invited email is not a member of the organization")))))
(defn- create-invitation (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) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
(if organization (if organization
(assert (check-create-org-invitation-params params)) (assert (check-create-organization-invitation-params params))
(assert (check-create-invitation-params params))) (assert (check-create-invitation-params params)))
(let [email (profile/clean-email email) (let [email (profile/clean-email email)
@ -142,11 +145,11 @@
:code :email-domain-is-not-allowed :code :email-domain-is-not-allowed
:hint "email domain is in the blacklist")) :hint "email domain is in the blacklist"))
;; When nitrate is active and the team belongs to an org, check that ;; When nitrate is active and the team belongs to an organization, check that
;; the email is already an org member unless the org explicitly allows adding anybody. ;; the email is already an organization member unless the organization explicitly allows adding anybody.
(when (and (contains? cf/flags :nitrate) (when (and (contains? cf/flags :nitrate)
(:organization team)) (: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 ;; When we have email verification disabled and invitation user is
@ -162,9 +165,9 @@
(get types.team/permissions-for-role role))] (get types.team/permissions-for-role role))]
(if organization (if organization
;; Insert the invited member to the org ;; Insert the invited member to the organization
(when (contains? cf/flags :nitrate) (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 ;; Insert the invited member to the team
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})) (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 "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days (ct/in-future "168h")) ;; 7 days
invitation (db/exec-one! conn (if organization invitation (db/exec-one! conn (if organization
[sql:upsert-org-invitation id [sql:upsert-organization-invitation id
(:id organization) (:id organization)
(str/lower email) (str/lower email)
(:id profile) (:id profile)
@ -201,6 +204,7 @@
(name role) expire])) (name role) expire]))
updated? (not= id (:id invitation)) updated? (not= id (:id invitation))
profile-id (:id profile) profile-id (:id profile)
team-organization-id (get-in team [:organization :id])
tprops {:profile-id profile-id tprops {:profile-id profile-id
:invitation-id (:id invitation) :invitation-id (:id invitation)
:valid-until expire :valid-until expire
@ -210,18 +214,37 @@
:member-email (:email-to invitation) :member-email (:email-to invitation)
:member-id (:id member) :member-id (:id member)
:role role} :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) itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile-id)] ptoken (create-profile-identity-token cfg profile-id)]
(when (contains? cf/flags :log-invitation-tokens) (when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken)) (l/info :hint "invitation token" :token itoken))
(let [props (-> (dissoc tprops :profile-id) (let [props (audit/clean-props audit-props)
(audit/clean-props))
evname (cond evname (cond
(and updated? organization) "update-org-invitation" (and updated? organization) "update-organization-invitation"
updated? "update-team-invitation" updated? "update-team-invitation"
organization "create-org-invitation" organization "create-organization-invitation"
:else "create-team-invitation") :else "create-team-invitation")
event (-> (audit/event-from-rpc-params params) event (-> (audit/event-from-rpc-params params)
(assoc :name evname) (assoc :name evname)
@ -232,7 +255,7 @@
(if organization (if organization
(when (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
(eml/send! {::eml/conn conn (eml/send! {::eml/conn conn
::eml/factory eml/invite-to-org ::eml/factory eml/invite-to-organization
:public-uri (cf/get :public-uri) :public-uri (cf/get :public-uri)
:to email :to email
:invited-by (:fullname profile) :invited-by (:fullname profile)
@ -246,13 +269,13 @@
:to email :to email
:invited-by (:fullname profile) :invited-by (:fullname profile)
:team (:name team) :team (:name team)
:organization (dm/get-in team [:organization :name]) :organization (:organization team)
:token itoken :token itoken
:extra-data ptoken}))) :extra-data ptoken})))
itoken))))) itoken)))))
(defn create-org-invitation (defn create-organization-invitation
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (db/get-by-id cfg :profile profile-id)] (let [profile (db/get-by-id cfg :profile profile-id)]
(create-invitation cfg (create-invitation cfg
@ -322,16 +345,21 @@
- emails (set) + role (single role for all emails) - emails (set) + role (single role for all emails)
- invitations (vector of {:email :role} maps)" - invitations (vector of {:email :role} maps)"
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}] [{: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) team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {}) (nitrate/add-organization-info-to-team cfg team {})
team) team)
org (:organization team) organization (:organization team)
org-id (:id org) organization-id (:id organization)
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org}))) restricted? (and organization-id (not (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization})))
org-member-ids (when restricted? all-organization-member-ids
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id}))) (when organization-id
params (assoc params :team team :org-member-ids org-member-ids) (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}] ;; Normalize input to a consistent format: [{:email :role}]
invitation-data (cond invitation-data (cond
@ -539,7 +567,7 @@
::doc/module :teams ::doc/module :teams
::sm/params schema:get-team-invitation-token} ::sm/params schema:get-team-invitation-token}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] [{: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) (let [email (profile/clean-email email)
invit (-> (db/get pool :team-invitation invit (-> (db/get pool :team-invitation
{:team-id team-id {:team-id team-id

View File

@ -85,13 +85,33 @@
::audit/props (audit/profile->props profile) ::audit/props (audit/profile->props profile)
::audit/profile-id (:id 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 (defmethod process-token :auth
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}] [{: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))) (assoc claims :profile profile)))
;; --- Team Invitation ;; --- 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 (defn- accept-invitation
[{:keys [::db/conn] :as cfg} [{:keys [::db/conn] :as cfg}
{:keys [team-id organization-id role member-email] :as claims} invitation member] {:keys [team-id organization-id role member-email] :as claims} invitation member]
@ -115,9 +135,9 @@
(get types.team/permissions-for-role role)) (get types.team/permissions-for-role role))
accepted-team-id (if organization-id 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) (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 ;; Insert the invited member to the team
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) (do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
team-id))] team-id))]
@ -136,10 +156,11 @@
{:id id-member})) {:id id-member}))
;; Delete the invitation ;; Delete the invitation
(db/delete! conn :team-invitation (if organization-id
(cond-> {:email-to member-email} (db/exec-one! conn [sql:delete-organization-invitation member-email organization-id])
team-id (assoc :team-id team-id) (db/delete! conn :team-invitation
organization-id (assoc :org-id organization-id))) {:email-to member-email
:team-id team-id}))
;; Delete any request (only applicable for team invitations) ;; Delete any request (only applicable for team invitations)
(when team-id (when team-id
@ -175,22 +196,17 @@
:code :invalid-invitation-token :code :invalid-invitation-token
:hint "invitation token contains unexpected data")) :hint "invitation token contains unexpected data"))
(let [invitation (db/get* conn :team-invitation (let [invitation (if organization-id
(cond-> {:email-to member-email} (db/exec-one! conn [sql:get-organization-invitation member-email organization-id])
team-id (assoc :team-id team-id) (db/get* conn :team-invitation
organization-id (assoc :org-id organization-id))) {:email-to member-email
:team-id team-id}))
profile (db/get* conn :profile profile (db/get* conn :profile
{:id profile-id} {:id profile-id}
{:columns [:id :email :default-team-id]}) {:columns [:id :email :default-team-id]})
registration-disabled? (not (contains? cf/flags :registration)) registration-disabled? (not (contains? cf/flags :registration))
org-invitation? (and (contains? cf/flags :nitrate) organization-id) organization-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}))]
(if profile (if profile
(do (do
@ -201,62 +217,130 @@
:reason :email-mismatch :reason :email-mismatch
:hint "logged-in user does not matches the invitation")) :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) (when (nil? invitation)
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-token :code (if organization-id :canceled-invitation :invalid-token)
:hint "no invitation associated with the 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 team-id
;; with accepting the invitation and joining the current profile to the (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id
;; invited team. :team-id team-id})))
(let [props {:team-id (:team-id claims)
:role (:role claims)
:invitation-id (:id invitation)}]
(audit/submit cfg organization-id-on-add
(-> (audit/event-from-rpc-params params) (when (and (:organization-id membership)
(assoc :name "accept-team-invitation") (not (:is-member membership)))
(assoc :props props))) (:organization-id membership))
;; NOTE: Backward compatibility; old invitations can organization-add-source
;; have the `created-by` to be nil; so in this case we (when organization-id-on-add
;; don't submit this event to the audit-log (if organization-id
(when-let [created-by (:created-by invitation)] "direct-organization-invitation"
(audit/submit cfg "team-invitation"))
(-> (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)] organization-event-origin
(cond-> (assoc claims :state :created) (when organization-id-on-add
;; when the invitation is to an org, instead of a team, add the (if organization-id
;; accepted-team-id as :org-team-id "organization-invitation-acceptance"
(:organization-id claims) "team-invitation-acceptance"))
(assoc :org-team-id accepted-team-id)))))
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 (do
;; If the user is not logged-in and the token is invalid we throw the error ;; If the user is not logged-in and the invitation has been canceled
;; Taiga issue #14182 ;; 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) (when (nil? invitation)
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-token :code (if organization-id :canceled-invitation :invalid-token)
:hint "no invitation associated with the 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 ;; 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 ;; redirect user to login, if no member-id is present and in the invitation
@ -272,4 +356,3 @@
[_ _ _] [_ _ _]
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-token)) :code :invalid-token))

View File

@ -16,6 +16,7 @@
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond] [app.rpc.cond :as-alias cond]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.permissions :as perms]
[app.util.services :as sv] [app.util.services :as sv]
[cuerdas.core :as str])) [cuerdas.core :as str]))
@ -125,8 +126,8 @@
::sm/params schema:get-view-only-bundle} ::sm/params schema:get-view-only-bundle}
[system {:keys [::rpc/profile-id file-id share-id] :as params}] [system {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! system (db/run! system
(fn [{:keys [::db/conn] :as system}] (fn [system]
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id) (let [perms (perms/get-file-read-permissions system profile-id file-id share-id)
params (-> params params (-> params
(assoc ::perms perms) (assoc ::perms perms)
(assoc :profile-id profile-id))] (assoc :profile-id profile-id))]
@ -139,5 +140,3 @@
:hint "object not found")) :hint "object not found"))
(get-view-only-bundle system params))))) (get-view-only-bundle system params)))))

View File

@ -172,6 +172,6 @@
::sm/params schema:get-webhooks} ::sm/params schema:get-webhooks}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)] (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]) (->> (db/exec! conn [sql:get-webhooks team-id])
(mapv decode-row)))) (mapv decode-row))))

View File

@ -8,27 +8,34 @@
"Internal Nitrate HTTP RPC API. Provides authenticated access to "Internal Nitrate HTTP RPC API. Provides authenticated access to
organization management and token validation endpoints." organization management and token validation endpoints."
(:require (:require
[app.auth :as aauth]
[app.auth.oidc :as oidc]
[app.common.data :as d] [app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [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.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]] [app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.email :as eml] [app.email :as eml]
[app.http :as-alias http]
[app.http.session :as session] [app.http.session :as session]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.media :as media] [app.media :as media]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as rpc] [app.rpc :as rpc]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.commands.nitrate :as cnit] [app.rpc.commands.nitrate :as cnit]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.commands.teams-invitations :as ti] [app.rpc.commands.teams-invitations :as ti]
[app.rpc.doc :as doc] [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.rpc.notifications :as notifications]
[app.storage :as sto] [app.storage :as sto]
[app.util.services :as sv] [app.util.services :as sv]
@ -40,6 +47,7 @@
{:id (:id profile) {:id (:id profile)
:name (:fullname profile) :name (:fullname profile)
:email (:email profile) :email (:email profile)
:created-at (:created-at profile)
:photo-url (files/resolve-public-uri (get profile :photo-id))}) :photo-url (files/resolve-public-uri (get profile :photo-id))})
;; ---- API: authenticate ;; ---- API: authenticate
@ -48,7 +56,8 @@
"Authenticate the current user" "Authenticate the current user"
{::doc/added "2.14" {::doc/added "2.14"
::sm/params [:map] ::sm/params [:map]
::sm/result schema:profile} ::sm/result schema:profile
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)] (let [profile (profile/get-profile cfg profile-id)]
(-> (profile-to-map profile) (-> (profile-to-map profile)
@ -99,30 +108,32 @@
"List teams for which current user is owner" "List teams for which current user is owner"
{::doc/added "2.14" {::doc/added "2.14"
::sm/params [:map] ::sm/params [:map]
::sm/result schema:get-teams-result} ::sm/result schema:get-teams-result
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id]}] [cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id]) (->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name]))))) (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 [:map
[:content media/schema:upload] [:content media/schema:upload]
[:organization-id ::sm/uuid] [:organization-id ::sm/uuid]
[:previous-id {:optional true} ::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]]) [: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. "Store an organization logo in penpot storage and return its ID.
Accepts an optional previous-id to mark the old logo for garbage Accepts an optional previous-id to mark the old logo for garbage
collection when replacing an existing one." collection when replacing an existing one."
{::doc/added "2.17" {::doc/added "2.17"
::sm/params schema:upload-org-logo ::sm/params schema:upload-organization-logo
::sm/result schema:upload-org-logo-result} ::sm/result schema:upload-organization-logo-result
::nitrate/sso false}
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
(when previous-id (when previous-id
(sto/touch-object! storage previous-id)) (sto/touch-object! storage previous-id))
@ -156,12 +167,12 @@
[:role ::sm/text]]) [:role ::sm/text]])
(sv/defmethod ::notify-user-added-to-organization (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" {::doc/added "2.14"
::sm/params schema:notify-user-added-to-organization ::sm/params schema:notify-user-added-to-organization
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [profile-id organization-id]}] [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 ;; ---- API: get-managed-profiles
@ -190,7 +201,8 @@
"List profiles that belong to teams for which current user is owner" "List profiles that belong to teams for which current user is owner"
{::doc/added "2.14" {::doc/added "2.14"
::sm/params [:map] ::sm/params [:map]
::sm/result schema:managed-profile-result} ::sm/result schema:managed-profile-result
::nitrate/sso false}
[cfg {:keys [::rpc/profile-id]}] [cfg {:keys [::rpc/profile-id]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :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]))) (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" "Get summary information for a list of teams"
{::doc/added "2.15" {::doc/added "2.15"
::sm/params schema:get-teams-summary-params ::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]}] [cfg {:keys [ids]}]
(let [;; Handle one or multiple params (let [;; Handle one or multiple params
ids (cond ids (cond
@ -301,7 +314,7 @@ RETURNING id, deleted_at;")
nil) nil)
(defn manage-deleted-organization-teams (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." delete member Your Penpot teams depending on whether they still contain files."
[cfg {:keys [organization-id organization-name teams]}] [cfg {:keys [organization-id organization-name teams]}]
(let [all-team-ids (->> teams (let [all-team-ids (->> teams
@ -316,7 +329,7 @@ RETURNING id, deleted_at;")
distinct distinct
(into []))] (into []))]
(when (seq all-team-ids) (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! (db/tx-run!
cfg cfg
(fn [{:keys [::db/conn] :as 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-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
teams-to-delete (->> your-penpot-team-ids (remove 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 ;; Organization teams move to the fallback organization unchanged. Only imported
;; Your Penpot teams keep the org prefix when they still have files. ;; Your Penpot teams keep the organization prefix when they still have files.
(when (seq teams-to-prefix) (when (seq teams-to-prefix)
(db/exec! conn [sql:prefix-teams-name-and-unset-default (db/exec! conn [sql:prefix-teams-name-and-unset-default
org-prefix organization-prefix
(db/create-array conn "uuid" teams-to-prefix)])) (db/create-array conn "uuid" teams-to-prefix)]))
;; Empty imported Your Penpot teams disappear entirely. ;; Empty imported Your Penpot teams disappear entirely.
@ -345,16 +358,16 @@ RETURNING id, deleted_at;")
(sv/defmethod ::notify-organization-deletion (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." imported Your Penpot teams before notifying connected users."
{::doc/added "2.15" {::doc/added "2.15"
::sm/params schema:notify-organization-deletion ::sm/params schema:notify-organization-deletion
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [organization-id]}] [cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
teams (:teams org-summary)] teams (:teams organization-summary)]
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary) (manage-deleted-organization-teams cfg {:organization-name (:name organization-summary)
:organization-id (:id org-summary) :organization-id (:id organization-summary)
:teams teams}) :teams teams})
nil)) nil))
@ -365,17 +378,18 @@ RETURNING id, deleted_at;")
[:profile-id ::sm/uuid]]) [:profile-id ::sm/uuid]])
(sv/defmethod ::notify-user-organizations-deletion (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." transfer rules to their imported Your Penpot teams."
{::doc/added "2.18" {::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]}] [cfg {:keys [profile-id]}]
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})] (let [owned-organizations (nitrate/call cfg :get-owned-organizations {:profile-id profile-id})]
(doseq [org owned-orgs] (doseq [organization owned-organizations]
(let [organization-name (:name org) (let [organization-name (:name organization)
teams (:teams org)] teams (:teams organization)]
(manage-deleted-organization-teams cfg {:organization-name organization-name (manage-deleted-organization-teams cfg {:organization-name organization-name
:organization-id (:id org) :organization-id (:id organization)
:teams teams})))) :teams teams}))))
nil) nil)
@ -394,7 +408,8 @@ RETURNING id, deleted_at;")
"Get profile by email" "Get profile by email"
{::doc/added "2.15" {::doc/added "2.15"
::sm/params [:map [:email ::sm/email]] ::sm/params [:map [:email ::sm/email]]
::sm/result schema:profile} ::sm/result schema:profile
::nitrate/sso false}
[cfg {:keys [email]}] [cfg {:keys [email]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])] (let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
(when-not profile (when-not profile
@ -417,7 +432,8 @@ RETURNING id, deleted_at;")
"Get profile by email" "Get profile by email"
{::doc/added "2.15" {::doc/added "2.15"
::sm/params [:map [:id ::sm/uuid]] ::sm/params [:map [:id ::sm/uuid]]
::sm/result schema:profile} ::sm/result schema:profile
::nitrate/sso false}
[cfg {:keys [id]}] [cfg {:keys [id]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])] (let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
(when-not profile (when-not profile
@ -428,9 +444,9 @@ RETURNING id, deleted_at;")
(profile-to-map profile))) (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 "SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
FROM team_profile_rel AS tpr FROM team_profile_rel AS tpr
JOIN team AS t ON t.id = tpr.team_id JOIN team AS t ON t.id = tpr.team_id
@ -439,19 +455,19 @@ RETURNING id, deleted_at;")
AND t.is_default IS FALSE AND t.is_default IS FALSE
GROUP BY tpr.profile_id;") 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]]]]) [: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 [:vector [:map
[:profile-id ::sm/uuid] [:profile-id ::sm/uuid]
[:team-count ::sm/int]]]) [: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." "Get the number of non-default teams each profile belongs to within a set of teams."
{::doc/added "2.15" {::doc/added "2.15"
::sm/params schema:get-org-member-team-counts-params ::sm/params schema:get-organization-member-team-counts-params
::sm/result schema:get-org-member-team-counts-result ::sm/result schema:get-organization-member-team-counts-result
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [team-ids]}] [cfg {:keys [team-ids]}]
(let [team-ids (cond (let [team-ids (cond
@ -467,46 +483,30 @@ RETURNING id, deleted_at;")
[] []
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)] (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" "Invite to organization"
{::doc/added "2.15" {::doc/added "2.15"
::sm/params [:map ::sm/params [:map
[:email ::sm/email] [:email ::sm/email]
[:organization schema:organization-with-avatar]]} [:organization schema:organization-with-avatar]]
::nitrate/sso false}
[cfg params] [cfg params]
(db/tx-run! cfg ti/create-org-invitation params) (db/tx-run! cfg ti/create-organization-invitation params)
nil) nil)
;; API: get-org-invitations ;; API: get-organization-invitations
(def ^:private sql:get-org-invitations (def ^:private schema:get-organization-invitations-params
"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
[:map [:map
[:organization-id ::sm/uuid]]) [:organization-id ::sm/uuid]])
(def ^:private schema:get-org-invitations-result (def ^:private schema:get-organization-invitations-result
[:vector [:vector
[:map [:map
[:id ::sm/uuid] [:id ::sm/uuid]
@ -517,84 +517,75 @@ LEFT JOIN profile AS p
[:profile-id {:optional true} [:maybe ::sm/uuid]] [:profile-id {:optional true} [:maybe ::sm/uuid]]
[:photo-url {:optional true} ::sm/uri]]]) [: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." "Get valid invitations for an organization, returning at most one invitation per email."
{::doc/added "2.16" {::doc/added "2.16"
::sm/params schema:get-org-invitations-params ::sm/params schema:get-organization-invitations-params
::sm/result schema:get-org-invitations-result} ::sm/result schema:get-organization-invitations-result
::nitrate/sso false}
[cfg {:keys [organization-id]}] [cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) (let [team-ids (noh/get-organization-team-ids cfg organization-id)]
team-ids (->> (:teams org-summary)
(map :id)
(filter uuid?)
(into []))]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)] (->> (noh/get-organization-invitations conn organization-id team-ids)
(->> (db/exec! conn [sql:get-org-invitations organization-id ids-array]) (mapv (fn [{:keys [photo-id] :as invitation}]
(mapv (fn [{:keys [photo-id] :as invitation}] (cond-> (dissoc invitation :photo-id)
(cond-> (dissoc invitation :photo-id) photo-id
photo-id (assoc :photo-url (files/resolve-public-uri 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 "DELETE FROM team_invitation AS ti
WHERE ti.email_to = ? WHERE ti.email_to = ?
AND (ti.org_id = ? OR ti.team_id = ANY(?));") AND (ti.org_id = ? OR ti.team_id = ANY(?));")
(def ^:private schema:delete-org-invitations-params (def ^:private schema:delete-organization-invitations-params
[:map [:map
[:organization-id ::sm/uuid] [:organization-id ::sm/uuid]
[:email ::sm/email]]) [:email ::sm/email]])
(sv/defmethod ::delete-org-invitations (sv/defmethod ::delete-organization-invitations
"Delete all invitations for one email in an organization scope (org + org teams)." "Delete all invitations for one email in an organization scope (organization + organization teams)."
{::doc/added "2.16" {::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]}] [cfg {:keys [organization-id email]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) (let [clean-email (profile/clean-email email)
clean-email (profile/clean-email email) team-ids (noh/get-organization-team-ids cfg organization-id)]
team-ids (->> (:teams org-summary)
(map :id)
(filter uuid?)
(into []))]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)] (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)) 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 "DELETE FROM team_invitation AS ti
WHERE ti.org_id = ? WHERE ti.org_id = ?
OR ti.team_id = ANY(?);") OR ti.team_id = ANY(?);")
(def ^:private schema:delete-all-org-invitations-params (def ^:private schema:delete-all-organization-invitations-params
[:map [:map
[:organization-id ::sm/uuid]]) [:organization-id ::sm/uuid]])
(sv/defmethod ::delete-all-org-invitations (sv/defmethod ::delete-all-organization-invitations
"Delete every pending invitation associated with an organization (org-level + team-level). "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 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." their invitation token hit the existing invalid-token landing page."
{::doc/added "2.18" {::doc/added "2.18"
::sm/params schema:delete-all-org-invitations-params ::sm/params schema:delete-all-organization-invitations-params
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [organization-id]}] [cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) (let [team-ids (noh/get-organization-team-ids cfg organization-id)]
team-ids (->> (:teams org-summary)
(map :id))]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)] (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)) nil))
;; API: remove-from-org ;; API: remove-from-organization
(def ^:private sql:get-reassign-to (def ^:private sql:get-reassign-to
"SELECT tpr.profile_id "SELECT tpr.profile_id
@ -619,7 +610,7 @@ LEFT JOIN profile AS p
(assoc team-to-transfer :reassign-to reassign-to))) (assoc team-to-transfer :reassign-to reassign-to)))
(sv/defmethod ::remove-from-org (sv/defmethod ::remove-from-organization
"Remove an user from an organization" "Remove an user from an organization"
{::doc/added "2.17" {::doc/added "2.17"
::sm/params [:map ::sm/params [:map
@ -627,9 +618,14 @@ LEFT JOIN profile AS p
[:organization-id ::sm/uuid] [:organization-id ::sm/uuid]
[:organization-name ::sm/text] [:organization-name ::sm/text]
[:default-team-id ::sm/uuid]] [:default-team-id ::sm/uuid]]
::db/transaction true} ::db/transaction true
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}] ::nitrate/sso false}
(let [{:keys [valid-teams-to-delete-ids [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-transfer
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id) 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) 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 valid-teams-to-leave (into valid-teams-to-exit
(map add-reassign-to valid-teams-to-transfer))] (map add-reassign-to valid-teams-to-transfer))]
(cnit/leave-org cfg (assoc params (cnit/leave-organization cfg (assoc params
:id organization-id :id organization-id
:name organization-name :name organization-name
:teams-to-delete valid-teams-to-delete-ids :teams-to-delete valid-teams-to-delete-ids
:teams-to-leave valid-teams-to-leave :teams-to-leave valid-teams-to-leave
:skip-validation true)) :skip-validation true
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org") :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)) 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 [:map
[:teams-to-delete ::sm/int] [:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int] [:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int] [:teams-to-exit ::sm/int]
[:teams-to-detach ::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 "Get a summary of the teams that would be deleted, transferred, or exited
if the user were removed from the organization" if the user were removed from the organization"
{::doc/added "2.17" {::doc/added "2.17"
@ -663,8 +662,9 @@ LEFT JOIN profile AS p
[:profile-id ::sm/uuid] [:profile-id ::sm/uuid]
[:organization-id ::sm/uuid] [:organization-id ::sm/uuid]
[:default-team-id ::sm/uuid]] [:default-team-id ::sm/uuid]]
::sm/result schema:get-remove-from-org-summary-result ::sm/result schema:get-remove-from-organization-summary-result
::db/transaction true} ::db/transaction true
::nitrate/sso false}
[cfg {:keys [profile-id organization-id default-team-id]}] [cfg {:keys [profile-id organization-id default-team-id]}]
(let [{:keys [valid-teams-to-delete-ids (let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer valid-teams-to-transfer
@ -673,11 +673,11 @@ LEFT JOIN profile AS p
(when-not valid-default-team (when-not valid-default-team
(ex/raise :type :validation (ex/raise :type :validation
:code :not-valid-teams)) :code :not-valid-teams))
(cnit/get-leave-org-summary cfg (cnit/get-leave-organization-summary cfg
default-team-id default-team-id
valid-teams-to-delete-ids valid-teams-to-delete-ids
(count valid-teams-to-transfer) (count valid-teams-to-transfer)
(count valid-teams-to-exit)))) (count valid-teams-to-exit))))
;; API: send-renewal-email ;; API: send-renewal-email
@ -711,8 +711,8 @@ LEFT JOIN profile AS p
:organizations organizations})))) :organizations organizations}))))
nil) nil)
;; API: exists-org-team-invitations-for-non-members / ;; API: exists-organization-team-invitations-for-non-members /
;; delete-org-team-invitations-for-non-members ;; delete-organization-team-invitations-for-non-members
(def ^:private sql:get-profile-emails-by-ids (def ^:private sql:get-profile-emails-by-ids
"SELECT email "SELECT email
@ -720,7 +720,7 @@ LEFT JOIN profile AS p
WHERE id = ANY(?) WHERE id = ANY(?)
AND deleted_at IS NULL") 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 EXISTS (
SELECT 1 SELECT 1
FROM team_invitation FROM team_invitation
@ -728,22 +728,22 @@ LEFT JOIN profile AS p
AND email_to <> ALL(?) AND email_to <> ALL(?)
) AS non_member") ) 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 "DELETE FROM team_invitation
WHERE team_id = ANY(?) WHERE team_id = ANY(?)
AND email_to <> ALL(?) AND email_to <> ALL(?)
RETURNING email_to") RETURNING email_to")
(def ^:private schema:org-team-invitations-for-non-members-params (def ^:private schema:organization-team-invitations-for-non-members-params
[:map [:map
[:team-ids [:vector ::sm/uuid]] [:team-ids [:vector ::sm/uuid]]
[:member-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]]) [:map [:exists ::sm/boolean]])
(defn- org-team-invitations-for-non-members-arrays (defn- organization-team-invitations-for-non-members-arrays
"Member emails and PG arrays used by exists/delete org team invitation endpoints." "Member emails and PG arrays used by exists/delete organization team invitation endpoints."
[conn {:keys [team-ids member-ids]}] [conn {:keys [team-ids member-ids]}]
(let [member-ids-array (db/create-array conn "uuid" 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]) 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)) {:emails-array (db/create-array conn "text" (vec member-emails))
:teams-array (db/create-array conn "uuid" team-ids)})) :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] [conn params]
(let [{:keys [emails-array teams-array]} (let [{:keys [emails-array teams-array]}
(org-team-invitations-for-non-members-arrays conn params)] (organization-team-invitations-for-non-members-arrays conn params)]
(-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations (-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations
teams-array teams-array
emails-array]) emails-array])
:non-member))) :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." "Return if there are any team invitations for emails that are not organization members."
{::doc/added "2.18" {::doc/added "2.18"
::sm/params schema:org-team-invitations-for-non-members-params ::sm/params schema:organization-team-invitations-for-non-members-params
::sm/result schema:exists-org-team-invitations-for-non-members-result} ::sm/result schema:exists-organization-team-invitations-for-non-members-result
::nitrate/sso false}
[cfg params] [cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}] (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." "Delete team invitations for emails that are not organization members."
{::doc/added "2.18" {::doc/added "2.18"
::sm/params schema:org-team-invitations-for-non-members-params ::sm/params schema:organization-team-invitations-for-non-members-params
::db/transaction true} ::db/transaction true
::nitrate/sso false}
[cfg params] [cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}] (db/run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [emails-array teams-array]} (let [{:keys [emails-array teams-array]}
(org-team-invitations-for-non-members-arrays conn params)] (organization-team-invitations-for-non-members-arrays conn params)]
(db/exec! conn [sql:delete-non-member-org-team-invitations (db/exec! conn [sql:delete-non-member-organization-team-invitations
teams-array teams-array
emails-array]) emails-array])
nil)))) nil))))
@ -790,53 +792,235 @@ LEFT JOIN profile AS p
[:map {:title "NitrateAuditEvent"} [:map {:title "NitrateAuditEvent"}
[:name [:and [:string {:max 250}] [:name [:and [:string {:max 250}]
[:re #"[\d\w-]{1,50}"]]] [:re #"[\d\w-]{1,50}"]]]
[:type {:optional true} ::sm/text]
[:profile-id ::sm/uuid] [: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 (def ^:private schema:push-audit-events-params
[:map {:title "PushAuditEventsParams"} [:map {:title "PushAuditEventsParams"}
[:events [:vector schema:nitrate-audit-event]]]) [: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 (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" {::doc/added "2.19"
::audit/skip true
::sm/params schema:push-audit-events-params ::sm/params schema:push-audit-events-params
::rpc/auth false} ::rpc/auth false}
[{:keys [::db/pool] :as cfg} {:keys [events]}] [cfg {:keys [::rpc/request-at events] :as params}]
(let [telemetry? (contains? cf/flags :telemetry) (let [request (-> params meta ::http/request)
audit-log? (contains? cf/flags :audit-log) context' (-> (audit/prepare-context-from-request request)
enabled? (and (not (db/read-only? pool)) (assoc :request-id (::rpc/request-id params)))
(or audit-log? telemetry?))]
(when (and enabled? (seq events)) ip-addr (::rpc/ip-addr params)]
(run! (partial submit-nitrate-audit-event cfg) events))
(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)) 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" "Nitrate notifies that an organization sso values have changed"
{::doc/added "2.19" {::doc/added "2.19"
::sm/params [:map ::sm/params [:map
[:organization-id ::sm/uuid] [:organization-id ::sm/uuid]
[:updated-props ::sm/boolean]] [:updated-props ::sm/boolean]
[:announce-activation ::sm/boolean]]
::rpc/auth false} ::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 (when updated-props
(rpc/invalidate-org-sso-cache-by-org! organization-id) (rpc/invalidate-organization-sso-cache-by-organization! organization-id)
(session/clear-org-sso-sessions! pool organization-id)) (session/clear-organization-sso-sessions! pool organization-id))
(notifications/notify-organization-change-sso cfg organization-id) (notifications/notify-organization-change-sso cfg organization-id)
(when announce-activation
(neh/send-organization-setup-sso-emails! cfg organization-id))
nil) 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)))))

View File

@ -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)))))))

View File

@ -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]))

View File

@ -16,17 +16,17 @@
;;TODO There is a bug on dashboard with teams notifications. ;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id ;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero :topic uuid/zero
:message {:type :team-org-change :message {:type :team-organization-change
:team team :team team
:notification notification}))) :notification notification})))
(defn notify-user-org-change (defn notify-user-organization-change
[cfg profile-id organization-id organization-name notification] [cfg profile-id organization-id organization-name notification]
(let [msgbus (::mbus/msgbus cfg)] (let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus (mbus/pub! msgbus
:topic profile-id :topic profile-id
:message {:type :user-org-change :message {:type :user-organization-change
:topic profile-id :topic profile-id
:organization-id organization-id :organization-id organization-id
:organization-name organization-name :organization-name organization-name

View File

@ -7,8 +7,11 @@
(ns app.rpc.permissions (ns app.rpc.permissions
"A permission checking helper factories." "A permission checking helper factories."
(:require (:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex] [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 (def schema:permissions
[:map {:title "Permissions"} [:map {:title "Permissions"}
@ -89,3 +92,64 @@
(ex/raise :type :not-found (ex/raise :type :not-found
:code :object-not-found :code :object-not-found
:hint "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))))

View File

@ -7,6 +7,7 @@
(ns app.srepl.binfile (ns app.srepl.binfile
(:require (:require
[app.binfile.v2 :as binfile.v2] [app.binfile.v2 :as binfile.v2]
[app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.srepl.helpers :as h] [app.srepl.helpers :as h]
[app.system :as sys] [app.system :as sys]
@ -30,7 +31,8 @@
(when owner (when owner
(db/insert! cfg :team-profile-rel (db/insert! cfg :team-profile-rel
{:team-id (:id team) {:id (uuid/next)
:team-id (:id team)
:profile-id (:id owner) :profile-id (:id owner)
:is-admin true :is-admin true
:is-owner true :is-owner true

View File

@ -63,6 +63,23 @@
(t/is (= :auto (#'oidc/select-user-info-source :token))) (t/is (= :auto (#'oidc/select-user-info-source :token)))
(t/is (= :auto (#'oidc/select-user-info-source :userinfo))))) (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/deftest int-in-range-checks-range-correctly
(t/testing "values within range return true" (t/testing "values within range return true"
(t/is (#'oidc/int-in-range? 200 200 300)) (t/is (#'oidc/int-in-range? 200 200 300))

View File

@ -6,10 +6,12 @@
(ns backend-tests.email-sending-test (ns backend-tests.email-sending-test
(:require (:require
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.email :as emails] [app.email :as emails]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.test :as t] [clojure.test :as t]
[cuerdas.core :as str]
[promesa.core :as p])) [promesa.core :as p]))
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
@ -23,3 +25,67 @@
(t/is (contains? result :to)) (t/is (contains? result :to))
#_(t/is (contains? result :reply-to)) #_(t/is (contains? result :reply-to))
(t/is (map? (:body result))))) (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)))))

View File

@ -79,59 +79,66 @@
:enable-auto-file-snapshot :enable-auto-file-snapshot
:disable-file-validation]) :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] [next]
(with-redefs [app.config/flags (flags/parse flags/default default-flags) (let [templates [{:id "test"
app.config/config config :name "test"
app.loggers.audit/submit (constantly nil) :file-uri "test"
app.auth/derive-password identity :thumbnail-uri "test"
app.auth/verify-password (fn [a b] {:valid (= a b)}) :path (-> "backend_tests/test_files/template.penpot" io/resource fs/path)}]
app.common.features/get-enabled-features (fn [& _] app.common.features/supported-features)] 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) (def state-init
(t/compose-fixtures init-config init-system))
(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))))))
(defn database-reset (defn database-reset
[next] [next]
@ -386,29 +393,15 @@
(assoc :app.rpc/request-at (ct/now))))))) (assoc :app.rpc/request-at (ct/now)))))))
(defn management-command! (defn management-command!
([data] [{:keys [::type] :as data}]
(management-command! data nil)) (let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])]
([{:keys [::type] :as data} flags-to-add] (when-not method-fn
(let [flags (reduce conj cf/flags (or flags-to-add [])) (ex/raise :type :assertion
:code :rpc-method-not-found
resolve-management-methods :hint (str/ffmt "management rpc method '%' not found" (name type))))
(requiring-resolve 'app.rpc/resolve-management-methods) (try-on! (method-fn (-> data
(dissoc ::type)
methods (assoc :app.rpc/request-at (ct/now)))))))
(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))))))))
(defn run-task! (defn run-task!
([name] ([name]

View File

@ -110,7 +110,8 @@
(doseq [bucket ["file-media-object" (doseq [bucket ["file-media-object"
"file-object-thumbnail" "file-object-thumbnail"
"team-font-variant" "team-font-variant"
"file-data-fragment"]] "file-data-fragment"
"organization"]]
(t/testing (str "bucket: " bucket) (t/testing (str "bucket: " bucket)
(let [object (create-storage-object! storage bucket "public data") (let [object (create-storage-object! storage bucket "public data")
request {:path-params {:id (str (:id object))}} request {:path-params {:id (str (:id object))}}
@ -120,6 +121,19 @@
(t/is (not= 404 (::yres/status response)) (t/is (not= 404 (::yres/status response))
(str "bucket " bucket " object should exist"))))))) (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 (t/deftest objects-handler-public-bucket-with-auth
;; Objects in public buckets should also be accessible WITH authentication. ;; Objects in public buckets should also be accessible WITH authentication.
(let [storage (-> (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)

View File

@ -9,6 +9,7 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.http :as http] [app.http :as http]
[app.http.access-token :as actoken]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.storage :as sto] [app.storage :as sto]
[backend-tests.helpers :as th] [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 first-mcp))))
(t/is (not (contains? all-ids (:id second-mcp)))) (t/is (not (contains? all-ids (:id second-mcp))))
(t/is (contains? all-ids (:id third-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)))))))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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)))))))

View File

@ -13,7 +13,9 @@
[app.db :as db] [app.db :as db]
[app.email.blacklist :as email.blacklist] [app.email.blacklist :as email.blacklist]
[app.http :as http] [app.http :as http]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.storage :as sto] [app.storage :as sto]
[app.tokens :as tokens] [app.tokens :as tokens]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
@ -103,6 +105,63 @@
(t/is (= :validation (:type edata))) (t/is (= :validation (:type edata)))
(t/is (= :member-is-muted (:code 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 (t/deftest create-team-invitations-blacklisted-domain
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true}) (let [profile1 (th/create-profile* 1 {:is-active true})
@ -378,6 +437,158 @@
(t/is (= :validation (:type edata))) (t/is (= :validation (:type edata)))
(t/is (= :invalid-token (:code 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 (t/deftest create-team-invitations-with-email-verification-disabled
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true}) (let [profile1 (th/create-profile* 1 {:is-active true})
@ -845,4 +1056,3 @@
:name "My Valid Team"} :name "My Valid Team"}
out (th/command! data)] out (th/command! data)]
(t/is (th/success? out))))) (t/is (th/success? out)))))

View File

@ -1,21 +1,21 @@
{:deps {:deps
{org.clojure/clojure {:mvn/version "1.12.5"} {org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/data.json {:mvn/version "2.5.2"} org.clojure/data.json {:mvn/version "2.5.2"}
org.clojure/tools.cli {:mvn/version "1.1.230"} org.clojure/tools.cli {:mvn/version "1.4.256"}
org.clojure/test.check {:mvn/version "1.1.1"} org.clojure/test.check {:mvn/version "1.1.3"}
org.clojure/data.fressian {:mvn/version "1.1.1"} 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"} org.apache.commons/commons-pool2 {:mvn/version "2.13.1"}
;; Logging ;; Logging
org.apache.logging.log4j/log4j-api {: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.0"} org.apache.logging.log4j/log4j-core {:mvn/version "2.26.1"}
org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"} org.apache.logging.log4j/log4j-web {:mvn/version "2.26.1"}
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"} org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.1"}
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"} org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.1"}
org.slf4j/slf4j-api {:mvn/version "2.0.18"} 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"} selmer/selmer {:mvn/version "1.13.4"}
criterium/criterium {:mvn/version "0.4.6"} criterium/criterium {:mvn/version "0.4.6"}
@ -23,22 +23,20 @@
metosin/jsonista {:mvn/version "1.0.0" metosin/jsonista {:mvn/version "1.0.0"
:exclusions [com.fasterxml.jackson.core/jackson-core :exclusions [com.fasterxml.jackson.core/jackson-core
com.fasterxml.jackson.core/jackson-databind]} com.fasterxml.jackson.core/jackson-databind]}
com.fasterxml.jackson.core/jackson-core {: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.0"} 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"} 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"} com.cognitect/transit-cljs {:mvn/version "0.8.280"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"} java-http-clj/java-http-clj {:mvn/version "0.4.3"}
integrant/integrant {:mvn/version "1.0.1"} integrant/integrant {:mvn/version "1.0.1"}
funcool/cuerdas {:mvn/version "2026.415"} funcool/cuerdas {:mvn/version "2026.415"}
funcool/promesa funcool/promesa {:mvn/version "12.0.1"}
{:git/sha "46048fc0d4bf5466a2a4121f5d52aefa6337f2e8"
:git/url "https://github.com/funcool/promesa"}
funcool/datoteka funcool/datoteka
{:git/tag "4.0.0" {:git/tag "4.0.0"
@ -53,7 +51,7 @@
com.sun.mail/jakarta.mail {:mvn/version "2.0.2"} com.sun.mail/jakarta.mail {:mvn/version "2.0.2"}
org.la4j/la4j {:mvn/version "0.6.0"} 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"} io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}} environ/environ {:mvn/version "1.2.0"}}
@ -62,7 +60,7 @@
{:dev {:dev
{:extra-deps {:extra-deps
{org.clojure/tools.namespace {:mvn/version "1.5.1"} {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.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
com.bhauman/rebel-readline {:mvn/version "0.1.11"} com.bhauman/rebel-readline {:mvn/version "0.1.11"}
criterium/criterium {:mvn/version "0.4.6"} criterium/criterium {:mvn/version "0.4.6"}

View File

@ -4,24 +4,25 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL", "author": "Kaleidos INC Sucursal en España SL",
"private": true, "private": true,
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/penpot/penpot" "url": "https://github.com/penpot/penpot"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.3", "concurrently": "^10.0.4",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"prettier": "3.9.4", "prettier": "3.9.6",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ws": "^8.21.0" "ws": "^8.21.1"
}, },
"dependencies": { "dependencies": {
"date-fns": "^4.4.0" "date-fns": "^4.4.0"
}, },
"scripts": { "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:clj": "cljfmt check --parallel=true src/ test/",
"check-fmt:js": "prettier -c src/**/*.js", "check-fmt:js": "prettier -c src/**/*.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj": "cljfmt fix --parallel=true src/ test/",

38
common/pnpm-lock.yaml generated
View File

@ -13,20 +13,20 @@ importers:
version: 4.4.0 version: 4.4.0
devDependencies: devDependencies:
concurrently: concurrently:
specifier: ^10.0.3 specifier: ^10.0.4
version: 10.0.3 version: 10.0.4
nodemon: nodemon:
specifier: ^3.1.14 specifier: ^3.1.14
version: 3.1.14 version: 3.1.14
prettier: prettier:
specifier: 3.9.4 specifier: 3.9.6
version: 3.9.4 version: 3.9.6
source-map-support: source-map-support:
specifier: ^0.5.21 specifier: ^0.5.21
version: 0.5.21 version: 0.5.21
ws: ws:
specifier: ^8.21.0 specifier: ^8.21.1
version: 8.21.0 version: 8.21.1
packages: packages:
@ -73,8 +73,8 @@ packages:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'} engines: {node: '>=20'}
concurrently@10.0.3: concurrently@10.0.4:
resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==} resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==}
engines: {node: '>=22'} engines: {node: '>=22'}
hasBin: true hasBin: true
@ -161,8 +161,8 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
prettier@3.9.4: prettier@3.9.6:
resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
@ -181,8 +181,8 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
shell-quote@1.8.4: shell-quote@1.9.0:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
simple-update-notifier@2.0.0: simple-update-notifier@2.0.0:
@ -234,8 +234,8 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'} engines: {node: '>=18'}
ws@8.21.0: ws@8.21.1:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
peerDependencies: peerDependencies:
bufferutil: ^4.0.1 bufferutil: ^4.0.1
@ -303,11 +303,11 @@ snapshots:
strip-ansi: 7.2.0 strip-ansi: 7.2.0
wrap-ansi: 9.0.2 wrap-ansi: 9.0.2
concurrently@10.0.3: concurrently@10.0.4:
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
rxjs: 7.8.2 rxjs: 7.8.2
shell-quote: 1.8.4 shell-quote: 1.9.0
supports-color: 10.2.2 supports-color: 10.2.2
tree-kill: 1.2.2 tree-kill: 1.2.2
yargs: 18.0.0 yargs: 18.0.0
@ -378,7 +378,7 @@ snapshots:
picomatch@2.3.2: {} picomatch@2.3.2: {}
prettier@3.9.4: {} prettier@3.9.6: {}
pstree.remy@1.1.8: {} pstree.remy@1.1.8: {}
@ -392,7 +392,7 @@ snapshots:
semver@7.8.4: {} semver@7.8.4: {}
shell-quote@1.8.4: {} shell-quote@1.9.0: {}
simple-update-notifier@2.0.0: simple-update-notifier@2.0.0:
dependencies: dependencies:
@ -439,7 +439,7 @@ snapshots:
string-width: 7.2.0 string-width: 7.2.0
strip-ansi: 7.2.0 strip-ansi: 7.2.0
ws@8.21.0: {} ws@8.21.1: {}
y18n@5.0.8: {} y18n@5.0.8: {}

View File

@ -244,6 +244,7 @@
[:page-id {:optional true} ::sm/uuid] [:page-id {:optional true} ::sm/uuid]
[:component-id {:optional true} ::sm/uuid] [:component-id {:optional true} ::sm/uuid]
[:ignore-touched {:optional true} :boolean] [:ignore-touched {:optional true} :boolean]
[:allow-altering-copies {:optional true} :boolean]
[:parent-id ::sm/uuid] [:parent-id ::sm/uuid]
[:shapes ::sm/any]]] [:shapes ::sm/any]]]
@ -633,22 +634,26 @@
(d/update-in-when data [:components component-id :objects] process-operations change))) (d/update-in-when data [:components component-id :objects] process-operations change)))
(defn- process-children-reordering (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])] (if-let [old-shapes (dm/get-in objects [parent-id :shapes])]
(let [id->idx ;; Component sync owns copy child ordering.
(update-vals (if (and (not allow-altering-copies)
(->> (d/enumerate shapes) (ctk/in-component-copy? (get objects parent-id)))
(group-by second)) objects
(comp first first)) (let [id->idx
(update-vals
(->> (d/enumerate shapes)
(group-by second))
(comp first first))
new-shapes new-shapes
(vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))] (vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))]
(if (not= old-shapes new-shapes) (if (not= old-shapes new-shapes)
(do (do
(some-> *touched-changes* (vswap! conj change)) (some-> *touched-changes* (vswap! conj change))
(update objects parent-id assoc :shapes new-shapes)) (update objects parent-id assoc :shapes new-shapes))
objects)) objects)))
objects)) objects))

View File

@ -198,6 +198,20 @@
::applied-changes-count (count redo-changes))) ::applied-changes-count (count redo-changes)))
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 ;; Page changes
(defn add-empty-page (defn add-empty-page
@ -603,68 +617,72 @@
(-> (reduce update-shape changes ids) (-> (reduce update-shape changes ids)
(apply-changes-local))))) (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 (defn remove-objects
([changes ids] (remove-objects changes ids nil)) ([changes ids] (remove-objects changes ids nil))
([changes ids {:keys [ignore-touched] :or {ignore-touched false}}] ([changes ids options]
(assert-page-id! changes) (assert-page-id! changes)
(assert-objects! changes) (assert-objects! changes)
(let [page-id (::page-id (meta changes)) (-> changes
objects (lookup-objects changes) (add-remove-objects-changes (::page-id (meta changes))
(lookup-objects changes)
add-redo-change ids
(fn [change-set id] options)
(conj change-set (apply-changes-local))))
(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)))))
;; FIXME: PERFORMANCE ;; FIXME: PERFORMANCE
(defn resize-parents (defn- add-resize-parents-changes
[changes ids] [changes page-id objects ids]
(assert-page-id! changes) (let [xform (comp
(assert-objects! changes)
(let [page-id (::page-id (meta changes))
objects (lookup-objects changes)
xform (comp
(mapcat #(cons % (cfh/get-parent-ids objects %))) (mapcat #(cons % (cfh/get-parent-ids objects %)))
(map (d/getf objects)) (map (d/getf objects))
(filter #(contains? #{:group :bool} (:type %))) (filter #(contains? #{:group :bool} (:type %)))
@ -698,9 +716,8 @@
(update :uops conj {:type :set :attr attr :val old-val :ignore-touched true}))))) (update :uops conj {:type :set :attr attr :val old-val :ignore-touched true})))))
resize-parent resize-parent
(fn [changes parent] (fn [[changes objects] parent]
(let [objects (lookup-objects changes) (let [children (->> parent :shapes (map (d/getf objects)))
children (->> parent :shapes (map (d/getf objects)))
resized-parent (cond resized-parent (cond
(empty? children) ;; a parent with no children will be deleted, (empty? children) ;; a parent with no children will be deleted,
nil ;; so it does not need resize nil ;; so it does not need resize
@ -727,14 +744,24 @@
:id (:id parent)}] :id (:id parent)}]
(if (seq rops) (if (seq rops)
(-> changes [(-> changes
(update :redo-changes conj (assoc change :operations rops)) (update :redo-changes conj (assoc change :operations rops))
(update :undo-changes conj (assoc change :operations uops)) (update :undo-changes conj (assoc change :operations uops)))
(apply-changes-local)) (assoc objects (:id parent) resized-parent)]
changes)) [changes objects]))
changes)))] [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 ;; Library changes
@ -1148,6 +1175,8 @@
(->> ids (->> ids
(map (d/getf objects)) (map (d/getf objects))
(filter ctl/grid-layout?) (filter ctl/grid-layout?)
;; Component sync owns copy child ordering.
(remove ctk/in-component-copy?)
(reduce reorder-grid changes))] (reduce reorder-grid changes))]
changes)) changes))

View File

@ -63,8 +63,8 @@
file-data))) file-data)))
(defn fix-missing-swap-slots (defn fix-missing-swap-slots
"Locate shapes that have been swapped (i.e. their shape-ref does not point to the near match) but "Locate shapes that have been swapped (see `ctf/swapped-subhead?`) but don't have a swap slot.
they don't have a swap slot. In this case, add one pointing to the near match." In this case, add one pointing to the near match."
[file-data libraries] [file-data libraries]
(try (try
(ctf/update-all-shapes (ctf/update-all-shapes
@ -73,7 +73,11 @@
(if (ctk/subcopy-head? shape) (if (ctk/subcopy-head? shape)
(let [container (:container (meta shape)) (let [container (:container (meta shape))
file {:id (:id file-data) :data file-data} 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) (if (and (some? near-match)
(not= (:shape-ref shape) (:id near-match)) (not= (:shape-ref shape) (:id near-match))
(nil? (ctk/get-swap-slot shape))) (nil? (ctk/get-swap-slot shape)))

View File

@ -1874,6 +1874,110 @@
(update :pages-index d/update-vals update-container) (update :pages-index d/update-vals update-container)
(d/update-when :components 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 (def available-migrations
(into (d/ordered-set) (into (d/ordered-set)
["legacy-2" ["legacy-2"
@ -1955,4 +2059,5 @@
"0021-fix-shape-svg-attrs" "0021-fix-shape-svg-attrs"
"0022-normalize-component-root-and-resync" "0022-normalize-component-root-and-resync"
"0023-repair-token-themes-with-inexistent-sets" "0023-repair-token-themes-with-inexistent-sets"
"0024b-fix-stroke-cap-placement"])) "0024b-fix-stroke-cap-placement"
"0025-repair-empty-text-content"]))

View File

@ -438,15 +438,15 @@
shape file page))) shape file page)))
(defn- check-required-swap-slot (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 "Validate that the shape has a swap slot if it's a subinstance head that has been
matching shape by position in the near main." swapped (see `ctf/swapped-subhead?`)."
[shape file page libraries] [shape file page libraries]
;; Guard first: if the shape already has a swap slot the invariant is satisfied ;; 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. ;; and we can avoid the ref-shape lookups entirely.
(when (nil? (ctk/get-swap-slot shape)) (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)] (let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)]
(when (and (some? near-match) (when (some? near-match)
(not= (:shape-ref shape) (:id near-match)))
(report-error :missing-slot (report-error :missing-slot
"Shape has been swapped, should have swap slot" "Shape has been swapped, should have swap slot"
shape file page shape file page

View File

@ -167,10 +167,18 @@
;; Activates the nitrate module ;; Activates the nitrate module
:nitrate :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 :mcp
:background-blur :background-blur
:available-viewer-wasm :available-viewer-wasm
:stroke-path}) :stroke-path
:stroke-per-side
:custom-shortcuts})
(def all-flags (def all-flags
(set/union email login varia)) (set/union email login varia))
@ -204,6 +212,7 @@
:enable-render-wasm-info :enable-render-wasm-info
:enable-available-viewer-wasm :enable-available-viewer-wasm
:enable-background-blur :enable-background-blur
:enable-stroke-path
:enable-token-combobox]) :enable-token-combobox])
(defn parse (defn parse

View File

@ -253,7 +253,7 @@
(swap! log-record (constantly lrecord))))] (swap! log-record (constantly lrecord))))]
(if sync? (if sync?
(logfn) (logfn)
(px/exec! *default-executor* logfn)))) (px/exec *default-executor* logfn))))
(defmacro log! (defmacro log!
"Emit a new log record to the global log-record state (asynchronously). " "Emit a new log record to the global log-record state (asynchronously). "

View File

@ -2505,15 +2505,49 @@
(pcb/concat-changes changes new-changes))) (pcb/concat-changes changes new-changes)))
(defn- reposition-shape (defn- reposition-shape
[shape origin-root dest-root] "Expresses the shape (belonging to the origin-root instance) in the frame of the
(let [shape-pos (fn [shape] dest-root instance, making the geometry of both instances directly comparable —
(gpt/point (get-in shape [:selrect :x]) and copyable.
(get-in shape [:selrect :y])))
origin-root-pos (shape-pos origin-root) If the dest root's geometry is NOT overridden (touched), the instance follows
dest-root-pos (shape-pos dest-root) the origin's transformation verbatim (including rotation and flips), so a
delta (gpt/subtract dest-root-pos origin-root-pos)] translation by the roots' (untransformed) position delta suffices — position is
(gsh/move shape delta))) 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 (defn- make-change
[container change] [container change]

View File

@ -113,6 +113,116 @@
(-> changes (-> changes
(pcb/update-shapes ids update-fn {:attrs #{:blocked :hidden}})))) (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 (defn generate-delete-shapes
([changes file page objects ids options] ([changes file page objects ids options]
(generate-delete-shapes (-> changes (generate-delete-shapes (-> changes
@ -185,17 +295,19 @@
ids-to-delete) 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) 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 changes
(->> (:flows page) (->> (:flows page)
(reduce (reduce
@ -261,19 +373,43 @@
[] []
(into ids-to-delete descendants-to-delete)) (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-to-delete
(->> (:guides page) (->> (:guides page)
(vals) (vals)
(filter #(contains? ids-set (:frame-id %))) (filter #(id-to-delete? (:frame-id %)))
(map :id)) (map :id))
changes (reduce (fn [changes guide-id] changes (reduce (fn [changes guide-id]
(-> changes (-> changes
(pcb/with-page page) (pcb/with-page page)
(pcb/set-flow guide-id nil))) (pcb/set-guide guide-id nil)))
changes changes
guides-to-delete) guides-to-delete)
@ -289,6 +425,7 @@
(pcb/remove-objects descendants-to-delete {:ignore-touched true}) (pcb/remove-objects descendants-to-delete {:ignore-touched true})
(pcb/remove-objects ids-to-delete {:ignore-touched ignore-touched}) (pcb/remove-objects ids-to-delete {:ignore-touched ignore-touched})
(pcb/remove-objects empty-parents) (pcb/remove-objects empty-parents)
(generate-copy-deletions data page pages-index copy-deletions)
(pcb/resize-parents all-parents) (pcb/resize-parents all-parents)
(pcb/update-shapes groups-to-unmask (pcb/update-shapes groups-to-unmask
(fn [shape] (fn [shape]
@ -299,7 +436,7 @@
(fn [interactions] (fn [interactions]
(into [] (into []
(remove #(and (ctsi/has-destination %) (remove #(and (ctsi/has-destination %)
(contains? ids-to-delete (:destination %)))) (id-to-delete? (:destination %))))
interactions))))))] interactions))))))]
[all-parents changes]))) [all-parents changes])))

View File

@ -34,12 +34,14 @@
#?(:cljs (js/isNaN v) #?(:cljs (js/isNaN v)
:clj (Double/isNaN v))) :clj (Double/isNaN v)))
;; NOTE: on cljs we don't need to check for `number?` so we explicitly ;; NOTE: we need `number?` guard on cljs because `js/isFinite` coerces
;; ommit it for performance reasons. ;; 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? (defn finite?
[v] [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)))) :clj (and (not (nil? v)) (number? v) (Double/isFinite v))))
(defn finite (defn finite

View File

@ -429,6 +429,17 @@
(with-meta (meta parent-ref-shape))))] (with-meta (meta parent-ref-shape))))]
near-match)) 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 (defn advance-shape-ref
"Get the shape-ref of the near main of the shape, recursively repeated as many times "Get the shape-ref of the near main of the shape, recursively repeated as many times
as the given levels." as the given levels."

View File

@ -14,29 +14,29 @@
:new-team-members "anyone"}) :new-team-members "anyone"})
(defn- can-create-team? (defn- can-create-team?
[{:keys [is-org-owner? permission-value]}] [{:keys [is-organization-owner? permission-value]}]
(or is-org-owner? (or is-organization-owner?
(= permission-value "any"))) (= permission-value "any")))
(defn- can-delete-team? (defn- can-delete-team?
[{:keys [is-org-owner? permission-value team-perms]}] [{:keys [is-organization-owner? permission-value team-perms]}]
(cond (cond
;; Org owners can always delete teams inside their organizations. ;; Organization owners can always delete teams inside their organizations.
is-org-owner? is-organization-owner?
true true
(= permission-value "onlyOwners") (= permission-value "onlyOwners")
(boolean (:is-owner team-perms)) (boolean (:is-owner team-perms))
:else false)) :else false))
(defn- can-move-team? (defn- can-move-team?
[{:keys [permission-value target-org-same-owner?]}] [{:keys [permission-value target-organization-same-owner?]}]
(cond (cond
(= permission-value "never") (= permission-value "never")
false false
(= permission-value "always") (= permission-value "always")
true true
(= permission-value "myOrganizations") (= permission-value "myOrganizations")
(true? target-org-same-owner?) (true? target-organization-same-owner?)
:else false)) :else false))
(defn- can-invite-to-team? (defn- can-invite-to-team?
@ -67,36 +67,36 @@
:add-anybody-to-team {:permission-key :new-team-members :add-anybody-to-team {:permission-key :new-team-members
:check-fn can-add-anybody-to-team?}}) :check-fn can-add-anybody-to-team?}})
(defn- normalize-org-permissions (defn- normalize-organization-permissions
[org-perms] [organization-perms]
(merge defaults (or (:permissions org-perms) {}))) (merge defaults (or (:permissions organization-perms) {})))
(defn- owner? (defn- owner?
[org-perms profile-id] [organization-perms profile-id]
(= profile-id (:owner-id org-perms))) (= profile-id (:owner-id organization-perms)))
(defn allowed? (defn allowed?
"Returns true only for explicitly allowed actions (fail-closed)." "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} (let [{:keys [permission-key check-fn] :as rule}
(get action-rules action) (get action-rules action)
permissions (normalize-org-permissions org-perms) permissions (normalize-organization-permissions organization-perms)
is-org-owner? (owner? org-perms profile-id) is-organization-owner? (owner? organization-perms profile-id)
permission-value (get permissions permission-key)] permission-value (get permissions permission-key)]
(cond (cond
(nil? rule) false (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 :permission-value permission-value
:team-perms team-perms :team-perms team-perms
:target-org-same-owner? target-org-same-owner?}))))) :target-organization-same-owner? target-organization-same-owner?})))))
(defn can-send-invitations? (defn can-send-invitations?
[{:keys [nitrate-enabled? organization profile-id team-permissions]}] [{:keys [nitrate-enabled? organization profile-id team-permissions]}]
(let [in-org? (and nitrate-enabled? organization)] (let [in-organization? (and nitrate-enabled? organization)]
(if in-org? (if in-organization?
(allowed? :send-invitations (allowed? :send-invitations
{:org-perms {:owner-id (:owner-id organization) {:organization-perms {:owner-id (:owner-id organization)
:permissions (:permissions organization)} :permissions (:permissions organization)}
:profile-id profile-id :profile-id profile-id
:team-perms team-permissions}) :team-perms team-permissions})
(or (boolean (:is-owner team-permissions)) (or (boolean (:is-owner team-permissions))

View File

@ -61,4 +61,14 @@
[:name ::sm/text] [:name ::sm/text]
[:initials [:maybe :string]] [:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]] [: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]]])

View File

@ -139,6 +139,12 @@
[:stroke-style {:optional true} [:stroke-style {:optional true}
[::sm/one-of #{:solid :dotted :dashed :mixed}]] [::sm/one-of #{:solid :dotted :dashed :mixed}]]
[:stroke-width {:optional true} ::sm/safe-number] [: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-dash {:optional true} ::sm/safe-number]
[:stroke-gap {:optional true} ::sm/safe-number] [:stroke-gap {:optional true} ::sm/safe-number]
[:stroke-alignment {:optional true} [:stroke-alignment {:optional true}
@ -692,6 +698,31 @@
:r3 :r3
:r4}) :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 (defn extract-props
"Retrieves an object with the 'pasteable' properties for a shape." "Retrieves an object with the 'pasteable' properties for a shape."
[shape] [shape]
@ -723,7 +754,15 @@
props))) props)))
(extract-layout-attrs [props shape] (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 (let [;; For texts we don't extract the fill
extract-props extract-props
@ -731,7 +770,8 @@
(-> shape (-> shape
(select-keys extract-props) (select-keys extract-props)
(cond-> (cfh/text-shape? shape) (extract-text-props shape)) (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 (defn patch-props
"Given the object of `extract-props` applies it to a shape. Adapt the shape if necessary" "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))] (let [shape (d/patch-object shape (select-keys props ctsl/layout-attrs))]
(cond-> shape (cond-> shape
(ctsl/grid-layout? 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 (-> shape
(d/patch-object (select-keys props basic-extract-props)) (d/patch-object (select-keys props basic-extract-props))
(cond-> (cfh/text-shape? shape) (patch-text-props 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))))

View File

@ -76,7 +76,10 @@
[:map {:title "AnimationDisolve"} [:map {:title "AnimationDisolve"}
[:animation-type [:= :dissolve]] [:animation-type [:= :dissolve]]
[:duration ::sm/safe-int] [: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 (def schema:slide-animation
[:map {:title "AnimationSlide"} [:map {:title "AnimationSlide"}

View File

@ -347,6 +347,22 @@
(+ pad-top pad-top) (+ pad-top pad-top)
(+ pad-top pad-bottom)))) (+ 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 (defn child-min-width
[child] [child]
(if (and (fill-width? child) (if (and (fill-width? child)
@ -1509,20 +1525,37 @@
(some? target-cell) (some? target-cell)
(add-children-to-cell ids objects [(:row target-cell) (:column 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 (defn reorder-grid-children
"Order cell children by grid position while preserving the indices of
hidden and absolute-positioned children."
[parent] [parent]
(let [cells (get-cells parent {:sort? true}) (let [cells (get-cells parent {:sort? true})
child? (set (:shapes parent)) child? (set (:shapes parent))
new-shapes
(into (d/ordered-set) in-cell-ids
(into []
(comp (keep (comp first :shapes)) (comp (keep (comp first :shapes))
(filter child?)) (filter child?)
cells) (distinct))
cells)]
;; Add the children that are not in cells (absolute positioned for example) ;; :shapes is ordered in reverse relative to the visual cell order
new-shapes (into new-shapes (:shapes parent))] (assoc parent :shapes (refill-slots (:shapes parent)
(set in-cell-ids)
(assoc parent :shapes (into [] (reverse new-shapes))))) (reverse in-cell-ids)))))
(defn cells-by-row (defn cells-by-row
([parent index] ([parent index]

View File

@ -20,44 +20,42 @@
[:type [:= "root"]] [:type [:= "root"]]
[:key {:optional true} :string] [:key {:optional true} :string]
[:children [:children
{:optional true} [:vector {:min 1 :gen/max 2 :gen/min 1}
[:maybe [:map
[:vector {:min 1 :gen/max 2 :gen/min 1} [:type [:= "paragraph-set"]]
[:map [:key {:optional true} :string]
[:type [:= "paragraph-set"]] [:children
[:key {:optional true} :string] [:vector {:min 1 :gen/max 2 :gen/min 1}
[:children [:map
[:vector {:min 1 :gen/max 2 :gen/min 1} [:type [:= "paragraph"]]
[:map [:key {:optional true} :string]
[:type [:= "paragraph"]] [:fills {:optional true}
[:key {:optional true} :string] [:maybe schema:fills]]
[:fills {:optional true} [:font-family {:optional true} ::sm/text]
[:maybe schema:fills]] [:font-size {:optional true} ::sm/text]
[:font-family {:optional true} ::sm/text] [:font-style {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text] [:direction {:optional true} ::sm/text]
[:font-weight {:optional true} ::sm/text] [:text-decoration {:optional true} ::sm/text]
[:direction {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text]
[:text-decoration {:optional true} ::sm/text] [:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
[:text-transform {:optional true} ::sm/text] [:typography-ref-file {:optional true} [:maybe ::sm/uuid]]
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]] [:children
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]] [:vector {:min 1 :gen/max 2 :gen/min 1}
[:children [:map
[:vector {:min 1 :gen/max 2 :gen/min 1} [:text :string]
[:map [:key {:optional true} :string]
[:text :string] [:fills {:optional true}
[:key {:optional true} :string] [:maybe schema:fills]]
[:fills {:optional true} [:font-family {:optional true} ::sm/text]
[:maybe schema:fills]] [:font-size {:optional true} ::sm/text]
[:font-family {:optional true} ::sm/text] [:font-style {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text] [:direction {:optional true} ::sm/text]
[:font-weight {:optional true} ::sm/text] [:text-decoration {:optional true} ::sm/text]
[:direction {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text]
[:text-decoration {:optional true} ::sm/text] [:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
[:text-transform {:optional true} ::sm/text] [:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]])
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]])
(def valid-content? (def valid-content?
(sm/lazy-validator schema:content)) (sm/lazy-validator schema:content))

View File

@ -235,6 +235,8 @@
[:row-gap {:optional true} schema:token-name] [:row-gap {:optional true} schema:token-name]
[:column-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 (def ^:private schema:spacing-padding
[:map {:title "SpacingPaddingTokenAttrs"} [:map {:title "SpacingPaddingTokenAttrs"}
[:p1 {:optional true} schema:token-name] [:p1 {:optional true} schema:token-name]

View File

@ -912,3 +912,4 @@
(nil? (get-in result2 [:pages-index page-id :default-grids]))))) (nil? (get-in result2 [:pages-index page-id :default-grids])))))
{:num 1000}))) {:num 1000})))

View File

@ -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")))

Some files were not shown because too many files have changed in this diff Show More