mirror of
https://github.com/penpot/penpot.git
synced 2026-08-04 03:48:46 +00:00
Merge remote-tracking branch 'origin/develop' into staging
This commit is contained in:
commit
eacdca3c0a
2
.github/workflows/auto-label.yml
vendored
2
.github/workflows/auto-label.yml
vendored
@ -3,7 +3,7 @@ name: Auto Label and Add to Project
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
|
||||
104
.github/workflows/build-bundle.yml
vendored
104
.github/workflows/build-bundle.yml
vendored
@ -9,16 +9,6 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
build_wasm:
|
||||
description: 'BUILD_WASM. Valid values: yes, no'
|
||||
type: string
|
||||
required: false
|
||||
default: 'yes'
|
||||
build_storybook:
|
||||
description: 'BUILD_STORYBOOK. Valid values: yes, no'
|
||||
type: string
|
||||
required: false
|
||||
default: 'yes'
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@ -26,29 +16,21 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
build_wasm:
|
||||
description: 'BUILD_WASM. Valid values: yes, no'
|
||||
type: string
|
||||
required: false
|
||||
default: 'yes'
|
||||
build_storybook:
|
||||
description: 'BUILD_STORYBOOK. Valid values: yes, no'
|
||||
type: string
|
||||
required: false
|
||||
default: 'yes'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
name: Build and Upload Penpot Bundle
|
||||
# ── 1. Decide whether there is anything to build ───────────────────────
|
||||
check:
|
||||
name: Check current bundle
|
||||
runs-on: penpot-runner-01
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@ -63,10 +45,52 @@ jobs:
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The uploaded zip carries its version as S3 metadata. If the
|
||||
# existing object was already built from this same commit, the
|
||||
# whole build job is skipped.
|
||||
- name: Check if this bundle is already built
|
||||
id: check
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
EXISTING_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
|
||||
--query 'Metadata."bundle-version"' \
|
||||
--output text 2>/dev/null || echo "none")
|
||||
|
||||
if [ "$EXISTING_VERSION" = "${{ steps.vars.outputs.bundle_version }}" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "### ⏭️ Bundle build skipped"
|
||||
echo ""
|
||||
echo "The bundle in S3 was already built from \`${{ steps.vars.outputs.bundle_version }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# ── 2. Build and upload, only when needed ──────────────────────────────
|
||||
build:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: penpot-runner-01
|
||||
timeout-minutes: 90
|
||||
needs: check
|
||||
if: needs.check.outputs.exists == 'false'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Build bundle
|
||||
env:
|
||||
BUILD_WASM: ${{ inputs.build_wasm }}
|
||||
BUILD_STORYBOOK: ${{ inputs.build_storybook }}
|
||||
BUILD_WASM: 'yes'
|
||||
BUILD_STORYBOOK: 'yes'
|
||||
run: ./manage.sh build-bundle
|
||||
|
||||
- name: Prepare directories for zipping
|
||||
@ -80,18 +104,32 @@ jobs:
|
||||
zip -r zips/penpot.zip penpot
|
||||
|
||||
- name: Upload Penpot bundle to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
aws s3 cp zips/penpot.zip s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip --metadata bundle-version=${{ steps.vars.outputs.bundle_version }}
|
||||
aws s3 cp zips/penpot.zip \
|
||||
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
|
||||
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
|
||||
|
||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-runner-01
|
||||
timeout-minutes: 5
|
||||
needs: [check, build]
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 📦 *[PENPOT] Error building penpot bundles.*
|
||||
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
|
||||
Bundle version: `${{ steps.vars.outputs.bundle_version }}`
|
||||
📄 Triggered from ref: `${{ needs.check.outputs.gh_ref || inputs.gh_ref }}`
|
||||
Bundle version: `${{ needs.check.outputs.bundle_version || 'n/a' }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
|
||||
8
.github/workflows/build-develop.yml
vendored
8
.github/workflows/build-develop.yml
vendored
@ -11,8 +11,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
build_wasm: "yes"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@ -20,3 +18,9 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
|
||||
build-admin-console-docker:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
|
||||
91
.github/workflows/build-docker-admin-console.yml
vendored
Normal file
91
.github/workflows/build-docker-admin-console.yml
vendored
Normal 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"
|
||||
13
.github/workflows/build-docker-devenv.yml
vendored
13
.github/workflows/build-docker-devenv.yml
vendored
@ -20,12 +20,19 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Registry
|
||||
- name: Login to Docker Registry (push destination)
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to Docker Hardened Images registry (base image pull)
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: dhi.io
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push DevEnv Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
@ -35,12 +42,14 @@ jobs:
|
||||
file: ./docker/devenv/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ env.DOCKER_IMAGE }}:latest
|
||||
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Notify Mattermost
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
321
.github/workflows/build-docker.yml
vendored
321
.github/workflows/build-docker.yml
vendored
@ -20,55 +20,117 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
ALL_IMAGES: backend frontend exporter storybook mcp
|
||||
# All runner instances live on the same server, so the bundle is
|
||||
# downloaded from S3 once and shared between build jobs through this
|
||||
# host-local directory. Each build job falls back to S3 if the file is
|
||||
# missing (e.g. if runners ever move to separate machines).
|
||||
BUNDLE_CACHE: /var/tmp/penpot-bundle-cache
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Penpot Docker Images
|
||||
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
build_key: ${{ steps.vars.outputs.build_key }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Extract some useful variables
|
||||
id: vars
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
|
||||
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
|
||||
|
||||
BUNDLE_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "penpot-$GH_REF.zip" \
|
||||
--query 'Metadata."bundle-version"' \
|
||||
--output text)
|
||||
echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Image content = bundle + docker build context, so the build key
|
||||
# combines both.
|
||||
CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12)
|
||||
echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
# The image set is a single block, so a single set-level check is
|
||||
# enough: `promote` drops a marker object in S3 only after every
|
||||
# image was built AND every branch tag was moved. Marker present
|
||||
# means there is nothing at all to do for this build key.
|
||||
- name: Check if this image set is already built
|
||||
id: check
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
if aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "markers/images-${{ steps.vars.outputs.build_key }}" \
|
||||
> /dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "### ⏭️ Image set build skipped"
|
||||
echo ""
|
||||
echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# Stage the bundle in the host-local cache, once, for all the
|
||||
# build jobs. Download to a temp name and mv for atomicity;
|
||||
# prune stale bundles while at it.
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||
mv "$ZIP.$$.tmp" "$ZIP"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 60
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
strategy:
|
||||
fail-fast: true
|
||||
# 4 runner slots are available for build jobs on this server; cap the
|
||||
# matrix at 3 so short jobs (prepare and other workflows' checks)
|
||||
# never queue behind long builds.
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
image: [backend, frontend, exporter, storybook, mcp]
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
# Each job execution will use its own docker configuration.
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Extract some useful variables
|
||||
id: vars
|
||||
run: |
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download Penpot Bundles
|
||||
id: bundles
|
||||
env:
|
||||
FILE_NAME: penpot-${{ steps.vars.outputs.gh_ref }}.zip
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
tmp=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "$FILE_NAME" \
|
||||
--query 'Metadata."bundle-version"' \
|
||||
--output text)
|
||||
echo "bundle_version=$tmp" >> $GITHUB_OUTPUT
|
||||
pushd docker/images
|
||||
aws s3 cp s3://${{ secrets.S3_BUCKET }}/$FILE_NAME .
|
||||
unzip $FILE_NAME > /dev/null
|
||||
mv penpot/backend bundle-backend
|
||||
mv penpot/frontend bundle-frontend
|
||||
mv penpot/exporter bundle-exporter
|
||||
mv penpot/storybook bundle-storybook
|
||||
mv penpot/mcp bundle-mcp
|
||||
popd
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
@ -85,103 +147,140 @@ jobs:
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
# Images now build FROM Docker Hardened Images (dhi.io). DHI
|
||||
# is free (Apache 2.0, no subscription), but pulling from it
|
||||
# still requires an authenticated login -- a separate `docker
|
||||
# login` against a different registry host, even though it
|
||||
# reuses the same PUB_DOCKER_* credentials as the DockerHub
|
||||
# login above.
|
||||
- name: Login to Docker Hardened Images registry (base image pull)
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: dhi.io
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
# Bundle staged once by `prepare` on this host; the S3 fallback only
|
||||
# triggers if the cache is unavailable (runners on another machine,
|
||||
# cache pruned mid-run, ...).
|
||||
- name: Prepare Penpot bundle
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
echo "Bundle not found in host cache; falling back to S3."
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.prepare.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||
mv "$ZIP.$$.tmp" "$ZIP"
|
||||
fi
|
||||
# Extract only the bundle this job needs.
|
||||
pushd docker/images
|
||||
unzip -q "$ZIP" "penpot/${{ matrix.image }}/*"
|
||||
mv "penpot/${{ matrix.image }}" "bundle-${{ matrix.image }}"
|
||||
popd
|
||||
|
||||
- name: Set up QEMU (stable)
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images:
|
||||
frontend
|
||||
backend
|
||||
exporter
|
||||
storybook
|
||||
mcp
|
||||
images: ${{ matrix.image }}
|
||||
labels: |
|
||||
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
||||
bundle_version=${{ needs.prepare.outputs.bundle_version }}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_IMAGE: 'backend'
|
||||
BUNDLE_PATH: './bundle-backend'
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.backend
|
||||
file: ./docker/images/Dockerfile.${{ matrix.image }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
# Immutable tag only; branch tags are moved atomically for the
|
||||
# whole image set by the `promote` job.
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_IMAGE: 'frontend'
|
||||
BUNDLE_PATH: './bundle-frontend'
|
||||
# ── 3. Move the branch tags of ALL images together ─────────────────────
|
||||
# Runs only when every build succeeded (default `needs` semantics); if
|
||||
# the set was already complete, `build` is skipped and so is this job —
|
||||
# the S3 marker guarantees the branch tags were already moved.
|
||||
promote:
|
||||
name: Promote image set
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 10
|
||||
needs: [prepare, build]
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.frontend
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Exporter Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
- name: Point branch tags to the new build key
|
||||
run: |
|
||||
set -e
|
||||
for image in $ALL_IMAGES; do
|
||||
docker buildx imagetools create \
|
||||
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
|
||||
"${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}"
|
||||
done
|
||||
|
||||
# The marker is written LAST: its presence certifies that all five
|
||||
# images exist and all branch tags point to this build key.
|
||||
- name: Write set-completed marker
|
||||
env:
|
||||
DOCKER_IMAGE: 'exporter'
|
||||
BUNDLE_PATH: './bundle-exporter'
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.exporter
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
echo "${{ github.run_id }}" | aws s3 cp - \
|
||||
"s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
|
||||
{
|
||||
echo "### ✅ Image set promoted"
|
||||
echo ""
|
||||
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build and push Storybook Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_IMAGE: 'storybook'
|
||||
BUNDLE_PATH: './bundle-storybook'
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.storybook
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push MCP Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_IMAGE: 'mcp'
|
||||
BUNDLE_PATH: './bundle-mcp'
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.mcp
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 5
|
||||
needs: [prepare, build, promote]
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🐳 *[PENPOT] Error building penpot docker images.*
|
||||
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
|
||||
📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}`
|
||||
❌ 🐳 *[PENPOT] Error building/promoting the penpot docker image set.*
|
||||
📄 Triggered from ref: `${{ needs.prepare.outputs.gh_ref || inputs.gh_ref }}`
|
||||
📦 Bundle: `${{ needs.prepare.outputs.bundle_version || 'n/a' }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
|
||||
8
.github/workflows/build-staging.yml
vendored
8
.github/workflows/build-staging.yml
vendored
@ -11,8 +11,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
build_wasm: "yes"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@ -20,3 +18,9 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
|
||||
build-admin-console-docker:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
|
||||
5
.github/workflows/build-tag.yml
vendored
5
.github/workflows/build-tag.yml
vendored
@ -12,8 +12,6 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
build_wasm: "yes"
|
||||
build_storybook: "yes"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@ -26,10 +24,9 @@ jobs:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-docker
|
||||
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
2
.github/workflows/plugins-deploy-api-doc.yml
vendored
2
.github/workflows/plugins-deploy-api-doc.yml
vendored
@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
2
.github/workflows/plugins-deploy-package.yml
vendored
2
.github/workflows/plugins-deploy-package.yml
vendored
@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -103,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
|
||||
69
.github/workflows/tests-composable-suite.yml
vendored
Normal file
69
.github/workflows/tests-composable-suite.yml
vendored
Normal 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
|
||||
57
.opencode/skills/testing/SKILL.md
Normal file
57
.opencode/skills/testing/SKILL.md
Normal 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
|
||||
@ -92,8 +92,8 @@ Fixtures can populate local data for manual testing/perf work. From the backend
|
||||
|
||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
||||
|
||||
* **Linting:** `clj-kondo --lint ../common/src/ src/`.
|
||||
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
|
||||
* **Linting:** `pnpm run lint:clj`.
|
||||
* **Formatting:** `pnpm run check-fmt:clj` to check, `pnpm run fmt:clj` to fix. After running `fmt:clj`, `check-fmt:clj` is redundant. Avoid unrelated whitespace diffs.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits), run
|
||||
`scripts/paren-repair` on the affected files first. Delimiter errors produce
|
||||
|
||||
@ -24,6 +24,12 @@ Variant masters are main instances and component roots. Their descendants may th
|
||||
|
||||
Masters are not normally touched through `set-shape-attr`, but touched flags can appear on master shapes through cloning/duplication paths. `add-touched-from-ref-chain` in `app.common.logic.variants` unions touched flags from ancestors into the copy being processed, so upstream/master touched state can affect downstream switch behavior.
|
||||
|
||||
## Swap slots and positional matching
|
||||
|
||||
- A swap slot (stored via `ctk/set-swap-slot`, a `:touched` group `swap-slot-<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
|
||||
|
||||
`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.
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
- `set-shape-attr` treats `:position-data` as derived and never touched. Geometry/content-path changes use approximate equality; geometry differences under about 1px can be ignored for touched purposes.
|
||||
- Width/height are excluded from the `is-geometry?` branch in `set-shape-attr`; do not assume all geometry-group attrs follow identical ignore-geometry behavior.
|
||||
- `process-touched-change` marks the owning component modified when a touched shape belongs to a main instance; component-data changes can come from shape ops through this second pass.
|
||||
- Copy structure is guarded at change application: `:mov-objects` (`is-valid-move?`) and `:reorder-children` both refuse to alter children of shapes inside component copies unless the change carries `allow-altering-copies` (sync/swap flows set it). New structural change types must follow the same rule.
|
||||
- `cls/generate-delete-shapes` propagates deletions from INSIDE a component main to the copy shapes referencing them (transitively, all pages of the file) so no dangling `shape-ref`s remain; skipped when the main root itself is deleted (copies then resolve into the deleted component) and for `allow-altering-copies` flows (swap replaces the shape; sync reconciles).
|
||||
|
||||
## Shape tree edits
|
||||
|
||||
@ -19,6 +21,7 @@
|
||||
- Full referential/semantic validation currently runs only when file features contain `"components/v2"`.
|
||||
- Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details.
|
||||
- `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes.
|
||||
- `:missing-slot` fires only for a REAL swap: a copy sub-head whose `shape-ref` is no longer a child of the near main parent. A pure positional mismatch (ref still a sibling elsewhere) is a reorder — valid, realigned by the async component sync; do not "repair" it by assigning swap slots (a slot freezes the child out of normal sync). `fix-missing-swap-slots` (migration 0019) follows the same membership rule.
|
||||
|
||||
## Migrations
|
||||
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
## Grid assignment
|
||||
|
||||
- Grid `assign-cells` ensures at least one column and row, skips absolute-position children, creates non-tracked rows/cols when children exceed tracked cells, and asserts that assigned cells do not overlap.
|
||||
- `position-absolute?` counts HIDDEN shapes as absolute: hiding a grid child frees its cell on the next `assign-cells`.
|
||||
- `reorder-grid-children` rewrites the parent's `:shapes` to the REVERSE of the sorted cell order, but children with no cell (hidden/absolute) keep their original index — do not "fix" this into moving them to an end; that broke copy/main positional slot alignment (referential-integrity crash).
|
||||
- The `:reorder-children` change it emits is refused on parents inside component copies unless `allow-altering-copies` (same rule as `:mov-objects`); `pcb/reorder-grid-children` also skips copy grids producer-side. Copy child order is owned by the component sync engine.
|
||||
- Grid deassignment removes cells for shapes that are no longer direct children or have become absolute-positioned.
|
||||
- Auto-positioning is not just sorting: some auto cells are converted to manual when empty/manual/span state would break the auto sequence, then auto single-span items can be compacted.
|
||||
- `fix-overlaps` is marked dev-only and removes one overlapping cell, preferring empty cells first. Avoid depending on it as normal production repair.
|
||||
@ -25,7 +25,9 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
|
||||
|
||||
## Worker policy
|
||||
|
||||
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
||||
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
||||
|
||||
Each workspace is independent and can be started/stopped in any order. Shared infra (postgres, minio, etc.) is shut down only when no instances remain running.
|
||||
|
||||
## Port layout
|
||||
|
||||
@ -63,8 +65,8 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
|
||||
|
||||
## CLI surface
|
||||
|
||||
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
|
||||
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra.
|
||||
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet).
|
||||
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra.
|
||||
- `run-devenv`: legacy alias, ws0 non-agentic attached.
|
||||
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
|
||||
- `run-devenv-shell [--instance 0|wsN|N] [cmd...]`: bash in target instance. (`--instance` flag not yet renamed to `--ws`.)
|
||||
|
||||
152
.serena/memories/frontend/composable-component-tests.md
Normal file
152
.serena/memories/frontend/composable-component-tests.md
Normal 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`.
|
||||
@ -23,7 +23,7 @@ From `frontend/`:
|
||||
- JS lint currently no-ops via `pnpm run lint:js`.
|
||||
- SCSS lint: `pnpm run lint:scss`.
|
||||
- Format checks: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss`.
|
||||
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`.
|
||||
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`. After running `fmt:*`, `check-fmt:*` is redundant.
|
||||
- Translation formatting after i18n edits: `pnpm run translations`.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits, or
|
||||
|
||||
@ -42,15 +42,15 @@ PR descriptions follow this structure:
|
||||
|
||||
## 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
|
||||
|
||||
<root cause or motivation, why this change was necessary>
|
||||
<root cause or motivation — a short paragraph or bullets>
|
||||
|
||||
## 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.
|
||||
@ -59,6 +59,8 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
|
||||
|
||||
- **Write for humans.** The diff shows what changed. The description explains why.
|
||||
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
|
||||
- **Prefer bullets over paragraphs.** Short bullet items, grouped by area with bold lead-ins where helpful, are far easier to digest than prose; keep any remaining paragraph to a few sentences.
|
||||
- **No manual line wraps.** Markdown renders adapting to the viewport; hard-wrapped lines degrade rendering. One line per paragraph or bullet, however long.
|
||||
- **Skip the obvious.** Don't explain what `git diff` already shows.
|
||||
|
||||
### What NOT to Include
|
||||
|
||||
@ -1,26 +1,31 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||
project_name: "penpot"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al ansible bash clojure cpp
|
||||
# cpp_ccls crystal csharp csharp_omnisharp dart
|
||||
# elixir elm erlang fortran fsharp
|
||||
# list of languages for which language servers are started (LSP backend only); choose from:
|
||||
# ada al angular ansible bash
|
||||
# bsl clojure cpp cpp_ccls crystal
|
||||
# csharp csharp_omnisharp cue dart elixir
|
||||
# elm erlang fortran fsharp gdscript
|
||||
# go groovy haskell haxe hlsl
|
||||
# java json julia kotlin lean4
|
||||
# lua luau markdown matlab msl
|
||||
# nix ocaml pascal perl php
|
||||
# php_phpactor powershell python python_jedi python_ty
|
||||
# r rego ruby ruby_solargraph rust
|
||||
# scala solidity swift systemverilog terraform
|
||||
# toml typescript typescript_vts vue yaml
|
||||
# zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# html java json julia kotlin
|
||||
# latex lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor php_phpantom powershell
|
||||
# python python_jedi python_pyrefly python_ty r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# scss solidity svelte swift systemverilog
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||
# For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
@ -54,12 +59,19 @@ ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
|
||||
# Example:
|
||||
# ignored_paths:
|
||||
# - "examples/**"
|
||||
# - ".worktrees/**"
|
||||
# - "**/bin/**"
|
||||
# - "**/obj/**"
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
@ -130,13 +142,38 @@ ignored_memory_patterns: []
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# list of additional workspace folder paths for cross-package reference support.
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# symbols and references across package boundaries, but these folders are not indexed by Serena,
|
||||
# i.e. the respective symbols will not be found using Serena's symbol search tools.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
ls_additional_workspace_folders: []
|
||||
|
||||
# list of workspace folder paths (LSP backend only).
|
||||
# These folders will be used to build up Serena's symbol index.
|
||||
# Paths must be within the project root and should thus be relative to the project root.
|
||||
# Furthermore, the paths should not be filtered by ignore settings.
|
||||
# Default setting: The entire project root folder (".") is considered.
|
||||
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
|
||||
# ls_workspace_folders:
|
||||
# - "./subproject1"
|
||||
# - "./subproject2"
|
||||
ls_workspace_folders:
|
||||
- .
|
||||
|
||||
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
|
||||
# the command runs in the project root directory and is only executed if the project is trusted
|
||||
# (see trusted_project_path_patterns in the global configuration).
|
||||
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
|
||||
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
|
||||
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
|
||||
# example: activation_command: "npx nx run-many -t build"
|
||||
activation_command:
|
||||
|
||||
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
||||
# must be a positive number.
|
||||
activation_command_timeout: 180.0
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
|
||||
- **Never amend a commit that has been pushed** unless the user explicitly asks.
|
||||
If the user pushes, treat that commit as final from the agent's side.
|
||||
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
|
||||
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
|
||||
This prevents hiding test failures. See `mem:testing` for details.
|
||||
- **Read the workflow memory BEFORE the corresponding action**:
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type)
|
||||
|
||||
24
CHANGES.md
24
CHANGES.md
@ -1,5 +1,29 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.18.0 (Unreleased)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
|
||||
- Fix synced component copy not reflowing children after spacing token update [#9892](https://github.com/penpot/penpot/issues/9892)
|
||||
- Fix spacebar activating pan mode while typing a comment (by @Krishcode264) [#10285](https://github.com/penpot/penpot/issues/10285) (PR: [#10287](https://github.com/penpot/penpot/pull/10287))
|
||||
- Fix plugin API rejecting negative letterSpacing values (by @filipsajdak) [#9780](https://github.com/penpot/penpot/issues/9780) (PR: [#10257](https://github.com/penpot/penpot/pull/10257))
|
||||
- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359))
|
||||
- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541))
|
||||
- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535))
|
||||
- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523))
|
||||
- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
|
||||
- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677))
|
||||
- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
|
||||
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
|
||||
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
|
||||
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
|
||||
|
||||
|
||||
## 2.17.1 (Unreleased)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
org.clojure/clojure {:mvn/version "1.12.5"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-11"}
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-12"}
|
||||
|
||||
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
||||
@ -34,15 +34,15 @@
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
com.github.seancorfield/next.jdbc
|
||||
{:mvn/version "1.3.1108"}
|
||||
{:mvn/version "1.3.1118"}
|
||||
|
||||
metosin/reitit-core {:mvn/version "0.10.1"}
|
||||
nrepl/nrepl {:mvn/version "1.7.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.12"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
|
||||
org.postgresql/postgresql {:mvn/version "42.7.13"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||
com.zaxxer/HikariCP {:mvn/version "7.1.0"}
|
||||
|
||||
io.whitfin/siphash {:mvn/version "2.0.0"}
|
||||
|
||||
@ -51,10 +51,10 @@
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.22.2"}
|
||||
org.jsoup/jsoup {:mvn/version "1.23.1"}
|
||||
|
||||
at.yawk.lz4/lz4-java
|
||||
{:mvn/version "1.11.0"}
|
||||
{:mvn/version "1.11.1"}
|
||||
|
||||
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
|
||||
|
||||
@ -63,8 +63,8 @@
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.46.18"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.46.18"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
|
||||
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
@ -19,8 +19,8 @@
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "clj-kondo --parallel --lint ../common/src src/",
|
||||
"check-fmt": "cljfmt check --parallel=true src/ test/",
|
||||
"fmt": "cljfmt fix --parallel=true src/ test/"
|
||||
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
|
||||
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
@ -195,21 +195,45 @@
|
||||
<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;">
|
||||
<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>
|
||||
<td width="20" height="20" align="center" valign="middle"
|
||||
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">
|
||||
background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}"
|
||||
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
|
||||
{% if organization.initials %}{{organization.initials}}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
|
||||
{{ organization.name|abbreviate:50 }}
|
||||
<span
|
||||
style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
|
||||
{{ organization.name|abbreviate:50 }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</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>
|
||||
<td align="center" vertical-align="middle"
|
||||
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
17
backend/resources/app/email/invite-to-organization/en.txt
Normal file
17
backend/resources/app/email/invite-to-organization/en.txt
Normal 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.
|
||||
@ -186,10 +186,31 @@
|
||||
<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;">
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
|
||||
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
|
||||
{{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 %}.</div>
|
||||
</td>
|
||||
</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>
|
||||
<td align="center" vertical-align="middle"
|
||||
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
Hello!
|
||||
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
|
||||
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:50 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}.
|
||||
|
||||
{% if organization.sso-active %}
|
||||
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes
|
||||
through your organization's identity provider.
|
||||
|
||||
If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
|
||||
{% endif %}
|
||||
|
||||
Accept invitation using this link:
|
||||
|
||||
|
||||
231
backend/resources/app/email/organization-setup-sso/en.html
Normal file
231
backend/resources/app/email/organization-setup-sso/en.html
Normal 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>
|
||||
@ -0,0 +1 @@
|
||||
“{{ organization-name|abbreviate:25 }}” uses single sign-on
|
||||
@ -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.
|
||||
@ -459,9 +459,10 @@
|
||||
(let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
|
||||
(if (= status 200)
|
||||
(let [data (json/decode body)
|
||||
data {:token/access (get data :access_token)
|
||||
:token/id (get data :id_token)
|
||||
:token/type (get data :token_type)}]
|
||||
data {:token/access (get data :access_token)
|
||||
:token/id (get data :id_token)
|
||||
:token/type (get data :token_type)
|
||||
:token/expires-in (get data :expires_in)}]
|
||||
(l/trc :hint "access token fetched"
|
||||
:token-id (:token/id data)
|
||||
:token-type (:token/type data)
|
||||
@ -619,6 +620,9 @@
|
||||
(some? (:external-session-id state))
|
||||
(assoc :external-session-id (:external-session-id state))
|
||||
|
||||
(some? (:token/expires-in tdata))
|
||||
(assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)}))
|
||||
|
||||
;; If state token comes with props, merge them. The state token
|
||||
;; props can contain pm_ and utm_ prefixed query params.
|
||||
(map? (:props state))
|
||||
@ -761,21 +765,112 @@
|
||||
;; ORG SSO HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn prepare-org-sso-provider
|
||||
"Build an OIDC provider map dynamically from the Nitrate org SSO config.
|
||||
Uses OIDC discovery via :base-url (or :issuer as fallback) when
|
||||
token/auth/user URIs are absent."
|
||||
[cfg {:keys [client-id client-secret base-url issuer scopes]}]
|
||||
(defn- non-blank-uri
|
||||
[value]
|
||||
(when-not (str/blank? value) value))
|
||||
|
||||
(defn organization-sso-discovery-uri
|
||||
"Return the OIDC discovery URI from an organization SSO config."
|
||||
[sso]
|
||||
(non-blank-uri (:issuer sso)))
|
||||
|
||||
(defn prepare-organization-sso-provider
|
||||
"Build an OIDC provider map dynamically from the Nitrate organization SSO config.
|
||||
Uses OIDC discovery via :issuer when token/auth/user URIs are absent."
|
||||
[cfg {:keys [client-id client-secret issuer]}]
|
||||
(prepare-oidc-provider cfg
|
||||
{:type "oidc"
|
||||
:client-id client-id
|
||||
:client-secret client-secret
|
||||
:base-uri (some-> (or base-url issuer)
|
||||
:base-uri (some-> (non-blank-uri issuer)
|
||||
(str/rtrim "/")
|
||||
(str "/"))
|
||||
:scopes (into default-oidc-scopes (or scopes #{}))
|
||||
:scopes default-oidc-scopes
|
||||
:skip-ssrf-check? true}))
|
||||
|
||||
(defn build-organization-sso-auth-redirect-uri
|
||||
"Build the OIDC authorization redirect URI for an organization SSO config.
|
||||
Raises if the config is incomplete or OIDC discovery fails."
|
||||
[cfg sso & {:keys [dest-url organization-id provider]}]
|
||||
(let [organization-id (or organization-id (:organization-id sso))
|
||||
issuer (organization-sso-discovery-uri sso)
|
||||
dest-url (or dest-url (str (cf/get :public-uri)))]
|
||||
(when-not issuer
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-sso-config
|
||||
:hint "missing issuer"))
|
||||
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
|
||||
state-token (tokens/generate cfg {:iss "oidc"
|
||||
:dest-url dest-url
|
||||
:organization-id organization-id
|
||||
:issuer issuer
|
||||
:exp (ct/in-future "4h")})]
|
||||
(build-auth-redirect-uri oidc-provider state-token))))
|
||||
|
||||
(def ^:private probe-auth-code "penpot-sso-config-probe")
|
||||
|
||||
(defn- decode-token-error-response
|
||||
[body]
|
||||
(when (and (string? body) (pos? (count body)))
|
||||
(try
|
||||
(json/decode body)
|
||||
(catch Throwable _ nil))))
|
||||
|
||||
(defn- token-endpoint-error
|
||||
[response]
|
||||
(some-> response :body decode-token-error-response :error d/name))
|
||||
|
||||
(defn- token-endpoint-error-description
|
||||
[response]
|
||||
(some-> response :body decode-token-error-response :error-description))
|
||||
|
||||
(defn- token-endpoint-valid-client-error?
|
||||
"Token endpoint rejected the dummy auth code but accepted the client credentials."
|
||||
[response]
|
||||
(= "invalid_grant" (token-endpoint-error response)))
|
||||
|
||||
(defn- token-endpoint-invalid-client-error?
|
||||
"Token endpoint rejected the client credentials."
|
||||
[{:keys [status] :as response}]
|
||||
(let [error (token-endpoint-error response)
|
||||
description (str/lower (or (token-endpoint-error-description response) ""))]
|
||||
(or (= status 401)
|
||||
(#{"invalid_client" "unauthorized_client"} error)
|
||||
(and (= error "access_denied")
|
||||
(str/includes? description "unauthorized")))))
|
||||
|
||||
(defn- probe-organization-sso-client-credentials
|
||||
"Probe the token endpoint with a dummy authorization code.
|
||||
Valid client credentials are expected to answer with `invalid_grant`."
|
||||
[cfg provider]
|
||||
(let [params {:client_id (:client-id provider)
|
||||
:client_secret (:client-secret provider)
|
||||
:code probe-auth-code
|
||||
:grant_type "authorization_code"
|
||||
:redirect_uri (build-redirect-uri)}
|
||||
req {:method :post
|
||||
:headers {"content-type" "application/x-www-form-urlencoded"
|
||||
"accept" "application/json"}
|
||||
:uri (:token-uri provider)
|
||||
:body (u/map->query-string params)}
|
||||
response (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
|
||||
(cond
|
||||
(token-endpoint-valid-client-error? response) true
|
||||
(token-endpoint-invalid-client-error? response) false
|
||||
:else false)))
|
||||
|
||||
(defn is-organization-sso-config-valid?
|
||||
"Return true when the SSO config can be discovered, can build a login URL,
|
||||
and the client credentials are accepted by the token endpoint."
|
||||
[cfg sso]
|
||||
(try
|
||||
(if (organization-sso-discovery-uri sso)
|
||||
(let [provider (prepare-organization-sso-provider cfg sso)]
|
||||
(and (build-organization-sso-auth-redirect-uri cfg sso :provider provider)
|
||||
(probe-organization-sso-client-credentials cfg provider)))
|
||||
false)
|
||||
(catch Throwable _ false)))
|
||||
|
||||
(defn- auth-handler
|
||||
[cfg {:keys [params] :as request}]
|
||||
(let [provider (resolve-provider cfg params)
|
||||
@ -802,17 +897,15 @@
|
||||
state (get params :state)
|
||||
state (tokens/verify cfg {:token state :iss "oidc"})]
|
||||
|
||||
;; Org SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; code with the OIDC provider to verify authentication actually occurred.
|
||||
(if-let [dest-url (:dest-url state)]
|
||||
(let [team-id (:team-id state)
|
||||
organization-id (:organization-id state)
|
||||
sso (nitrate/call cfg :get-org-sso-by-team {:team-id team-id})
|
||||
provider (prepare-org-sso-provider cfg sso)
|
||||
;; verify token or throw error
|
||||
_info (get-info cfg provider state code)
|
||||
(let [organization-id (:organization-id state)
|
||||
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (ct/in-future {:hours 48})]
|
||||
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
|
||||
@ -419,10 +419,19 @@
|
||||
:id ::change-email
|
||||
:schema schema:change-email))
|
||||
|
||||
(def ^:private schema:organization-data
|
||||
[:map
|
||||
[:name ::sm/text]
|
||||
[:initials {:optional true} [:maybe :string]]
|
||||
[:logo {:optional true} [:maybe ::sm/uri]]
|
||||
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||
[:sso-active {:optional true} [:maybe ::sm/boolean]]])
|
||||
|
||||
(def ^:private schema:invite-to-team
|
||||
[:map
|
||||
[:invited-by ::sm/text]
|
||||
[:team ::sm/text]
|
||||
[:organization {:optional true} [:maybe schema:organization-data]]
|
||||
[:token ::sm/text]])
|
||||
|
||||
(def invite-to-team
|
||||
@ -431,27 +440,28 @@
|
||||
:id ::invite-to-team
|
||||
:schema schema:invite-to-team))
|
||||
|
||||
(def ^:private schema:organization-data
|
||||
[:map
|
||||
[:name ::sm/text]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo [:maybe ::sm/uri]]
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]])
|
||||
|
||||
(def ^:private schema:invite-to-org
|
||||
(def ^:private schema:invite-to-organization
|
||||
[:map
|
||||
[:invited-by ::sm/text]
|
||||
[:user-name [:maybe ::sm/text]]
|
||||
[:token ::sm/text]
|
||||
[:organization schema:organization-data]])
|
||||
|
||||
(def invite-to-org
|
||||
"Org member invitation email."
|
||||
(def invite-to-organization
|
||||
"Organization member invitation email."
|
||||
(template-factory
|
||||
:id ::invite-to-org
|
||||
:schema schema:invite-to-org))
|
||||
:id ::invite-to-organization
|
||||
:schema schema:invite-to-organization))
|
||||
|
||||
(def ^:private schema:organization-setup-sso
|
||||
[:map
|
||||
[:organization-name ::sm/text]])
|
||||
|
||||
(def organization-setup-sso
|
||||
"Email when an organization set up SSO"
|
||||
(template-factory
|
||||
:id ::organization-setup-sso
|
||||
:schema schema:organization-setup-sso))
|
||||
|
||||
(def ^:private schema:renewal-notice
|
||||
[:map
|
||||
|
||||
@ -31,7 +31,8 @@
|
||||
#{"file-media-object"
|
||||
"file-object-thumbnail"
|
||||
"team-font-variant"
|
||||
"file-data-fragment"})
|
||||
"file-data-fragment"
|
||||
"organization"})
|
||||
|
||||
(defn get-id
|
||||
[{:keys [path-params]}]
|
||||
|
||||
@ -226,19 +226,19 @@
|
||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||
(db/get-update-count))))
|
||||
|
||||
(def ^:private sql:clear-org-sso-sessions
|
||||
(def ^:private sql:clear-organization-sso-sessions
|
||||
(str "UPDATE http_session_v2 "
|
||||
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
||||
"WHERE props IS NOT NULL "
|
||||
"AND jsonb_exists(props -> '~:sso', ?)"))
|
||||
|
||||
(defn clear-org-sso-sessions!
|
||||
(defn clear-organization-sso-sessions!
|
||||
"Remove the SSO entry for organization-id from the props of every
|
||||
session that currently holds it. The key is transit-encoded as the
|
||||
string '~u<uuid>' under the '~:sso' path."
|
||||
[pool organization-id]
|
||||
(let [org-key (str "~u" organization-id)]
|
||||
(db/exec! pool [sql:clear-org-sso-sessions org-key org-key])))
|
||||
(let [organization-key (str "~u" organization-id)]
|
||||
(db/exec! pool [sql:clear-organization-sso-sessions organization-key organization-key])))
|
||||
|
||||
(defn- renew-session?
|
||||
[{:keys [id modified-at] :as session}]
|
||||
|
||||
@ -88,7 +88,8 @@
|
||||
#{:session-id
|
||||
:password
|
||||
:old-password
|
||||
:token})
|
||||
:token
|
||||
:client-secret})
|
||||
|
||||
(defn extract-utm-params
|
||||
"Extracts additional data from params and namespace them under
|
||||
@ -153,7 +154,7 @@
|
||||
;; COLLECTOR API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare ^:private prepare-context-from-request)
|
||||
(declare prepare-context-from-request)
|
||||
|
||||
;; Defines a service that collects the audit/activity log using
|
||||
;; internal database. Later this audit log can be transferred to
|
||||
@ -182,7 +183,7 @@
|
||||
(def valid-event?
|
||||
(sm/validator schema:event))
|
||||
|
||||
(defn- prepare-context-from-request
|
||||
(defn prepare-context-from-request
|
||||
"Prepare backend event context from request"
|
||||
[request]
|
||||
(let [client-event-origin (get-client-event-origin request)
|
||||
@ -413,7 +414,7 @@
|
||||
(update :ip-addr d/nilv "0.0.0.0")
|
||||
(update :props d/nilv {})
|
||||
(update :context d/nilv {})
|
||||
(assoc :source "backend")
|
||||
(update :source d/nilv "backend")
|
||||
(d/without-nils))]
|
||||
(submit* cfg event)))
|
||||
|
||||
@ -430,7 +431,7 @@
|
||||
(update :profile-id d/nilv uuid/zero)
|
||||
(update :props d/nilv {})
|
||||
(update :context d/nilv {})
|
||||
(assoc :source "backend")
|
||||
(update :source d/nilv "backend")
|
||||
(select-keys event-keys)
|
||||
(check-event))]
|
||||
(db/run! cfg append-audit-entry event))))
|
||||
|
||||
@ -495,6 +495,9 @@
|
||||
{:name "0151-mod-file-tagged-object-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
|
||||
|
||||
{:name "0152-improve-uuid-defaults-and-drop-extension"
|
||||
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")}
|
||||
|
||||
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
|
||||
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
|
||||
|
||||
|
||||
@ -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();
|
||||
@ -14,19 +14,49 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as sg]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.organization :as cto]
|
||||
[app.common.types.organization :as cto
|
||||
:refer [schema:nitrate-sso]]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.http.session :as session]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.cache :as cache]
|
||||
[clojure.core :as c]
|
||||
[clojure.string :as str]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- join-path-segments
|
||||
"Build a single relative path from Nitrate URI segments, normalizing slashes."
|
||||
[segments]
|
||||
(let [path (->> segments (map str) (str/join "/"))]
|
||||
(->> (str/split path #"/")
|
||||
(remove str/blank?)
|
||||
(str/join "/"))))
|
||||
|
||||
(defn- join-base-uri
|
||||
"Join path segments to a base URI."
|
||||
[base-uri & segments]
|
||||
(u/join (u/ensure-path-slash base-uri)
|
||||
(join-path-segments segments)))
|
||||
|
||||
(defn- generate-nitrate-uri
|
||||
"Joins relative path segments to the Nitrate backend URI.
|
||||
Segments must not start with `/`"
|
||||
[& segments]
|
||||
(apply join-base-uri (cf/get :nitrate-backend-uri) segments))
|
||||
|
||||
(defn- generate-public-uri
|
||||
"Joins relative path segments to the public backend URI.
|
||||
Segments must not start with `/`"
|
||||
[& segments]
|
||||
(apply join-base-uri (cf/get :public-uri) segments))
|
||||
|
||||
(defn- request-builder
|
||||
[cfg method uri shared-key profile-id request-params]
|
||||
(fn []
|
||||
@ -132,7 +162,7 @@
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:org-summary
|
||||
(def ^:private schema:organization-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
@ -143,13 +173,6 @@
|
||||
[:id ::sm/uuid]
|
||||
[:is-your-penpot :boolean]]]]])
|
||||
|
||||
(def ^:private schema:profile-org
|
||||
[:map
|
||||
[:is-member :boolean]
|
||||
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
|
||||
|
||||
|
||||
;; TODO Unify with schemas on backend/src/app/http/management.clj
|
||||
(def ^:private schema:timestamp
|
||||
(sm/type-schema
|
||||
@ -166,6 +189,13 @@
|
||||
:decode/json ct/inst
|
||||
:encode/json inst-ms}}))
|
||||
|
||||
(def ^:private schema:profile-organization
|
||||
[:map
|
||||
[:is-member :boolean]
|
||||
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:default-team-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:created-at {:optional true} [:maybe schema:timestamp]]])
|
||||
|
||||
(def ^:private schema:subscription
|
||||
[:map {:title "Subscription"}
|
||||
[:id ::sm/text]
|
||||
@ -223,60 +253,52 @@
|
||||
[:map
|
||||
[:licenses ::sm/boolean]])
|
||||
|
||||
(defn- get-team-org-api
|
||||
(defn- get-team-organization-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id)
|
||||
cto/schema:team-with-organization params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/teams/" team-id)
|
||||
cto/schema:team-with-organization params))
|
||||
|
||||
(defn- get-org-membership-api
|
||||
(defn- get-organization-membership-api
|
||||
[cfg {:keys [profile-id organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/members/"
|
||||
profile-id)
|
||||
schema:profile-org params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"members/"
|
||||
profile-id)
|
||||
schema:profile-organization params))
|
||||
|
||||
(defn- get-org-membership-by-team-api
|
||||
(defn- get-organization-membership-by-team-api
|
||||
[cfg {:keys [profile-id team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id
|
||||
"/users/"
|
||||
profile-id)
|
||||
schema:profile-org params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/teams/"
|
||||
team-id
|
||||
"users/"
|
||||
profile-id)
|
||||
schema:profile-organization params))
|
||||
|
||||
|
||||
(defn- get-org-summary-api
|
||||
(defn- get-organization-summary-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/summary")
|
||||
schema:org-summary params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"summary")
|
||||
schema:organization-summary params))
|
||||
|
||||
(defn- get-owned-orgs-api
|
||||
(defn- get-owned-organizations-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/users/"
|
||||
profile-id
|
||||
"/owned-organizations")
|
||||
[:vector schema:org-summary]
|
||||
params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/users/"
|
||||
profile-id
|
||||
"owned-organizations")
|
||||
[:vector schema:organization-summary]
|
||||
params))
|
||||
|
||||
(def ^:private schema:org-summary-counts
|
||||
(def ^:private schema:organization-summary-counts
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
@ -286,101 +308,94 @@
|
||||
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
|
||||
|
||||
(defn- get-owned-orgs-summary-api
|
||||
(defn- get-owned-organizations-summary-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
orgs (request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/users/"
|
||||
profile-id
|
||||
"/owned-organizations-summary")
|
||||
[:vector schema:org-summary-counts]
|
||||
params)]
|
||||
(mapv (fn [org]
|
||||
(if-let [logo-id (:logo-id org)]
|
||||
(assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id))
|
||||
org))
|
||||
orgs)))
|
||||
(let [organizations (request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/users/"
|
||||
profile-id
|
||||
"owned-organizations-summary")
|
||||
[:vector schema:organization-summary-counts]
|
||||
params)]
|
||||
(mapv (fn [organization]
|
||||
(if-let [logo-id (:logo-id organization)]
|
||||
(assoc organization :custom-photo (generate-public-uri "assets/by-id/" logo-id))
|
||||
organization))
|
||||
organizations)))
|
||||
|
||||
(defn- cleanup-deleted-penpot-user-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/users/"
|
||||
profile-id
|
||||
"/cleanup-after-deletion")
|
||||
nil params)))
|
||||
(request-to-nitrate cfg :post
|
||||
(generate-nitrate-uri
|
||||
"api/users/"
|
||||
profile-id
|
||||
"cleanup-after-deletion")
|
||||
nil params))
|
||||
|
||||
(defn- set-team-org-api
|
||||
(defn- set-team-organization-api
|
||||
[cfg {:keys [organization-id team-id is-default] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
params (assoc params :request-params {:team-id team-id
|
||||
(let [params (assoc params :request-params {:team-id team-id
|
||||
:is-your-penpot (true? is-default)})
|
||||
team (request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/add-team")
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"add-team")
|
||||
cto/schema:team-with-organization params)
|
||||
custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])]
|
||||
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
|
||||
(generate-public-uri "assets/by-id/" logo-id))]
|
||||
(cond-> team
|
||||
custom-photo
|
||||
(assoc-in [:organization :custom-photo] custom-photo))))
|
||||
|
||||
(defn- add-profile-to-org-api
|
||||
(defn- add-profile-to-organization-api
|
||||
[cfg {:keys [profile-id organization-id team-id email] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
request-params (cond-> {:user-id profile-id :team-id team-id}
|
||||
(let [request-params (cond-> {:user-id profile-id :team-id team-id}
|
||||
(some? email) (assoc :email email))
|
||||
params (assoc params :request-params request-params)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/add-user")
|
||||
schema:profile-org params)))
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"add-user")
|
||||
schema:profile-organization params)))
|
||||
|
||||
(defn- remove-profile-from-org-api
|
||||
[cfg {:keys [profile-id organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
params (assoc params :request-params {:user-id profile-id})]
|
||||
(defn- remove-profile-from-organization-api
|
||||
[cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}]
|
||||
(let [request-params (cond-> {:user-id profile-id}
|
||||
(some? user-who-delete-member)
|
||||
(assoc :user-who-delete-member user-who-delete-member)
|
||||
(some? deleted-by-role)
|
||||
(assoc :deleted-by-role deleted-by-role))
|
||||
params (assoc params :request-params request-params)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/remove-user")
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"remove-user")
|
||||
nil params)))
|
||||
|
||||
(defn- remove-team-from-org-api
|
||||
(defn- remove-team-from-organization-api
|
||||
[cfg {:keys [team-id organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
params (assoc params :request-params {:team-id team-id})]
|
||||
(let [params (assoc params :request-params {:team-id team-id})]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/remove-team")
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"remove-team")
|
||||
nil params)))
|
||||
|
||||
(defn- delete-team-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :delete
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id)
|
||||
nil params)))
|
||||
(request-to-nitrate cfg :delete
|
||||
(generate-nitrate-uri "api/teams/" team-id)
|
||||
nil params))
|
||||
|
||||
(defn- get-subscription-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/subscriptions/"
|
||||
profile-id)
|
||||
schema:subscription params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/subscriptions/" profile-id)
|
||||
schema:subscription params))
|
||||
|
||||
(def ^:private schema:subscription-warning
|
||||
[:maybe
|
||||
@ -392,80 +407,79 @@
|
||||
|
||||
(defn- get-subscription-warning-api
|
||||
[cfg {:keys [penpot-id profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
penpot-id (or penpot-id profile-id)]
|
||||
(let [penpot-id (or penpot-id profile-id)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/subscription-warning/"
|
||||
penpot-id)
|
||||
(generate-nitrate-uri "api/subscription-warning/" penpot-id)
|
||||
schema:subscription-warning params)))
|
||||
|
||||
(defn- get-connectivity-api
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/connectivity")
|
||||
schema:connectivity params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/connectivity")
|
||||
schema:connectivity params))
|
||||
|
||||
(def ^:private schema:identity
|
||||
[:map
|
||||
[:nitrate-id ::sm/text]
|
||||
[:public-key ::sm/text]])
|
||||
|
||||
(defn- get-identity-api
|
||||
[cfg params]
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/identity")
|
||||
schema:identity params))
|
||||
|
||||
(def ^:private schema:redeem-result
|
||||
[:map
|
||||
[:cancel-at [:maybe schema:timestamp]]])
|
||||
|
||||
(defn- get-org-permissions-api
|
||||
(defn- get-organization-permissions-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/permissions")
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:owner-id ::sm/uuid]
|
||||
[:permissions [:map-of :keyword :string]]]
|
||||
params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"permissions")
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:owner-id ::sm/uuid]
|
||||
[:permissions [:map-of :keyword :string]]]
|
||||
params))
|
||||
|
||||
(def ^:private schema:nitrate-sso
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:active [:maybe :boolean]]
|
||||
[:provider [:maybe :string]]
|
||||
[:client-id [:maybe :string]]
|
||||
[:base-url [:maybe :string]]
|
||||
[:client-secret [:maybe :string]]
|
||||
[:issuer [:maybe :string]]
|
||||
[:scopes [:maybe [::sm/set ::sm/text]]]])
|
||||
(defn- get-organization-sso-api
|
||||
"Fetches the SSO configuration for an organization from Nitrate."
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"sso")
|
||||
schema:nitrate-sso
|
||||
params))
|
||||
|
||||
(defn- get-org-sso-by-team-api
|
||||
(defn- get-organization-sso-by-team-api
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/teams/"
|
||||
team-id
|
||||
"/sso")
|
||||
schema:nitrate-sso
|
||||
params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri "api/teams/" team-id "sso")
|
||||
schema:nitrate-sso
|
||||
params))
|
||||
|
||||
(defn- get-org-members-api
|
||||
(defn- get-organization-members-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/members-list")
|
||||
[:vector ::sm/uuid]
|
||||
params)))
|
||||
(request-to-nitrate cfg :get
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
organization-id
|
||||
"members-list")
|
||||
[:vector ::sm/uuid]
|
||||
params))
|
||||
|
||||
(defn- redeem-activation-code-api
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :post
|
||||
(str baseuri "/api/activation-codes/redeem")
|
||||
schema:redeem-result
|
||||
(assoc params :throw-on-error? true))))
|
||||
(request-to-nitrate cfg :post
|
||||
(generate-nitrate-uri "api/activation-codes/redeem")
|
||||
schema:redeem-result
|
||||
(assoc params :throw-on-error? true)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INITIALIZATION
|
||||
@ -474,39 +488,85 @@
|
||||
(defmethod ig/init-key ::client
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
{:get-team-org (partial get-team-org-api cfg)
|
||||
:set-team-org (partial set-team-org-api cfg)
|
||||
:get-org-membership (partial get-org-membership-api cfg)
|
||||
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
|
||||
:get-org-summary (partial get-org-summary-api cfg)
|
||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
||||
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
|
||||
:get-org-members (partial get-org-members-api cfg)
|
||||
{:get-team-organization (partial get-team-organization-api cfg)
|
||||
:set-team-organization (partial set-team-organization-api cfg)
|
||||
:get-organization-membership (partial get-organization-membership-api cfg)
|
||||
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
|
||||
:get-organization-summary (partial get-organization-summary-api cfg)
|
||||
:get-owned-organizations (partial get-owned-organizations-api cfg)
|
||||
:get-owned-organizations-summary (partial get-owned-organizations-summary-api cfg)
|
||||
:get-organization-members (partial get-organization-members-api cfg)
|
||||
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg)
|
||||
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
||||
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
||||
:get-org-permissions (partial get-org-permissions-api cfg)
|
||||
:get-org-sso-by-team (partial get-org-sso-by-team-api cfg)
|
||||
:add-profile-to-organization (partial add-profile-to-organization-api cfg)
|
||||
:remove-profile-from-organization (partial remove-profile-from-organization-api cfg)
|
||||
:get-organization-permissions (partial get-organization-permissions-api cfg)
|
||||
:get-organization-sso-by-team (partial get-organization-sso-by-team-api cfg)
|
||||
:get-organization-sso (partial get-organization-sso-api cfg)
|
||||
:delete-team (partial delete-team-api cfg)
|
||||
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
||||
:remove-team-from-organization (partial remove-team-from-organization-api cfg)
|
||||
:get-subscription (partial get-subscription-api cfg)
|
||||
:get-subscription-warning (partial get-subscription-warning-api cfg)
|
||||
:connectivity (partial get-connectivity-api cfg)
|
||||
:get-identity (partial get-identity-api cfg)
|
||||
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; UTILS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defonce ^:private team-organization-owner-cache
|
||||
;; Short TTL: permission checks run on the read path, so we avoid an
|
||||
;; HTTP call to nitrate per check. The organization owner of a team rarely
|
||||
;; changes, and stale entries only grant read access for a few seconds.
|
||||
(cache/create :expire "30s" :max-size 2048))
|
||||
|
||||
(defn- nitrate-client?
|
||||
"True when `cfg` is a config map carrying the nitrate client (i.e. not
|
||||
a raw db connection/pool passed by an internal caller)."
|
||||
[cfg]
|
||||
(and (map? cfg) (some? (get cfg ::client))))
|
||||
|
||||
(def ^:private cache-miss ::no-organization-owner)
|
||||
|
||||
(defn- get-team-organization-owner-id
|
||||
"Returns the organization owner-id for `team-id`, or nil. Cached
|
||||
briefly, including negative results (teams with no organization) so
|
||||
repeated unauthorized probes don't each hit nitrate."
|
||||
[cfg team-id]
|
||||
(let [owner-id (cache/get team-organization-owner-cache team-id
|
||||
(fn [team-id]
|
||||
(let [team-with-organization (call cfg :get-team-organization {:team-id team-id})]
|
||||
(or (get-in team-with-organization [:organization :owner-id])
|
||||
cache-miss))))]
|
||||
(when-not (= owner-id cache-miss)
|
||||
owner-id)))
|
||||
|
||||
(defn organization-owner-of-team?
|
||||
"True if `profile-id` is the owner of the organization that owns
|
||||
`team-id`. Used to grant non-member organization owners read-only access to the
|
||||
teams of their organizations. `cfg` must be a config map with the
|
||||
nitrate client; raw db connections/pools yield false so internal
|
||||
callers are unaffected. Returns false when the :nitrate flag is off."
|
||||
[cfg profile-id team-id]
|
||||
(boolean
|
||||
(when (and (contains? cf/flags :nitrate)
|
||||
(nitrate-client? cfg)
|
||||
(some? team-id)
|
||||
(some? profile-id))
|
||||
(= profile-id (get-team-organization-owner-id cfg team-id)))))
|
||||
|
||||
(defn sso-session-authorized?
|
||||
"Fetches the org-SSO config for the given team and checks whether
|
||||
the HTTP request has a valid session entry for it. Returns a map
|
||||
"Fetches the organization-SSO config for the given organization or team and checks
|
||||
whether the HTTP request has a valid session entry for it. Returns a map
|
||||
with :authorized and :sso keys."
|
||||
[cfg team-id request]
|
||||
(let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})]
|
||||
[cfg organization-id team-id request]
|
||||
(let [session (session/get-session request)
|
||||
sso (if organization-id
|
||||
(call cfg :get-organization-sso {:organization-id organization-id})
|
||||
(call cfg :get-organization-sso-by-team {:team-id team-id}))]
|
||||
(if-not (:active sso)
|
||||
{:authorized true :sso sso}
|
||||
(if (or (:issuer sso) (:base-url sso))
|
||||
(if-not (str/blank? (:issuer sso))
|
||||
(let [props (:props session)
|
||||
sso-map (get props :sso {})
|
||||
organization-id (:organization-id sso)
|
||||
@ -536,21 +596,21 @@
|
||||
:cause cause)
|
||||
profile)))))
|
||||
|
||||
(defn add-org-info-to-team
|
||||
(defn add-organization-info-to-team
|
||||
"Enriches a team map with organization information from Nitrate.
|
||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||
Returns the original team unchanged if the request fails or org data is nil.
|
||||
Returns the original team unchanged if the request fails or organization data is nil.
|
||||
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
||||
[cfg team params]
|
||||
(try
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
team-with-org (call cfg :get-team-org params)
|
||||
org (:organization team-with-org)]
|
||||
(if (some? org)
|
||||
(-> (cto/apply-organization team (assoc org :custom-photo
|
||||
(when-let [logo-id (:logo-id org)]
|
||||
(str (cf/get :public-uri) "/assets/by-id/" logo-id))))
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
|
||||
team-with-organization (call cfg :get-team-organization params)
|
||||
organization (:organization team-with-organization)]
|
||||
(if (some? organization)
|
||||
(-> (cto/apply-organization team (assoc organization :custom-photo
|
||||
(when-let [logo-id (:logo-id organization)]
|
||||
(generate-public-uri "assets/by-id/" logo-id))))
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
||||
team))
|
||||
(catch Throwable cause
|
||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||
@ -570,10 +630,10 @@
|
||||
:team-id (:id team)
|
||||
:organization-id (:organization-id params)
|
||||
:is-default (:is-default params))
|
||||
result (call cfg :set-team-org params)]
|
||||
result (call cfg :set-team-organization params)]
|
||||
(when (nil? result)
|
||||
(ex/raise :type :internal
|
||||
:code :failed-to-set-team-org
|
||||
:code :failed-to-set-team-organization
|
||||
:context {:team-id (:id team)
|
||||
:organization-id (:organization-id params)}))
|
||||
team))
|
||||
|
||||
@ -250,64 +250,71 @@
|
||||
f))
|
||||
|
||||
|
||||
(defonce ^:private org-sso-auth-cache
|
||||
(defonce ^:private organization-sso-auth-cache
|
||||
(cache/create :expire "15m" :max-size 1024))
|
||||
|
||||
(defn invalidate-org-sso-cache-by-org!
|
||||
"Invalidates all org-SSO authorization cache entries for the given organization-id."
|
||||
(defn invalidate-organization-sso-cache-by-organization!
|
||||
"Invalidates all organization-SSO authorization cache entries for the given organization-id."
|
||||
[organization-id]
|
||||
(cache/invalidate-if org-sso-auth-cache #(= (:organization-id %) organization-id)))
|
||||
(cache/invalidate-if organization-sso-auth-cache #(= (:organization-id %) organization-id)))
|
||||
|
||||
(defn- wrap-nitrate-sso
|
||||
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
||||
|
||||
Resolves the team context from request params using priority order:
|
||||
1. Explicit :team-id param
|
||||
2. Explicit :project-id param → lookup project.team_id
|
||||
3. Explicit :file-id param → lookup file's team via join
|
||||
4. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
||||
Resolves the organization/team context from request params using priority order:
|
||||
1. Explicit :organization-id param
|
||||
2. Explicit :team-id param
|
||||
3. Explicit :project-id param -> lookup project.team_id
|
||||
4. Explicit :file-id param -> lookup file's team via join
|
||||
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
||||
|
||||
Once team-id is resolved, checks if the user is authorized within that org's SSO
|
||||
session using nitrate/sso-session-authorized?. Results are cached by [profile-id cache-ref]
|
||||
for 15 minutes to avoid repeated lookups.
|
||||
Once the context is resolved, checks if the user is authorized within that organization's
|
||||
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
|
||||
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
|
||||
|
||||
Only activates when:
|
||||
- Nitrate flag is enabled
|
||||
- Endpoint requires authentication (::auth true by default)
|
||||
- Endpoint is not marked with ::nitrate/org-sso false
|
||||
- Endpoint is not marked with ::nitrate/organization-sso false
|
||||
|
||||
Raises :nitrate-sso-required error if user is not authorized in the org."
|
||||
Raises :nitrate-sso-required error if user is not authorized in the organization."
|
||||
[_ f mdata]
|
||||
(if (and (contains? cf/flags :nitrate)
|
||||
(::auth mdata true) ;; only for endpoints that needs auth
|
||||
(::nitrate/sso mdata true))
|
||||
(fn [cfg params]
|
||||
;; Resolve team/project/file from explicit keys or from :id via metadata
|
||||
(let [id-type (::id-type mdata)
|
||||
id (uuid/coerce (:id params))
|
||||
team-id (or (uuid/coerce (:team-id params))
|
||||
(when (= id-type :team) id))
|
||||
project-id (or (uuid/coerce (:project-id params))
|
||||
(when (= id-type :project) id))
|
||||
file-id (or (uuid/coerce (:file-id params))
|
||||
(when (= id-type :file) id))]
|
||||
(if (or team-id project-id file-id)
|
||||
(let [cache-ref (or team-id project-id file-id)
|
||||
profile-id (::profile-id params)
|
||||
(let [profile-id (::profile-id params)
|
||||
organization-id (uuid/coerce (:organization-id params))
|
||||
id-type (::id-type mdata)
|
||||
id (uuid/coerce (:id params))
|
||||
team-id (or (uuid/coerce (:team-id params))
|
||||
(when (= id-type :team) id))
|
||||
project-id (or (uuid/coerce (:project-id params))
|
||||
(when (= id-type :project) id))
|
||||
file-id (or (uuid/coerce (:file-id params))
|
||||
(when (= id-type :file) id))]
|
||||
(if (and profile-id
|
||||
(or organization-id team-id project-id file-id))
|
||||
(let [cache-ref (or organization-id team-id project-id file-id)
|
||||
|
||||
cache-key [profile-id cache-ref]
|
||||
cached (cache/get org-sso-auth-cache cache-key)
|
||||
cached (cache/get organization-sso-auth-cache cache-key)
|
||||
result (if (some? cached)
|
||||
cached
|
||||
(let [team-id (or team-id
|
||||
(when project-id
|
||||
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
|
||||
(:id (teams/get-team-for-file cfg file-id)))
|
||||
(let [team-id (when-not organization-id
|
||||
(or team-id
|
||||
(when project-id
|
||||
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
|
||||
(:id (teams/get-team-for-file cfg file-id))))
|
||||
request (-> (meta params) (get ::http/request))
|
||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)
|
||||
{:keys [authorized sso]} (if organization-id
|
||||
(nitrate/sso-session-authorized? cfg organization-id nil request)
|
||||
(nitrate/sso-session-authorized? cfg nil team-id request))
|
||||
entry {:authorized authorized
|
||||
:organization-id (:organization-id sso)}]
|
||||
(when authorized
|
||||
(cache/get org-sso-auth-cache cache-key (constantly entry)))
|
||||
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
||||
entry))]
|
||||
(if (:authorized result)
|
||||
(f cfg params)
|
||||
|
||||
@ -37,7 +37,8 @@
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
created-at (ct/now)
|
||||
token (tokens/generate cfg {:iss "access-token"
|
||||
token-iss (if (= type "mcp") "urn:penpot:mcp-token" "access-token")
|
||||
token (tokens/generate cfg {:iss token-iss
|
||||
:uid profile-id
|
||||
:iat created-at
|
||||
:tid token-id})
|
||||
|
||||
@ -74,8 +74,8 @@
|
||||
::doc/changes [["2.12" "Remove version parameter, only one version is supported"]]
|
||||
::webhooks/event? true
|
||||
::sm/params schema:export-binfile}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(files/check-read-permissions! cfg profile-id file-id)
|
||||
(sse/response (partial export-binfile cfg params)))
|
||||
|
||||
;; --- Command: import-binfile
|
||||
|
||||
@ -230,8 +230,8 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:get-comment-threads}
|
||||
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(get-comment-threads conn profile-id file-id))))
|
||||
|
||||
(defn- get-comment-threads-sql
|
||||
@ -328,8 +328,8 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:get-comment-thread}
|
||||
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
|
||||
(decode-row)))))
|
||||
|
||||
@ -347,9 +347,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:get-comments}
|
||||
[cfg {:keys [::rpc/profile-id thread-id share-id]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [{:keys [file-id]} (get-comment-thread conn thread-id)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(get-comments conn thread-id)))))
|
||||
|
||||
(def sql:get-comments
|
||||
@ -406,8 +406,8 @@
|
||||
::doc/changes ["1.15" "Imported from queries and renamed."]
|
||||
::sm/params schema:get-profiles-for-file-comments}
|
||||
[cfg {:keys [::rpc/profile-id file-id share-id]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(get-file-comments-users conn file-id profile-id))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -534,9 +534,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread-status
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
|
||||
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(upsert-comment-thread-status! conn profile-id id)))
|
||||
|
||||
;; --- COMMAND: Update Comment Thread
|
||||
@ -552,9 +552,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id is-resolved share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id]}]
|
||||
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:is-resolved is-resolved}
|
||||
{:id id})
|
||||
@ -582,7 +582,7 @@
|
||||
{:keys [team-id project-id] :as file}
|
||||
(get-file cfg file-id page-id)]
|
||||
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
|
||||
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
|
||||
::quotes/profile-id profile-id
|
||||
@ -653,7 +653,7 @@
|
||||
{:keys [file-id page-id] :as thread}
|
||||
(get-comment-thread conn thread-id ::sql/for-update true)]
|
||||
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
|
||||
;; Don't allow edit comments to not owners
|
||||
(when-not (= owner-id profile-id)
|
||||
@ -690,9 +690,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:delete-comment-thread
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
|
||||
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
@ -713,14 +713,14 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:delete-comment
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id share-id]}]
|
||||
(let [{:keys [owner-id thread-id] :as comment}
|
||||
(get-comment conn id ::sql/for-update true)
|
||||
|
||||
{:keys [file-id]}
|
||||
(get-comment-thread conn thread-id)]
|
||||
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed))
|
||||
@ -743,9 +743,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread-position
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
|
||||
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at request-at
|
||||
:position (db/pgpoint position)
|
||||
@ -767,9 +767,9 @@
|
||||
{::doc/added "1.15"
|
||||
::sm/params schema:update-comment-thread-frame
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
|
||||
(let [{:keys [file-id]} (get-comment-thread conn id ::sql/for-update true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at request-at
|
||||
:frame-id frame-id}
|
||||
|
||||
@ -84,10 +84,10 @@
|
||||
(perms/make-edition-predicate-fn bfc/get-file-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn bfc/get-file-permissions))
|
||||
(perms/make-read-predicate-fn perms/get-file-read-permissions))
|
||||
|
||||
(def has-comment-permissions?
|
||||
(perms/make-comment-predicate-fn bfc/get-file-permissions))
|
||||
(perms/make-comment-predicate-fn perms/get-file-read-permissions))
|
||||
|
||||
(def check-edition-permissions!
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
@ -99,8 +99,8 @@
|
||||
;; explicit comment permissions through the share-id
|
||||
|
||||
(defn check-comment-permissions!
|
||||
[conn profile-id file-id share-id]
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
[cfg profile-id file-id share-id]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
|
||||
can-read (has-read-permissions? perms)
|
||||
can-comment (has-comment-permissions? perms)]
|
||||
(when-not (or can-read can-comment)
|
||||
@ -152,7 +152,7 @@
|
||||
(defn- get-minimal-file-with-perms
|
||||
[cfg {:keys [:id ::rpc/profile-id]}]
|
||||
(let [mfile (get-minimal-file cfg id)
|
||||
perms (bfc/get-file-permissions cfg profile-id id)]
|
||||
perms (perms/get-file-read-permissions cfg profile-id id)]
|
||||
(assoc mfile :permissions perms)))
|
||||
|
||||
(defn get-file-etag
|
||||
@ -173,7 +173,7 @@
|
||||
::sm/params schema:get-file
|
||||
::sm/result schema:file-with-permissions
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id project-id] :as params}]
|
||||
[cfg {:keys [::rpc/profile-id id project-id] :as params}]
|
||||
;; The COND middleware makes initial request for a file and
|
||||
;; permissions when the incoming request comes with an
|
||||
;; ETAG. When ETAG does not matches, the request is resolved
|
||||
@ -181,10 +181,10 @@
|
||||
;; will be already prefetched and we just reuse them instead
|
||||
;; of making an additional database queries.
|
||||
(let [perms (or (:permissions (::cond/object params))
|
||||
(bfc/get-file-permissions conn profile-id id))]
|
||||
(perms/get-file-read-permissions cfg profile-id id))]
|
||||
(check-read-permissions! perms)
|
||||
|
||||
(let [team (teams/get-team conn
|
||||
(let [team (teams/get-team cfg
|
||||
:profile-id profile-id
|
||||
:project-id project-id
|
||||
:file-id id)
|
||||
@ -244,7 +244,7 @@
|
||||
::sm/result schema:file-fragment}
|
||||
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
|
||||
(db/run! cfg (fn [cfg]
|
||||
(let [perms (bfc/get-file-permissions cfg profile-id file-id share-id)]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
|
||||
(check-read-permissions! perms)
|
||||
(-> (get-file-fragment cfg file-id fragment-id)
|
||||
(rph/with-http-cache long-cache-duration))))))
|
||||
@ -288,7 +288,7 @@
|
||||
::sm/params schema:get-project-files
|
||||
::sm/result schema:files}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}]
|
||||
(projects/check-read-permissions! pool profile-id project-id)
|
||||
(projects/check-read-permissions! cfg profile-id project-id)
|
||||
(get-project-files pool project-id))
|
||||
|
||||
;; --- COMMAND QUERY: has-file-libraries
|
||||
@ -306,7 +306,7 @@
|
||||
::sm/result ::sm/boolean}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! pool profile-id file-id)
|
||||
(check-read-permissions! cfg profile-id file-id)
|
||||
(get-has-file-libraries conn file-id)))
|
||||
|
||||
(def ^:private sql:has-file-libraries
|
||||
@ -339,7 +339,7 @@
|
||||
::sm/result ::sm/int}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! pool profile-id file-id)
|
||||
(check-read-permissions! cfg profile-id file-id)
|
||||
(get-library-usage conn file-id)))
|
||||
|
||||
(def ^:private sql:get-library-usage
|
||||
@ -389,7 +389,7 @@
|
||||
:code :params-validation
|
||||
:hint "page-id is required when object-id is provided"))
|
||||
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
|
||||
file (bfc/get-file cfg file-id :read-only? true)
|
||||
|
||||
proj (db/get conn :project {:id (:project-id file)})
|
||||
@ -440,8 +440,8 @@
|
||||
::sm/params schema:get-page}
|
||||
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(check-read-permissions! conn profile-id file-id share-id)
|
||||
(fn [cfg]
|
||||
(check-read-permissions! cfg profile-id file-id share-id)
|
||||
(get-page cfg (assoc params :profile-id profile-id)))))
|
||||
|
||||
;; --- COMMAND QUERY: get-team-shared-files
|
||||
@ -564,7 +564,7 @@
|
||||
|
||||
(defn- get-team-shared-files
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
|
||||
(let [process-row
|
||||
(fn [{:keys [id library-file-ids]}]
|
||||
@ -677,8 +677,8 @@
|
||||
::sm/params schema:get-file-stats
|
||||
::sm/result schema:get-file-stats-result
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(check-read-permissions! conn profile-id id)
|
||||
[cfg {:keys [::rpc/profile-id id]}]
|
||||
(check-read-permissions! cfg profile-id id)
|
||||
(get-file-stats cfg id))
|
||||
|
||||
|
||||
@ -721,7 +721,7 @@
|
||||
::sm/params schema:get-library-file-references}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id file-id)
|
||||
(check-read-permissions! cfg profile-id file-id)
|
||||
(get-library-file-references conn file-id)))
|
||||
|
||||
;; --- COMMAND QUERY: get-team-recent-files
|
||||
@ -765,7 +765,7 @@
|
||||
::sm/params schema:get-team-recent-files}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-recent-files conn team-id)))
|
||||
|
||||
|
||||
@ -810,8 +810,8 @@
|
||||
{::doc/added "2.12"
|
||||
::sm/params schema:get-team-deleted-files}
|
||||
[cfg {:keys [::rpc/profile-id team-id]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-deleted-files conn team-id))))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-info
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
@ -33,8 +34,8 @@
|
||||
{::doc/added "1.20"
|
||||
::sm/params schema:get-file-snapshots}
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-read-permissions! cfg profile-id file-id)
|
||||
(fsnap/get-visible-snapshots conn file-id))))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-snapshot
|
||||
@ -52,8 +53,8 @@
|
||||
::sm/params schema:get-file-snapshot
|
||||
::sm/result files/schema:file-with-permissions
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
|
||||
[cfg {:keys [::rpc/profile-id file-id id] :as params}]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id)]
|
||||
(files/check-read-permissions! perms)
|
||||
(let [snapshot (fsnap/get-snapshot cfg file-id id)]
|
||||
(when-not snapshot
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
::sm/result [:map-of [:string {:max 250}] [:string {:max 250}]]}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id tag] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(files/check-read-permissions! cfg profile-id file-id)
|
||||
(if tag
|
||||
(get-object-thumbnails-by-tag conn file-id tag)
|
||||
(get-object-thumbnails conn file-id))))
|
||||
@ -197,9 +197,9 @@
|
||||
::sm/params schema:get-file-data-for-thumbnail
|
||||
::sm/result schema:partial-file}
|
||||
[cfg {:keys [::rpc/profile-id file-id strip-frames-with-thumbnails] :as params}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(let [team (teams/get-team conn
|
||||
(db/run! cfg (fn [cfg]
|
||||
(files/check-read-permissions! cfg profile-id file-id)
|
||||
(let [team (teams/get-team cfg
|
||||
:profile-id profile-id
|
||||
:file-id file-id)
|
||||
file (bfc/get-file cfg file-id
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
(ns app.rpc.commands.fonts
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
@ -30,6 +29,7 @@
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
@ -71,14 +71,14 @@
|
||||
(cond
|
||||
(uuid? team-id)
|
||||
(do
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id team-id
|
||||
:deleted-at nil}))
|
||||
|
||||
(uuid? project-id)
|
||||
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})]
|
||||
(projects/check-read-permissions! conn profile-id project-id)
|
||||
(projects/check-read-permissions! cfg profile-id project-id)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id (:team-id project)
|
||||
:deleted-at nil}))
|
||||
@ -86,7 +86,7 @@
|
||||
(uuid? file-id)
|
||||
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]})
|
||||
project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]})
|
||||
perms (bfc/get-file-permissions conn profile-id file-id share-id)]
|
||||
perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
|
||||
(files/check-read-permissions! perms)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id (:team-id project)
|
||||
@ -400,7 +400,7 @@
|
||||
::sm/params schema:download-font}
|
||||
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(let [variant (db/get pool :team-font-variant {:id id})]
|
||||
(teams/check-read-permissions! pool profile-id (:team-id variant))
|
||||
(teams/check-read-permissions! cfg profile-id (:team-id variant))
|
||||
|
||||
;; Try to get the best available font format (prefer TTF for broader compatibility).
|
||||
(let [media-id (or (:ttf-file-id variant)
|
||||
@ -432,7 +432,7 @@
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found))
|
||||
|
||||
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
|
||||
(teams/check-read-permissions! cfg profile-id (:team-id (first variants)))
|
||||
|
||||
(let [tempfile (tmp/tempfile :suffix ".zip")
|
||||
ffamily (-> variants first :font-family)]
|
||||
|
||||
@ -176,7 +176,7 @@
|
||||
;; profile-id is present; it can be ommited if this function is
|
||||
;; called from SREPL helpers where no profile is available
|
||||
(when (uuid? profile-id)
|
||||
(teams/check-read-permissions! conn profile-id team-id))
|
||||
(teams/check-read-permissions! cfg profile-id team-id))
|
||||
|
||||
(binding [bfc/*state* (volatile! {:index {team-id (uuid/next)}})]
|
||||
(let [projs (bfc/get-team-projects cfg team-id)
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.auth.oidc :as oidc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.json :as json]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||
@ -21,9 +22,11 @@
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.nitrate.emails-helper :as neh]
|
||||
[app.rpc.nitrate.organization-helper :as noh]
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]))
|
||||
[app.util.services :as sv]
|
||||
[buddy.core.codecs :as bc]))
|
||||
|
||||
|
||||
(defn assert-is-owner [cfg profile-id team-id]
|
||||
@ -39,11 +42,11 @@
|
||||
:code :cant-move-default-team))))
|
||||
|
||||
(defn assert-membership [cfg profile-id organization-id]
|
||||
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(when-not (:organization-id membership)
|
||||
(ex/raise :type :validation
|
||||
:code :organization-doesnt-exists))
|
||||
:code :organization-does-not-exist))
|
||||
|
||||
(when-not (:is-member membership)
|
||||
(ex/raise :type :validation
|
||||
@ -113,6 +116,35 @@
|
||||
:cause cause)
|
||||
(throw cause)))))))
|
||||
|
||||
(def ^:private activation-code-request-filename
|
||||
"penpot-activation-code-request.txt")
|
||||
|
||||
(sv/defmethod ::get-nitrate-activation-code-request
|
||||
"Returns a Base64-encoded JSON file requesting a Nitrate activation code.
|
||||
Payload includes nitrateId, publicKey, email and iat."
|
||||
{::rpc/auth true
|
||||
::doc/added "2.20"
|
||||
::sm/params [:map]
|
||||
::sm/result ::sm/text}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
(let [profile (db/get cfg :profile {:id profile-id})
|
||||
nitrate-identity (nitrate/call cfg :get-identity {})]
|
||||
(when-not nitrate-identity
|
||||
(ex/raise :type :validation
|
||||
:code :nitrate-identity-unavailable
|
||||
:hint "Unable to retrieve nitrate identity"))
|
||||
(-> (json/encode {:nitrate-id (:nitrate-id nitrate-identity)
|
||||
:public-key (:public-key nitrate-identity)
|
||||
:email (:email profile)
|
||||
:iat (ct/seconds (ct/now))}
|
||||
:key-fn json/write-camel-key)
|
||||
(bc/str->bytes)
|
||||
(bc/bytes->b64-str)
|
||||
(rph/wrap)
|
||||
(rph/with-header "content-type" "text/plain")
|
||||
(rph/with-header "content-disposition"
|
||||
(str "attachment; filename=\"" activation-code-request-filename "\"")))))
|
||||
|
||||
(def ^:private sql:prefix-team-name-and-unset-default
|
||||
"UPDATE team
|
||||
SET name = ? || name,
|
||||
@ -149,7 +181,7 @@
|
||||
{})))
|
||||
{}))
|
||||
|
||||
(defn- build-leave-org-plan
|
||||
(defn- build-leave-organization-plan
|
||||
[{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?]
|
||||
(let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
|
||||
files-counts (get-team-files-counts conn all-teams)
|
||||
@ -162,18 +194,18 @@
|
||||
{:deletable-team-ids deletable
|
||||
:keep-default-team? keep-default?
|
||||
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
|
||||
:detach-from-org-team-ids to-detach}))
|
||||
:detach-from-organization-team-ids to-detach}))
|
||||
|
||||
(defn get-leave-org-summary
|
||||
(defn get-leave-organization-summary
|
||||
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
|
||||
(let [{:keys [deletable-team-ids detach-from-org-team-ids]}
|
||||
(build-leave-org-plan cfg default-team-id teams-to-delete nil)]
|
||||
(let [{:keys [deletable-team-ids detach-from-organization-team-ids]}
|
||||
(build-leave-organization-plan cfg default-team-id teams-to-delete nil)]
|
||||
{:teams-to-delete (count deletable-team-ids)
|
||||
:teams-to-transfer teams-to-transfer-count
|
||||
:teams-to-exit teams-to-exit-count
|
||||
:teams-to-detach (count detach-from-org-team-ids)}))
|
||||
:teams-to-detach (count detach-from-organization-team-ids)}))
|
||||
|
||||
(def ^:private schema:leave-org
|
||||
(def ^:private schema:leave-organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
@ -186,47 +218,49 @@
|
||||
[:id ::sm/uuid]
|
||||
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
||||
|
||||
(def ^:private schema:get-leave-org-summary-result
|
||||
(def ^:private schema:get-leave-organization-summary-result
|
||||
[:map
|
||||
[:teams-to-delete ::sm/int]
|
||||
[:teams-to-transfer ::sm/int]
|
||||
[:teams-to-exit ::sm/int]
|
||||
[:teams-to-detach ::sm/int]])
|
||||
[:teams-to-detach ::sm/int]
|
||||
[:member-added-at [:maybe ct/schema:inst]]
|
||||
[:organization-member-count-before ::sm/int]])
|
||||
|
||||
(def ^:private schema:get-leave-org-summary
|
||||
(def ^:private schema:get-leave-organization-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:default-team-id ::sm/uuid]])
|
||||
|
||||
|
||||
(defn- get-organization-teams-for-user
|
||||
[{:keys [::db/conn] :as cfg} org-summary profile-id]
|
||||
(let [org-team-ids (->> (:teams org-summary)
|
||||
(map :id))
|
||||
ids-array (db/create-array conn "uuid" org-team-ids)]
|
||||
[{:keys [::db/conn] :as cfg} organization-summary profile-id]
|
||||
(let [organization-team-ids (->> (:teams organization-summary)
|
||||
(map :id))
|
||||
ids-array (db/create-array conn "uuid" organization-team-ids)]
|
||||
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
|
||||
|
||||
(defn- calculate-valid-teams
|
||||
([org-teams default-team-id]
|
||||
([organization-teams default-team-id]
|
||||
(let [;; valid default team is the one which id is default-team-id
|
||||
valid-default-team (d/seek #(= default-team-id (:id %)) org-teams)
|
||||
valid-default-team (d/seek #(= default-team-id (:id %)) organization-teams)
|
||||
|
||||
;; Remove your-penpot for the rest of validations
|
||||
org-teams (remove #(= default-team-id (:id %)) org-teams)
|
||||
organization-teams (remove #(= default-team-id (:id %)) organization-teams)
|
||||
|
||||
;; valid teams to delete are those that the user is owner, and only have one member
|
||||
valid-teams-to-delete-ids (->> org-teams
|
||||
valid-teams-to-delete-ids (->> organization-teams
|
||||
(filter #(and (:is-owner %)
|
||||
(= (:num-members %) 1)))
|
||||
(map :id)
|
||||
(into #{}))
|
||||
;; valid teams to transfer are those that the user is owner, and have more than one member
|
||||
valid-teams-to-transfer (->> org-teams
|
||||
valid-teams-to-transfer (->> organization-teams
|
||||
(filter #(and (:is-owner %)
|
||||
(> (:num-members %) 1))))
|
||||
|
||||
;; valid teams to exit are those that the user isn't owner, and have more than one member
|
||||
valid-teams-to-exit (->> org-teams
|
||||
valid-teams-to-exit (->> organization-teams
|
||||
(filter #(and (not (:is-owner %))
|
||||
(> (:num-members %) 1))))]
|
||||
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
|
||||
@ -235,17 +269,17 @@
|
||||
:valid-default-team valid-default-team})))
|
||||
|
||||
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
org-teams (get-organization-teams-for-user cfg org-summary profile-id)]
|
||||
(calculate-valid-teams org-teams default-team-id)))
|
||||
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)]
|
||||
(calculate-valid-teams organization-teams default-team-id)))
|
||||
|
||||
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
org-teams (get-organization-teams-for-user cfg org-summary profile-id)
|
||||
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||
organization-teams (get-organization-teams-for-user cfg organization-summary profile-id)
|
||||
{:keys [valid-teams-to-delete-ids
|
||||
valid-teams-to-transfer
|
||||
valid-teams-to-exit
|
||||
valid-default-team]} (calculate-valid-teams org-teams default-team-id)
|
||||
valid-default-team]} (calculate-valid-teams organization-teams default-team-id)
|
||||
|
||||
|
||||
|
||||
@ -263,7 +297,7 @@
|
||||
;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and
|
||||
;; the reassign-to is a member of the team and not the current user;
|
||||
;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit
|
||||
teams-by-id (d/index-by :id org-teams)
|
||||
teams-by-id (d/index-by :id organization-teams)
|
||||
valid-teams-to-leave? (and
|
||||
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
|
||||
(every? (fn [{:keys [id reassign-to]}]
|
||||
@ -274,10 +308,10 @@
|
||||
(contains? members reassign-to)))
|
||||
(contains? valid-teams-to-exit-ids id)))
|
||||
teams-to-leave))]
|
||||
;; the org owner cannot leave
|
||||
(when (= (:owner-id org-summary) profile-id)
|
||||
;; the organization owner cannot leave
|
||||
(when (= (:owner-id organization-summary) profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :org-owner-cannot-leave))
|
||||
:code :organization-owner-cannot-leave))
|
||||
|
||||
(when (or
|
||||
(not valid-teams-to-delete?)
|
||||
@ -288,13 +322,14 @@
|
||||
|
||||
|
||||
|
||||
(defn leave-org
|
||||
(defn leave-organization
|
||||
[{:keys [::db/conn] :as cfg}
|
||||
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?]}]
|
||||
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
||||
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?
|
||||
user-who-delete-member deleted-by-role]}]
|
||||
(let [organization-prefix (str "[" (d/sanitize-string name) "] ")
|
||||
{:keys [deletable-team-ids
|
||||
keep-default-team?
|
||||
detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
||||
detach-from-organization-team-ids]} (build-leave-organization-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
||||
|
||||
;; assert that the received teams are valid, checking the different constraints
|
||||
(when-not skip-validation
|
||||
@ -311,62 +346,77 @@
|
||||
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
||||
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
||||
|
||||
;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||
;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||
(when default-team-id
|
||||
(if keep-default-team?
|
||||
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])
|
||||
(db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id])
|
||||
(teams/delete-team cfg {:profile-id profile-id
|
||||
:team-id default-team-id})))
|
||||
|
||||
;; Detach retained owned teams from the organization in Nitrate.
|
||||
;; Nitrate will rehome them to its fallback/default org.
|
||||
(doseq [team-id detach-from-org-team-ids]
|
||||
(nitrate/call cfg :remove-team-from-org {:team-id team-id
|
||||
:organization-id id}))
|
||||
;; Nitrate will rehome them to its fallback/default organization.
|
||||
(doseq [team-id detach-from-organization-team-ids]
|
||||
(nitrate/call cfg :remove-team-from-organization {:team-id team-id
|
||||
:organization-id id}))
|
||||
|
||||
;; Api call to nitrate
|
||||
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
|
||||
(nitrate/call cfg :remove-profile-from-organization
|
||||
{:profile-id profile-id
|
||||
:organization-id id
|
||||
:user-who-delete-member user-who-delete-member
|
||||
:deleted-by-role deleted-by-role})
|
||||
|
||||
nil))
|
||||
|
||||
|
||||
(sv/defmethod ::leave-org
|
||||
(sv/defmethod ::leave-organization
|
||||
{::rpc/auth true
|
||||
::doc/added "2.15"
|
||||
::sm/params schema:leave-org
|
||||
::sm/params schema:leave-organization
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(leave-org cfg (assoc params :profile-id profile-id)))
|
||||
(leave-organization cfg (assoc params
|
||||
:profile-id profile-id
|
||||
:user-who-delete-member profile-id
|
||||
:deleted-by-role "organization-member")))
|
||||
|
||||
|
||||
(sv/defmethod ::get-leave-org-summary
|
||||
(sv/defmethod ::get-leave-organization-summary
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:get-leave-org-summary
|
||||
::sm/result schema:get-leave-org-summary-result
|
||||
::sm/params schema:get-leave-organization-summary
|
||||
::sm/result schema:get-leave-organization-summary-result
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id id default-team-id]}]
|
||||
(let [{:keys [valid-teams-to-delete-ids
|
||||
valid-teams-to-transfer
|
||||
valid-teams-to-exit
|
||||
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
|
||||
membership (nitrate/call cfg :get-organization-membership
|
||||
{:profile-id profile-id
|
||||
:organization-id id})
|
||||
organization-members (nitrate/call cfg :get-organization-members
|
||||
{:organization-id id})
|
||||
teams-to-transfer-count (count valid-teams-to-transfer)
|
||||
teams-to-exit-count (count valid-teams-to-exit)]
|
||||
(when-not valid-default-team
|
||||
(ex/raise :type :validation
|
||||
:code :not-valid-teams))
|
||||
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)))
|
||||
(assoc
|
||||
(get-leave-organization-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
|
||||
:member-added-at (:created-at membership)
|
||||
:organization-member-count-before (count organization-members))))
|
||||
|
||||
|
||||
(def ^:private schema:remove-team-from-org
|
||||
(def ^:private schema:remove-team-from-organization
|
||||
[:map
|
||||
[:team-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
[:organization-name ::sm/text]])
|
||||
|
||||
(sv/defmethod ::remove-team-from-org
|
||||
(sv/defmethod ::remove-team-from-organization
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:remove-team-from-org}
|
||||
::sm/params schema:remove-team-from-organization}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
||||
|
||||
(assert-is-owner cfg profile-id team-id)
|
||||
@ -374,32 +424,26 @@
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
;; Check moveTeams permission on the source organization
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [org-perms (nitrate/call cfg :get-org-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? org-perms)
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? organization-perms)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions")
|
||||
(when-not (nitrate-perms/allowed? :move-team
|
||||
{:org-perms org-perms
|
||||
{:organization-perms organization-perms
|
||||
:profile-id profile-id})
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))))
|
||||
|
||||
;; Api call to nitrate
|
||||
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
|
||||
(nitrate/call cfg :remove-team-from-organization {:team-id team-id :organization-id organization-id})
|
||||
|
||||
;; Notify connected users
|
||||
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
|
||||
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-organization")
|
||||
nil)
|
||||
|
||||
(def ^:private sql:get-team-invitation-emails
|
||||
"SELECT email_to
|
||||
FROM team_invitation
|
||||
WHERE team_id = ?
|
||||
AND valid_until > now()")
|
||||
|
||||
(def ^:private sql:delete-team-external-invitations
|
||||
"DELETE FROM team_invitation
|
||||
WHERE team_id = ?
|
||||
@ -413,23 +457,22 @@
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(defn- get-external-invitation-info
|
||||
"Returns info about external (non-org-member) invitations pending for a team.
|
||||
External invitations are those sent to users who are not members of the given org.
|
||||
"Returns info about external (non-organization-member) invitations pending for a team.
|
||||
External invitations are those sent to users who are not members of the given organization.
|
||||
Returns {:allows-anybody bool :external-emails [...]}"
|
||||
[{:keys [::db/conn] :as cfg} team-id organization-id]
|
||||
(let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id})
|
||||
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})]
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})
|
||||
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization-perms})]
|
||||
(if allows-anybody
|
||||
{:allows-anybody true :external-emails []}
|
||||
(let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id])
|
||||
emails (map :email-to invitation-emails)]
|
||||
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
|
||||
(if (empty? emails)
|
||||
{:allows-anybody false :external-emails []}
|
||||
(let [emails-array (db/create-array conn "text" (vec emails))
|
||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
|
||||
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||
external-emails (->> profiles
|
||||
(remove #(contains? org-member-ids (:id %)))
|
||||
(remove #(contains? organization-member-ids (:id %)))
|
||||
(map :email)
|
||||
(vec))]
|
||||
{:allows-anybody false :external-emails external-emails}))))))
|
||||
@ -444,81 +487,90 @@
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:add-team-to-organization
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
|
||||
(assert-is-owner cfg profile-id team-id)
|
||||
(assert-not-default-team cfg team-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id})
|
||||
source-org-id (get-in team-with-org [:organization :id])
|
||||
source-org-perms (when source-org-id
|
||||
(nitrate/call cfg :get-org-permissions
|
||||
{:organization-id source-org-id}))
|
||||
target-org-perms (nitrate/call cfg :get-org-permissions
|
||||
{:organization-id organization-id})
|
||||
target-org-same-owner? (and (some? source-org-perms)
|
||||
(some? target-org-perms)
|
||||
(= (:owner-id source-org-perms)
|
||||
(:owner-id target-org-perms)))]
|
||||
(when (nil? target-org-perms)
|
||||
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||
team-with-organization (nitrate/call cfg :get-team-organization {:team-id team-id})
|
||||
source-organization-id (get-in team-with-organization [:organization :id])
|
||||
source-organization-perms (when source-organization-id
|
||||
(nitrate/call cfg :get-organization-permissions
|
||||
{:organization-id source-organization-id}))
|
||||
target-organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
{:organization-id organization-id})
|
||||
target-organization-same-owner? (and (some? source-organization-perms)
|
||||
(some? target-organization-perms)
|
||||
(= (:owner-id source-organization-perms)
|
||||
(:owner-id target-organization-perms)))]
|
||||
(when (nil? target-organization-perms)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions"))
|
||||
|
||||
;; Team already belongs to an organization: check move-teams on source org.
|
||||
(when (some? source-org-id)
|
||||
(when (nil? source-org-perms)
|
||||
;; Team already belongs to an organization: check move-teams on the source organization.
|
||||
(when (some? source-organization-id)
|
||||
(when (nil? source-organization-perms)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions"))
|
||||
(when-not (nitrate-perms/allowed? :move-team
|
||||
{:org-perms source-org-perms
|
||||
{:organization-perms source-organization-perms
|
||||
:profile-id profile-id
|
||||
:target-org-same-owner? target-org-same-owner?})
|
||||
:target-organization-same-owner? target-organization-same-owner?})
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))
|
||||
|
||||
;; Always check target create-teams permission (new/add and move flows).
|
||||
(when-not (nitrate-perms/allowed? :create-team
|
||||
{:org-perms target-org-perms
|
||||
{:organization-perms target-organization-perms
|
||||
:profile-id profile-id})
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "You are not allowed to add teams in this organization")))
|
||||
:hint "You are not allowed to add teams in this organization"))
|
||||
|
||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
|
||||
;; Add teammates to the org if needed
|
||||
(doseq [{member-id :profile-id} team-members
|
||||
:when (not= member-id profile-id)]
|
||||
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
|
||||
;; Add teammates to the organization if needed
|
||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
new-member-ids (->> team-members
|
||||
(map :profile-id)
|
||||
(remove #{profile-id})
|
||||
(remove organization-member-ids-before))]
|
||||
(doseq [member-id new-member-ids]
|
||||
(teams/initialize-user-in-nitrate-organization cfg member-id organization-id)))
|
||||
|
||||
;; Api call to nitrate
|
||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
||||
;; Api call to nitrate
|
||||
(let [team (nitrate/call cfg :set-team-organization {:team-id team-id
|
||||
:organization-id organization-id
|
||||
:is-default false})]
|
||||
;; Notify connected users
|
||||
(notifications/notify-team-change cfg team "dashboard.team-belong-organization"))
|
||||
|
||||
;; Notify connected users
|
||||
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
||||
;; Delete pending invitations for users who are not members of the target organization
|
||||
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
||||
(when (and (not allows-anybody) (seq external-emails))
|
||||
(let [conn (::db/conn cfg)
|
||||
emails-array (db/create-array conn "text" external-emails)]
|
||||
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))
|
||||
|
||||
;; Delete pending invitations for users who are not members of the target organization
|
||||
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
||||
(when (and (not allows-anybody) (seq external-emails))
|
||||
(let [conn (::db/conn cfg)
|
||||
emails-array (db/create-array conn "text" external-emails)]
|
||||
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array])))))
|
||||
;; Send warnings via email if the organization has sso
|
||||
(neh/send-organization-setup-sso-emails-for-team!
|
||||
cfg organization-id team-id organization-member-ids-before)))
|
||||
|
||||
nil)
|
||||
|
||||
(def ^:private schema:check-org-members-params
|
||||
[:map {:title "CheckOrgMembersParams"}
|
||||
(def ^:private schema:check-organization-members-params
|
||||
[:map {:title "CheckOrganizationMembersParams"}
|
||||
[:organization-id ::sm/uuid]
|
||||
[:emails [:vector ::sm/email]]])
|
||||
|
||||
(sv/defmethod ::check-org-members
|
||||
(sv/defmethod ::check-organization-members
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:check-org-members-params
|
||||
::sm/params schema:check-organization-members-params
|
||||
::sm/result [:map-of :string :boolean]
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
||||
@ -527,23 +579,23 @@
|
||||
(let [emails-array (db/create-array conn "text" emails)
|
||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
||||
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))]
|
||||
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))]
|
||||
(into {}
|
||||
(map (fn [email]
|
||||
(let [pid (get email->id email)]
|
||||
[email (boolean (and pid (contains? org-member-ids pid)))])))
|
||||
[email (boolean (and pid (contains? organization-member-ids pid)))])))
|
||||
emails)))
|
||||
{}))
|
||||
|
||||
(def ^:private schema:all-org-members-in-team-params
|
||||
[:map {:title "CheckOrgMembersInTeamParams"}
|
||||
(def ^:private schema:all-organization-members-in-team-params
|
||||
[:map {:title "CheckOrganizationMembersInTeamParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::all-org-members-in-team
|
||||
(sv/defmethod ::all-organization-members-in-team
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:all-org-members-in-team-params
|
||||
::sm/params schema:all-organization-members-in-team-params
|
||||
::sm/result ::sm/boolean}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
@ -552,22 +604,22 @@
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
||||
org-member-ids (into #{} org-members)
|
||||
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||
organization-member-ids (into #{} organization-members)
|
||||
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
team-member-ids (into #{} (map :profile-id team-members))]
|
||||
(every? #(contains? team-member-ids %) org-member-ids)))
|
||||
(every? #(contains? team-member-ids %) organization-member-ids)))
|
||||
false))
|
||||
|
||||
(def ^:private schema:all-team-members-in-orgs-params
|
||||
[:map {:title "CheckTeamMembersInOrgsParams"}
|
||||
(def ^:private schema:all-team-members-in-organizations-params
|
||||
[:map {:title "CheckTeamMembersInOrganizationsParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:organization-ids [:vector ::sm/uuid]]])
|
||||
|
||||
(sv/defmethod ::all-team-members-in-orgs
|
||||
(sv/defmethod ::all-team-members-in-organizations
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:all-team-members-in-orgs-params
|
||||
::sm/params schema:all-team-members-in-organizations-params
|
||||
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
@ -578,15 +630,15 @@
|
||||
|
||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
team-member-ids (into #{} (map :profile-id team-members))]
|
||||
;; Validate requester membership in all orgs before fetching members.
|
||||
;; Validate requester membership in all organizations before fetching members.
|
||||
(run! #(assert-membership cfg profile-id %) organization-ids)
|
||||
|
||||
(into {}
|
||||
(map (fn [organization-id]
|
||||
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
||||
org-member-ids (into #{} org-members)]
|
||||
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||
organization-member-ids (into #{} organization-members)]
|
||||
[organization-id
|
||||
(every? #(contains? org-member-ids %) team-member-ids)])))
|
||||
(every? #(contains? organization-member-ids %) team-member-ids)])))
|
||||
organization-ids)))
|
||||
{}))
|
||||
|
||||
@ -621,13 +673,17 @@
|
||||
|
||||
|
||||
(def ^:private schema:check-nitrate-sso
|
||||
[:map {:title "AuthSsoParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:url ::sm/uri]])
|
||||
[:and
|
||||
[:map {:title "CheckNitrateSsoParams"}
|
||||
[:team-id {:optional true} ::sm/uuid]
|
||||
[:organization-id {:optional true} ::sm/uuid]
|
||||
[:url ::sm/uri]]
|
||||
[::sm/contains-any #{:team-id :organization-id}]])
|
||||
|
||||
(sv/defmethod ::check-nitrate-sso
|
||||
"Check if a user needs to login into the organization SSO.
|
||||
Returns {:authorized true} when SSO is not active for the team.
|
||||
Accepts either team-id (to look up the organization via the team) or organization-id directly.
|
||||
Returns {:authorized true} when SSO is not active or the user cannot access the team.
|
||||
Returns {:authorized false :redirect-uri <url>} when SSO is active;
|
||||
the client must redirect there. The OIDC provider itself handles
|
||||
re-authentication transparently if the user already has an active SSO session."
|
||||
@ -635,24 +691,22 @@
|
||||
::doc/added "2.19"
|
||||
::sm/params schema:check-nitrate-sso
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [team-id url] :as params}]
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(let [request (rph/get-request params)
|
||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)]
|
||||
(if authorized
|
||||
{:authorized true}
|
||||
(if-let [issuer (or (:issuer sso) (:base-url sso))]
|
||||
(let [oidc-provider (oidc/prepare-org-sso-provider cfg sso)
|
||||
organization-id (:organization-id sso)
|
||||
state-token (tokens/generate cfg {:iss "oidc"
|
||||
:dest-url url
|
||||
:team-id team-id
|
||||
:organization-id organization-id
|
||||
:issuer issuer
|
||||
:exp (ct/in-future "4h")})
|
||||
redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)]
|
||||
(if (and team-id
|
||||
(not (teams/has-read-permissions? cfg profile-id team-id)))
|
||||
;; Let the destination RPC enforce its own permissions. Starting SSO before
|
||||
;; access is established sends unrelated users through the organization's IdP.
|
||||
{:authorized true}
|
||||
(let [request (rph/get-request params)
|
||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
||||
(if authorized
|
||||
{:authorized true}
|
||||
(if (oidc/organization-sso-discovery-uri sso)
|
||||
{:authorized false
|
||||
:redirect-uri redirect-uri})
|
||||
{:authorized false
|
||||
:redirect-uri nil})))
|
||||
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
|
||||
:dest-url url
|
||||
:organization-id organization-id)}
|
||||
{:authorized false
|
||||
:redirect-uri nil}))))
|
||||
{:authorized true}))
|
||||
|
||||
@ -54,18 +54,23 @@
|
||||
[:newsletter-news {:optional true} ::sm/boolean]
|
||||
[:onboarding-team-id {:optional true} ::sm/uuid]
|
||||
[:onboarding-viewed {:optional true} ::sm/boolean]
|
||||
[:nitrate-onboarding-viewed {:optional true} ::sm/boolean]
|
||||
[:v2-info-shown {:optional true} ::sm/boolean]
|
||||
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
||||
[:release-notes-viewed {:optional true}
|
||||
[::sm/text {:max 100}]]
|
||||
[:notifications {:optional true} schema:props-notifications]
|
||||
[:workspace-visited {:optional true} ::sm/boolean]])
|
||||
[:workspace-visited {:optional true} ::sm/boolean]
|
||||
[:custom-shortcuts {:optional true}
|
||||
[:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]]])
|
||||
|
||||
(def schema:profile
|
||||
[:map {:title "Profile"}
|
||||
[:id ::sm/uuid]
|
||||
[:fullname [::sm/word-string {:max 250}]]
|
||||
[:email ::sm/email]
|
||||
[:theme {:optional true} :string]
|
||||
[:is-admin {:optional true} ::sm/boolean]
|
||||
[:is-active {:optional true} ::sm/boolean]
|
||||
[:is-blocked {:optional true} ::sm/boolean]
|
||||
[:is-demo {:optional true} ::sm/boolean]
|
||||
@ -491,10 +496,10 @@
|
||||
{:id profile-id})
|
||||
|
||||
;; Delete owned organizations on the fly (no grace period).
|
||||
;; Nitrate iterates the user's owned orgs and, per org, calls
|
||||
;; Nitrate iterates the user's owned organizations and, per organization, calls
|
||||
;; Penpot back through two paths: ::notify-user-organizations-deletion
|
||||
;; (during delete-owned-orgs) and ::notify-organization-deletion.
|
||||
;; Both preserve org teams unchanged and only prefix or delete
|
||||
;; (during delete-owned-organizations) and ::notify-organization-deletion.
|
||||
;; Both preserve organization teams unchanged and only prefix or delete
|
||||
;; imported "Your Penpot" teams according to whether they still have files.
|
||||
;; Let Nitrate clean up the data associated with the deleted Penpot user:
|
||||
;; owned organizations, remaining memberships, and subscription cancellation.
|
||||
@ -558,7 +563,7 @@
|
||||
::sm/result schema:get-owned-organizations-summary-result}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) [])
|
||||
(or (nitrate/call cfg :get-owned-organizations-summary {:profile-id profile-id}) [])
|
||||
[]))
|
||||
|
||||
;; --- HELPERS
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.logical-deletion :as ldel]
|
||||
@ -56,11 +57,16 @@
|
||||
:can-edit (or is-owner is-admin can-edit)
|
||||
:can-read true})))
|
||||
|
||||
(defn- get-read-permissions
|
||||
[cfg profile-id project-id]
|
||||
(or (get-permissions cfg profile-id project-id)
|
||||
(perms/get-organization-owner-permissions cfg profile-id :project-id project-id)))
|
||||
|
||||
(def has-edit-permissions?
|
||||
(perms/make-edition-predicate-fn get-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn get-permissions))
|
||||
(perms/make-read-predicate-fn get-read-permissions))
|
||||
|
||||
(def check-edition-permissions!
|
||||
(perms/make-check-fn has-edit-permissions?))
|
||||
@ -159,10 +165,10 @@
|
||||
{::doc/added "1.18"
|
||||
::rpc/id-type :project
|
||||
::sm/params schema:get-project}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(let [project (db/get-by-id conn :project id)]
|
||||
(check-read-permissions! conn profile-id id)
|
||||
(check-read-permissions! cfg profile-id id)
|
||||
project)))
|
||||
|
||||
|
||||
@ -179,7 +185,8 @@
|
||||
timestamp (::rpc/request-at params)]
|
||||
(teams/create-project-role conn profile-id (:id project) :owner)
|
||||
(db/insert! conn :team-project-profile-rel
|
||||
{:project-id (:id project)
|
||||
{:id (uuid/next)
|
||||
:project-id (:id project)
|
||||
:profile-id profile-id
|
||||
:created-at timestamp
|
||||
:modified-at timestamp
|
||||
@ -230,8 +237,8 @@
|
||||
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
||||
::webhooks/event? true
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
|
||||
(check-read-permissions! conn profile-id id)
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
|
||||
(check-read-permissions! cfg profile-id id)
|
||||
(db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned])
|
||||
nil)
|
||||
|
||||
|
||||
@ -60,6 +60,11 @@
|
||||
:can-edit (or is-owner is-admin can-edit)
|
||||
:can-read true})))
|
||||
|
||||
(defn get-read-permissions
|
||||
[cfg profile-id team-id]
|
||||
(or (get-permissions cfg profile-id team-id)
|
||||
(perms/get-organization-owner-permissions cfg profile-id :team-id team-id)))
|
||||
|
||||
(def has-admin-permissions?
|
||||
(perms/make-admin-predicate-fn get-permissions))
|
||||
|
||||
@ -67,7 +72,7 @@
|
||||
(perms/make-edition-predicate-fn get-permissions))
|
||||
|
||||
(def has-read-permissions?
|
||||
(perms/make-read-predicate-fn get-permissions))
|
||||
(perms/make-read-predicate-fn get-read-permissions))
|
||||
|
||||
(def check-admin-permissions!
|
||||
(perms/make-check-fn has-admin-permissions?))
|
||||
@ -180,7 +185,6 @@
|
||||
sql (if (contains? cf/flags :subscriptions)
|
||||
sql:get-teams-with-permissions-and-subscription
|
||||
sql:get-teams-with-permissions)]
|
||||
|
||||
(->> (db/exec! conn [sql (:default-team-id profile) profile-id])
|
||||
(into [] xform:process-teams))))
|
||||
|
||||
@ -194,7 +198,7 @@
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(cond->> (get-teams conn profile-id)
|
||||
(contains? cf/flags :nitrate)
|
||||
(map #(nitrate/add-org-info-to-team cfg % params))
|
||||
(map #(nitrate/add-organization-info-to-team cfg % params))
|
||||
(contains? cf/flags :nitrate)
|
||||
(remove #(get-in % [:organization :expired-license])))))
|
||||
|
||||
@ -238,19 +242,34 @@
|
||||
{::doc/added "1.17"
|
||||
::rpc/id-type :team
|
||||
::sm/params schema:get-team}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}]
|
||||
(get-team pool :profile-id profile-id :team-id id :file-id file-id))
|
||||
[cfg {:keys [::rpc/profile-id id file-id] :as params}]
|
||||
(let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-organization-info-to-team cfg team params)
|
||||
team)))
|
||||
|
||||
(defn- get-organization-owner-viewer-team
|
||||
"When `profile-id` is a non-member owner of the organization that owns
|
||||
the requested team, returns the team shaped with viewer permissions;
|
||||
otherwise nil. `cfg` must carry the nitrate client."
|
||||
[cfg profile-id default-team-id params]
|
||||
(when-let [team-id (perms/resolve-team-id cfg params)]
|
||||
(when (nitrate/organization-owner-of-team? cfg profile-id team-id)
|
||||
(when-let [team (db/get* cfg :team {:id team-id})]
|
||||
(when-not (db/is-row-deleted? team)
|
||||
(-> team
|
||||
(decode-row)
|
||||
(merge perms/viewer-role-flags)
|
||||
(assoc :is-default (= team-id default-team-id))
|
||||
(process-permissions)))))))
|
||||
|
||||
(defn get-team
|
||||
[conn & {:keys [profile-id team-id project-id file-id] :as params}]
|
||||
[cfg & {:keys [profile-id team-id project-id file-id] :as params}]
|
||||
|
||||
(assert (uuid? profile-id) "profile-id is mandatory")
|
||||
(assert (or (db/connection? conn)
|
||||
(db/pool? conn))
|
||||
"connection or pool is mandatory")
|
||||
|
||||
(let [{:keys [default-team-id] :as profile}
|
||||
(profile/get-profile conn profile-id)
|
||||
(profile/get-profile cfg profile-id)
|
||||
|
||||
sql
|
||||
(if (contains? cf/flags :subscriptions)
|
||||
@ -262,14 +281,14 @@
|
||||
(some? team-id)
|
||||
(let [sql (str "WITH teams AS (" sql ") "
|
||||
"SELECT * FROM teams WHERE id=?")]
|
||||
(db/exec-one! conn [sql default-team-id profile-id team-id]))
|
||||
(db/exec-one! cfg [sql default-team-id profile-id team-id]))
|
||||
|
||||
(some? project-id)
|
||||
(let [sql (str "WITH teams AS (" sql ") "
|
||||
"SELECT t.* FROM teams AS t "
|
||||
" JOIN project AS p ON (p.team_id = t.id) "
|
||||
" WHERE p.id=?")]
|
||||
(db/exec-one! conn [sql default-team-id profile-id project-id]))
|
||||
(db/exec-one! cfg [sql default-team-id profile-id project-id]))
|
||||
|
||||
(some? file-id)
|
||||
(let [sql (str "WITH teams AS (" sql ") "
|
||||
@ -277,17 +296,18 @@
|
||||
" JOIN project AS p ON (p.team_id = t.id) "
|
||||
" JOIN file AS f ON (f.project_id = p.id) "
|
||||
" WHERE f.id=?")]
|
||||
(db/exec-one! conn [sql default-team-id profile-id file-id]))
|
||||
(db/exec-one! cfg [sql default-team-id profile-id file-id]))
|
||||
|
||||
:else
|
||||
(throw (IllegalArgumentException. "invalid arguments")))]
|
||||
|
||||
(when-not result
|
||||
(ex/raise :type :not-found
|
||||
:code :team-does-not-exist))
|
||||
(-> result
|
||||
(decode-row)
|
||||
(process-permissions))))
|
||||
(if result
|
||||
(-> result
|
||||
(decode-row)
|
||||
(process-permissions))
|
||||
(or (get-organization-owner-viewer-team cfg profile-id default-team-id params)
|
||||
(ex/raise :type :not-found
|
||||
:code :team-does-not-exist)))))
|
||||
|
||||
;; --- Query: Team Members
|
||||
|
||||
@ -316,7 +336,7 @@
|
||||
::sm/params schema:get-team-memebrs}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-members conn team-id)))
|
||||
|
||||
;; --- Query: Team Users
|
||||
@ -342,10 +362,10 @@
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(if team-id
|
||||
(do
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-users conn team-id))
|
||||
(let [{team-id :id} (get-team-for-file conn file-id)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-users conn team-id)))))
|
||||
|
||||
;; This is a similar query to team members but can contain more data
|
||||
@ -432,7 +452,7 @@
|
||||
::sm/params schema:get-team-stats}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-stats conn team-id)))
|
||||
|
||||
(def sql:team-stats
|
||||
@ -468,7 +488,7 @@
|
||||
::sm/params schema:get-team-invitations}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(get-team-invitations conn team-id)))
|
||||
|
||||
|
||||
@ -515,17 +535,17 @@
|
||||
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
|
||||
;; When creating inside an org, verify the user has permission to do so.
|
||||
;; Fail closed: if org permissions cannot be fetched, deny the operation.
|
||||
;; When creating inside an organization, verify the user has permission to do so.
|
||||
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
|
||||
(when (and organization-id (contains? cf/flags :nitrate))
|
||||
(let [org-perms (nitrate/call cfg :get-org-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? org-perms)
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? organization-perms)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions")
|
||||
(when-not (nitrate-perms/allowed? :create-team
|
||||
{:org-perms org-perms
|
||||
{:organization-perms organization-perms
|
||||
:profile-id profile-id})
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
@ -543,7 +563,7 @@
|
||||
{::audit/props {:id (:id team)}})))
|
||||
|
||||
|
||||
(defn create-default-org-team
|
||||
(defn create-default-organization-team
|
||||
[cfg profile-id organization-id]
|
||||
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
@ -559,11 +579,11 @@
|
||||
team (create-team cfg params)]
|
||||
(select-keys team [:id])))
|
||||
|
||||
(defn initialize-user-in-nitrate-org
|
||||
(defn initialize-user-in-nitrate-organization
|
||||
"If needed, create a default team for the user on the organization,
|
||||
and notify Nitrate that an user has been added to an org."
|
||||
and notify Nitrate that a user has been added to an organization."
|
||||
([cfg profile-id organization-id]
|
||||
(initialize-user-in-nitrate-org cfg profile-id organization-id nil))
|
||||
(initialize-user-in-nitrate-organization cfg profile-id organization-id nil))
|
||||
([cfg profile-id organization-id email]
|
||||
(assert (db/connection-map? cfg)
|
||||
"expected cfg with valid connection")
|
||||
@ -572,24 +592,24 @@
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as tx-cfg}]
|
||||
|
||||
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
;; Only when the user doesn't belong to the organization yet
|
||||
(when (and
|
||||
(some? (:organization-id membership)) ;; the organization exists
|
||||
(not (:is-member membership))) ;; the user is not a member of the org yet
|
||||
(not (:is-member membership))) ;; the user is not a member of the organization yet
|
||||
|
||||
|
||||
(let [organization-id organization-id
|
||||
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
|
||||
default-team (create-default-organization-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
|
||||
default-team-id (:id default-team)
|
||||
result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id
|
||||
:team-id default-team-id
|
||||
:organization-id organization-id}
|
||||
(some? email) (assoc :email email)))]
|
||||
result (nitrate/call tx-cfg :add-profile-to-organization (cond-> {:profile-id profile-id
|
||||
:team-id default-team-id
|
||||
:organization-id organization-id}
|
||||
(some? email) (assoc :email email)))]
|
||||
(when (not (:is-member result))
|
||||
(ex/raise :type :internal
|
||||
:code :failed-add-profile-org-nitrate
|
||||
:code :failed-add-profile-organization-nitrate
|
||||
:context {:profile-id profile-id
|
||||
:organization-id organization-id
|
||||
:default-team-id default-team-id}))
|
||||
@ -602,13 +622,13 @@
|
||||
(assert (db/connection-map? cfg)
|
||||
"expected cfg with valid connection")
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})]
|
||||
(let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})]
|
||||
;; Only when the team belong to an organization and the user is not a member
|
||||
(when (and
|
||||
(some? (:organization-id membership)) ;; the team do belong to an organization
|
||||
(not (:is-member membership))) ;; the user is not a member of the org yet
|
||||
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
|
||||
(db/insert! conn :team-profile-rel params options)))
|
||||
(not (:is-member membership))) ;; the user is not a member of the organization yet
|
||||
(initialize-user-in-nitrate-organization cfg profile-id (:organization-id membership)))))
|
||||
(db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options)))
|
||||
|
||||
(defn create-team
|
||||
"This is a complete team creation process, it creates the team
|
||||
@ -679,7 +699,8 @@
|
||||
(defn create-project-role
|
||||
[conn profile-id project-id role]
|
||||
(let [params {:project-id project-id
|
||||
:profile-id profile-id}]
|
||||
:profile-id profile-id
|
||||
:id (uuid/next)}]
|
||||
(->> (perms/assign-role-flags params role)
|
||||
(db/insert! conn :project-profile-rel))))
|
||||
|
||||
@ -783,16 +804,16 @@
|
||||
|
||||
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
||||
team (if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-org-info-to-team cfg team params)
|
||||
(nitrate/add-organization-info-to-team cfg team params)
|
||||
team)
|
||||
perms (get team :permissions)
|
||||
org (:organization team)
|
||||
in-org? (and (contains? cf/flags :nitrate) org)
|
||||
organization (:organization team)
|
||||
in-organization? (and (contains? cf/flags :nitrate) organization)
|
||||
can-delete?
|
||||
(if in-org?
|
||||
(if in-organization?
|
||||
(nitrate-perms/allowed? :delete-team
|
||||
{:org-perms {:owner-id (dm/get-in team [:organization :owner-id])
|
||||
:permissions (dm/get-in team [:organization :permissions])}
|
||||
{:organization-perms {:owner-id (dm/get-in team [:organization :owner-id])
|
||||
:permissions (dm/get-in team [:organization :permissions])}
|
||||
:profile-id profile-id
|
||||
:team-perms perms})
|
||||
(boolean (:is-owner perms)))]
|
||||
@ -802,8 +823,8 @@
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
;; Protect the user's personal default team from deletion.
|
||||
;; Org-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
||||
(when (and (:is-default team) (not in-org?))
|
||||
;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
||||
(when (and (:is-default team) (not in-organization?))
|
||||
(ex/raise :type :validation
|
||||
:code :non-deletable-team
|
||||
:hint "impossible to delete default team"))
|
||||
@ -930,12 +951,23 @@
|
||||
|
||||
(db/delete! conn :team-profile-rel {:profile-id member-id
|
||||
:team-id team-id})
|
||||
(mbus/pub! msgbus
|
||||
:topic member-id
|
||||
:message {:type :team-membership-change
|
||||
:change :removed
|
||||
:team-id team-id
|
||||
:team-name (:name team)})
|
||||
|
||||
;; A removed member that owns the organization of this team keeps
|
||||
;; read-only access to it, so instead of kicking them out we degrade
|
||||
;; their session to viewer, same as any other role change.
|
||||
(if (nitrate/organization-owner-of-team? cfg member-id team-id)
|
||||
(mbus/pub! msgbus
|
||||
:topic member-id
|
||||
:message {:type :team-role-change
|
||||
:topic member-id
|
||||
:team-id team-id
|
||||
:role :viewer})
|
||||
(mbus/pub! msgbus
|
||||
:topic member-id
|
||||
:message {:type :team-membership-change
|
||||
:change :removed
|
||||
:team-id team-id
|
||||
:team-name (:name team)}))
|
||||
|
||||
nil))
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@
|
||||
update set role = ?, valid_until = ?, updated_at = now()
|
||||
returning *")
|
||||
|
||||
(def sql:upsert-org-invitation
|
||||
(def sql:upsert-organization-invitation
|
||||
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
||||
values (?, null, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, email_to) where team_id is null do
|
||||
@ -86,15 +86,17 @@
|
||||
[:role types.team/schema:role]
|
||||
[:email ::sm/email]])
|
||||
|
||||
(def ^:private schema:create-org-invitation
|
||||
[:map {:title "params:create-org-invitation"}
|
||||
(def ^:private schema:create-organization-invitation
|
||||
[:map {:title "params:create-organization-invitation"}
|
||||
[::rpc/profile-id ::sm/uuid]
|
||||
[:organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo ::sm/uri]]]
|
||||
[:logo ::sm/uri]
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]
|
||||
[:sso-active [:maybe ::sm/boolean]]]]
|
||||
[:profile
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
@ -105,8 +107,8 @@
|
||||
(def ^:private check-create-invitation-params
|
||||
(sm/check-fn schema:create-invitation))
|
||||
|
||||
(def ^:private check-create-org-invitation-params
|
||||
(sm/check-fn schema:create-org-invitation))
|
||||
(def ^:private check-create-organization-invitation-params
|
||||
(sm/check-fn schema:create-organization-invitation))
|
||||
|
||||
(defn- allow-invitation-emails?
|
||||
[member]
|
||||
@ -114,23 +116,24 @@
|
||||
(not= :none (:email-invites notifications))))
|
||||
|
||||
(defn- assert-email-can-be-invited
|
||||
"Asserts that member is an org member when the org
|
||||
"Asserts that member is an organization member when the organization
|
||||
restricts who can be added to teams."
|
||||
[member org-member-ids]
|
||||
(when (some? org-member-ids)
|
||||
(let [is-member? (and (some? member) (contains? org-member-ids (:id member)))]
|
||||
[member organization-member-ids]
|
||||
(when (some? organization-member-ids)
|
||||
(let [is-member? (and (some? member) (contains? organization-member-ids (:id member)))]
|
||||
(when-not is-member?
|
||||
(ex/raise :type :validation
|
||||
:code :email-not-org-member
|
||||
:code :email-not-organization-member
|
||||
:hint "The invited email is not a member of the organization")))))
|
||||
|
||||
(defn- create-invitation
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}]
|
||||
[{:keys [::db/conn] :as cfg}
|
||||
{:keys [team organization profile role email organization-member-ids all-organization-member-ids] :as params}]
|
||||
|
||||
(assert (db/connection-map? cfg)
|
||||
"expected cfg with valid connection")
|
||||
(if organization
|
||||
(assert (check-create-org-invitation-params params))
|
||||
(assert (check-create-organization-invitation-params params))
|
||||
(assert (check-create-invitation-params params)))
|
||||
|
||||
(let [email (profile/clean-email email)
|
||||
@ -142,11 +145,11 @@
|
||||
:code :email-domain-is-not-allowed
|
||||
:hint "email domain is in the blacklist"))
|
||||
|
||||
;; When nitrate is active and the team belongs to an org, check that
|
||||
;; the email is already an org member unless the org explicitly allows adding anybody.
|
||||
;; When nitrate is active and the team belongs to an organization, check that
|
||||
;; the email is already an organization member unless the organization explicitly allows adding anybody.
|
||||
(when (and (contains? cf/flags :nitrate)
|
||||
(:organization team))
|
||||
(assert-email-can-be-invited member org-member-ids))
|
||||
(assert-email-can-be-invited member organization-member-ids))
|
||||
|
||||
|
||||
;; When we have email verification disabled and invitation user is
|
||||
@ -162,9 +165,9 @@
|
||||
(get types.team/permissions-for-role role))]
|
||||
|
||||
(if organization
|
||||
;; Insert the invited member to the org
|
||||
;; Insert the invited member to the organization
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization) email))
|
||||
(teams/initialize-user-in-nitrate-organization cfg (:id member) (:id organization) email))
|
||||
;; Insert the invited member to the team
|
||||
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
|
||||
|
||||
@ -187,7 +190,7 @@
|
||||
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||
(ct/in-future "168h")) ;; 7 days
|
||||
invitation (db/exec-one! conn (if organization
|
||||
[sql:upsert-org-invitation id
|
||||
[sql:upsert-organization-invitation id
|
||||
(:id organization)
|
||||
(str/lower email)
|
||||
(:id profile)
|
||||
@ -201,6 +204,7 @@
|
||||
(name role) expire]))
|
||||
updated? (not= id (:id invitation))
|
||||
profile-id (:id profile)
|
||||
team-organization-id (get-in team [:organization :id])
|
||||
tprops {:profile-id profile-id
|
||||
:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
@ -210,18 +214,37 @@
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role}
|
||||
audit-props
|
||||
(cond-> {:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
:team-id (:id team)
|
||||
:organization-id (:id organization)
|
||||
:organization-name (:name organization)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role}
|
||||
organization
|
||||
(assoc :user-who-send-invitation (str profile-id))
|
||||
|
||||
(not organization)
|
||||
(assoc :team-belongs-to-organization (boolean team-organization-id)
|
||||
:adds-invitee-to-organization (boolean team-organization-id)
|
||||
:invitee-already-organization-member
|
||||
(boolean
|
||||
(and team-organization-id
|
||||
member
|
||||
(contains? all-organization-member-ids (:id member))))))
|
||||
itoken (create-invitation-token cfg tprops)
|
||||
ptoken (create-profile-identity-token cfg profile-id)]
|
||||
|
||||
(when (contains? cf/flags :log-invitation-tokens)
|
||||
(l/info :hint "invitation token" :token itoken))
|
||||
|
||||
(let [props (-> (dissoc tprops :profile-id)
|
||||
(audit/clean-props))
|
||||
(let [props (audit/clean-props audit-props)
|
||||
evname (cond
|
||||
(and updated? organization) "update-org-invitation"
|
||||
(and updated? organization) "update-organization-invitation"
|
||||
updated? "update-team-invitation"
|
||||
organization "create-org-invitation"
|
||||
organization "create-organization-invitation"
|
||||
:else "create-team-invitation")
|
||||
event (-> (audit/event-from-rpc-params params)
|
||||
(assoc :name evname)
|
||||
@ -232,7 +255,7 @@
|
||||
(if organization
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-org
|
||||
::eml/factory eml/invite-to-organization
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
@ -246,13 +269,13 @@
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:organization (dm/get-in team [:organization :name])
|
||||
:organization (:organization team)
|
||||
:token itoken
|
||||
:extra-data ptoken})))
|
||||
|
||||
itoken)))))
|
||||
|
||||
(defn create-org-invitation
|
||||
(defn create-organization-invitation
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(let [profile (db/get-by-id cfg :profile profile-id)]
|
||||
(create-invitation cfg
|
||||
@ -322,16 +345,21 @@
|
||||
- emails (set) + role (single role for all emails)
|
||||
- invitations (vector of {:email :role} maps)"
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
||||
(let [;; Enrich team with org info once for all invitations when nitrate is active
|
||||
(let [;; Enrich team with organization info once for all invitations when nitrate is active
|
||||
team (if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-org-info-to-team cfg team {})
|
||||
(nitrate/add-organization-info-to-team cfg team {})
|
||||
team)
|
||||
org (:organization team)
|
||||
org-id (:id org)
|
||||
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
|
||||
org-member-ids (when restricted?
|
||||
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
|
||||
params (assoc params :team team :org-member-ids org-member-ids)
|
||||
organization (:organization team)
|
||||
organization-id (:id organization)
|
||||
restricted? (and organization-id (not (nitrate-perms/allowed? :add-anybody-to-team {:organization-perms organization})))
|
||||
all-organization-member-ids
|
||||
(when organization-id
|
||||
(into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})))
|
||||
organization-member-ids (when restricted? all-organization-member-ids)
|
||||
params (assoc params
|
||||
:team team
|
||||
:organization-member-ids organization-member-ids
|
||||
:all-organization-member-ids all-organization-member-ids)
|
||||
|
||||
;; Normalize input to a consistent format: [{:email :role}]
|
||||
invitation-data (cond
|
||||
@ -539,7 +567,7 @@
|
||||
::doc/module :teams
|
||||
::sm/params schema:get-team-invitation-token}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
|
||||
(teams/check-read-permissions! pool profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(let [email (profile/clean-email email)
|
||||
invit (-> (db/get pool :team-invitation
|
||||
{:team-id team-id
|
||||
|
||||
@ -85,13 +85,33 @@
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))))
|
||||
|
||||
(defn- with-nitrate-licence
|
||||
[profile cfg]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||
profile))
|
||||
|
||||
(defmethod process-token :auth
|
||||
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}]
|
||||
(let [profile (profile/get-profile conn profile-id)]
|
||||
(let [profile (-> (profile/get-profile conn profile-id)
|
||||
(profile/strip-private-attrs)
|
||||
(update :props profile/filter-props)
|
||||
(with-nitrate-licence cfg))]
|
||||
(assoc claims :profile profile)))
|
||||
|
||||
;; --- Team Invitation
|
||||
|
||||
(def ^:private sql:get-organization-invitation
|
||||
"SELECT *
|
||||
FROM team_invitation
|
||||
WHERE email_to = ?
|
||||
AND org_id = ?")
|
||||
|
||||
(def ^:private sql:delete-organization-invitation
|
||||
"DELETE FROM team_invitation
|
||||
WHERE email_to = ?
|
||||
AND org_id = ?")
|
||||
|
||||
(defn- accept-invitation
|
||||
[{:keys [::db/conn] :as cfg}
|
||||
{:keys [team-id organization-id role member-email] :as claims} invitation member]
|
||||
@ -115,9 +135,9 @@
|
||||
(get types.team/permissions-for-role role))
|
||||
|
||||
accepted-team-id (if organization-id
|
||||
;; Insert the invited member to the org
|
||||
;; Insert the invited member to the organization
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email))
|
||||
(teams/initialize-user-in-nitrate-organization cfg id-member organization-id member-email))
|
||||
;; Insert the invited member to the team
|
||||
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
|
||||
team-id))]
|
||||
@ -136,10 +156,11 @@
|
||||
{:id id-member}))
|
||||
|
||||
;; Delete the invitation
|
||||
(db/delete! conn :team-invitation
|
||||
(cond-> {:email-to member-email}
|
||||
team-id (assoc :team-id team-id)
|
||||
organization-id (assoc :org-id organization-id)))
|
||||
(if organization-id
|
||||
(db/exec-one! conn [sql:delete-organization-invitation member-email organization-id])
|
||||
(db/delete! conn :team-invitation
|
||||
{:email-to member-email
|
||||
:team-id team-id}))
|
||||
|
||||
;; Delete any request (only applicable for team invitations)
|
||||
(when team-id
|
||||
@ -175,22 +196,17 @@
|
||||
:code :invalid-invitation-token
|
||||
:hint "invitation token contains unexpected data"))
|
||||
|
||||
(let [invitation (db/get* conn :team-invitation
|
||||
(cond-> {:email-to member-email}
|
||||
team-id (assoc :team-id team-id)
|
||||
organization-id (assoc :org-id organization-id)))
|
||||
(let [invitation (if organization-id
|
||||
(db/exec-one! conn [sql:get-organization-invitation member-email organization-id])
|
||||
(db/get* conn :team-invitation
|
||||
{:email-to member-email
|
||||
:team-id team-id}))
|
||||
profile (db/get* conn :profile
|
||||
{:id profile-id}
|
||||
{:columns [:id :email :default-team-id]})
|
||||
registration-disabled? (not (contains? cf/flags :registration))
|
||||
|
||||
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
|
||||
;; Membership only makes sense for a logged-in profile; querying it for
|
||||
;; an anonymous recipient would call nitrate with a nil profile-id and
|
||||
;; mask the clean :invalid-token response with a generic error.
|
||||
membership (when (and profile org-invitation?)
|
||||
(nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||
:organization-id organization-id}))]
|
||||
organization-invitation? (and (contains? cf/flags :nitrate) organization-id)]
|
||||
|
||||
(if profile
|
||||
(do
|
||||
@ -201,62 +217,130 @@
|
||||
:reason :email-mismatch
|
||||
:hint "logged-in user does not matches the invitation"))
|
||||
|
||||
(when (:is-member membership)
|
||||
(ex/raise :type :validation
|
||||
:code :already-an-org-member
|
||||
:team-id (:default-team-id membership)
|
||||
:hint "the user is already a member of the organization"))
|
||||
|
||||
(when (and org-invitation? (not (:organization-id membership)))
|
||||
(ex/raise :type :validation
|
||||
:code :org-not-found
|
||||
:team-id (:default-team-id profile)
|
||||
:hint "the organization doesn't exist"))
|
||||
|
||||
(when (nil? invitation)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token
|
||||
:hint "no invitation associated with the token"))
|
||||
:code (if organization-id :canceled-invitation :invalid-token)
|
||||
:hint (if organization-id
|
||||
"the invitation has been canceled"
|
||||
"no invitation associated with the token")))
|
||||
|
||||
;; Membership only makes sense for a logged-in profile with an
|
||||
;; existing invitation; querying it when the invitation is absent
|
||||
;; would call nitrate needlessly and could mask the clean
|
||||
;; :canceled-invitation/:invalid-token response with a generic error.
|
||||
(let [membership
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(cond
|
||||
organization-id
|
||||
(nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})
|
||||
|
||||
;; if we have logged-in user and it matches the invitation we proceed
|
||||
;; with accepting the invitation and joining the current profile to the
|
||||
;; invited team.
|
||||
(let [props {:team-id (:team-id claims)
|
||||
:role (:role claims)
|
||||
:invitation-id (:id invitation)}]
|
||||
team-id
|
||||
(nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id
|
||||
:team-id team-id})))
|
||||
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :name "accept-team-invitation")
|
||||
(assoc :props props)))
|
||||
organization-id-on-add
|
||||
(when (and (:organization-id membership)
|
||||
(not (:is-member membership)))
|
||||
(:organization-id membership))
|
||||
|
||||
;; NOTE: Backward compatibility; old invitations can
|
||||
;; have the `created-by` to be nil; so in this case we
|
||||
;; don't submit this event to the audit-log
|
||||
(when-let [created-by (:created-by invitation)]
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :profile-id created-by)
|
||||
(assoc :name "accept-team-invitation-from")
|
||||
(assoc :props (assoc props
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile))))))
|
||||
organization-add-source
|
||||
(when organization-id-on-add
|
||||
(if organization-id
|
||||
"direct-organization-invitation"
|
||||
"team-invitation"))
|
||||
|
||||
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||
(cond-> (assoc claims :state :created)
|
||||
;; when the invitation is to an org, instead of a team, add the
|
||||
;; accepted-team-id as :org-team-id
|
||||
(:organization-id claims)
|
||||
(assoc :org-team-id accepted-team-id)))))
|
||||
organization-event-origin
|
||||
(when organization-id-on-add
|
||||
(if organization-id
|
||||
"organization-invitation-acceptance"
|
||||
"team-invitation-acceptance"))
|
||||
|
||||
organization-member-count-before
|
||||
(when organization-id-on-add
|
||||
(count
|
||||
(nitrate/call cfg :get-organization-members
|
||||
{:organization-id organization-id-on-add})))]
|
||||
|
||||
(when (:is-member membership)
|
||||
(when organization-invitation?
|
||||
(ex/raise :type :validation
|
||||
:code :already-an-organization-member
|
||||
:team-id (:default-team-id membership)
|
||||
:hint "the user is already a member of the organization")))
|
||||
|
||||
(when (and organization-invitation? (not (:organization-id membership)))
|
||||
(ex/raise :type :validation
|
||||
:code :organization-not-found
|
||||
:team-id (:default-team-id profile)
|
||||
:hint "the organization doesn't exist"))
|
||||
|
||||
;; if we have logged-in user and it matches the invitation we proceed
|
||||
;; with accepting the invitation and joining the current profile to the
|
||||
;; invited team.
|
||||
(let [props {:team-id (:team-id claims)
|
||||
:role (:role claims)
|
||||
:invitation-id (:id invitation)}]
|
||||
|
||||
(when team-id
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :name "accept-team-invitation")
|
||||
(assoc :props props)))
|
||||
|
||||
;; NOTE: Backward compatibility; old invitations can
|
||||
;; have the `created-by` to be nil; so in this case we
|
||||
;; don't submit this event to the audit-log
|
||||
(when-let [created-by (:created-by invitation)]
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :profile-id created-by)
|
||||
(assoc :name "accept-team-invitation-from")
|
||||
(assoc :props (assoc props
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)))))))
|
||||
|
||||
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||
(when organization-id-on-add
|
||||
(audit/submit
|
||||
cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :name "accept-organization-invitation")
|
||||
(assoc :props
|
||||
(-> props
|
||||
(assoc :organization-id organization-id-on-add)
|
||||
(audit/clean-props))))))
|
||||
|
||||
(cond-> (assoc claims :state :created)
|
||||
;; when the invitation is to an organization, instead of a team, add the
|
||||
;; accepted-team-id as :organization-team-id
|
||||
(:organization-id claims)
|
||||
(assoc :organization-team-id accepted-team-id)
|
||||
|
||||
organization-id-on-add
|
||||
(assoc :organization-invitation-audit
|
||||
{:origin organization-event-origin
|
||||
:props
|
||||
(-> props
|
||||
(assoc :organization-id organization-id-on-add
|
||||
:organization-member-add-source organization-add-source
|
||||
:belongs-to-team-on-add (boolean team-id)
|
||||
:organization-member-count-before
|
||||
organization-member-count-before)
|
||||
(audit/clean-props))}))))))
|
||||
|
||||
(do
|
||||
;; If the user is not logged-in and the token is invalid we throw the error
|
||||
;; Taiga issue #14182
|
||||
;; If the user is not logged-in and the invitation has been canceled
|
||||
;; we return a specific error code so the frontend can redirect to
|
||||
;; login with an appropriate message instead of showing the error page.
|
||||
;; This only applies to organization invitations; team invitations keep the
|
||||
;; existing :invalid-token behavior.
|
||||
(when (nil? invitation)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token
|
||||
:hint "no invitation associated with the token"))
|
||||
:code (if organization-id :canceled-invitation :invalid-token)
|
||||
:hint (if organization-id
|
||||
"the invitation has been canceled"
|
||||
"no invitation associated with the token")))
|
||||
|
||||
;; If we have not logged-in user, and invitation comes with member-id we
|
||||
;; redirect user to login, if no member-id is present and in the invitation
|
||||
@ -272,4 +356,3 @@
|
||||
[_ _ _]
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token))
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.cond :as-alias cond]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
@ -125,8 +126,8 @@
|
||||
::sm/params schema:get-view-only-bundle}
|
||||
[system {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(db/run! system
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(let [perms (bfc/get-file-permissions conn profile-id file-id share-id)
|
||||
(fn [system]
|
||||
(let [perms (perms/get-file-read-permissions system profile-id file-id share-id)
|
||||
params (-> params
|
||||
(assoc ::perms perms)
|
||||
(assoc :profile-id profile-id))]
|
||||
@ -139,5 +140,3 @@
|
||||
:hint "object not found"))
|
||||
|
||||
(get-view-only-bundle system params)))))
|
||||
|
||||
|
||||
|
||||
@ -172,6 +172,6 @@
|
||||
::sm/params schema:get-webhooks}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(check-read-permissions! conn profile-id team-id)
|
||||
(check-read-permissions! cfg profile-id team-id)
|
||||
(->> (db/exec! conn [sql:get-webhooks team-id])
|
||||
(mapv decode-row))))
|
||||
|
||||
@ -8,27 +8,34 @@
|
||||
"Internal Nitrate HTTP RPC API. Provides authenticated access to
|
||||
organization management and token validation endpoints."
|
||||
(:require
|
||||
[app.auth :as aauth]
|
||||
[app.auth.oidc :as oidc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar]]
|
||||
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]]
|
||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||
[app.common.types.team :refer [schema:team]]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[app.http :as-alias http]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.media :as media]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as rpc]
|
||||
[app.rpc.commands.auth :as auth]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.nitrate :as cnit]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.commands.teams-invitations :as ti]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.rpc.nitrate.emails-helper :as neh]
|
||||
[app.rpc.nitrate.organization-helper :as noh]
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
@ -40,6 +47,7 @@
|
||||
{:id (:id profile)
|
||||
:name (:fullname profile)
|
||||
:email (:email profile)
|
||||
:created-at (:created-at profile)
|
||||
:photo-url (files/resolve-public-uri (get profile :photo-id))})
|
||||
|
||||
;; ---- API: authenticate
|
||||
@ -48,7 +56,8 @@
|
||||
"Authenticate the current user"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:profile}
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(let [profile (profile/get-profile cfg profile-id)]
|
||||
(-> (profile-to-map profile)
|
||||
@ -99,30 +108,32 @@
|
||||
"List teams for which current user is owner"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-teams-result}
|
||||
::sm/result schema:get-teams-result
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
|
||||
(->> (db/exec! cfg [sql:get-teams current-user-id])
|
||||
(map #(select-keys % [:id :name])))))
|
||||
|
||||
;; ---- API: upload-org-logo
|
||||
;; ---- API: upload-organization-logo
|
||||
|
||||
(def ^:private schema:upload-org-logo
|
||||
(def ^:private schema:upload-organization-logo
|
||||
[:map
|
||||
[:content media/schema:upload]
|
||||
[:organization-id ::sm/uuid]
|
||||
[:previous-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(def ^:private schema:upload-org-logo-result
|
||||
(def ^:private schema:upload-organization-logo-result
|
||||
[:map [:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::upload-org-logo
|
||||
(sv/defmethod ::upload-organization-logo
|
||||
"Store an organization logo in penpot storage and return its ID.
|
||||
Accepts an optional previous-id to mark the old logo for garbage
|
||||
collection when replacing an existing one."
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:upload-org-logo
|
||||
::sm/result schema:upload-org-logo-result}
|
||||
::sm/params schema:upload-organization-logo
|
||||
::sm/result schema:upload-organization-logo-result
|
||||
::nitrate/sso false}
|
||||
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||
(when previous-id
|
||||
(sto/touch-object! storage previous-id))
|
||||
@ -156,12 +167,12 @@
|
||||
[:role ::sm/text]])
|
||||
|
||||
(sv/defmethod ::notify-user-added-to-organization
|
||||
"Notify to Penpot that an user has joined an org from nitrate"
|
||||
"Notify to Penpot that an user has joined an organization from nitrate"
|
||||
{::doc/added "2.14"
|
||||
::sm/params schema:notify-user-added-to-organization
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [profile-id organization-id]}]
|
||||
(db/tx-run! cfg teams/create-default-org-team profile-id organization-id))
|
||||
(db/tx-run! cfg teams/create-default-organization-team profile-id organization-id))
|
||||
|
||||
|
||||
;; ---- API: get-managed-profiles
|
||||
@ -190,7 +201,8 @@
|
||||
"List profiles that belong to teams for which current user is owner"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:managed-profile-result}
|
||||
::sm/result schema:managed-profile-result
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
|
||||
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))
|
||||
@ -229,7 +241,8 @@
|
||||
"Get summary information for a list of teams"
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:get-teams-summary-params
|
||||
::sm/result schema:get-teams-summary-result}
|
||||
::sm/result schema:get-teams-summary-result
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [ids]}]
|
||||
(let [;; Handle one or multiple params
|
||||
ids (cond
|
||||
@ -301,7 +314,7 @@ RETURNING id, deleted_at;")
|
||||
nil)
|
||||
|
||||
(defn manage-deleted-organization-teams
|
||||
"For a deleted organization, preserve org teams unchanged and only prefix or
|
||||
"For a deleted organization, preserve organization teams unchanged and only prefix or
|
||||
delete member Your Penpot teams depending on whether they still contain files."
|
||||
[cfg {:keys [organization-id organization-name teams]}]
|
||||
(let [all-team-ids (->> teams
|
||||
@ -316,7 +329,7 @@ RETURNING id, deleted_at;")
|
||||
distinct
|
||||
(into []))]
|
||||
(when (seq all-team-ids)
|
||||
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||
(let [organization-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
@ -330,11 +343,11 @@ RETURNING id, deleted_at;")
|
||||
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
|
||||
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
|
||||
|
||||
;; Org teams move to the fallback org unchanged. Only imported
|
||||
;; Your Penpot teams keep the org prefix when they still have files.
|
||||
;; Organization teams move to the fallback organization unchanged. Only imported
|
||||
;; Your Penpot teams keep the organization prefix when they still have files.
|
||||
(when (seq teams-to-prefix)
|
||||
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
||||
org-prefix
|
||||
organization-prefix
|
||||
(db/create-array conn "uuid" teams-to-prefix)]))
|
||||
|
||||
;; Empty imported Your Penpot teams disappear entirely.
|
||||
@ -345,16 +358,16 @@ RETURNING id, deleted_at;")
|
||||
|
||||
|
||||
(sv/defmethod ::notify-organization-deletion
|
||||
"For a deleted organization, preserve org teams and only prefix or delete
|
||||
"For a deleted organization, preserve organization teams and only prefix or delete
|
||||
imported Your Penpot teams before notifying connected users."
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:notify-organization-deletion
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
teams (:teams org-summary)]
|
||||
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary)
|
||||
:organization-id (:id org-summary)
|
||||
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||
teams (:teams organization-summary)]
|
||||
(manage-deleted-organization-teams cfg {:organization-name (:name organization-summary)
|
||||
:organization-id (:id organization-summary)
|
||||
:teams teams})
|
||||
nil))
|
||||
|
||||
@ -365,17 +378,18 @@ RETURNING id, deleted_at;")
|
||||
[:profile-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::notify-user-organizations-deletion
|
||||
"For a given user, find all owned organizations and apply the deleted-org
|
||||
"For a given user, find all owned organizations and apply the deleted-organization
|
||||
transfer rules to their imported Your Penpot teams."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-user-organizations-deletion}
|
||||
::sm/params schema:notify-user-organizations-deletion
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [profile-id]}]
|
||||
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
|
||||
(doseq [org owned-orgs]
|
||||
(let [organization-name (:name org)
|
||||
teams (:teams org)]
|
||||
(let [owned-organizations (nitrate/call cfg :get-owned-organizations {:profile-id profile-id})]
|
||||
(doseq [organization owned-organizations]
|
||||
(let [organization-name (:name organization)
|
||||
teams (:teams organization)]
|
||||
(manage-deleted-organization-teams cfg {:organization-name organization-name
|
||||
:organization-id (:id org)
|
||||
:organization-id (:id organization)
|
||||
:teams teams}))))
|
||||
nil)
|
||||
|
||||
@ -394,7 +408,8 @@ RETURNING id, deleted_at;")
|
||||
"Get profile by email"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map [:email ::sm/email]]
|
||||
::sm/result schema:profile}
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [email]}]
|
||||
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
|
||||
(when-not profile
|
||||
@ -417,7 +432,8 @@ RETURNING id, deleted_at;")
|
||||
"Get profile by email"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map [:id ::sm/uuid]]
|
||||
::sm/result schema:profile}
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [id]}]
|
||||
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
|
||||
(when-not profile
|
||||
@ -428,9 +444,9 @@ RETURNING id, deleted_at;")
|
||||
(profile-to-map profile)))
|
||||
|
||||
|
||||
;; ---- API: get-org-member-team-counts
|
||||
;; ---- API: get-organization-member-team-counts
|
||||
|
||||
(def ^:private sql:get-org-member-team-counts
|
||||
(def ^:private sql:get-organization-member-team-counts
|
||||
"SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
|
||||
FROM team_profile_rel AS tpr
|
||||
JOIN team AS t ON t.id = tpr.team_id
|
||||
@ -439,19 +455,19 @@ RETURNING id, deleted_at;")
|
||||
AND t.is_default IS FALSE
|
||||
GROUP BY tpr.profile_id;")
|
||||
|
||||
(def ^:private schema:get-org-member-team-counts-params
|
||||
(def ^:private schema:get-organization-member-team-counts-params
|
||||
[:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
|
||||
|
||||
(def ^:private schema:get-org-member-team-counts-result
|
||||
(def ^:private schema:get-organization-member-team-counts-result
|
||||
[:vector [:map
|
||||
[:profile-id ::sm/uuid]
|
||||
[:team-count ::sm/int]]])
|
||||
|
||||
(sv/defmethod ::get-org-member-team-counts
|
||||
(sv/defmethod ::get-organization-member-team-counts
|
||||
"Get the number of non-default teams each profile belongs to within a set of teams."
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:get-org-member-team-counts-params
|
||||
::sm/result schema:get-org-member-team-counts-result
|
||||
::sm/params schema:get-organization-member-team-counts-params
|
||||
::sm/result schema:get-organization-member-team-counts-result
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [team-ids]}]
|
||||
(let [team-ids (cond
|
||||
@ -467,46 +483,30 @@ RETURNING id, deleted_at;")
|
||||
[]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||
(db/exec! conn [sql:get-org-member-team-counts ids-array])))))))
|
||||
(db/exec! conn [sql:get-organization-member-team-counts ids-array])))))))
|
||||
|
||||
|
||||
;; API: invite-to-org
|
||||
;; API: invite-to-organization
|
||||
|
||||
(sv/defmethod ::invite-to-org
|
||||
(sv/defmethod ::invite-to-organization
|
||||
"Invite to organization"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map
|
||||
[:email ::sm/email]
|
||||
[:organization schema:organization-with-avatar]]}
|
||||
[:organization schema:organization-with-avatar]]
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/tx-run! cfg ti/create-org-invitation params)
|
||||
(db/tx-run! cfg ti/create-organization-invitation params)
|
||||
nil)
|
||||
|
||||
|
||||
;; API: get-org-invitations
|
||||
;; API: get-organization-invitations
|
||||
|
||||
(def ^:private sql:get-org-invitations
|
||||
"SELECT DISTINCT ON (email_to)
|
||||
ti.id,
|
||||
ti.org_id AS organization_id,
|
||||
ti.email_to AS email,
|
||||
ti.created_at AS sent_at,
|
||||
p.fullname AS name,
|
||||
p.id AS profile_id,
|
||||
p.photo_id
|
||||
FROM team_invitation AS ti
|
||||
LEFT JOIN profile AS p
|
||||
ON p.email = ti.email_to
|
||||
AND p.deleted_at IS NULL
|
||||
WHERE ti.valid_until >= now()
|
||||
AND (ti.org_id = ? OR ti.team_id = ANY(?))
|
||||
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
|
||||
|
||||
(def ^:private schema:get-org-invitations-params
|
||||
(def ^:private schema:get-organization-invitations-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(def ^:private schema:get-org-invitations-result
|
||||
(def ^:private schema:get-organization-invitations-result
|
||||
[:vector
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
@ -517,84 +517,75 @@ LEFT JOIN profile AS p
|
||||
[:profile-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:photo-url {:optional true} ::sm/uri]]])
|
||||
|
||||
(sv/defmethod ::get-org-invitations
|
||||
(sv/defmethod ::get-organization-invitations
|
||||
"Get valid invitations for an organization, returning at most one invitation per email."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-org-invitations-params
|
||||
::sm/result schema:get-org-invitations-result}
|
||||
::sm/params schema:get-organization-invitations-params
|
||||
::sm/result schema:get-organization-invitations-result
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
team-ids (->> (:teams org-summary)
|
||||
(map :id)
|
||||
(filter uuid?)
|
||||
(into []))]
|
||||
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||
(->> (db/exec! conn [sql:get-org-invitations organization-id ids-array])
|
||||
(mapv (fn [{:keys [photo-id] :as invitation}]
|
||||
(cond-> (dissoc invitation :photo-id)
|
||||
photo-id
|
||||
(assoc :photo-url (files/resolve-public-uri photo-id)))))))))))
|
||||
(->> (noh/get-organization-invitations conn organization-id team-ids)
|
||||
(mapv (fn [{:keys [photo-id] :as invitation}]
|
||||
(cond-> (dissoc invitation :photo-id)
|
||||
photo-id
|
||||
(assoc :photo-url (files/resolve-public-uri photo-id))))))))))
|
||||
|
||||
|
||||
;; API: delete-org-invitations
|
||||
;; API: delete-organization-invitations
|
||||
|
||||
(def ^:private sql:delete-org-invitations
|
||||
(def ^:private sql:delete-organization-invitations
|
||||
"DELETE FROM team_invitation AS ti
|
||||
WHERE ti.email_to = ?
|
||||
AND (ti.org_id = ? OR ti.team_id = ANY(?));")
|
||||
|
||||
(def ^:private schema:delete-org-invitations-params
|
||||
(def ^:private schema:delete-organization-invitations-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:email ::sm/email]])
|
||||
|
||||
(sv/defmethod ::delete-org-invitations
|
||||
"Delete all invitations for one email in an organization scope (org + org teams)."
|
||||
(sv/defmethod ::delete-organization-invitations
|
||||
"Delete all invitations for one email in an organization scope (organization + organization teams)."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:delete-org-invitations-params}
|
||||
::sm/params schema:delete-organization-invitations-params
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [organization-id email]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
clean-email (profile/clean-email email)
|
||||
team-ids (->> (:teams org-summary)
|
||||
(map :id)
|
||||
(filter uuid?)
|
||||
(into []))]
|
||||
(let [clean-email (profile/clean-email email)
|
||||
team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||
(db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array]))))
|
||||
(db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array]))))
|
||||
nil))
|
||||
|
||||
|
||||
;; API: delete-all-org-invitations
|
||||
;; API: delete-all-organization-invitations
|
||||
|
||||
(def ^:private sql:delete-all-org-invitations
|
||||
(def ^:private sql:delete-all-organization-invitations
|
||||
"DELETE FROM team_invitation AS ti
|
||||
WHERE ti.org_id = ?
|
||||
OR ti.team_id = ANY(?);")
|
||||
|
||||
(def ^:private schema:delete-all-org-invitations-params
|
||||
(def ^:private schema:delete-all-organization-invitations-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::delete-all-org-invitations
|
||||
"Delete every pending invitation associated with an organization (org-level + team-level).
|
||||
(sv/defmethod ::delete-all-organization-invitations
|
||||
"Delete every pending invitation associated with an organization (organization-level + team-level).
|
||||
Called from Nitrate when an organization is about to be deleted, so users that click
|
||||
their invitation token hit the existing invalid-token landing page."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:delete-all-org-invitations-params
|
||||
::sm/params schema:delete-all-organization-invitations-params
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
team-ids (->> (:teams org-summary)
|
||||
(map :id))]
|
||||
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||
(db/exec! conn [sql:delete-all-org-invitations organization-id ids-array]))))
|
||||
(db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array]))))
|
||||
nil))
|
||||
|
||||
|
||||
;; API: remove-from-org
|
||||
;; API: remove-from-organization
|
||||
|
||||
(def ^:private sql:get-reassign-to
|
||||
"SELECT tpr.profile_id
|
||||
@ -619,7 +610,7 @@ LEFT JOIN profile AS p
|
||||
|
||||
(assoc team-to-transfer :reassign-to reassign-to)))
|
||||
|
||||
(sv/defmethod ::remove-from-org
|
||||
(sv/defmethod ::remove-from-organization
|
||||
"Remove an user from an organization"
|
||||
{::doc/added "2.17"
|
||||
::sm/params [:map
|
||||
@ -627,9 +618,14 @@ LEFT JOIN profile AS p
|
||||
[:organization-id ::sm/uuid]
|
||||
[:organization-name ::sm/text]
|
||||
[:default-team-id ::sm/uuid]]
|
||||
::db/transaction true}
|
||||
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
|
||||
(let [{:keys [valid-teams-to-delete-ids
|
||||
::db/transaction true
|
||||
::nitrate/sso false}
|
||||
[cfg {actor-profile-id ::rpc/profile-id
|
||||
:keys [profile-id organization-id organization-name default-team-id]
|
||||
:as params}]
|
||||
(let [actor-profile-id (when-not (= actor-profile-id uuid/zero)
|
||||
actor-profile-id)
|
||||
{:keys [valid-teams-to-delete-ids
|
||||
valid-teams-to-transfer
|
||||
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
||||
add-reassign-to (partial add-reassign-to cfg profile-id)
|
||||
@ -637,25 +633,28 @@ LEFT JOIN profile AS p
|
||||
valid-teams-to-leave (into valid-teams-to-exit
|
||||
(map add-reassign-to valid-teams-to-transfer))]
|
||||
|
||||
(cnit/leave-org cfg (assoc params
|
||||
:id organization-id
|
||||
:name organization-name
|
||||
:teams-to-delete valid-teams-to-delete-ids
|
||||
:teams-to-leave valid-teams-to-leave
|
||||
:skip-validation true))
|
||||
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
|
||||
(cnit/leave-organization cfg (assoc params
|
||||
:id organization-id
|
||||
:name organization-name
|
||||
:teams-to-delete valid-teams-to-delete-ids
|
||||
:teams-to-leave valid-teams-to-leave
|
||||
:skip-validation true
|
||||
:user-who-delete-member actor-profile-id
|
||||
:deleted-by-role (when actor-profile-id
|
||||
"organization-owner")))
|
||||
(notifications/notify-user-organization-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-organization")
|
||||
nil))
|
||||
|
||||
;; API: get-remove-from-org-summary
|
||||
;; API: get-remove-from-organization-summary
|
||||
|
||||
(def ^:private schema:get-remove-from-org-summary-result
|
||||
(def ^:private schema:get-remove-from-organization-summary-result
|
||||
[:map
|
||||
[:teams-to-delete ::sm/int]
|
||||
[:teams-to-transfer ::sm/int]
|
||||
[:teams-to-exit ::sm/int]
|
||||
[:teams-to-detach ::sm/int]])
|
||||
|
||||
(sv/defmethod ::get-remove-from-org-summary
|
||||
(sv/defmethod ::get-remove-from-organization-summary
|
||||
"Get a summary of the teams that would be deleted, transferred, or exited
|
||||
if the user were removed from the organization"
|
||||
{::doc/added "2.17"
|
||||
@ -663,8 +662,9 @@ LEFT JOIN profile AS p
|
||||
[:profile-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
[:default-team-id ::sm/uuid]]
|
||||
::sm/result schema:get-remove-from-org-summary-result
|
||||
::db/transaction true}
|
||||
::sm/result schema:get-remove-from-organization-summary-result
|
||||
::db/transaction true
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [profile-id organization-id default-team-id]}]
|
||||
(let [{:keys [valid-teams-to-delete-ids
|
||||
valid-teams-to-transfer
|
||||
@ -673,11 +673,11 @@ LEFT JOIN profile AS p
|
||||
(when-not valid-default-team
|
||||
(ex/raise :type :validation
|
||||
:code :not-valid-teams))
|
||||
(cnit/get-leave-org-summary cfg
|
||||
default-team-id
|
||||
valid-teams-to-delete-ids
|
||||
(count valid-teams-to-transfer)
|
||||
(count valid-teams-to-exit))))
|
||||
(cnit/get-leave-organization-summary cfg
|
||||
default-team-id
|
||||
valid-teams-to-delete-ids
|
||||
(count valid-teams-to-transfer)
|
||||
(count valid-teams-to-exit))))
|
||||
|
||||
;; API: send-renewal-email
|
||||
|
||||
@ -711,8 +711,8 @@ LEFT JOIN profile AS p
|
||||
:organizations organizations}))))
|
||||
nil)
|
||||
|
||||
;; API: exists-org-team-invitations-for-non-members /
|
||||
;; delete-org-team-invitations-for-non-members
|
||||
;; API: exists-organization-team-invitations-for-non-members /
|
||||
;; delete-organization-team-invitations-for-non-members
|
||||
|
||||
(def ^:private sql:get-profile-emails-by-ids
|
||||
"SELECT email
|
||||
@ -720,7 +720,7 @@ LEFT JOIN profile AS p
|
||||
WHERE id = ANY(?)
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(def ^:private sql:exists-non-member-org-team-invitations
|
||||
(def ^:private sql:exists-non-member-organization-team-invitations
|
||||
"SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM team_invitation
|
||||
@ -728,22 +728,22 @@ LEFT JOIN profile AS p
|
||||
AND email_to <> ALL(?)
|
||||
) AS non_member")
|
||||
|
||||
(def ^:private sql:delete-non-member-org-team-invitations
|
||||
(def ^:private sql:delete-non-member-organization-team-invitations
|
||||
"DELETE FROM team_invitation
|
||||
WHERE team_id = ANY(?)
|
||||
AND email_to <> ALL(?)
|
||||
RETURNING email_to")
|
||||
|
||||
(def ^:private schema:org-team-invitations-for-non-members-params
|
||||
(def ^:private schema:organization-team-invitations-for-non-members-params
|
||||
[:map
|
||||
[:team-ids [:vector ::sm/uuid]]
|
||||
[:member-ids [:vector ::sm/uuid]]])
|
||||
|
||||
(def ^:private schema:exists-org-team-invitations-for-non-members-result
|
||||
(def ^:private schema:exists-organization-team-invitations-for-non-members-result
|
||||
[:map [:exists ::sm/boolean]])
|
||||
|
||||
(defn- org-team-invitations-for-non-members-arrays
|
||||
"Member emails and PG arrays used by exists/delete org team invitation endpoints."
|
||||
(defn- organization-team-invitations-for-non-members-arrays
|
||||
"Member emails and PG arrays used by exists/delete organization team invitation endpoints."
|
||||
[conn {:keys [team-ids member-ids]}]
|
||||
(let [member-ids-array (db/create-array conn "uuid" member-ids)
|
||||
member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
|
||||
@ -752,34 +752,36 @@ LEFT JOIN profile AS p
|
||||
{:emails-array (db/create-array conn "text" (vec member-emails))
|
||||
:teams-array (db/create-array conn "uuid" team-ids)}))
|
||||
|
||||
(defn- non-member-org-team-invitations-exist?
|
||||
(defn- non-member-organization-team-invitations-exist?
|
||||
[conn params]
|
||||
(let [{:keys [emails-array teams-array]}
|
||||
(org-team-invitations-for-non-members-arrays conn params)]
|
||||
(-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations
|
||||
(organization-team-invitations-for-non-members-arrays conn params)]
|
||||
(-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations
|
||||
teams-array
|
||||
emails-array])
|
||||
:non-member)))
|
||||
|
||||
(sv/defmethod ::exists-org-team-invitations-for-non-members
|
||||
(sv/defmethod ::exists-organization-team-invitations-for-non-members
|
||||
"Return if there are any team invitations for emails that are not organization members."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:org-team-invitations-for-non-members-params
|
||||
::sm/result schema:exists-org-team-invitations-for-non-members-result}
|
||||
::sm/params schema:organization-team-invitations-for-non-members-params
|
||||
::sm/result schema:exists-organization-team-invitations-for-non-members-result
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
{:exists (boolean (non-member-org-team-invitations-exist? conn params))})))
|
||||
{:exists (boolean (non-member-organization-team-invitations-exist? conn params))})))
|
||||
|
||||
(sv/defmethod ::delete-org-team-invitations-for-non-members
|
||||
(sv/defmethod ::delete-organization-team-invitations-for-non-members
|
||||
"Delete team invitations for emails that are not organization members."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:org-team-invitations-for-non-members-params
|
||||
::db/transaction true}
|
||||
::sm/params schema:organization-team-invitations-for-non-members-params
|
||||
::db/transaction true
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [{:keys [emails-array teams-array]}
|
||||
(org-team-invitations-for-non-members-arrays conn params)]
|
||||
(db/exec! conn [sql:delete-non-member-org-team-invitations
|
||||
(organization-team-invitations-for-non-members-arrays conn params)]
|
||||
(db/exec! conn [sql:delete-non-member-organization-team-invitations
|
||||
teams-array
|
||||
emails-array])
|
||||
nil))))
|
||||
@ -790,53 +792,235 @@ LEFT JOIN profile AS p
|
||||
[:map {:title "NitrateAuditEvent"}
|
||||
[:name [:and [:string {:max 250}]
|
||||
[:re #"[\d\w-]{1,50}"]]]
|
||||
[:type {:optional true} ::sm/text]
|
||||
[:profile-id ::sm/uuid]
|
||||
[:props {:optional true} [:map-of :keyword :any]]])
|
||||
[:props {:optional true} [:map-of :keyword :any]]
|
||||
[:context {:optional true} [:map-of :keyword :any]]])
|
||||
|
||||
(def ^:private schema:push-audit-events-params
|
||||
[:map {:title "PushAuditEventsParams"}
|
||||
[:events [:vector schema:nitrate-audit-event]]])
|
||||
|
||||
(defn- submit-nitrate-audit-event
|
||||
[cfg {:keys [name profile-id props]}]
|
||||
(let [now (ct/now)]
|
||||
(audit/submit* cfg {:type "action"
|
||||
:name name
|
||||
:profile-id profile-id
|
||||
:props (or props {})
|
||||
:context {}
|
||||
:tracked-at now
|
||||
:created-at now
|
||||
:source "nitrate"
|
||||
:ip-addr "0.0.0.0"})))
|
||||
|
||||
(sv/defmethod ::push-audit-events
|
||||
"Push audit events from Nitrate to Penpot audit log"
|
||||
"Push audit events from nitrate (strictly for nitrate backend
|
||||
events)"
|
||||
|
||||
{::doc/added "2.19"
|
||||
::audit/skip true
|
||||
::sm/params schema:push-audit-events-params
|
||||
::rpc/auth false}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [events]}]
|
||||
(let [telemetry? (contains? cf/flags :telemetry)
|
||||
audit-log? (contains? cf/flags :audit-log)
|
||||
enabled? (and (not (db/read-only? pool))
|
||||
(or audit-log? telemetry?))]
|
||||
(when (and enabled? (seq events))
|
||||
(run! (partial submit-nitrate-audit-event cfg) events))
|
||||
[cfg {:keys [::rpc/request-at events] :as params}]
|
||||
(let [request (-> params meta ::http/request)
|
||||
context' (-> (audit/prepare-context-from-request request)
|
||||
(assoc :request-id (::rpc/request-id params)))
|
||||
|
||||
ip-addr (::rpc/ip-addr params)]
|
||||
|
||||
(run! (fn [{:keys [type name profile-id props context] :as event}]
|
||||
(let [context (-> (merge context (d/without-nils context'))
|
||||
(d/without-nils))]
|
||||
(audit/submit cfg {:type (d/nilv type "action")
|
||||
:name name
|
||||
:profile-id profile-id
|
||||
:props (or props {})
|
||||
:context context
|
||||
:tracked-at request-at
|
||||
:ip-addr ip-addr})))
|
||||
events)
|
||||
|
||||
nil))
|
||||
|
||||
|
||||
;; ---- API: notify-org-sso-change
|
||||
;; ---- API: get-teams-detail
|
||||
|
||||
(sv/defmethod ::notify-org-sso-change
|
||||
(def ^:private sql:get-teams-detail
|
||||
"SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.photo_id,
|
||||
t.created_at,
|
||||
(SELECT MAX(activity.modified_at)
|
||||
FROM (
|
||||
SELECT p2.modified_at
|
||||
FROM project AS p2
|
||||
WHERE p2.team_id = t.id
|
||||
AND p2.deleted_at IS NULL
|
||||
AND p2.is_default IS FALSE
|
||||
UNION ALL
|
||||
SELECT f.modified_at
|
||||
FROM file AS f
|
||||
JOIN project AS p ON p.id = f.project_id
|
||||
WHERE p.team_id = t.id
|
||||
AND p.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT tpr2.created_at
|
||||
FROM team_profile_rel AS tpr2
|
||||
WHERE tpr2.team_id = t.id
|
||||
AND tpr2.is_owner IS NOT TRUE
|
||||
UNION ALL
|
||||
SELECT ti.updated_at
|
||||
FROM team_invitation AS ti
|
||||
WHERE ti.team_id = t.id
|
||||
) AS activity) AS last_activity_at,
|
||||
owner_tpr.profile_id AS owner_profile_id,
|
||||
owner_p.fullname AS owner_name,
|
||||
owner_p.photo_id AS owner_photo_id,
|
||||
(SELECT COUNT(*)
|
||||
FROM project AS p3
|
||||
WHERE p3.team_id = t.id
|
||||
AND p3.deleted_at IS NULL
|
||||
AND p3.is_default IS FALSE) AS num_projects,
|
||||
(SELECT COUNT(*)
|
||||
FROM file AS f
|
||||
JOIN project AS p4 ON p4.id = f.project_id
|
||||
WHERE p4.team_id = t.id
|
||||
AND f.deleted_at IS NULL
|
||||
AND p4.deleted_at IS NULL) AS num_files,
|
||||
(SELECT COUNT(*)
|
||||
FROM team_profile_rel AS tpr
|
||||
WHERE tpr.team_id = t.id) AS num_members
|
||||
FROM team AS t
|
||||
LEFT JOIN team_profile_rel AS owner_tpr
|
||||
ON owner_tpr.team_id = t.id AND owner_tpr.is_owner IS TRUE
|
||||
LEFT JOIN profile AS owner_p
|
||||
ON owner_p.id = owner_tpr.profile_id
|
||||
WHERE t.id = ANY(?)
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.is_default IS FALSE
|
||||
ORDER BY last_activity_at DESC NULLS LAST")
|
||||
|
||||
(def ^:private schema:get-teams-detail-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(def ^:private schema:get-teams-detail-result
|
||||
[:vector
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:photo-url {:optional true} ::sm/uri]
|
||||
[:created-at ::sm/inst]
|
||||
[:last-activity-at {:optional true} [:maybe ::sm/inst]]
|
||||
[:owner-profile-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:owner-name {:optional true} [:maybe ::sm/text]]
|
||||
[:owner-photo-url {:optional true} ::sm/uri]
|
||||
[:num-projects ::sm/int]
|
||||
[:num-files ::sm/int]
|
||||
[:num-members ::sm/int]]])
|
||||
|
||||
(sv/defmethod ::get-teams-detail
|
||||
"Get detailed information for all non-deleted teams in an organization,
|
||||
including owner info and project/file/member counts."
|
||||
{::doc/added "2.20"
|
||||
::sm/params schema:get-teams-detail-params
|
||||
::sm/result schema:get-teams-detail-result
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
||||
team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams organization-summary))]
|
||||
(if (empty? team-ids)
|
||||
[]
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||
(->> (db/exec! conn [sql:get-teams-detail ids-array])
|
||||
(mapv (fn [{:keys [photo-id owner-photo-id] :as row}]
|
||||
(cond-> (dissoc row :photo-id :owner-photo-id)
|
||||
photo-id (assoc :photo-url (files/resolve-public-uri photo-id))
|
||||
owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id))))))))))))
|
||||
|
||||
;; ---- API: check-organization-sso
|
||||
|
||||
(def ^:private schema:check-organization-sso-result
|
||||
[:map
|
||||
[:valid ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::check-organization-sso
|
||||
"Validate an organization SSO configuration by generating a login redirect URL.
|
||||
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
||||
discovery before saving the settings."
|
||||
{::doc/added "2.20"
|
||||
::sm/params schema:nitrate-sso
|
||||
::sm/result schema:check-organization-sso-result
|
||||
::rpc/auth false}
|
||||
[cfg params]
|
||||
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
|
||||
|
||||
;; ---- API: notify-organization-sso-change
|
||||
(sv/defmethod ::notify-organization-sso-change
|
||||
"Nitrate notifies that an organization sso values have changed"
|
||||
{::doc/added "2.19"
|
||||
::sm/params [:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:updated-props ::sm/boolean]]
|
||||
[:updated-props ::sm/boolean]
|
||||
[:announce-activation ::sm/boolean]]
|
||||
::rpc/auth false}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}]
|
||||
(when updated-props
|
||||
(rpc/invalidate-org-sso-cache-by-org! organization-id)
|
||||
(session/clear-org-sso-sessions! pool organization-id))
|
||||
(rpc/invalidate-organization-sso-cache-by-organization! organization-id)
|
||||
(session/clear-organization-sso-sessions! pool organization-id))
|
||||
(notifications/notify-organization-change-sso cfg organization-id)
|
||||
(when announce-activation
|
||||
(neh/send-organization-setup-sso-emails! cfg organization-id))
|
||||
nil)
|
||||
|
||||
;; ---- API: bulk-create-profiles
|
||||
|
||||
(def ^:private schema:bulk-create-profiles-params
|
||||
[:map
|
||||
[:password [::sm/word-string {:max 500}]]
|
||||
[:emails [:vector ::sm/email]]])
|
||||
|
||||
(def ^:private schema:bulk-create-profiles-result
|
||||
[:map
|
||||
[:created [:vector ::sm/email]]
|
||||
[:skipped [:vector ::sm/email]]])
|
||||
|
||||
(defn- create-active-profile!
|
||||
"Create a single already-active profile (email pre-verified, onboarding
|
||||
skipped) plus its default team. Returns nil; existence checks happen in the
|
||||
caller so duplicates are skipped instead of aborting the whole batch."
|
||||
[cfg email password]
|
||||
(let [fullname (-> (str/split email "@") first)]
|
||||
(->> {:email email
|
||||
:fullname fullname
|
||||
:password password
|
||||
:is-active true
|
||||
:props {:onboarding-viewed true}}
|
||||
(auth/create-profile cfg)
|
||||
(auth/create-profile-rels cfg))
|
||||
nil))
|
||||
|
||||
(sv/defmethod ::bulk-create-profiles
|
||||
"Create multiple already-active profiles that share a single password. The
|
||||
created users skip email verification and onboarding. Emails that already
|
||||
belong to an existing profile are skipped. Intended for the Nitrate admin
|
||||
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
|
||||
email allow-list. Requires the `nitrate-bulk-create-profiles` flag, disabled
|
||||
by default so it is only available on test environments."
|
||||
{::doc/added "2.19"
|
||||
::sm/params schema:bulk-create-profiles-params
|
||||
::sm/result schema:bulk-create-profiles-result
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [password emails]}]
|
||||
|
||||
(when-not (contains? cf/flags :nitrate-bulk-create-profiles)
|
||||
(ex/raise :type :restriction
|
||||
:code :nitrate-bulk-create-profiles-not-allowed
|
||||
:hint "Bulk profile creation is disabled by config."))
|
||||
|
||||
(let [derived (aauth/derive-password password)]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(reduce
|
||||
(fn [acc email]
|
||||
(let [email (eml/clean email)]
|
||||
(if (profile/get-profile-by-email conn email)
|
||||
(update acc :skipped conj email)
|
||||
(do
|
||||
(create-active-profile! cfg email derived)
|
||||
(update acc :created conj email)))))
|
||||
{:created [] :skipped []}
|
||||
emails)))))
|
||||
|
||||
104
backend/src/app/rpc/nitrate/emails_helper.clj
Normal file
104
backend/src/app/rpc/nitrate/emails_helper.clj
Normal 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)))))))
|
||||
60
backend/src/app/rpc/nitrate/organization_helper.clj
Normal file
60
backend/src/app/rpc/nitrate/organization_helper.clj
Normal 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]))
|
||||
@ -16,17 +16,17 @@
|
||||
;;TODO There is a bug on dashboard with teams notifications.
|
||||
;;For now we send it to uuid/zero instead of team-id
|
||||
:topic uuid/zero
|
||||
:message {:type :team-org-change
|
||||
:message {:type :team-organization-change
|
||||
:team team
|
||||
:notification notification})))
|
||||
|
||||
|
||||
(defn notify-user-org-change
|
||||
(defn notify-user-organization-change
|
||||
[cfg profile-id organization-id organization-name notification]
|
||||
(let [msgbus (::mbus/msgbus cfg)]
|
||||
(mbus/pub! msgbus
|
||||
:topic profile-id
|
||||
:message {:type :user-org-change
|
||||
:message {:type :user-organization-change
|
||||
:topic profile-id
|
||||
:organization-id organization-id
|
||||
:organization-name organization-name
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
(ns app.rpc.permissions
|
||||
"A permission checking helper factories."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]))
|
||||
[app.common.schema :as sm]
|
||||
[app.db :as db]
|
||||
[app.nitrate :as nitrate]))
|
||||
|
||||
(def schema:permissions
|
||||
[:map {:title "Permissions"}
|
||||
@ -89,3 +92,64 @@
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))))
|
||||
|
||||
;; --- Organization owner (Nitrate) viewer access
|
||||
;;
|
||||
;; Read-permission helpers that augment normal Penpot membership with
|
||||
;; Nitrate organization-owner viewer access. Edit/admin permission
|
||||
;; providers intentionally stay membership-only.
|
||||
|
||||
(def viewer-role-flags
|
||||
"Role flags granted to a non-member organization owner: read-only.
|
||||
Shared so callers that build full team/file rows shape permissions the
|
||||
same way the permission lookups do."
|
||||
{:is-owner false
|
||||
:is-admin false
|
||||
:can-edit false})
|
||||
|
||||
(def ^:private sql:get-team-id-for-project
|
||||
"SELECT team_id FROM project WHERE id = ?")
|
||||
|
||||
(def ^:private sql:get-team-id-for-file
|
||||
"SELECT p.team_id
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE f.id = ?")
|
||||
|
||||
(defn get-team-id-for-project
|
||||
[cfg project-id]
|
||||
(some-> (db/exec-one! cfg [sql:get-team-id-for-project project-id])
|
||||
(:team-id)))
|
||||
|
||||
(defn get-team-id-for-file
|
||||
[cfg file-id]
|
||||
(some-> (db/exec-one! cfg [sql:get-team-id-for-file file-id])
|
||||
(:team-id)))
|
||||
|
||||
(defn resolve-team-id
|
||||
[cfg {:keys [team-id project-id file-id]}]
|
||||
(cond
|
||||
(some? team-id) team-id
|
||||
(some? project-id) (get-team-id-for-project cfg project-id)
|
||||
(some? file-id) (get-team-id-for-file cfg file-id)))
|
||||
|
||||
(defn get-organization-owner-permissions
|
||||
"When `profile-id` is a non-member owner of the organization that owns
|
||||
the team/project/file referenced by `params`, returns read-only viewer
|
||||
permissions; otherwise nil."
|
||||
[cfg profile-id & {:as params}]
|
||||
(when-let [team-id (resolve-team-id cfg params)]
|
||||
(when (nitrate/organization-owner-of-team? cfg profile-id team-id)
|
||||
(assoc viewer-role-flags
|
||||
:can-read true
|
||||
:type :membership
|
||||
:is-logged (some? profile-id)))))
|
||||
|
||||
(defn get-file-read-permissions
|
||||
([cfg profile-id file-id]
|
||||
(or (bfc/get-file-permissions cfg profile-id file-id)
|
||||
(get-organization-owner-permissions cfg profile-id :file-id file-id)))
|
||||
|
||||
([cfg profile-id file-id share-id]
|
||||
(or (bfc/get-file-permissions cfg profile-id file-id share-id)
|
||||
(get-organization-owner-permissions cfg profile-id :file-id file-id))))
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.srepl.binfile
|
||||
(:require
|
||||
[app.binfile.v2 :as binfile.v2]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.srepl.helpers :as h]
|
||||
[app.system :as sys]
|
||||
@ -30,7 +31,8 @@
|
||||
|
||||
(when owner
|
||||
(db/insert! cfg :team-profile-rel
|
||||
{:team-id (:id team)
|
||||
{:id (uuid/next)
|
||||
:team-id (:id team)
|
||||
:profile-id (:id owner)
|
||||
:is-admin true
|
||||
:is-owner true
|
||||
|
||||
@ -63,6 +63,23 @@
|
||||
(t/is (= :auto (#'oidc/select-user-info-source :token)))
|
||||
(t/is (= :auto (#'oidc/select-user-info-source :userinfo)))))
|
||||
|
||||
(t/deftest token-endpoint-errors-detect-valid-client-credentials
|
||||
(let [response {:status 403
|
||||
:body "{\"error\":\"invalid_grant\",\"error_description\":\"Invalid authorization code\"}"}]
|
||||
(t/is (#'oidc/token-endpoint-valid-client-error? response))
|
||||
(t/is (not (#'oidc/token-endpoint-invalid-client-error? response)))))
|
||||
|
||||
(t/deftest token-endpoint-errors-detect-invalid-client-credentials
|
||||
(t/is (#'oidc/token-endpoint-invalid-client-error?
|
||||
{:status 401
|
||||
:body "{\"error\":\"access_denied\",\"error_description\":\"Unauthorized\"}"}))
|
||||
(t/is (#'oidc/token-endpoint-invalid-client-error?
|
||||
{:status 400
|
||||
:body "{\"error\":\"invalid_client\"}"}))
|
||||
(t/is (not (#'oidc/token-endpoint-valid-client-error?
|
||||
{:status 400
|
||||
:body "{\"error\":\"invalid_client\"}"}))))
|
||||
|
||||
(t/deftest int-in-range-checks-range-correctly
|
||||
(t/testing "values within range return true"
|
||||
(t/is (#'oidc/int-in-range? 200 200 300))
|
||||
|
||||
@ -6,10 +6,12 @@
|
||||
|
||||
(ns backend-tests.email-sending-test
|
||||
(:require
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as emails]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
@ -23,3 +25,67 @@
|
||||
(t/is (contains? result :to))
|
||||
#_(t/is (contains? result :reply-to))
|
||||
(t/is (map? (:body result)))))
|
||||
|
||||
(def ^:private sso-notice-snippet
|
||||
"has set up single sign-on (SSO) in Penpot")
|
||||
|
||||
(defn- email-text-body
|
||||
[result]
|
||||
(get-in result [:body "text/plain"]))
|
||||
|
||||
(defn- invite-email-params
|
||||
[organization]
|
||||
{:to "invitee@example.com"
|
||||
:public-uri (cf/get :public-uri)
|
||||
:invited-by "Owner User"
|
||||
:user-name "Invitee User"
|
||||
:token "test-token"
|
||||
:organization organization})
|
||||
|
||||
(t/deftest invite-to-organization-includes-sso-notice-when-active
|
||||
(let [result (emails/render emails/invite-to-organization
|
||||
(invite-email-params {:name "Acme Inc"
|
||||
:sso-active true}))]
|
||||
(t/is (str/includes? (email-text-body result) sso-notice-snippet))
|
||||
(t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))
|
||||
|
||||
(t/deftest invite-to-organization-omits-sso-notice-when-inactive
|
||||
(let [result (emails/render emails/invite-to-organization
|
||||
(invite-email-params {:name "Acme Inc"
|
||||
:sso-active false}))]
|
||||
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
|
||||
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
|
||||
|
||||
(t/deftest invite-to-team-includes-sso-notice-when-active
|
||||
(let [result (emails/render emails/invite-to-team
|
||||
{:to "invitee@example.com"
|
||||
:public-uri (cf/get :public-uri)
|
||||
:invited-by "Owner User"
|
||||
:team "Design Team"
|
||||
:token "test-token"
|
||||
:organization {:name "Acme Inc"
|
||||
:sso-active true}})]
|
||||
(t/is (str/includes? (email-text-body result) sso-notice-snippet))
|
||||
(t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))
|
||||
|
||||
(t/deftest invite-to-team-omits-sso-notice-when-inactive
|
||||
(let [result (emails/render emails/invite-to-team
|
||||
{:to "invitee@example.com"
|
||||
:public-uri (cf/get :public-uri)
|
||||
:invited-by "Owner User"
|
||||
:team "Design Team"
|
||||
:token "test-token"
|
||||
:organization {:name "Acme Inc"
|
||||
:sso-active false}})]
|
||||
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
|
||||
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
|
||||
|
||||
(t/deftest invite-to-team-omits-sso-notice-without-organization
|
||||
(let [result (emails/render emails/invite-to-team
|
||||
{:to "invitee@example.com"
|
||||
:public-uri (cf/get :public-uri)
|
||||
:invited-by "Owner User"
|
||||
:team "Design Team"
|
||||
:token "test-token"})]
|
||||
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
|
||||
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
|
||||
|
||||
@ -79,59 +79,66 @@
|
||||
:enable-auto-file-snapshot
|
||||
:disable-file-validation])
|
||||
|
||||
(defn state-init
|
||||
(defn init-config
|
||||
([next]
|
||||
(init-config nil next))
|
||||
([extra-flags next]
|
||||
(let [flags (into default-flags extra-flags)]
|
||||
(with-redefs [app.config/flags (flags/parse flags/default flags)
|
||||
app.config/config config
|
||||
app.loggers.audit/submit (constantly nil)
|
||||
app.auth/derive-password identity
|
||||
app.auth/verify-password (fn [a b] {:valid (= a b)})
|
||||
app.common.features/get-enabled-features
|
||||
(fn [& _] app.common.features/supported-features)]
|
||||
(cf/validate! :exit-on-error? false)
|
||||
(fs/create-dir "/tmp/penpot")
|
||||
(next)))))
|
||||
|
||||
(defn init-system
|
||||
[next]
|
||||
(with-redefs [app.config/flags (flags/parse flags/default default-flags)
|
||||
app.config/config config
|
||||
app.loggers.audit/submit (constantly nil)
|
||||
app.auth/derive-password identity
|
||||
app.auth/verify-password (fn [a b] {:valid (= a b)})
|
||||
app.common.features/get-enabled-features (fn [& _] app.common.features/supported-features)]
|
||||
(let [templates [{:id "test"
|
||||
:name "test"
|
||||
:file-uri "test"
|
||||
:thumbnail-uri "test"
|
||||
:path (-> "backend_tests/test_files/template.penpot" io/resource fs/path)}]
|
||||
system (-> (merge main/system-config main/worker-config)
|
||||
(assoc-in [:app.redis/client :app.redis/uri] (:redis-uri config))
|
||||
(assoc-in [::db/pool ::db/uri] (:database-uri config))
|
||||
(assoc-in [::db/pool ::db/username] (:database-username config))
|
||||
(assoc-in [::db/pool ::db/password] (:database-password config))
|
||||
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
|
||||
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
|
||||
(update :app.rpc/rlimit assoc
|
||||
:app.loggers.mattermost/reporter nil
|
||||
:app.loggers.database/reporter nil)
|
||||
(update :app.rpc/methods assoc
|
||||
:app.setup/templates templates
|
||||
:app.loggers.mattermost/reporter nil
|
||||
:app.loggers.database/reporter nil)
|
||||
(dissoc :app.srepl/server
|
||||
:app.http/server
|
||||
:app.http/route
|
||||
:app.setup/templates
|
||||
:app.http.oauth/handler
|
||||
:app.notifications/handler
|
||||
:app.loggers.mattermost/reporter
|
||||
:app.loggers.database/reporter
|
||||
:app.worker/cron
|
||||
:app.worker/dispatcher
|
||||
[:app.main/default :app.worker/runner]
|
||||
[:app.main/webhook :app.worker/runner]))
|
||||
_ (ig/load-namespaces system)
|
||||
system (-> (ig/expand system) (ig/init))]
|
||||
(try
|
||||
(binding [*system* system
|
||||
*pool* (:app.db/pool system)]
|
||||
(next))
|
||||
(finally
|
||||
(ig/halt! system)))))
|
||||
|
||||
(cf/validate! :exit-on-error? false)
|
||||
|
||||
(fs/create-dir "/tmp/penpot")
|
||||
|
||||
(let [templates [{:id "test"
|
||||
:name "test"
|
||||
:file-uri "test"
|
||||
:thumbnail-uri "test"
|
||||
:path (-> "backend_tests/test_files/template.penpot" io/resource fs/path)}]
|
||||
system (-> (merge main/system-config main/worker-config)
|
||||
(assoc-in [:app.redis/client :app.redis/uri] (:redis-uri config))
|
||||
(assoc-in [::db/pool ::db/uri] (:database-uri config))
|
||||
(assoc-in [::db/pool ::db/username] (:database-username config))
|
||||
(assoc-in [::db/pool ::db/password] (:database-password config))
|
||||
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
|
||||
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
|
||||
(update :app.rpc/rlimit assoc
|
||||
:app.loggers.mattermost/reporter nil
|
||||
:app.loggers.database/reporter nil)
|
||||
(update :app.rpc/methods assoc
|
||||
:app.setup/templates templates
|
||||
:app.loggers.mattermost/reporter nil
|
||||
:app.loggers.database/reporter nil)
|
||||
(dissoc :app.srepl/server
|
||||
:app.http/server
|
||||
:app.http/route
|
||||
:app.setup/templates
|
||||
:app.http.oauth/handler
|
||||
:app.notifications/handler
|
||||
:app.loggers.mattermost/reporter
|
||||
:app.loggers.database/reporter
|
||||
:app.worker/cron
|
||||
:app.worker/dispatcher
|
||||
[:app.main/default :app.worker/runner]
|
||||
[:app.main/webhook :app.worker/runner]))
|
||||
_ (ig/load-namespaces system)
|
||||
system (-> (ig/expand system)
|
||||
(ig/init))]
|
||||
(try
|
||||
(binding [*system* system
|
||||
*pool* (:app.db/pool system)]
|
||||
(next))
|
||||
(finally
|
||||
(ig/halt! system))))))
|
||||
(def state-init
|
||||
(t/compose-fixtures init-config init-system))
|
||||
|
||||
(defn database-reset
|
||||
[next]
|
||||
@ -386,29 +393,15 @@
|
||||
(assoc :app.rpc/request-at (ct/now)))))))
|
||||
|
||||
(defn management-command!
|
||||
([data]
|
||||
(management-command! data nil))
|
||||
([{:keys [::type] :as data} flags-to-add]
|
||||
(let [flags (reduce conj cf/flags (or flags-to-add []))
|
||||
|
||||
resolve-management-methods
|
||||
(requiring-resolve 'app.rpc/resolve-management-methods)
|
||||
|
||||
methods
|
||||
(with-redefs [cf/flags flags]
|
||||
(resolve-management-methods *system*))
|
||||
|
||||
[_ method-fn]
|
||||
(get methods type)]
|
||||
|
||||
(when-not method-fn
|
||||
(ex/raise :type :assertion
|
||||
:code :rpc-method-not-found
|
||||
:hint (str/ffmt "management rpc method '%' not found" (name type))))
|
||||
|
||||
(try-on! (method-fn (-> data
|
||||
(dissoc ::type)
|
||||
(assoc :app.rpc/request-at (ct/now))))))))
|
||||
[{:keys [::type] :as data}]
|
||||
(let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])]
|
||||
(when-not method-fn
|
||||
(ex/raise :type :assertion
|
||||
:code :rpc-method-not-found
|
||||
:hint (str/ffmt "management rpc method '%' not found" (name type))))
|
||||
(try-on! (method-fn (-> data
|
||||
(dissoc ::type)
|
||||
(assoc :app.rpc/request-at (ct/now)))))))
|
||||
|
||||
(defn run-task!
|
||||
([name]
|
||||
|
||||
@ -110,7 +110,8 @@
|
||||
(doseq [bucket ["file-media-object"
|
||||
"file-object-thumbnail"
|
||||
"team-font-variant"
|
||||
"file-data-fragment"]]
|
||||
"file-data-fragment"
|
||||
"organization"]]
|
||||
(t/testing (str "bucket: " bucket)
|
||||
(let [object (create-storage-object! storage bucket "public data")
|
||||
request {:path-params {:id (str (:id object))}}
|
||||
@ -120,6 +121,19 @@
|
||||
(t/is (not= 404 (::yres/status response))
|
||||
(str "bucket " bucket " object should exist")))))))
|
||||
|
||||
(t/deftest objects-handler-organization-logo-no-auth
|
||||
;; Organization logos are embedded in unauthenticated contexts, such as
|
||||
;; the invitation email image shown to a not-yet-registered invitee, so
|
||||
;; they must be servable without a session or access token.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
object (create-storage-object! storage "organization" "logo data")
|
||||
request {:path-params {:id (str (:id object))}}
|
||||
response (assets/objects-handler cfg request)]
|
||||
(t/is (not= 401 (::yres/status response)))
|
||||
(t/is (not= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest objects-handler-public-bucket-with-auth
|
||||
;; Objects in public buckets should also be accessible WITH authentication.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as http]
|
||||
[app.http.access-token :as actoken]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.storage :as sto]
|
||||
[backend-tests.helpers :as th]
|
||||
@ -205,3 +206,29 @@
|
||||
(t/is (not (contains? all-ids (:id first-mcp))))
|
||||
(t/is (not (contains? all-ids (:id second-mcp))))
|
||||
(t/is (contains? all-ids (:id third-mcp))))))))
|
||||
|
||||
(t/deftest mcp-tokens-cannot-be-used-as-access-tokens
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
cfg th/*system*]
|
||||
|
||||
(t/testing "MCP tokens use different issuer claim"
|
||||
(let [{:keys [result]} (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "mcp token"})
|
||||
mcp-token (:token result)
|
||||
|
||||
;; Try to decode as access token (should fail)
|
||||
decoded (actoken/decode-token cfg mcp-token)]
|
||||
(t/is (nil? decoded))))
|
||||
|
||||
(t/testing "Regular access tokens use access-token issuer claim"
|
||||
(let [{:keys [result]} (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "regular token"})
|
||||
access-token (:token result)
|
||||
|
||||
;; Should decode successfully
|
||||
decoded (actoken/decode-token cfg access-token)]
|
||||
(t/is (some? decoded))
|
||||
(t/is (= (:id prof) (:uid decoded)))))))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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)))))))
|
||||
@ -13,7 +13,9 @@
|
||||
[app.db :as db]
|
||||
[app.email.blacklist :as email.blacklist]
|
||||
[app.http :as http]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.storage :as sto]
|
||||
[app.tokens :as tokens]
|
||||
[backend-tests.helpers :as th]
|
||||
@ -103,6 +105,63 @@
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :member-is-muted (:code edata))))))))
|
||||
|
||||
(t/deftest create-and-update-team-invitations-include-organization-props
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
||||
(let [owner (th/create-profile* 101 {:is-active true})
|
||||
invitee (th/create-profile* 102 {:is-active true})
|
||||
organization-team (th/create-team* 101 {:profile-id (:id owner)})
|
||||
plain-team (th/create-team* 102 {:profile-id (:id owner)})
|
||||
organization-id (uuid/random)
|
||||
organization {:id organization-id
|
||||
:name "Acme"
|
||||
:slug "acme"
|
||||
:owner-id (:id owner)
|
||||
:avatar-bg-url "https://example.com/avatar.svg"
|
||||
:permissions {:new-team-members "anyone"}}
|
||||
nitrate-call
|
||||
(fn [_cfg method params]
|
||||
(case method
|
||||
:get-team-organization
|
||||
(if (= (:team-id params) (:id organization-team))
|
||||
{:organization organization :is-your-penpot false}
|
||||
{:organization nil :is-your-penpot false})
|
||||
|
||||
:get-organization-members
|
||||
[(:id invitee)]
|
||||
|
||||
nil))
|
||||
invite! (fn [team email]
|
||||
(th/command! {::th/type :create-team-invitations
|
||||
::rpc/profile-id (:id owner)
|
||||
:team-id (:id team)
|
||||
:role :editor
|
||||
:emails [email]}))]
|
||||
(with-redefs [cf/flags (conj cf/flags :nitrate :email-verification)
|
||||
nitrate/call nitrate-call]
|
||||
(t/is (th/success? (invite! organization-team (:email invitee))))
|
||||
(t/is (th/success? (invite! organization-team (:email invitee))))
|
||||
(t/is (th/success? (invite! plain-team "external@example.com"))))
|
||||
|
||||
(let [events (mapv second (:call-args-list @audit-mock))
|
||||
create-organization (first (filter #(and (= "create-team-invitation" (:name %))
|
||||
(= (:email invitee)
|
||||
(get-in % [:props :member-email])))
|
||||
events))
|
||||
update-organization (first (filter #(= "update-team-invitation" (:name %)) events))
|
||||
create-plain (first (filter #(and (= "create-team-invitation" (:name %))
|
||||
(= "external@example.com"
|
||||
(get-in % [:props :member-email])))
|
||||
events))]
|
||||
(doseq [event [create-organization update-organization]]
|
||||
(t/is (true? (get-in event [:props :team-belongs-to-organization])))
|
||||
(t/is (true? (get-in event [:props :adds-invitee-to-organization])))
|
||||
(t/is (true? (get-in event [:props :invitee-already-organization-member]))))
|
||||
|
||||
(t/is (false? (get-in create-plain [:props :team-belongs-to-organization])))
|
||||
(t/is (false? (get-in create-plain [:props :adds-invitee-to-organization])))
|
||||
(t/is (false? (get-in create-plain [:props :invitee-already-organization-member])))))))
|
||||
|
||||
(t/deftest create-team-invitations-blacklisted-domain
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
@ -378,6 +437,158 @@
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :invalid-token (:code edata)))))))))
|
||||
|
||||
(t/deftest accept-organization-invitation-audit-event
|
||||
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
||||
(let [inviter (th/create-profile* 201 {:is-active true})
|
||||
invitee (th/create-profile* 202 {:is-active true})
|
||||
team (th/create-team* 201 {:profile-id (:id inviter)})
|
||||
organization-id (uuid/random)
|
||||
default-team-id (uuid/random)
|
||||
direct-token (tokens/generate
|
||||
th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "1h")
|
||||
:profile-id (:id inviter)
|
||||
:role :editor
|
||||
:organization-id organization-id
|
||||
:member-email (:email invitee)
|
||||
:member-id (:id invitee)})
|
||||
team-token (tokens/generate
|
||||
th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "1h")
|
||||
:profile-id (:id inviter)
|
||||
:role :editor
|
||||
:team-id (:id team)
|
||||
:member-email (:email invitee)
|
||||
:member-id (:id invitee)})
|
||||
verify! (fn [token]
|
||||
(th/command! {::th/type :verify-token
|
||||
::rpc/profile-id (:id invitee)
|
||||
:token token}))
|
||||
organization-event
|
||||
(fn []
|
||||
(->> (:call-args-list @audit-mock)
|
||||
(map second)
|
||||
(filter #(= "accept-organization-invitation" (:name %)))
|
||||
first))
|
||||
frontend-event (atom nil)]
|
||||
|
||||
(db/insert! (:app.db/pool th/*system*)
|
||||
:team-invitation
|
||||
{:org-id organization-id
|
||||
:email-to (:email invitee)
|
||||
:created-by (:id inviter)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "48h")})
|
||||
|
||||
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||
nitrate/call
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
:get-organization-membership {:organization-id organization-id
|
||||
:is-member false}
|
||||
:get-organization-members [(:id inviter) (uuid/random) (uuid/random)]
|
||||
nil))
|
||||
teams/initialize-user-in-nitrate-organization
|
||||
(fn [& _] default-team-id)]
|
||||
(let [out (verify! direct-token)]
|
||||
(t/is (th/success? out))
|
||||
(reset! frontend-event
|
||||
(get-in out [:result :organization-invitation-audit]))))
|
||||
|
||||
(let [event (organization-event)]
|
||||
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||
(t/is (= :editor (get-in event [:props :role])))
|
||||
(t/is (uuid? (get-in event [:props :invitation-id])))
|
||||
(t/is (= "organization-invitation-acceptance"
|
||||
(:origin @frontend-event)))
|
||||
(t/is (= organization-id
|
||||
(get-in @frontend-event [:props :organization-id])))
|
||||
(t/is (= "direct-organization-invitation"
|
||||
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||
(t/is (= 3
|
||||
(get-in @frontend-event [:props :organization-member-count-before])))
|
||||
(t/is (not-any? #(contains? #{"accept-team-invitation"
|
||||
"accept-team-invitation-from"}
|
||||
(:name (second %)))
|
||||
(:call-args-list @audit-mock))))
|
||||
|
||||
(th/reset-mock! audit-mock)
|
||||
(db/insert! (:app.db/pool th/*system*)
|
||||
:team-invitation
|
||||
{:team-id (:id team)
|
||||
:email-to (:email invitee)
|
||||
:created-by (:id inviter)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "48h")})
|
||||
|
||||
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||
nitrate/call
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
:get-organization-membership-by-team {:organization-id organization-id
|
||||
:is-member false}
|
||||
:get-organization-members (into [(:id inviter)]
|
||||
(repeatedly 4 uuid/random))
|
||||
nil))
|
||||
teams/add-profile-to-team! (fn [& _] nil)]
|
||||
(let [out (verify! team-token)]
|
||||
(t/is (th/success? out))
|
||||
(reset! frontend-event
|
||||
(get-in out [:result :organization-invitation-audit]))))
|
||||
|
||||
(let [events (mapv second (:call-args-list @audit-mock))
|
||||
event (organization-event)]
|
||||
(t/is (some #(= "accept-team-invitation" (:name %)) events))
|
||||
(t/is (some #(= "accept-team-invitation-from" (:name %)) events))
|
||||
(t/is (= (:id team) (get-in event [:props :team-id])))
|
||||
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||
(t/is (= "team-invitation-acceptance"
|
||||
(:origin @frontend-event)))
|
||||
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
|
||||
(t/is (= organization-id
|
||||
(get-in @frontend-event [:props :organization-id])))
|
||||
(t/is (= "team-invitation"
|
||||
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||
(t/is (= 5
|
||||
(get-in @frontend-event [:props :organization-member-count-before]))))
|
||||
|
||||
(th/reset-mock! audit-mock)
|
||||
(db/insert! (:app.db/pool th/*system*)
|
||||
:team-invitation
|
||||
{:team-id (:id team)
|
||||
:email-to (:email invitee)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "48h")})
|
||||
|
||||
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||
nitrate/call
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
:get-organization-membership-by-team {:organization-id organization-id
|
||||
:is-member true}
|
||||
:get-organization-members (throw (ex-info "unexpected member count" {}))
|
||||
nil))
|
||||
teams/add-profile-to-team! (fn [& _] nil)]
|
||||
(let [out (verify! team-token)]
|
||||
(t/is (th/success? out))
|
||||
(reset! frontend-event
|
||||
(get-in out [:result :organization-invitation-audit]))))
|
||||
|
||||
(let [events (mapv second (:call-args-list @audit-mock))]
|
||||
(t/is (some #(= "accept-team-invitation" (:name %)) events))
|
||||
(t/is (not-any? #(= "accept-organization-invitation" (:name %)) events))
|
||||
(t/is (nil? @frontend-event))))))
|
||||
|
||||
(t/deftest create-team-invitations-with-email-verification-disabled
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
@ -845,4 +1056,3 @@
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
{:deps
|
||||
{org.clojure/clojure {:mvn/version "1.12.5"}
|
||||
org.clojure/data.json {:mvn/version "2.5.2"}
|
||||
org.clojure/tools.cli {:mvn/version "1.1.230"}
|
||||
org.clojure/test.check {:mvn/version "1.1.1"}
|
||||
org.clojure/tools.cli {:mvn/version "1.4.256"}
|
||||
org.clojure/test.check {:mvn/version "1.1.3"}
|
||||
org.clojure/data.fressian {:mvn/version "1.1.1"}
|
||||
org.clojure/clojurescript {:mvn/version "1.12.42"}
|
||||
org.clojure/clojurescript {:mvn/version "1.12.145"}
|
||||
|
||||
org.apache.commons/commons-pool2 {:mvn/version "2.13.1"}
|
||||
|
||||
;; Logging
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"}
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.1"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.26.1"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.26.1"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.1"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.1"}
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.18"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.42"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.43"}
|
||||
|
||||
selmer/selmer {:mvn/version "1.13.4"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
@ -23,22 +23,20 @@
|
||||
metosin/jsonista {:mvn/version "1.0.0"
|
||||
:exclusions [com.fasterxml.jackson.core/jackson-core
|
||||
com.fasterxml.jackson.core/jackson-databind]}
|
||||
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.0"}
|
||||
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.0"}
|
||||
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.1"}
|
||||
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.1"}
|
||||
|
||||
|
||||
metosin/malli {:mvn/version "0.19.1"}
|
||||
metosin/malli {:mvn/version "0.20.1"}
|
||||
|
||||
expound/expound {:mvn/version "0.9.0"}
|
||||
com.cognitect/transit-clj {:mvn/version "1.0.333"}
|
||||
com.cognitect/transit-clj {:mvn/version "1.1.363"}
|
||||
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
|
||||
integrant/integrant {:mvn/version "1.0.1"}
|
||||
|
||||
funcool/cuerdas {:mvn/version "2026.415"}
|
||||
funcool/promesa
|
||||
{:git/sha "46048fc0d4bf5466a2a4121f5d52aefa6337f2e8"
|
||||
:git/url "https://github.com/funcool/promesa"}
|
||||
funcool/promesa {:mvn/version "12.0.1"}
|
||||
|
||||
funcool/datoteka
|
||||
{:git/tag "4.0.0"
|
||||
@ -53,7 +51,7 @@
|
||||
com.sun.mail/jakarta.mail {:mvn/version "2.0.2"}
|
||||
org.la4j/la4j {:mvn/version "0.6.0"}
|
||||
|
||||
me.flowthing/pp {:mvn/version "2024-11-13.77"}
|
||||
me.flowthing/pp {:mvn/version "2026-03-01.107"}
|
||||
|
||||
io.aviso/pretty {:mvn/version "1.4.4"}
|
||||
environ/environ {:mvn/version "1.2.0"}}
|
||||
@ -62,7 +60,7 @@
|
||||
{:dev
|
||||
{:extra-deps
|
||||
{org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
||||
thheller/shadow-cljs {:mvn/version "3.2.0"}
|
||||
thheller/shadow-cljs {:mvn/version "3.4.11"}
|
||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
|
||||
com.bhauman/rebel-readline {:mvn/version "0.1.11"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
|
||||
@ -4,24 +4,25 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC Sucursal en España SL",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
|
||||
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.3",
|
||||
"concurrently": "^10.0.4",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "3.9.4",
|
||||
"prettier": "3.9.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ws": "^8.21.0"
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint:clj": "clj-kondo --parallel=true --lint src/",
|
||||
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint src/",
|
||||
"lint:js": "exit 0",
|
||||
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
|
||||
"check-fmt:js": "prettier -c src/**/*.js",
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||
|
||||
38
common/pnpm-lock.yaml
generated
38
common/pnpm-lock.yaml
generated
@ -13,20 +13,20 @@ importers:
|
||||
version: 4.4.0
|
||||
devDependencies:
|
||||
concurrently:
|
||||
specifier: ^10.0.3
|
||||
version: 10.0.3
|
||||
specifier: ^10.0.4
|
||||
version: 10.0.4
|
||||
nodemon:
|
||||
specifier: ^3.1.14
|
||||
version: 3.1.14
|
||||
prettier:
|
||||
specifier: 3.9.4
|
||||
version: 3.9.4
|
||||
specifier: 3.9.6
|
||||
version: 3.9.6
|
||||
source-map-support:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
ws:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
specifier: ^8.21.1
|
||||
version: 8.21.1
|
||||
|
||||
packages:
|
||||
|
||||
@ -73,8 +73,8 @@ packages:
|
||||
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
concurrently@10.0.3:
|
||||
resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==}
|
||||
concurrently@10.0.4:
|
||||
resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
|
||||
@ -161,8 +161,8 @@ packages:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
prettier@3.9.4:
|
||||
resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==}
|
||||
prettier@3.9.6:
|
||||
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
@ -181,8 +181,8 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
shell-quote@1.8.4:
|
||||
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
|
||||
shell-quote@1.9.0:
|
||||
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
@ -234,8 +234,8 @@ packages:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
ws@8.21.1:
|
||||
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@ -303,11 +303,11 @@ snapshots:
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
concurrently@10.0.3:
|
||||
concurrently@10.0.4:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
rxjs: 7.8.2
|
||||
shell-quote: 1.8.4
|
||||
shell-quote: 1.9.0
|
||||
supports-color: 10.2.2
|
||||
tree-kill: 1.2.2
|
||||
yargs: 18.0.0
|
||||
@ -378,7 +378,7 @@ snapshots:
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
prettier@3.9.4: {}
|
||||
prettier@3.9.6: {}
|
||||
|
||||
pstree.remy@1.1.8: {}
|
||||
|
||||
@ -392,7 +392,7 @@ snapshots:
|
||||
|
||||
semver@7.8.4: {}
|
||||
|
||||
shell-quote@1.8.4: {}
|
||||
shell-quote@1.9.0: {}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
@ -439,7 +439,7 @@ snapshots:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
ws@8.21.0: {}
|
||||
ws@8.21.1: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
|
||||
@ -244,6 +244,7 @@
|
||||
[:page-id {:optional true} ::sm/uuid]
|
||||
[:component-id {:optional true} ::sm/uuid]
|
||||
[:ignore-touched {:optional true} :boolean]
|
||||
[:allow-altering-copies {:optional true} :boolean]
|
||||
[:parent-id ::sm/uuid]
|
||||
[:shapes ::sm/any]]]
|
||||
|
||||
@ -633,22 +634,26 @@
|
||||
(d/update-in-when data [:components component-id :objects] process-operations change)))
|
||||
|
||||
(defn- process-children-reordering
|
||||
[objects {:keys [parent-id shapes] :as change}]
|
||||
[objects {:keys [parent-id shapes allow-altering-copies] :as change}]
|
||||
(if-let [old-shapes (dm/get-in objects [parent-id :shapes])]
|
||||
(let [id->idx
|
||||
(update-vals
|
||||
(->> (d/enumerate shapes)
|
||||
(group-by second))
|
||||
(comp first first))
|
||||
;; Component sync owns copy child ordering.
|
||||
(if (and (not allow-altering-copies)
|
||||
(ctk/in-component-copy? (get objects parent-id)))
|
||||
objects
|
||||
(let [id->idx
|
||||
(update-vals
|
||||
(->> (d/enumerate shapes)
|
||||
(group-by second))
|
||||
(comp first first))
|
||||
|
||||
new-shapes
|
||||
(vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))]
|
||||
new-shapes
|
||||
(vec (sort-by #(d/nilv (id->idx %) -1) < old-shapes))]
|
||||
|
||||
(if (not= old-shapes new-shapes)
|
||||
(do
|
||||
(some-> *touched-changes* (vswap! conj change))
|
||||
(update objects parent-id assoc :shapes new-shapes))
|
||||
objects))
|
||||
(if (not= old-shapes new-shapes)
|
||||
(do
|
||||
(some-> *touched-changes* (vswap! conj change))
|
||||
(update objects parent-id assoc :shapes new-shapes))
|
||||
objects)))
|
||||
|
||||
objects))
|
||||
|
||||
|
||||
@ -198,6 +198,20 @@
|
||||
::applied-changes-count (count redo-changes)))
|
||||
changes))
|
||||
|
||||
(defn- without-changes-local
|
||||
"Append changes through `f` without applying them to the mounted page's
|
||||
working state."
|
||||
[changes f]
|
||||
(if (contains? (meta changes) ::file-data)
|
||||
(let [changes (-> changes (apply-changes-local) (f))]
|
||||
(vary-meta changes assoc ::applied-changes-count (count (:redo-changes changes))))
|
||||
(f changes)))
|
||||
|
||||
(defn concat-changes-without-local
|
||||
"Append `other` without applying it to the mounted page's working state."
|
||||
[changes other]
|
||||
(without-changes-local changes #(concat-changes % other)))
|
||||
|
||||
;; Page changes
|
||||
|
||||
(defn add-empty-page
|
||||
@ -603,68 +617,72 @@
|
||||
(-> (reduce update-shape changes ids)
|
||||
(apply-changes-local)))))
|
||||
|
||||
(defn- add-remove-objects-changes
|
||||
[changes page-id objects ids {:keys [ignore-touched allow-altering-copies]
|
||||
:or {ignore-touched false
|
||||
allow-altering-copies false}}]
|
||||
(let [add-redo-change
|
||||
(fn [change-set id]
|
||||
(conj change-set
|
||||
(cond-> {:type :del-obj
|
||||
:page-id page-id
|
||||
:id id}
|
||||
ignore-touched
|
||||
(assoc :ignore-touched true))))
|
||||
|
||||
add-undo-change-shape
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)]
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj {:type :add-obj
|
||||
:id id
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:frame-id (:frame-id shape)
|
||||
:index (cfh/get-position-on-parent objects id)
|
||||
:obj (cond-> shape
|
||||
(contains? shape :shapes)
|
||||
(assoc :shapes []))}))))
|
||||
|
||||
add-undo-change-parent
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)
|
||||
prev-sibling (cfh/get-prev-sibling objects (:id shape))]
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj (cond-> {:type :mov-objects
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:shapes [id]
|
||||
:after-shape prev-sibling
|
||||
:index 0
|
||||
:ignore-touched true}
|
||||
allow-altering-copies
|
||||
(assoc :allow-altering-copies true))))))]
|
||||
|
||||
(-> changes
|
||||
(update :redo-changes #(reduce add-redo-change % ids))
|
||||
(update :undo-changes #(as-> % $
|
||||
(reduce add-undo-change-parent $ ids)
|
||||
(reduce add-undo-change-shape $ ids))))))
|
||||
|
||||
(defn remove-objects
|
||||
([changes ids] (remove-objects changes ids nil))
|
||||
([changes ids {:keys [ignore-touched] :or {ignore-touched false}}]
|
||||
([changes ids options]
|
||||
(assert-page-id! changes)
|
||||
(assert-objects! changes)
|
||||
(let [page-id (::page-id (meta changes))
|
||||
objects (lookup-objects changes)
|
||||
|
||||
add-redo-change
|
||||
(fn [change-set id]
|
||||
(conj change-set
|
||||
(cond-> {:type :del-obj
|
||||
:page-id page-id
|
||||
:id id}
|
||||
ignore-touched
|
||||
(assoc :ignore-touched true))))
|
||||
|
||||
add-undo-change-shape
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)]
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj {:type :add-obj
|
||||
:id id
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:frame-id (:frame-id shape)
|
||||
:index (cfh/get-position-on-parent objects id)
|
||||
:obj (cond-> shape
|
||||
(contains? shape :shapes)
|
||||
(assoc :shapes []))}))))
|
||||
|
||||
add-undo-change-parent
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)
|
||||
prev-sibling (cfh/get-prev-sibling objects (:id shape))]
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj {:type :mov-objects
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:shapes [id]
|
||||
:after-shape prev-sibling
|
||||
:index 0
|
||||
:ignore-touched true}))))]
|
||||
|
||||
(-> changes
|
||||
(update :redo-changes #(reduce add-redo-change % ids))
|
||||
(update :undo-changes #(as-> % $
|
||||
(reduce add-undo-change-parent $ ids)
|
||||
(reduce add-undo-change-shape $ ids)))
|
||||
(apply-changes-local)))))
|
||||
(-> changes
|
||||
(add-remove-objects-changes (::page-id (meta changes))
|
||||
(lookup-objects changes)
|
||||
ids
|
||||
options)
|
||||
(apply-changes-local))))
|
||||
|
||||
;; FIXME: PERFORMANCE
|
||||
(defn resize-parents
|
||||
[changes ids]
|
||||
(assert-page-id! changes)
|
||||
(assert-objects! changes)
|
||||
(let [page-id (::page-id (meta changes))
|
||||
|
||||
objects (lookup-objects changes)
|
||||
xform (comp
|
||||
(defn- add-resize-parents-changes
|
||||
[changes page-id objects ids]
|
||||
(let [xform (comp
|
||||
(mapcat #(cons % (cfh/get-parent-ids objects %)))
|
||||
(map (d/getf objects))
|
||||
(filter #(contains? #{:group :bool} (:type %)))
|
||||
@ -698,9 +716,8 @@
|
||||
(update :uops conj {:type :set :attr attr :val old-val :ignore-touched true})))))
|
||||
|
||||
resize-parent
|
||||
(fn [changes parent]
|
||||
(let [objects (lookup-objects changes)
|
||||
children (->> parent :shapes (map (d/getf objects)))
|
||||
(fn [[changes objects] parent]
|
||||
(let [children (->> parent :shapes (map (d/getf objects)))
|
||||
resized-parent (cond
|
||||
(empty? children) ;; a parent with no children will be deleted,
|
||||
nil ;; so it does not need resize
|
||||
@ -727,14 +744,24 @@
|
||||
:id (:id parent)}]
|
||||
|
||||
(if (seq rops)
|
||||
(-> changes
|
||||
(update :redo-changes conj (assoc change :operations rops))
|
||||
(update :undo-changes conj (assoc change :operations uops))
|
||||
(apply-changes-local))
|
||||
changes))
|
||||
changes)))]
|
||||
[(-> changes
|
||||
(update :redo-changes conj (assoc change :operations rops))
|
||||
(update :undo-changes conj (assoc change :operations uops)))
|
||||
(assoc objects (:id parent) resized-parent)]
|
||||
[changes objects]))
|
||||
[changes objects])))]
|
||||
|
||||
(reduce resize-parent changes all-parents)))
|
||||
(first (reduce resize-parent [changes objects] all-parents))))
|
||||
|
||||
(defn resize-parents
|
||||
[changes ids]
|
||||
(assert-page-id! changes)
|
||||
(assert-objects! changes)
|
||||
(-> changes
|
||||
(add-resize-parents-changes (::page-id (meta changes))
|
||||
(lookup-objects changes)
|
||||
ids)
|
||||
(apply-changes-local)))
|
||||
|
||||
;; Library changes
|
||||
|
||||
@ -1148,6 +1175,8 @@
|
||||
(->> ids
|
||||
(map (d/getf objects))
|
||||
(filter ctl/grid-layout?)
|
||||
;; Component sync owns copy child ordering.
|
||||
(remove ctk/in-component-copy?)
|
||||
(reduce reorder-grid changes))]
|
||||
|
||||
changes))
|
||||
|
||||
@ -63,8 +63,8 @@
|
||||
file-data)))
|
||||
|
||||
(defn fix-missing-swap-slots
|
||||
"Locate shapes that have been swapped (i.e. their shape-ref does not point to the near match) but
|
||||
they don't have a swap slot. In this case, add one pointing to the near match."
|
||||
"Locate shapes that have been swapped (see `ctf/swapped-subhead?`) but don't have a swap slot.
|
||||
In this case, add one pointing to the near match."
|
||||
[file-data libraries]
|
||||
(try
|
||||
(ctf/update-all-shapes
|
||||
@ -73,7 +73,11 @@
|
||||
(if (ctk/subcopy-head? shape)
|
||||
(let [container (:container (meta shape))
|
||||
file {:id (:id file-data) :data file-data}
|
||||
near-match (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false)]
|
||||
swapped? (ctf/swapped-subhead?
|
||||
shape container
|
||||
#(ctf/find-ref-shape file container libraries % :include-deleted? true))
|
||||
near-match (when swapped?
|
||||
(ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false))]
|
||||
(if (and (some? near-match)
|
||||
(not= (:shape-ref shape) (:id near-match))
|
||||
(nil? (ctk/get-swap-slot shape)))
|
||||
|
||||
@ -1874,6 +1874,110 @@
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(defmethod migrate-data "0025-repair-empty-text-content"
|
||||
;; Repair text shapes whose :content tree has empty/missing :children
|
||||
;; at any of the three levels:
|
||||
;; Level 1: root with no paragraph-set
|
||||
;; Level 2: paragraph-set with no paragraph
|
||||
;; Level 3: paragraph with no span
|
||||
;; Such shapes fail the backend `validate-shape` schema and would also
|
||||
;; break the v2 editor's `cljs->dom` roundtrip. Re-seed the canonical
|
||||
;; root -> paragraph-set -> paragraph -> span tree, preserving the
|
||||
;; original root-level attributes (e.g. :vertical-align) when present.
|
||||
;; Idempotent on healthy content.
|
||||
[data _]
|
||||
(let [default-span {:text "" :fills types.text/default-text-fills}
|
||||
default-paragraph {:type "paragraph" :children [default-span]}
|
||||
default-paragraph-set {:type "paragraph-set" :children [default-paragraph]}
|
||||
|
||||
;; Level 3: repair paragraph with empty/missing children
|
||||
repair-span (fn [span]
|
||||
(if (and (map? span)
|
||||
(string? (:text span)))
|
||||
span
|
||||
default-span))
|
||||
|
||||
repair-paragraph (fn [paragraph]
|
||||
(if (and (map? paragraph)
|
||||
(= "paragraph" (:type paragraph)))
|
||||
(cond
|
||||
;; Children is nil or empty vector - seed with default span
|
||||
(or (nil? (:children paragraph))
|
||||
(and (vector? (:children paragraph))
|
||||
(empty? (:children paragraph))))
|
||||
(assoc paragraph :children [default-span])
|
||||
|
||||
;; Children is a vector - repair any invalid spans
|
||||
(vector? (:children paragraph))
|
||||
(update paragraph :children
|
||||
(fn [children]
|
||||
(mapv repair-span children)))
|
||||
|
||||
;; Children is not a vector - replace with default
|
||||
:else
|
||||
(assoc paragraph :children [default-span]))
|
||||
default-paragraph))
|
||||
|
||||
;; Level 2: repair paragraph-set with empty/missing children
|
||||
repair-paragraph-set (fn [paragraph-set]
|
||||
(if (and (map? paragraph-set)
|
||||
(= "paragraph-set" (:type paragraph-set)))
|
||||
(cond
|
||||
;; Children is nil or empty vector - seed with default paragraph
|
||||
(or (nil? (:children paragraph-set))
|
||||
(and (vector? (:children paragraph-set))
|
||||
(empty? (:children paragraph-set))))
|
||||
(assoc paragraph-set :children [default-paragraph])
|
||||
|
||||
;; Children is a vector - repair any invalid paragraphs
|
||||
(vector? (:children paragraph-set))
|
||||
(update paragraph-set :children
|
||||
(fn [children]
|
||||
(mapv repair-paragraph children)))
|
||||
|
||||
;; Children is not a vector - replace with default
|
||||
:else
|
||||
(assoc paragraph-set :children [default-paragraph]))
|
||||
default-paragraph-set))
|
||||
|
||||
;; Repair content at all levels, handling all edge cases
|
||||
repair-content (fn [content]
|
||||
(cond
|
||||
;; Content is not a valid root map - create default
|
||||
;; Preserve root-level attrs if content is a map
|
||||
(or (nil? content)
|
||||
(not (map? content))
|
||||
(not= "root" (:type content)))
|
||||
(merge types.text/default-root-attrs
|
||||
{:type "root"
|
||||
:children [default-paragraph-set]}
|
||||
(when (map? content)
|
||||
(select-keys content types.text/root-attrs)))
|
||||
|
||||
;; Content is a valid root - repair all levels
|
||||
:else
|
||||
(let [children (if (and (vector? (:children content))
|
||||
(seq (:children content)))
|
||||
(:children content)
|
||||
[default-paragraph-set])]
|
||||
(merge types.text/default-root-attrs
|
||||
{:type "root"}
|
||||
(select-keys content types.text/root-attrs)
|
||||
{:children (mapv repair-paragraph-set children)}))))
|
||||
|
||||
;; Simplified gatekeeper - just check if it's a text shape
|
||||
fix-shape (fn [shape]
|
||||
(if (cfh/text-shape? shape)
|
||||
(update shape :content repair-content)
|
||||
shape))
|
||||
|
||||
update-container (fn [container]
|
||||
(d/update-when container :objects d/update-vals fix-shape))]
|
||||
|
||||
(-> data
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(def available-migrations
|
||||
(into (d/ordered-set)
|
||||
["legacy-2"
|
||||
@ -1955,4 +2059,5 @@
|
||||
"0021-fix-shape-svg-attrs"
|
||||
"0022-normalize-component-root-and-resync"
|
||||
"0023-repair-token-themes-with-inexistent-sets"
|
||||
"0024b-fix-stroke-cap-placement"]))
|
||||
"0024b-fix-stroke-cap-placement"
|
||||
"0025-repair-empty-text-content"]))
|
||||
|
||||
@ -438,15 +438,15 @@
|
||||
shape file page)))
|
||||
|
||||
(defn- check-required-swap-slot
|
||||
"Validate that the shape has swap-slot if it's a subinstance head and the ref shape is not the
|
||||
matching shape by position in the near main."
|
||||
"Validate that the shape has a swap slot if it's a subinstance head that has been
|
||||
swapped (see `ctf/swapped-subhead?`)."
|
||||
[shape file page libraries]
|
||||
;; Guard first: if the shape already has a swap slot the invariant is satisfied
|
||||
;; and we can avoid the expensive `find-near-match` call entirely.
|
||||
(when (nil? (ctk/get-swap-slot shape))
|
||||
;; and we can avoid the ref-shape lookups entirely.
|
||||
(when (and (nil? (ctk/get-swap-slot shape))
|
||||
(ctf/swapped-subhead? shape page #(find-ref-shape* file page libraries %)))
|
||||
(let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)]
|
||||
(when (and (some? near-match)
|
||||
(not= (:shape-ref shape) (:id near-match)))
|
||||
(when (some? near-match)
|
||||
(report-error :missing-slot
|
||||
"Shape has been swapped, should have swap slot"
|
||||
shape file page
|
||||
|
||||
@ -167,10 +167,18 @@
|
||||
;; Activates the nitrate module
|
||||
:nitrate
|
||||
|
||||
;; disabled by default. When enabled, allows the nitrate
|
||||
;; `bulk-create-profiles` method to create batches of already
|
||||
;; active profiles. Only intended for test environments.
|
||||
:nitrate-bulk-create-profiles
|
||||
|
||||
:mcp
|
||||
:background-blur
|
||||
:available-viewer-wasm
|
||||
:stroke-path})
|
||||
:stroke-path
|
||||
:stroke-per-side
|
||||
|
||||
:custom-shortcuts})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
@ -204,6 +212,7 @@
|
||||
:enable-render-wasm-info
|
||||
:enable-available-viewer-wasm
|
||||
:enable-background-blur
|
||||
:enable-stroke-path
|
||||
:enable-token-combobox])
|
||||
|
||||
(defn parse
|
||||
|
||||
@ -253,7 +253,7 @@
|
||||
(swap! log-record (constantly lrecord))))]
|
||||
(if sync?
|
||||
(logfn)
|
||||
(px/exec! *default-executor* logfn))))
|
||||
(px/exec *default-executor* logfn))))
|
||||
|
||||
(defmacro log!
|
||||
"Emit a new log record to the global log-record state (asynchronously). "
|
||||
|
||||
@ -2505,15 +2505,49 @@
|
||||
(pcb/concat-changes changes new-changes)))
|
||||
|
||||
(defn- reposition-shape
|
||||
[shape origin-root dest-root]
|
||||
(let [shape-pos (fn [shape]
|
||||
(gpt/point (get-in shape [:selrect :x])
|
||||
(get-in shape [:selrect :y])))
|
||||
"Expresses the shape (belonging to the origin-root instance) in the frame of the
|
||||
dest-root instance, making the geometry of both instances directly comparable —
|
||||
and copyable.
|
||||
|
||||
origin-root-pos (shape-pos origin-root)
|
||||
dest-root-pos (shape-pos dest-root)
|
||||
delta (gpt/subtract dest-root-pos origin-root-pos)]
|
||||
(gsh/move shape delta)))
|
||||
If the dest root's geometry is NOT overridden (touched), the instance follows
|
||||
the origin's transformation verbatim (including rotation and flips), so a
|
||||
translation by the roots' (untransformed) position delta suffices — position is
|
||||
free per-instance placement.
|
||||
|
||||
If the dest root's geometry IS overridden (e.g. the user rotated the copy as a
|
||||
whole), the instance keeps its own placement transform, so the origin shape is
|
||||
additionally transformed by the roots' relative transformation (rotation /
|
||||
flips) around the dest root center — geometric changes then land expressed in
|
||||
the dest instance's own frame instead of wiping its placement."
|
||||
[shape origin-root dest-root]
|
||||
(let [shape-pos (fn [shape]
|
||||
(gpt/point (dm/get-in shape [:selrect :x])
|
||||
(dm/get-in shape [:selrect :y])))
|
||||
|
||||
origin-root-pos (shape-pos origin-root)
|
||||
dest-root-pos (shape-pos dest-root)
|
||||
delta (gpt/subtract dest-root-pos origin-root-pos)
|
||||
|
||||
shape (gsh/move shape delta)]
|
||||
(if-not (ctk/touched-group? dest-root :geometry-group)
|
||||
shape
|
||||
(let [origin-transform (d/nilv (:transform origin-root) (gmt/matrix))
|
||||
dest-transform (d/nilv (:transform dest-root) (gmt/matrix))
|
||||
rel-transform (gmt/multiply dest-transform (gmt/inverse origin-transform))]
|
||||
(if ^boolean (gmt/unit? rel-transform)
|
||||
shape
|
||||
;; The roots differ in rotation/flips: rotate the whole (already moved)
|
||||
;; shape around the dest root center by the roots' relative transform.
|
||||
;; The :rotation attribute delta is fed through the :modifiers path so
|
||||
;; apply-transform keeps it consistent with the resulting matrix.
|
||||
(let [center (grc/rect->center (:selrect dest-root))
|
||||
rel-rotation (mod (- (d/nilv (:rotation dest-root) 0)
|
||||
(d/nilv (:rotation origin-root) 0))
|
||||
360)]
|
||||
(-> shape
|
||||
(assoc-in [:modifiers :rotation] rel-rotation)
|
||||
(gsh/apply-transform (gmt/transform-in center rel-transform))
|
||||
(dissoc :modifiers))))))))
|
||||
|
||||
(defn- make-change
|
||||
[container change]
|
||||
|
||||
@ -113,6 +113,116 @@
|
||||
(-> changes
|
||||
(pcb/update-shapes ids update-fn {:attrs #{:blocked :hidden}}))))
|
||||
|
||||
;; Deleting shapes inside a surviving component main transitively removes their
|
||||
;; copies across all pages. Whole-main and component-swap deletions are excluded.
|
||||
|
||||
(defn- mutilates-main?
|
||||
"Whether deleting `id` takes a shape out of a component main that survives.
|
||||
`deleted-ids` are all the ids the deletion removes from the page."
|
||||
[objects deleted-ids id]
|
||||
(->> (cfh/get-parent-ids objects id)
|
||||
(some (fn [parent-id]
|
||||
(let [parent (get objects parent-id)]
|
||||
(and (:main-instance parent)
|
||||
(not (contains? deleted-ids parent-id))))))))
|
||||
|
||||
(defn- build-shape-ref-index
|
||||
"Index every referencing shape by shape-ref and page."
|
||||
[pages-index page-objects]
|
||||
(reduce (fn [index page-id]
|
||||
(reduce (fn [index shape]
|
||||
(if-let [shape-ref (:shape-ref shape)]
|
||||
(update index shape-ref (fnil conj []) [page-id (:id shape)])
|
||||
index))
|
||||
index
|
||||
(vals (page-objects page-id))))
|
||||
{}
|
||||
(keys pages-index)))
|
||||
|
||||
(defn- collect-copy-deletions
|
||||
"Collect dangling copy roots and their subtree ids by page.
|
||||
`scheduled` prevents duplicate deletions during transitive traversal."
|
||||
[ref-index descendants-of dangling scheduled]
|
||||
(loop [dangling dangling
|
||||
scheduled scheduled
|
||||
result {}]
|
||||
(if (empty? dangling)
|
||||
result
|
||||
(let [hits
|
||||
(into #{}
|
||||
(comp (mapcat ref-index)
|
||||
(remove (fn [[_ id]] (contains? scheduled id))))
|
||||
dangling)
|
||||
|
||||
;; Ancestor subtrees already contain nested hits.
|
||||
nested
|
||||
(into #{}
|
||||
(mapcat (fn [[page-id id]] (descendants-of page-id id)))
|
||||
hits)
|
||||
|
||||
subtrees
|
||||
(into []
|
||||
(comp (remove (fn [[_ id]] (contains? nested id)))
|
||||
(map (fn [[page-id id]]
|
||||
[page-id id (-> (descendants-of page-id id)
|
||||
(set)
|
||||
(conj id))])))
|
||||
hits)
|
||||
|
||||
deleted
|
||||
(into #{} (mapcat #(nth % 2)) subtrees)]
|
||||
(recur deleted
|
||||
(into scheduled deleted)
|
||||
(reduce (fn [result [page-id root-id ids]]
|
||||
(-> result
|
||||
(update-in [page-id :roots]
|
||||
(fnil conj (d/ordered-set))
|
||||
root-id)
|
||||
(update-in [page-id :ids] (fnil into #{}) ids)))
|
||||
result
|
||||
subtrees))))))
|
||||
|
||||
(defn- propagated-copy-deletions
|
||||
"Collect propagated copy deletions unless component sync owns the operation."
|
||||
[objects pages-index page-objects deleted-ids allow-altering-copies]
|
||||
(if (or allow-altering-copies (nil? pages-index))
|
||||
{}
|
||||
(let [dangling (into #{}
|
||||
(filter #(mutilates-main? objects deleted-ids %))
|
||||
deleted-ids)]
|
||||
(if (empty? dangling)
|
||||
{}
|
||||
(collect-copy-deletions (build-shape-ref-index pages-index page-objects)
|
||||
;; Cache subtrees shared by multiple references.
|
||||
(memoize (fn [page-id id]
|
||||
(cfh/get-children-ids (page-objects page-id) id)))
|
||||
dangling
|
||||
deleted-ids)))))
|
||||
|
||||
(declare generate-delete-shapes)
|
||||
|
||||
(defn- generate-copy-deletions
|
||||
"Delete propagated copy roots through each page's normal deletion workflow."
|
||||
[changes data page pages-index copy-deletions]
|
||||
(reduce-kv (fn [changes pid {:keys [roots]}]
|
||||
(let [options {:ignore-touched true
|
||||
:allow-altering-copies true}
|
||||
target-page (if (= pid (:id page))
|
||||
page
|
||||
(get pages-index pid))]
|
||||
(if (= pid (:id page))
|
||||
(second (generate-delete-shapes changes roots options))
|
||||
(let [[_ target-changes]
|
||||
(generate-delete-shapes (pcb/empty-changes nil pid)
|
||||
data
|
||||
target-page
|
||||
(:objects target-page)
|
||||
roots
|
||||
options)]
|
||||
(pcb/concat-changes-without-local changes target-changes)))))
|
||||
changes
|
||||
copy-deletions))
|
||||
|
||||
(defn generate-delete-shapes
|
||||
([changes file page objects ids options]
|
||||
(generate-delete-shapes (-> changes
|
||||
@ -185,17 +295,19 @@
|
||||
ids-to-delete)
|
||||
[])
|
||||
|
||||
interacting-shapes
|
||||
(filter (fn [shape]
|
||||
;; If any of the deleted shapes is the destination of
|
||||
;; some interaction, this must be deleted, too.
|
||||
(let [interactions (:interactions shape)]
|
||||
(some #(and (ctsi/has-destination %)
|
||||
(contains? ids-to-delete (:destination %)))
|
||||
interactions)))
|
||||
(vals objects))
|
||||
|
||||
id-to-delete? (set ids-to-delete)
|
||||
|
||||
interacting-shapes
|
||||
(into []
|
||||
(filter (fn [shape]
|
||||
;; If any of the deleted shapes is the destination of
|
||||
;; some interaction, this must be deleted, too.
|
||||
(let [interactions (:interactions shape)]
|
||||
(some #(and (ctsi/has-destination %)
|
||||
(id-to-delete? (:destination %)))
|
||||
interactions))))
|
||||
(vals objects))
|
||||
|
||||
changes
|
||||
(->> (:flows page)
|
||||
(reduce
|
||||
@ -261,19 +373,43 @@
|
||||
[]
|
||||
(into ids-to-delete descendants-to-delete))
|
||||
|
||||
;; Empty main parents also leave their copies dangling.
|
||||
all-deleted-ids
|
||||
(-> (set ids-to-delete)
|
||||
(into descendants-to-delete)
|
||||
(into empty-parents))
|
||||
|
||||
ids-set (set ids-to-delete)
|
||||
pages-index
|
||||
(when data
|
||||
(or (:pages-index data)
|
||||
(dm/get-in data [:data :pages-index])))
|
||||
|
||||
page-objects
|
||||
(fn [id]
|
||||
(if (= id (:id page))
|
||||
objects
|
||||
(dm/get-in pages-index [id :objects])))
|
||||
|
||||
copy-deletions
|
||||
(propagated-copy-deletions objects pages-index page-objects
|
||||
all-deleted-ids allow-altering-copies)
|
||||
|
||||
;; Propagated copy deletions supersede hiding the same shapes.
|
||||
ids-to-hide
|
||||
(if-let [deleted (seq (get-in copy-deletions [(:id page) :ids]))]
|
||||
(into [] (remove (set deleted)) ids-to-hide)
|
||||
ids-to-hide)
|
||||
|
||||
guides-to-delete
|
||||
(->> (:guides page)
|
||||
(vals)
|
||||
(filter #(contains? ids-set (:frame-id %)))
|
||||
(filter #(id-to-delete? (:frame-id %)))
|
||||
(map :id))
|
||||
|
||||
changes (reduce (fn [changes guide-id]
|
||||
(-> changes
|
||||
(pcb/with-page page)
|
||||
(pcb/set-flow guide-id nil)))
|
||||
(pcb/set-guide guide-id nil)))
|
||||
changes
|
||||
guides-to-delete)
|
||||
|
||||
@ -289,6 +425,7 @@
|
||||
(pcb/remove-objects descendants-to-delete {:ignore-touched true})
|
||||
(pcb/remove-objects ids-to-delete {:ignore-touched ignore-touched})
|
||||
(pcb/remove-objects empty-parents)
|
||||
(generate-copy-deletions data page pages-index copy-deletions)
|
||||
(pcb/resize-parents all-parents)
|
||||
(pcb/update-shapes groups-to-unmask
|
||||
(fn [shape]
|
||||
@ -299,7 +436,7 @@
|
||||
(fn [interactions]
|
||||
(into []
|
||||
(remove #(and (ctsi/has-destination %)
|
||||
(contains? ids-to-delete (:destination %))))
|
||||
(id-to-delete? (:destination %))))
|
||||
interactions))))))]
|
||||
[all-parents changes])))
|
||||
|
||||
|
||||
@ -34,12 +34,14 @@
|
||||
#?(:cljs (js/isNaN v)
|
||||
:clj (Double/isNaN v)))
|
||||
|
||||
;; NOTE: on cljs we don't need to check for `number?` so we explicitly
|
||||
;; ommit it for performance reasons.
|
||||
;; NOTE: we need `number?` guard on cljs because `js/isFinite` coerces
|
||||
;; strings to numbers, accepting "16" as finite when it shouldn't.
|
||||
;; This caused a bug where string values from format-number were
|
||||
;; propagated through the system until Malli rejected them (issue #10638).
|
||||
|
||||
(defn finite?
|
||||
[v]
|
||||
#?(:cljs (and (not (nil? v)) (js/isFinite v))
|
||||
#?(:cljs (and (not (nil? v)) (number? v) (js/isFinite v))
|
||||
:clj (and (not (nil? v)) (number? v) (Double/isFinite v))))
|
||||
|
||||
(defn finite
|
||||
|
||||
@ -429,6 +429,17 @@
|
||||
(with-meta (meta parent-ref-shape))))]
|
||||
near-match))
|
||||
|
||||
(defn swapped-subhead?
|
||||
"Whether `shape` references outside its near main parent and needs a swap slot.
|
||||
Same-parent positional differences are synchronized as reorders."
|
||||
[shape container find-parent-ref-shape]
|
||||
(let [parent-shape (ctst/get-shape container (:parent-id shape))
|
||||
parent-ref-shape (when parent-shape
|
||||
(find-parent-ref-shape parent-shape))]
|
||||
(and (some? parent-ref-shape)
|
||||
(not-any? #(= % (:shape-ref shape))
|
||||
(:shapes parent-ref-shape)))))
|
||||
|
||||
(defn advance-shape-ref
|
||||
"Get the shape-ref of the near main of the shape, recursively repeated as many times
|
||||
as the given levels."
|
||||
|
||||
@ -14,29 +14,29 @@
|
||||
:new-team-members "anyone"})
|
||||
|
||||
(defn- can-create-team?
|
||||
[{:keys [is-org-owner? permission-value]}]
|
||||
(or is-org-owner?
|
||||
[{:keys [is-organization-owner? permission-value]}]
|
||||
(or is-organization-owner?
|
||||
(= permission-value "any")))
|
||||
|
||||
(defn- can-delete-team?
|
||||
[{:keys [is-org-owner? permission-value team-perms]}]
|
||||
[{:keys [is-organization-owner? permission-value team-perms]}]
|
||||
(cond
|
||||
;; Org owners can always delete teams inside their organizations.
|
||||
is-org-owner?
|
||||
;; Organization owners can always delete teams inside their organizations.
|
||||
is-organization-owner?
|
||||
true
|
||||
(= permission-value "onlyOwners")
|
||||
(boolean (:is-owner team-perms))
|
||||
:else false))
|
||||
|
||||
(defn- can-move-team?
|
||||
[{:keys [permission-value target-org-same-owner?]}]
|
||||
[{:keys [permission-value target-organization-same-owner?]}]
|
||||
(cond
|
||||
(= permission-value "never")
|
||||
false
|
||||
(= permission-value "always")
|
||||
true
|
||||
(= permission-value "myOrganizations")
|
||||
(true? target-org-same-owner?)
|
||||
(true? target-organization-same-owner?)
|
||||
:else false))
|
||||
|
||||
(defn- can-invite-to-team?
|
||||
@ -67,36 +67,36 @@
|
||||
:add-anybody-to-team {:permission-key :new-team-members
|
||||
:check-fn can-add-anybody-to-team?}})
|
||||
|
||||
(defn- normalize-org-permissions
|
||||
[org-perms]
|
||||
(merge defaults (or (:permissions org-perms) {})))
|
||||
(defn- normalize-organization-permissions
|
||||
[organization-perms]
|
||||
(merge defaults (or (:permissions organization-perms) {})))
|
||||
|
||||
(defn- owner?
|
||||
[org-perms profile-id]
|
||||
(= profile-id (:owner-id org-perms)))
|
||||
[organization-perms profile-id]
|
||||
(= profile-id (:owner-id organization-perms)))
|
||||
|
||||
(defn allowed?
|
||||
"Returns true only for explicitly allowed actions (fail-closed)."
|
||||
[action {:keys [org-perms profile-id team-perms target-org-same-owner?]}]
|
||||
[action {:keys [organization-perms profile-id team-perms target-organization-same-owner?]}]
|
||||
(let [{:keys [permission-key check-fn] :as rule}
|
||||
(get action-rules action)
|
||||
permissions (normalize-org-permissions org-perms)
|
||||
is-org-owner? (owner? org-perms profile-id)
|
||||
permissions (normalize-organization-permissions organization-perms)
|
||||
is-organization-owner? (owner? organization-perms profile-id)
|
||||
permission-value (get permissions permission-key)]
|
||||
(cond
|
||||
(nil? rule) false
|
||||
:else (boolean (check-fn {:is-org-owner? is-org-owner?
|
||||
:else (boolean (check-fn {:is-organization-owner? is-organization-owner?
|
||||
:permission-value permission-value
|
||||
:team-perms team-perms
|
||||
:target-org-same-owner? target-org-same-owner?})))))
|
||||
:target-organization-same-owner? target-organization-same-owner?})))))
|
||||
|
||||
(defn can-send-invitations?
|
||||
[{:keys [nitrate-enabled? organization profile-id team-permissions]}]
|
||||
(let [in-org? (and nitrate-enabled? organization)]
|
||||
(if in-org?
|
||||
(let [in-organization? (and nitrate-enabled? organization)]
|
||||
(if in-organization?
|
||||
(allowed? :send-invitations
|
||||
{:org-perms {:owner-id (:owner-id organization)
|
||||
:permissions (:permissions organization)}
|
||||
{:organization-perms {:owner-id (:owner-id organization)
|
||||
:permissions (:permissions organization)}
|
||||
:profile-id profile-id
|
||||
:team-perms team-permissions})
|
||||
(or (boolean (:is-owner team-permissions))
|
||||
|
||||
@ -61,4 +61,14 @@
|
||||
[:name ::sm/text]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo [:maybe ::sm/uri]]
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]])
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]
|
||||
[:sso-active {:optional true} [:maybe :boolean]]])
|
||||
|
||||
(def schema:nitrate-sso
|
||||
[:map {:title "NitrateOrganizationSso"}
|
||||
[:organization-id ::sm/uuid]
|
||||
[:active {:optional true} [:maybe :boolean]]
|
||||
[:provider {:optional true} [:maybe :string]]
|
||||
[:client-id {:optional true} [:maybe :string]]
|
||||
[:client-secret {:optional true} [:maybe :string]]
|
||||
[:issuer {:optional true} [:maybe :string]]])
|
||||
|
||||
@ -139,6 +139,12 @@
|
||||
[:stroke-style {:optional true}
|
||||
[::sm/one-of #{:solid :dotted :dashed :mixed}]]
|
||||
[:stroke-width {:optional true} ::sm/safe-number]
|
||||
;; wasm-render only, backwards compatible
|
||||
[:stroke-per-side {:optional true} :boolean]
|
||||
[:stroke-width-top {:optional true} ::sm/safe-number]
|
||||
[:stroke-width-right {:optional true} ::sm/safe-number]
|
||||
[:stroke-width-bottom {:optional true} ::sm/safe-number]
|
||||
[:stroke-width-left {:optional true} ::sm/safe-number]
|
||||
[:stroke-dash {:optional true} ::sm/safe-number]
|
||||
[:stroke-gap {:optional true} ::sm/safe-number]
|
||||
[:stroke-alignment {:optional true}
|
||||
@ -692,6 +698,31 @@
|
||||
:r3
|
||||
:r4})
|
||||
|
||||
(def ^:private text-extract-props
|
||||
(into #{} cat [txt/root-attrs txt/paragraph-attrs txt/text-node-attrs]))
|
||||
|
||||
(def ^:private layout-extract-props
|
||||
(set ctsl/layout-attrs))
|
||||
|
||||
;; Token attrs are not shape attrs (:fill token vs :fills attr, :m1..:m4 vs
|
||||
;; :layout-item-margin). A token may only travel with the value it resolves to,
|
||||
;; so its domain is derived from the props that are actually written. The attrs
|
||||
;; holding a map of edges are patched edge by edge, so only the edges present
|
||||
;; in the map are written.
|
||||
(defn- token-attrs
|
||||
[props]
|
||||
(reduce-kv (fn [result attr value]
|
||||
(let [sub-attrs (when (map? value)
|
||||
(not-empty (set (keys value))))]
|
||||
(into result
|
||||
(if (= :layout-gap attr)
|
||||
(if (some? sub-attrs)
|
||||
(filter cto/spacing-gap-keys sub-attrs)
|
||||
cto/spacing-gap-keys)
|
||||
(cto/shape-attr->token-attrs attr sub-attrs)))))
|
||||
#{}
|
||||
props))
|
||||
|
||||
(defn extract-props
|
||||
"Retrieves an object with the 'pasteable' properties for a shape."
|
||||
[shape]
|
||||
@ -723,7 +754,15 @@
|
||||
props)))
|
||||
|
||||
(extract-layout-attrs [props shape]
|
||||
(d/patch-object props (select-keys shape ctsl/layout-attrs)))]
|
||||
(d/patch-object props (select-keys shape ctsl/layout-attrs)))
|
||||
|
||||
(extract-token-props [props shape]
|
||||
(let [tokens (-> (:applied-tokens shape)
|
||||
(select-keys (token-attrs props))
|
||||
(not-empty))]
|
||||
(cond-> props
|
||||
(some? tokens)
|
||||
(assoc :applied-tokens tokens))))]
|
||||
|
||||
(let [;; For texts we don't extract the fill
|
||||
extract-props
|
||||
@ -731,7 +770,8 @@
|
||||
(-> shape
|
||||
(select-keys extract-props)
|
||||
(cond-> (cfh/text-shape? shape) (extract-text-props shape))
|
||||
(cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape))))))
|
||||
(cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape))
|
||||
(extract-token-props shape)))))
|
||||
|
||||
(defn patch-props
|
||||
"Given the object of `extract-props` applies it to a shape. Adapt the shape if necessary"
|
||||
@ -759,12 +799,33 @@
|
||||
(let [shape (d/patch-object shape (select-keys props ctsl/layout-attrs))]
|
||||
(cond-> shape
|
||||
(ctsl/grid-layout? shape)
|
||||
(ctsl/assign-cells objects))))]
|
||||
(ctsl/assign-cells objects))))
|
||||
|
||||
(patched-props [shape props]
|
||||
(let [text? (cfh/text-shape? shape)
|
||||
frame? (cfh/frame-shape? shape)]
|
||||
(select-keys props
|
||||
(filter (fn [attr]
|
||||
(or (contains? basic-extract-props attr)
|
||||
(and text? (contains? text-extract-props attr))
|
||||
(and frame? (contains? layout-extract-props attr))))
|
||||
(keys props)))))
|
||||
|
||||
(patch-token-props [shape props]
|
||||
(let [attrs (token-attrs (patched-props shape props))
|
||||
tokens (-> (:applied-tokens shape)
|
||||
(d/without-keys attrs)
|
||||
(merge (select-keys (:applied-tokens props) attrs))
|
||||
(not-empty))]
|
||||
(if (some? tokens)
|
||||
(assoc shape :applied-tokens tokens)
|
||||
(dissoc shape :applied-tokens))))]
|
||||
|
||||
(-> shape
|
||||
(d/patch-object (select-keys props basic-extract-props))
|
||||
(cond-> (cfh/text-shape? shape) (patch-text-props props))
|
||||
(cond-> (cfh/frame-shape? shape) (patch-layout-props props)))))
|
||||
(cond-> (cfh/frame-shape? shape) (patch-layout-props props))
|
||||
(patch-token-props props))))
|
||||
|
||||
|
||||
|
||||
|
||||
@ -76,7 +76,10 @@
|
||||
[:map {:title "AnimationDisolve"}
|
||||
[:animation-type [:= :dissolve]]
|
||||
[:duration ::sm/safe-int]
|
||||
[:easing [::sm/one-of easing-types]]])
|
||||
[:easing [::sm/one-of easing-types]]
|
||||
[:way {:optional true} [::sm/one-of way-types]]
|
||||
[:offset-effect {:optional true} :boolean]
|
||||
[:direction {:optional true} [::sm/one-of direction-types]]])
|
||||
|
||||
(def schema:slide-animation
|
||||
[:map {:title "AnimationSlide"}
|
||||
|
||||
@ -347,6 +347,22 @@
|
||||
(+ pad-top pad-top)
|
||||
(+ pad-top pad-bottom))))
|
||||
|
||||
(defn padding-type-for
|
||||
"`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)."
|
||||
[{:keys [p1 p2 p3 p4]}]
|
||||
(if (and (mth/close? (d/nilv p1 0) (d/nilv p3 0))
|
||||
(mth/close? (d/nilv p2 0) (d/nilv p4 0)))
|
||||
:simple
|
||||
:multiple))
|
||||
|
||||
(defn margin-type-for
|
||||
"`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)."
|
||||
[{:keys [m1 m2 m3 m4]}]
|
||||
(if (and (mth/close? (d/nilv m1 0) (d/nilv m3 0))
|
||||
(mth/close? (d/nilv m2 0) (d/nilv m4 0)))
|
||||
:simple
|
||||
:multiple))
|
||||
|
||||
(defn child-min-width
|
||||
[child]
|
||||
(if (and (fill-width? child)
|
||||
@ -1509,20 +1525,37 @@
|
||||
(some? target-cell)
|
||||
(add-children-to-cell ids objects [(:row target-cell) (:column target-cell)]))))
|
||||
|
||||
(defn- refill-slots
|
||||
"Fill matching positions in `shapes` from `ordered`, preserving other indices.
|
||||
`ordered` must contain exactly the ids accepted by `slot?`."
|
||||
[shapes slot? ordered]
|
||||
(loop [shapes (seq shapes)
|
||||
ordered (seq ordered)
|
||||
result (transient [])]
|
||||
(if (nil? shapes)
|
||||
(persistent! result)
|
||||
(let [id (first shapes)]
|
||||
(if (slot? id)
|
||||
(recur (next shapes) (next ordered) (conj! result (first ordered)))
|
||||
(recur (next shapes) ordered (conj! result id)))))))
|
||||
|
||||
(defn reorder-grid-children
|
||||
"Order cell children by grid position while preserving the indices of
|
||||
hidden and absolute-positioned children."
|
||||
[parent]
|
||||
(let [cells (get-cells parent {:sort? true})
|
||||
(let [cells (get-cells parent {:sort? true})
|
||||
child? (set (:shapes parent))
|
||||
new-shapes
|
||||
(into (d/ordered-set)
|
||||
|
||||
in-cell-ids
|
||||
(into []
|
||||
(comp (keep (comp first :shapes))
|
||||
(filter child?))
|
||||
cells)
|
||||
|
||||
;; Add the children that are not in cells (absolute positioned for example)
|
||||
new-shapes (into new-shapes (:shapes parent))]
|
||||
|
||||
(assoc parent :shapes (into [] (reverse new-shapes)))))
|
||||
(filter child?)
|
||||
(distinct))
|
||||
cells)]
|
||||
;; :shapes is ordered in reverse relative to the visual cell order
|
||||
(assoc parent :shapes (refill-slots (:shapes parent)
|
||||
(set in-cell-ids)
|
||||
(reverse in-cell-ids)))))
|
||||
|
||||
(defn cells-by-row
|
||||
([parent index]
|
||||
|
||||
@ -20,44 +20,42 @@
|
||||
[:type [:= "root"]]
|
||||
[:key {:optional true} :string]
|
||||
[:children
|
||||
{:optional true}
|
||||
[:maybe
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph-set"]]
|
||||
[:key {:optional true} :string]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph"]]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:text :string]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]])
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph-set"]]
|
||||
[:key {:optional true} :string]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph"]]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:text :string]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]])
|
||||
|
||||
(def valid-content?
|
||||
(sm/lazy-validator schema:content))
|
||||
|
||||
@ -235,6 +235,8 @@
|
||||
[:row-gap {:optional true} schema:token-name]
|
||||
[:column-gap {:optional true} schema:token-name]])
|
||||
|
||||
(def spacing-gap-keys (schema-keys schema:spacing-gap))
|
||||
|
||||
(def ^:private schema:spacing-padding
|
||||
[:map {:title "SpacingPaddingTokenAttrs"}
|
||||
[:p1 {:optional true} schema:token-name]
|
||||
|
||||
@ -912,3 +912,4 @@
|
||||
(nil? (get-in result2 [:pages-index page-id :default-grids])))))
|
||||
|
||||
{:num 1000})))
|
||||
|
||||
|
||||
833
common/test/common_tests/files_migrations_0025_test.cljc
Normal file
833
common/test/common_tests/files_migrations_0025_test.cljc
Normal 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
Loading…
x
Reference in New Issue
Block a user