Compare commits

...

359 Commits

Author SHA1 Message Date
Andrey Antukh
7fd4e35203 ♻️ Refactor CI workflows 2026-06-09 18:57:44 +02:00
Madalena Melo
1a4cee7e5a
📎 Update SECURITY.md (#10082)
Update SECURITY.md file to request that vulnerabilities be reported through the GitHub Security Advisories feature in the Penpot repository

Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
2026-06-09 18:48:03 +02:00
Andrey Antukh
4b03cfd20c Merge remote-tracking branch 'origin/staging' into develop 2026-06-09 14:39:07 +02:00
Andrey Antukh
f7c5ce7ac9 Merge remote-tracking branch 'origin/main' into staging 2026-06-09 14:38:48 +02:00
Andrey Antukh
e0a44eede0 Backport serena memory and other minor config fixes from develop 2026-06-09 14:37:59 +02:00
Madalena Melo
4df399ab5a
📎 Update THANKYOU.md (#10020)
Update THANKYOU.md to include Alisher (@7megaumka7)

Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
2026-06-09 14:32:06 +02:00
Andrey Antukh
6671037ff7 📎 Update tooks/gh.py script 2026-06-09 14:01:52 +02:00
David Barragán Merino
c37cff7687 📎 Set pnpm version on docs/package.json 2026-06-09 13:54:31 +02:00
David Barragán Merino
82acec1191 📎 Set pnpm version on docs/package.json 2026-06-09 13:50:00 +02:00
David Barragán Merino
11a8d08f95 📎 Set pnpm version on docs/package.json 2026-06-09 13:28:54 +02:00
Andrey Antukh
9ae84dbfe9 📎 Update changelog 2026-06-09 13:01:01 +02:00
Andrey Antukh
62c85467f9 🔧 Update the default github issues template 2026-06-09 13:01:01 +02:00
Andrey Antukh
06c83553fd 📚 Add creating issues workflow to serena memory 2026-06-09 13:01:01 +02:00
Codex
744c1b98c0 🐛 Anchor variant switch geometry to target
Preserve real size overrides during variant switches without copying stale absolute composite geometry from the source variant.

Signed-off-by: Codex <codex@openai.com>
2026-06-09 12:20:15 +02:00
Alonso Torres
d249fd106a
🐛 Fix theme problem after update (#9955) 2026-06-09 11:17:06 +02:00
Aitor Moreno
facea16444
Merge pull request #10038 from penpot/superalex-viewer-wasm
🎉 Basic viewer with wasm
2026-06-09 11:00:19 +02:00
alonso.torres
70e8dbb38a 🐛 Fix cropped outer stroke of rotated board in view mode 2026-06-09 09:40:26 +02:00
Alonso Torres
3444c0589f
🐛 Fix parallel environments css hot reload (#10064) 2026-06-08 17:56:21 +02:00
Andrey Antukh
f450e09e08 🔧 Remove direct project reference on issue templates
We will use workflows for this purpose
2026-06-08 14:47:30 +02:00
Andrey Antukh
25ee47a2d4 🔧 Fix issue on but report github template 2026-06-08 14:43:20 +02:00
Andrey Antukh
7aa720f150 Merge remote-tracking branch 'origin/staging' into develop 2026-06-08 14:36:44 +02:00
Andrey Antukh
8a2274cbc0 🔧 Update default github issue templates 2026-06-08 14:25:38 +02:00
David Barragán Merino
67ee0b0625 🔧 Remove wokflow to build main-staging branch 2026-06-08 13:15:56 +02:00
Pablo Alba
2a48747cf6 Review nitrate add team members permission 2026-06-08 10:56:18 +02:00
Dr. Dominik Jain
03c02d5adf
🎉 Enable multi-instance horizontal scaling for MCP server (#10013)
* 📎 Ignore .iml files (IntelliJ module files)

* 🎉 Enable multi-instance horizontal scaling for MCP server

Allow the MCP server to run as multiple instances behind a plain
round-robin load balancer, removing the previous requirement that a
user's plugin WebSocket and MCP client connection terminate on the same
instance. Behaviour is unchanged when run as a single instance or
without Redis.

Cross-instance MCP sessions: when a request arrives with an
mcp-session-id that was initialised on another instance, the session is
adopted locally instead of rejected. The user token is read from the
query parameter (present on every request, as the configured endpoint
URL is never rewritten), so no shared session store is needed; the
transport is pre-initialised so the SDK's validateSession() accepts it.

Cross-instance task routing: when a Redis URI is configured in
multi-user mode, plugin task requests are routed via Redis pub/sub keyed
by user token. The instance holding a plugin's WebSocket subscribes to
that token's request channel; any instance handling a tool call
publishes the request and awaits the response on a per-request channel.
RedisBridge is a pure transport for the existing serialised
PluginTaskRequest/Response objects. PluginTask is split into an abstract
base plus a local (promise-backed) PluginTask and a RemotePluginTask
whose resolve/reject publish the outcome back over Redis, so the
existing local dispatch and response-correlation paths are reused
unchanged on the executing instance.

Refs #10000
2026-06-08 09:53:54 +02:00
Andrey Antukh
c183380e0d Merge remote-tracking branch 'origin/staging' into develop 2026-06-08 09:51:03 +02:00
Alexis Morin
3da7e7eb77
🐛 Fix French Canada locale not loading correct translations (#10027)
* 🐛 Fix French Canada locale not loading correct translations

Signed-off-by: Alexis Morin <alexis.morin@autodesk.com>

* Update CHANGES.md

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Alexis Morin <alexis.morin@autodesk.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-08 09:36:45 +02:00
Alejandro Alonso
9f5e89d5f8 🎉 Basic viewer with wasm 2026-06-05 16:41:30 +02:00
Alejandro Alonso
f9f4d7e2cd
🐛 Fix offscreen canvas resizing 2026-06-05 14:55:17 +02:00
Andrey Antukh
15f469becb Merge remote-tracking branch 'origin/staging' into develop 2026-06-05 11:49:37 +02:00
Andrey Antukh
adcc2ebd1a Merge remote-tracking branch 'origin/staging' into develop 2026-06-05 11:44:50 +02:00
Andrey Antukh
e2f96a6ba0 📎 Add minor changes to opencode setup 2026-06-05 11:30:58 +02:00
Marina López
fc038e72fc Allow send events from nitrate 2026-06-05 09:35:14 +02:00
Elena Torró
60d3c81450
Add wasm rulers (#9858)
*  Add wasm rulers

* 🔧 Fix dpr on page zoom

Co-authored-by: Alejandro Alonso <alejandroalonsofernandez@gmail.com>
Co-authored-by: Elena Torro <elenatorro@gmail.com>

* 🔧 Change page-switch behavior to refresh rulers and keep blurred snapshot

* 🐛 Restore WASM rulers after WebGL context recovery

Co-Authored-By: Elena Torro <elenatorro@gmail.com>
Co-Authored-By: Alejandro Alonso <alejandroalonsofernandez@gmail.com>

---------

Co-authored-by: Alejandro Alonso <alejandroalonsofernandez@gmail.com>
2026-06-05 07:51:35 +02:00
alonso.torres
8d3516d06d 🐛 Fix path export crop when stroke has arrow/marker caps 2026-06-04 23:48:55 +02:00
Andrey Antukh
9911ff7959 Merge remote-tracking branch 'origin/staging' into develop 2026-06-04 19:01:54 +02:00
Juanfran
cb2994fc3b Add lang to nitrate authenticate endpoint response
Expose the user's `:lang` profile field alongside `:theme` from the
internal nitrate `authenticate` RPC so the Nitrate admin console can
load translations matching the user's Penpot language preference.
2026-06-04 14:58:09 +02:00
Andrey Antukh
3a44e291f4 📎 Add minor improvements to manage.sh 2026-06-04 13:00:04 +02:00
Andrey Antukh
945c44c505
🔧 Update docker terminal settings and refactor devenv management (#10018)
* 🔧 Update docker terminal settings

* 🔧 Get back the HTTP listener for devenv

* 🐛 Fix problem with https port

* 📎 Fixup

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-06-04 12:22:35 +02:00
Alonso Torres
ccc734055f
🐛 Fix problem with stroke-cap migration (#10019) 2026-06-04 11:38:46 +02:00
Pablo Alba
785b07313b Add nitrate endpoint to send renewal email 2026-06-04 10:31:47 +02:00
Eva Marco
c3f107e830
🎉 Add color list to colorpicker (#9953)
* 🎉 Add color list to colorpicker

* 🎉 Improve performance

* 🎉 Add accessibility roles

* 🎉 Add test

* 🎉 Add empty state
2026-06-04 08:47:00 +02:00
alonso.torres
892869b039 🐛 Fix stroke caps misplaced when adding node in middle of path 2026-06-03 16:10:28 +02:00
alonso.torres
6a0f24e691 🐛 Fix view mode child click blocked by parent mouse-leave interaction overlay 2026-06-03 16:10:13 +02:00
alonso.torres
e90b14eb37 🐛 Fix grid layout track menu button missing in WebKit/Safari 2026-06-03 16:09:52 +02:00
Michael Panchenko
16dc83616a
Add the ability to launch parallel devenv instances (#9906)
* 🐳 Split devenv compose for parallel workspaces

Move shared services into an infra compose file and keep the main devenv container plus Valkey in a separate compose file driven by defaults.env. Parameterize host-side ports, container names, source path, and runtime env while keeping container-internal ports fixed for same-origin proxying.

Make tmux startup idempotent, add attach-devenv for the live instance, move shared MinIO user setup to infra startup, and let exporter scripts load backend _env.local overrides.

Co-authored-by: Codex <codex@openai.com>

* 🐳 Run parallel devenv instances against shared infra

Add support for running N parallel devenv instances under separate compose
projects sharing Postgres, MinIO, mailer, and LDAP. Each instance has its
own main container, Valkey, source checkout, tmux session, and host port
range offset by 10000 (3449 -> 13449 -> 23449, etc.).

./manage.sh run-devenv-agentic --n-instances N reconciles the running set
to exactly {ws0..ws(N-1)}: missing instances are created (workspace sync
from the live repo via git ls-files + per-instance env-file generation
under docker/devenv/instances/ + detached tmux startup), surplus instances
are stopped highest-first via compose down (never -v), already-running
instances are left untouched. ws0 binds the live repo at PWD; ws1+ are
scratch clones under ~/.penpot/penpot_workspaces/.

Backend workers (enable-backend-worker) are gated on PENPOT_BACKEND_WORKER
in backend/scripts/_env; ws1+ overlays disable them so async-task
notifications stay bound to a single Valkey Pub/Sub instance.

Compose helpers wrap docker compose with env -i so per-instance overlay
--env-file actually overrides defaults.env -- without the strip, the shell
env from sourcing defaults.env at startup would shadow the overlay (Compose
gives shell precedence over --env-file).

Other:
- Drop network aliases (- main, - redis); use container_name for
  cross-container DNS so multiple instances on the shared network don't
  fight over the same DNS name.
- Pin volume names via name: (PENPOT_*_VOLUME) so volumes survive project
  renames; ws0 keeps the pre-existing physical names (penpotdev_*).
- Remove cross-project depends_on from main.yml (postgres/minio-setup now
  live in penpotdev-infra); manage.sh ensure-infra-up docker-waits on the
  minio-setup one-shot.
- Strict arg parsing in run-devenv / run-devenv-agentic; --n-instances 0
  rejected.
- Remove unused Host-matched server block from the Caddyfile.

Memory mem:devenv/core and developer docs updated.

Co-authored-by: Codex <codex@openai.com>

*  Document and stabilise the parallel-workspace CLI; wire AI agents

Improve parallel-workspaces developer CLI,
and add an opt-in layer that lets four AI
coding agents (Claude Code, opencode, VS Code Copilot, OpenAI Codex CLI)
drive a specific workspace through a single launcher command.

Parallel-workspace semantics
----------------------------

each run-devenv-agentic call brings up one wsN;
--ws N (integer; default 0) targets a specific workspace and auto-starts
ws0 first when N>=1 so the worker invariant holds. --sync is forbidden on
ws0 and re-seeds the workspace from the live repo for ws1+. Stop semantics
mirror the start invariant -- ws0 is the last to stop, shared infra stops
with it, --all walks every instance highest-first. The worker policy
section explains why workers run only on ws0 (Postgres FOR UPDATE
SKIP LOCKED is safe across many workers but the cron dedup primitive is
best-effort, and :telemetry / :audit-log-archive are not idempotent).
Per-instance Valkey Pub/Sub isolation, msgbus topology, and the
"async task notifications miss ws1+ tabs" caveat are stated explicitly.

The mem:prod-infra/core memory captures the same external-services and
task-queue / Pub-Sub topology in agent-readable form, and
mem:backend/core and mem:critical-info now cross-link it so backend work
surfaces the horizontal-scaling constraints from the start.

AI coding agent integration
---------------------------

New top-level .devenv/ directory holds committed templates
(templates/{claude-code,opencode,vscode}.json and templates/codex.toml,
each with \${PENPOT_MCP_PORT} and \${SERENA_MCP_PORT} placeholders) plus
committed shared entries (matching shared/* files for Playwright, the
only workspace-independent server we ship today).

./manage.sh start-coding-agent <claude|opencode|vscode|codex> [--ws N]
launches the chosen client against one workspace. It cd's into the
target's directory (the live repo for ws0; workspace-path "wsN" for ws1+)
and refuses to launch unless (a) the binary is on PATH, (b) the
workspace directory exists for ws1+, and (c) the instance is up
(devenv-main-running) -- the MCP servers only exist while the devenv is
running. The agentic-devenv guide is restructured around this Quick
start path, with a per-client table and a Manual configuration fallback
for clients we don't cover.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ Scope the shadow devtools to the dev build

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 15:48:25 +02:00
Andrey Antukh
88f50b6ddd Merge remote-tracking branch 'origin/staging' into develop 2026-06-03 14:36:30 +02:00
Andrey Antukh
7ddc93a4df Merge remote-tracking branch 'origin/staging' into develop 2026-06-03 14:19:47 +02:00
yong2bba
bb89ca526b
Avoid deduplicating temporary export files (#9959)
* 🐛 Avoid deduplicating temporary export files

* 📎 Update changelog

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: yongjin <yongjin@yongjinui-Macmini.local>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-03 14:08:10 +02:00
Aitor Moreno
ff3587ca2d
Merge pull request #9997 from penpot/elenatorro-fix-background-clear
🐛 Fix clear canvas
2026-06-03 12:02:48 +02:00
Dexterity
ea0e248d4b
♻️ Migrate release-notes to modern component syntax (#9436)
* ♻️ Migrate release-notes to modern component syntax

* 📎 Minor changes

Remove props metadata from release-notes component.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-02 18:12:34 +02:00
Dexterity
8b9a7b257f
♻️ Migrate rulers component to modern syntax (#9437)
* ♻️ Migrate rulers component to modern syntax

* 📎 Remove the usage of mf/memo on rules

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-02 18:12:03 +02:00
Dexterity
cb5f59533d
♻️ Migrate color-item asset component to modern syntax (#9440)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-02 18:11:33 +02:00
Andrey Antukh
feca7cef41 Merge remote-tracking branch 'origin/staging' into develop 2026-06-02 17:50:45 +02:00
Alonso Torres
ba9d225c2b
🐛 Fix stroke-cap-start/end stored at wrong level in SVG imports (#9982) 2026-06-02 17:42:35 +02:00
Elena Torro
d9fea603f8 🐛 Fix clear canvas 2026-06-02 17:14:25 +02:00
ruizterce
e6f5b270de
💄 Fix typos in configuration.md (#9975)
Corrected typos in the configuration documentation.

Signed-off-by: ruizterce <127963868+ruizterce@users.noreply.github.com>
2026-06-02 16:32:34 +02:00
David Barragán Merino
9df1e99c08 🔧 Remove the confirmation step for publishing docker images 2026-06-02 11:02:35 +02:00
Andrey Antukh
1f2f1bdaf4
📚 Add minor improvements to AGENTS.md and serena memories (#9919)
* 📚 Add minor improvements to AGENTS.md and serena memories

*  Add minor format and linter restructuration on memories
2026-06-02 10:39:51 +02:00
Andrey Antukh
7517ba1559 Merge remote-tracking branch 'origin/staging' into develop 2026-06-02 10:38:54 +02:00
Marina López
fc3a95765d Add expired subscription banner 2026-06-02 10:26:56 +02:00
Juanfran
e12e5f8373 🐛 Fix overflow on delete account modal with many owned orgs 2026-06-02 09:47:02 +02:00
Aitor Moreno
d0f6d5b3a1
♻️ Refactor render pipeline (#9891)
* ♻️ Refactor viewbox

* 🎉 Add draw_atlas alternative to draw tiles

* 🐛 Fix minor glitches

* ♻️ Change how process_animation_frame works

* ♻️ Refactor document atlas

* ♻️ Refactor max texture size

* ♻️ Refactor entrypoints and dead_code
2026-06-02 09:38:52 +02:00
María Valderrama
7bf519a127 Inherit subscriptions perks to Nitrate 2026-06-02 09:33:02 +02:00
Andrés Moya
06c9a18ab0
🔧 Revert migration for tokens with clashing names (#9950)
* Revert "🐛 Detect duplicated token names in the whole library (#9034)"

This reverts commit 61cd7573553b1c5e9fc2d7300cf9b2c36b4dcbb6.

* 🔧 Preserve some enhancements and fixes that are still valid

* 🔧 Fix broken integration tests
2026-06-02 09:09:58 +02:00
Eva Marco
53a4d2a18a
🐛 Fix CI (#9952) 2026-06-01 17:47:17 +02:00
Andrey Antukh
5d80f7f5b2 🌐 Validate and Rehash translation files 2026-06-01 14:58:07 +02:00
Anonymous
d91f5be12f
🌐 Add translations for: Spanish
Currently translated at 95.2% (2226 of 2338 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es/
2026-06-01 14:56:07 +02:00
Nicola Bortoletto
78609f776d
🌐 Add translations for: Italian
Currently translated at 89.7% (2099 of 2338 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2026-06-01 14:56:07 +02:00
Nicola Bortoletto
badf38922c
🌐 Add translations for: Italian
Currently translated at 90.1% (2100 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2026-06-01 13:52:27 +02:00
VKing9
d9257d8187
🌐 Add translations for: Hindi
Currently translated at 84.5% (1969 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/hi/
2026-06-01 13:52:27 +02:00
K.B.Dharun Krishna
2163d40c8c
🌐 Add translations for: Tamil
Currently translated at 1.9% (46 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ta/
2026-06-01 13:52:26 +02:00
Henrik Allberg
4c18837c12
🌐 Add translations for: Swedish
Currently translated at 83.9% (1956 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2026-06-01 13:52:26 +02:00
Hugo Vermaak
92cfe174e8
🌐 Add translations for: Afrikaans
Currently translated at 3.2% (75 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/af/
2026-06-01 13:52:26 +02:00
Late Night Defender
68aa5c0ce7
🌐 Add translations for: Thai
Currently translated at 7.8% (184 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/th/
2026-06-01 13:52:26 +02:00
Revenant
0ea1b6d95a
🌐 Add translations for: Malay
Currently translated at 27.7% (646 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ms/
2026-06-01 13:52:26 +02:00
Eranot
ab7ce12785
🌐 Add translations for: Portuguese (Brazil)
Currently translated at 59.0% (1375 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt_BR/
2026-06-01 13:52:26 +02:00
Renan Mayrinck
948936116a
🌐 Add translations for: Portuguese (Brazil)
Currently translated at 59.0% (1375 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt_BR/
2026-06-01 13:52:26 +02:00
deveronica
095ab6d822
🌐 Add translations for: Korean
Currently translated at 85.3% (1988 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ko/
2026-06-01 13:52:26 +02:00
Dário
e64a83e995
🌐 Add translations for: Portuguese (Portugal)
Currently translated at 66.1% (1542 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt_PT/
2026-06-01 13:52:26 +02:00
TheScientistPT
b3ffb63434
🌐 Add translations for: Portuguese (Portugal)
Currently translated at 66.1% (1542 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt_PT/
2026-06-01 13:52:26 +02:00
Tummas Jóhan Sigvardsen
b8c7954f98
🌐 Add translations for: Faroese
Currently translated at 7.0% (164 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fo/
2026-06-01 13:52:26 +02:00
AlexTECPlayz
57477f203e
🌐 Add translations for: Romanian
Currently translated at 82.0% (1911 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ro/
2026-06-01 13:52:26 +02:00
George Lemon
4e6eb83829
🌐 Add translations for: Romanian
Currently translated at 82.0% (1911 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ro/
2026-06-01 13:52:26 +02:00
Црнобог
79880593f5
🌐 Add translations for: Serbian
Currently translated at 57.5% (1341 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sr/
2026-06-01 13:52:26 +02:00
Nicola Bortoletto
d85576d6ee
🌐 Add translations for: Italian
Currently translated at 86.5% (2016 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2026-06-01 13:52:26 +02:00
Valentina Chapellu
50d2af9930
🌐 Add translations for: Italian
Currently translated at 86.5% (2016 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/it/
2026-06-01 13:52:26 +02:00
Mikel Larreategi
a0c1e519ba
🌐 Add translations for: Basque
Currently translated at 49.4% (1153 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/eu/
2026-06-01 13:52:26 +02:00
Louis Chance
db98140d3a
🌐 Add translations for: French
Currently translated at 85.7% (1999 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2026-06-01 13:52:26 +02:00
Ingrid Pigueron
879d9df47e
🌐 Add translations for: French
Currently translated at 85.7% (1999 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2026-06-01 13:52:26 +02:00
Pablo Alba
11ee30d05a
🌐 Add translations for: French
Currently translated at 85.7% (1999 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr/
2026-06-01 13:52:25 +02:00
Alexis Morin
ef07cb8f7c
🌐 Add translations for: French (Canada)
Currently translated at 85.4% (1991 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-06-01 13:52:25 +02:00
Simon Bechmann
9c3c1fafec
🌐 Add translations for: Danish
Currently translated at 4.5% (105 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/da/
2026-06-01 13:52:25 +02:00
Vin
0c99a64cf0
🌐 Add translations for: Russian
Currently translated at 71.4% (1664 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2026-06-01 13:52:25 +02:00
Egor Filatov
50d8c581f2
🌐 Add translations for: Russian
Currently translated at 71.4% (1664 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2026-06-01 13:52:25 +02:00
The_BadUser
197808c2af
🌐 Add translations for: Russian
Currently translated at 71.4% (1664 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ru/
2026-06-01 13:52:25 +02:00
Stephan Paternotte
a9712ab77e
🌐 Add translations for: Dutch
Currently translated at 86.7% (2021 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2026-06-01 13:52:25 +02:00
Sebastiaan Pasma
fe0bc1f0b8
🌐 Add translations for: Dutch
Currently translated at 86.7% (2021 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2026-06-01 13:52:25 +02:00
Radek Sawicki
b566e6df01
🌐 Add translations for: Polish
Currently translated at 48.4% (1128 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pl/
2026-06-01 13:52:25 +02:00
Oğuz Ersen
c74750664d
🌐 Add translations for: Turkish
Currently translated at 86.6% (2020 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2026-06-01 13:52:25 +02:00
Anonymous
cbbefa3410
🌐 Add translations for: Turkish
Currently translated at 86.6% (2020 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/tr/
2026-06-01 13:52:25 +02:00
Vincas Dundzys
0aa7c06309
🌐 Add translations for: Lithuanian
Currently translated at 5.0% (118 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lt/
2026-06-01 13:52:25 +02:00
Josep Ponsà
f5ac88d43e
🌐 Add translations for: Catalan
Currently translated at 45.7% (1067 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ca/
2026-06-01 13:52:25 +02:00
Aryiu
f6b883b44b
🌐 Add translations for: Catalan
Currently translated at 45.7% (1067 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ca/
2026-06-01 13:52:25 +02:00
Zvonimir Juranko
25e4597d1a
🌐 Add translations for: Croatian
Currently translated at 67.3% (1570 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/hr/
2026-06-01 13:52:25 +02:00
Ahmad HosseinBor
62e840d6c0
🌐 Add translations for: Persian
Currently translated at 32.7% (763 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fa/
2026-06-01 13:52:25 +02:00
Semon Xue
e64840e788
🌐 Add translations for: Chinese (Simplified Han script)
Currently translated at 76.0% (1773 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hans/
2026-06-01 13:52:25 +02:00
Maemolee
8185fe51ea
🌐 Add translations for: Chinese (Simplified Han script)
Currently translated at 76.0% (1773 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hans/
2026-06-01 13:52:25 +02:00
Tatsuto Yamamoto
d69ba665ac
🌐 Add translations for: Japanese
Currently translated at 10.1% (236 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ja/
2026-06-01 13:52:25 +02:00
william chen
13afcb372d
🌐 Add translations for: Chinese (Traditional Han script)
Currently translated at 67.3% (1569 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hant/
2026-06-01 13:52:25 +02:00
Andy Li
89af02da96
🌐 Add translations for: Chinese (Traditional Han script)
Currently translated at 67.3% (1569 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/zh_Hant/
2026-06-01 13:52:25 +02:00
Yaron Shahrabani
3ca98e34df
🌐 Add translations for: Hebrew
Currently translated at 86.5% (2017 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/he/
2026-06-01 13:52:25 +02:00
ascarida
c69a834412
🌐 Add translations for: Galician
Currently translated at 15.8% (370 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/gl/
2026-06-01 13:52:25 +02:00
Joseph V M
75ca626f55
🌐 Add translations for: Malayalam
Currently translated at 2.2% (52 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ml/
2026-06-01 13:52:25 +02:00
Ņikita K.
01ea3be262
🌐 Add translations for: Latvian
Currently translated at 79.2% (1846 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lv/
2026-06-01 13:52:25 +02:00
Edgars Andersons
d6a0fac9ab
🌐 Add translations for: Latvian
Currently translated at 79.2% (1846 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/lv/
2026-06-01 13:52:25 +02:00
Alejandro Alonso
1e66f8d637
🌐 Add translations for: Yoruba
Currently translated at 49.1% (1146 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/yo/
2026-06-01 13:52:25 +02:00
Alejandro Alonso
e472304d64
🌐 Add translations for: Igbo
Currently translated at 21.0% (490 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ig/
2026-06-01 13:52:25 +02:00
Amerey.eu
19faebf292
🌐 Add translations for: Czech
Currently translated at 67.1% (1565 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/cs/
2026-06-01 13:52:25 +02:00
matl-17
af36428a29
🌐 Add translations for: Czech
Currently translated at 67.1% (1565 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/cs/
2026-06-01 13:52:25 +02:00
Anonymous
901ffe0c09
🌐 Add translations for: Greek
Currently translated at 21.8% (509 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/el/
2026-06-01 13:52:25 +02:00
Denys Kisil
1d4e4aa7df
🌐 Add translations for: Ukrainian (ukr_UA)
Currently translated at 85.9% (2002 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ukr_UA/
2026-06-01 13:52:25 +02:00
Linerly
6690803559
🌐 Add translations for: Indonesian
Currently translated at 71.5% (1668 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/id/
2026-06-01 13:52:25 +02:00
liimee
2b3a256461
🌐 Add translations for: Indonesian
Currently translated at 71.5% (1668 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/id/
2026-06-01 13:52:25 +02:00
Stas Haas
490a7bc046
🌐 Add translations for: German
Currently translated at 83.4% (1945 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2026-06-01 13:52:25 +02:00
Marius
6974dfdd4d
🌐 Add translations for: German
Currently translated at 83.4% (1945 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2026-06-01 13:52:25 +02:00
Pablo Alba
6e985a460f
🌐 Add translations for: German
Currently translated at 83.4% (1945 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/de/
2026-06-01 13:52:25 +02:00
Alejandro Alonso
47d6601e13
🌐 Add translations for: Hausa
Currently translated at 52.0% (1212 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ha/
2026-06-01 13:52:24 +02:00
Andrey Antukh
dadab03891
🌐 Add translations for: Spanish
Currently translated at 95.3% (2221 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es/
2026-06-01 13:52:24 +02:00
Andrés Moya
adc0c967f3
🌐 Add translations for: Spanish
Currently translated at 95.3% (2221 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es/
2026-06-01 13:52:24 +02:00
Anderson Paulo
ed6e4db749
🌐 Add translations for: Portuguese
Currently translated at 3.2% (75 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/pt/
2026-06-01 13:52:24 +02:00
Yessenia Villarte Vaca
8a9e2722ab
🌐 Add translations for: Spanish (Latin America)
Currently translated at 4.8% (114 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/es_419/
2026-06-01 13:52:24 +02:00
jonnysemon
582dd3beef
🌐 Add translations for: Arabic
Currently translated at 48.0% (1120 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ar/
2026-06-01 13:52:24 +02:00
Amine Gdoura
5a7a8aa83d
🌐 Add translations for: Arabic
Currently translated at 48.0% (1120 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/ar/
2026-06-01 13:52:24 +02:00
Anonymous
a77147a22b
🌐 Add translations for: Finnish
Currently translated at 2.4% (58 of 2330 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fi/
2026-06-01 13:52:24 +02:00
Hosted Weblate
c753506039
🌐 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/
2026-06-01 13:52:24 +02:00
Andrey Antukh
4a8fb5af53 Merge remote-tracking branch 'origin/staging' into develop 2026-06-01 13:15:57 +02:00
Andrey Antukh
9e12e413ca
🐛 Fix typo in icon name elipse to ellipse (#9948)
Rename the icon file and fix the icon ID from "elipse" (single "l")
to "ellipse" (double "l") across the codebase.

The root cause was a mismatch: the icon file/ID was named "elipse"
but get-shape-icon returns "ellipse" for circle shapes. Since the
icon* component validates icon-id against the auto-generated
icon-list set, the string "ellipse" failed validation.

Changes:
- Rename frontend/resources/images/icons/elipse.svg to ellipse.svg
- Fix icon def in icon.cljs: ^:icon-id elipse -> ^:icon-id ellipse
- Fix deprecated icon in icons.cljs: ^:icon elipse -> ^:icon ellipse
- Fix usage in top_toolbar.cljs: deprecated-icon/elipse -> ellipse
- Fix usage in history.cljs: deprecated-icon/elipse -> ellipse

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-01 12:49:24 +02:00
Dexterity
a9e88b8fa8
🐛 Translate layout panel header and add-layout options (#9424)
* 🐛 Translate layout panel header and add-layout options

* ♻️ Wrap shared layout dropdown binding in mf/html
2026-06-01 12:13:56 +02:00
Dexterity
67f6786809
♻️ Migrate plugin-entry to modern component syntax (#9462)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-01 11:56:23 +02:00
BitToby
4b2ddfd7b2
♻️ Migrate inspect annotation to modern component syntax (#9402)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-01 11:56:10 +02:00
Andrey Antukh
a9c0b5394c
Refresh dependencies on plugins directory (#9935) 2026-06-01 10:07:06 +02:00
Josh Cullum
156c888e2d
🐛 Add STS dependency for IRSA/web identity token S3 auth (#9928)
The S3 storage backend uses DefaultCredentialsProvider which includes
  WebIdentityTokenFileCredentialsProvider in its chain. However, that
  provider requires software.amazon.awssdk/sts on the classpath to call
  AssumeRoleWithWebIdentity. Without it, the provider silently fails and
  credentials cannot be resolved when using IRSA on EKS.

  Closes #9927

  Signed-off-by: Joshua C <joshua.cullum@gmail.com>

Co-authored-by: Joshua C <joshua.c@data-edge.co.uk>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-01 10:06:18 +02:00
Dexterity
61d44a374a
♻️ Migrate session-widget to modern component syntax (#9460)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-01 10:04:54 +02:00
John Eismeier
c156559f2c
📚 Fix several typos on code comments and messages (#9946)
Signed-off-by: John E <jeis4wpi@outlook.com>
2026-06-01 09:43:07 +02:00
Dexterity
d7c155ac4f
🐛 Route render fallback errors through the project logger (#9421)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-06-01 09:40:12 +02:00
Francis Santiago
3d7dbbe6fc
🐛 Fix duplicated GitHub issue templates (#9937)
Signed-off-by: Francis Santiago <francis.santiago@kaleidos.net>
2026-05-29 13:00:54 +02:00
Jeff
300be392f6
🐛 Fix onboarding template spinner stuck after failed download (#9504)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-29 12:13:37 +02:00
Jeff
bec21e69e6
🐛 Preserve explicit hide-in-viewer when adding prototype interactions (#9695)
`cls/show-in-viewer` unconditionally dissoc'ed `:hide-in-viewer` on the
interaction destination, so every `add-interaction`, `add-new-interaction`,
and `update-interaction` call silently re-enabled the destination's
view-mode visibility — even when the user had just deliberately hidden
that frame. Reporter (#9049) hid a board, dragged a prototype arrow at
it, and watched the board reappear in View Mode.

Make `show-in-viewer` a no-op when the destination already has
`:hide-in-viewer true`. The auto-unhide still fires on destinations with
no explicit hide flag (the original ergonomic — new prototype targets
default to visible), but explicit user intent is now preserved across
interaction-add / interaction-update.

Behaviour change: dropping the auto-unhide on explicitly-hidden
destinations matches the reporter's expectation ("nothing would show up
in View Mode unless explicitly marked as such") and the surrounding
`:hide-in-viewer`-aware UI in `measures.cljs`, which already lets users
toggle the same property directly.

Closes #9049.

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-29 11:31:59 +02:00
Pablo Alba
c51a137ca9 Add nitrate permission team members design review changes 2026-05-29 11:23:53 +02:00
Chan
ac3950e36c
🐛 Fix CORS middleware reflecting arbitrary origins (#9675)
*  Align profile and dashboard files with penpot develop

* 🐛 Fix CORS origin allowlist for issue #9659

---------

Signed-off-by: Chan <101856681+enjoyandlove@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-29 11:23:16 +02:00
NativeTeachingAidsB
b08ceca81d
🐛 Remove dead css/ui.css <link> from frontend index template (#9840)
Fixes #9135.

The <link href="css/ui.css">  tag in
frontend/resources/templates/index.mustache references a CSS file that
the build pipeline never produces:

- compileStyles() in frontend/scripts/_helpers.js only writes main.css
  (always) and debug.css (dev-only) — there is no write to ui.css
- compileStorybookStyles() writes ds.css (design system), not ui.css
- No ui.scss source exists anywhere in frontend/resources/styles/

The reference was added in 45d04942c (" Add example ui
storybook") but no corresponding build step was added to emit the file.

Result: every page load issues a request for /css/ui.css that nginx
returns as 404. In self-hosted Penpot deployments behind a reverse
proxy, the SPA's CSS init promise rejects on the 404, the React root
never mounts, and the user sees a black screen.

This patch removes the dead reference. If a future change actually
emits ui.css (or another distinct UI bundle), the <link> can be
re-added at that time.

Co-authored-by: Admin <admin@Admins-MacBook-Pro.local>
2026-05-29 11:19:52 +02:00
Bolaji Ayodeji
3b6cefbb85
Update Verified DPG badge in README (#9815)
Signed-off-by: Bolaji Ayodeji <sirbeejay1@gmail.com>
2026-05-29 11:16:37 +02:00
Dexterity
c4a5f0098e
♻️ Migrate color-bullet and color-name to modern component syntax (#9433)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-29 09:56:12 +02:00
Dexterity
53b1837b11
♻️ Migrate button-link to modern component syntax (#9428)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-29 09:46:28 +02:00
Dexterity
01ac1529e1
♻️ Migrate perf/profiler to modern component syntax (#9429)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-29 09:41:21 +02:00
Floris
ede1cd86f4
📚 Update available plugin link to actual Penpot Hub URL (#9481)
Signed-off-by: Floris <floris@fmjansen.nl>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-29 09:27:48 +02:00
Milos Milic
9c6e3f5ec3
🐛 Fix Storybook docs and canvas pages missing scrollbar (#6049) (#9319)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-28 16:25:06 +02:00
Dexterity
78597374ab
♻️ Migrate history-entry to modern component syntax (#9461)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-28 15:37:20 +02:00
Milos Milic
09c274bd92
🐛 Match version preview banner text to History sidebar labels (#9697)
The version preview banner in `enter-preview` derives its title from
`(:label snapshot)` directly. For system-created autosaves that
field is the internal snapshot label (e.g. `internal/snapshot/20`),
so the banner shows the raw internal string while the History sidebar
already renders the same autosave through `workspace.versions.autosaved.version`
plus a localized date. The mismatch makes it hard to be sure which
sidebar entry you're previewing, especially when several autosaves
sit close together (#9503).

Switch the label resolution to mirror the sidebar's `snapshot-entry*`:
- `:created-by "system"` snapshots format the label as
  `(tr "workspace.versions.autosaved.version" (ct/format-inst ...
   :localized-date))` — the exact same translation key + date format
  the sidebar's autosave group already uses
- `:created-by "user"` (pinned) versions keep their custom `:label`
  with the existing `unnamed` fallback

No behavior change for pinned/user-named versions or for the
restore/exit dialog buttons.

Closes #9503.

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-28 15:26:15 +02:00
Aitor Moreno
bda977202a
🐛 Fix shift enter not working on text editor v2 2026-05-28 11:52:46 +02:00
Andrés Moya
1f35f57258
🐛 Fix DTCG token import discriminator and group-level $type inheritance (#9912)
* 🐛 Fix DTCG token import discriminator and group-level $type inheritance

Closes #8342.

The DTCG Community Group Final Report (W3C, 2025-10-28) specifies:

  "The presence of a $value property definitively identifies an object
   as a token."

  "A token's type can be specified by the optional $type property [...]
   Furthermore, the $type property on a group applies to all tokens
   nested within that group."

Two bugs in `common/src/app/common/types/tokens_lib.cljc` violate the
spec and silently break import of any third-party DTCG file that uses
group-level type inheritance:

1. `flatten-nested-tokens-json` used `(not (contains? v "$type"))` as
   the group-vs-token discriminator. A group node carrying only a
   `$type` (to set a default for child tokens) was misidentified as a
   token, then immediately discarded because it had no `$value`.

2. `schema:dtcg-node` declared both `$type` and `$value` as required,
   so even after the discriminator was fixed any leaf token that
   relied on group-level type inheritance failed `dtcg-node?`
   validation and never reached the parser.

The combined effect: importing a spec-compliant DTCG file that
expressed types at the group level produced a TokensLib with no
tokens at all, because every leaf was discarded as "unknown type".

Penpot-exported files were unaffected because Penpot always emits
both `$type` and `$value` on every token and never attaches `$type`
to a group, so the existing tests covered only the inline-type
shape.

- `schema:dtcg-node`: mark `$type` optional.
- `flatten-nested-tokens-json`: use `$value` as the discriminator
  (anything without `$value` is a group), accept an optional
  `inherited-type` accumulator that carries the nearest enclosing
  group `$type` down through recursion, and resolve a token's type
  from its own `$type` first, falling back to the inherited type.
  A token's own `$type` always wins over the inherited one (per
  spec).

Added `parse-dtcg-group-type-inheritance` covering both cases:
- group `$type` is inherited by tokens that don't declare their own
  (`colors.red`, `colors.blue`, `space.small`)
- token `$type` overrides the inherited group `$type`
  (`colors.danger`, `space.large`)

Existing DTCG round-trip tests continue to pass because they all
declare `$type` at the token level, which the new code still honours.

CHANGES.md entry added under the 2.17.0 Bugs-fixed section.

* 📚 Do not update CHANGES.md

We are changing the procedures to not update the changelog on each PR. Instead, we use github tracking to check what issues come in a release, and update the changelog automatically in a batch.

Signed-off-by: Andrés Moya <hirunatan@hammo.org>

---------

Signed-off-by: Andrés Moya <hirunatan@hammo.org>
Co-authored-by: MilosM348 <milos.milic001@outlook.com>
2026-05-28 11:01:11 +02:00
Juanfran
4c8b33691a Use shared org-avatar component in delete account modal
Render owned organizations in the delete-account modal with the same
org-avatar* component used across the dashboard, so logo and avatar
background are shown consistently and initials are extracted via
d/get-initials instead of a raw first-character substring.

Extends the get-owned-organizations-summary endpoint and the underlying
nitrate API schema to carry :avatar-bg-url and :logo-id, deriving
:custom-photo from logo-id with the public uri, matching the pattern
already used by set-team-org-api.
2026-05-28 11:01:08 +02:00
María Valderrama
dd7d5bb113 🐛 Fix nitrate's plan button size 2026-05-28 10:23:06 +02:00
Juanfran
5c5ee73f2d
🐛 Fix createToken calls missing textFieldType argument 2026-05-28 09:36:33 +02:00
Andrey Antukh
8430358621 Merge remote-tracking branch 'origin/staging' into develop 2026-05-27 17:46:44 +02:00
Andrey Antukh
bee1a89698 Add minor usability improvements to update-changelog skill 2026-05-27 17:44:50 +02:00
Andrey Antukh
50ec6ad777 📚 Add missing entries on changelog 2026-05-27 16:11:14 +02:00
Andrey Antukh
3ba70337ea 📎 Update changelog 2026-05-27 15:37:46 +02:00
María Valderrama
1a0e497c84 Remove nitrate's yearly plan 2026-05-27 15:07:22 +02:00
Belén Albeza
0dd40776f8
🐛 Fix default path stroke thickness 2026-05-27 14:47:11 +02:00
Alonso Torres
0fe59cac94
🐛 Fix problem with fill/stroke proxy properties (#9647) 2026-05-27 14:05:28 +02:00
girafic
763ec4c4fe
📎 Update changelog for #8632 (#9701)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-27 13:50:33 +02:00
Eva Marco
7d76a1caa3
🐛 Fix settings dropdown (#9883) 2026-05-27 13:44:06 +02:00
Andrey Antukh
1a1c7355e2 Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2026-05-27 13:37:17 +02:00
Andrey Antukh
3858993a57 Merge remote-tracking branch 'origin/staging' into develop 2026-05-27 13:37:02 +02:00
María Valderrama
15d6df48f5 🐛 Fix default team showing up in count 2026-05-27 13:36:35 +02:00
Eva Marco
5ffec3e5e9
🐛 Fix shadow token creation 2026-05-27 13:06:47 +02:00
Pablo Alba
3cecc29276 🐛 Fix update library dialog when a component position changes
Do not show the library sync popup when the only differences are global x/y changes on library components. We now generate the actual sync changes and only notify if there are real redo-changes to apply.
Run cll/generate-sync-file-changes for candidate libraries and filter out those with empty :redo-changes. The expensive check is deferred via rx/timer 0 so it runs asynchronously and does not block the UI.
Why: Position-only changes are normalized during sync (via reposition-shape) and never propagate to copies; showing the popup in that case was a false positive.
Performance: The check is deferred to the next tick to avoid UI stutter on large files with many libraries.
2026-05-27 11:22:57 +02:00
Dexterity
56d8dc678c
🐛 Populate is-indirect flag on file libraries from relation graph (#9289)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-27 09:23:48 +02:00
Eva Marco
0eb8cabd39
🐛 Fix text color on tooltip (#9851) 2026-05-26 16:13:58 +02:00
Renzo
02ab41f420
🐛 Token remap preserves child component sync after renaming a token group (#9566)
* 🐛 Token remap preserves child component sync after renaming a token group

* 📚 Do not update CHANGES.md

We are changing the procedures to not update the changelog on each PR. Instead, we use github tracking to check what issues come in a release, and update the changelog automatically in a batch.

Signed-off-by: Andrés Moya <andres.moya@kaleidos.net>

---------

Signed-off-by: Andrés Moya <andres.moya@kaleidos.net>
Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
2026-05-26 15:46:53 +02:00
María Valderrama
04d4abc766 📎 Code review 2026-05-26 13:34:19 +02:00
María Valderrama
8542d44aaa 🐛 Fix nitrate remove-team permission flow 2026-05-26 13:34:19 +02:00
Pablo Alba
a637dda554 Check nitrate permission only org members for move teams 2026-05-26 13:25:20 +02:00
Juanfran
5c93ad0ab3 🐛 Fix delete account modal copy for users with organizations 2026-05-26 12:43:50 +02:00
Eva Marco
10074bc700
🎉 Add combobox test (#9864) 2026-05-26 10:52:09 +02:00
Marina López
40b1757c6e
🐛 Fix separate penpot from organizations (#9853) 2026-05-25 15:49:34 +02:00
Marina López
b9e13c12f2
🐛 Fix subscription plan icon alignment (#9857) 2026-05-25 15:49:16 +02:00
Juanfran
0b84ada977 🐛 Fix unavailable logo state to match the design 2026-05-25 15:20:11 +02:00
María Valderrama
81f1668e3d 🐛 Fix nitrate invitation empty state layout 2026-05-25 15:15:04 +02:00
María Valderrama
87384aaccd 🐛 Fix nitrate delete and leave org flow 2026-05-25 14:39:03 +02:00
Juanfran
6b3d4e38b0 🎉 Enable Nitrate renewal with a manual activation code
Add a manual activation code flow to renew Nitrate when automatic activation is not available.
2026-05-25 14:37:14 +02:00
Marina López
57d47f8e5e
🐛 Fix navigate to admin console after subscription (#9848) 2026-05-25 13:06:52 +02:00
Eva Marco
f3f697b4a2
🐛 Fix colorpicker inputs (#9793) 2026-05-25 10:45:49 +02:00
Eva Marco
841ad69d26
🐛 Fix typography asset name color and ellipsis (#9784) 2026-05-25 07:48:49 +02:00
Pablo Alba
dac98c0625 Add nitrate add team members permission 2026-05-23 17:18:27 +02:00
Andrés Moya
3e733bb762
🐛 Skip group nodes when processing StyleDictionary tokens (#9025) (#9825)
StyleDictionary returns all nodes from the token tree, including
intermediate group nodes that have no corresponding origin token.
Previously process-sd-tokens tried to parse these as real tokens,
which produced garbage resolved values (e.g. {"$meta$": null, …})
and caused the entire token set validation to fail, blocking all
token creation and editing.

Skip sd-tokens whose origin lookup returns nil so that only actual
tokens are processed.

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Signed-off-by: Andrés Moya <andres.moya@kaleidos.net>
Co-authored-by: moorsecopers99 <46223049+moorsecopers99@users.noreply.github.com>
2026-05-22 12:46:09 +02:00
Xaviju
ee1b61914e
🐛 Scroll to newly created tokens on the token tree (#9803) 2026-05-22 11:32:51 +02:00
Marina López
1d8da08144
🐛 Fix typo in organization name invitation email (#9817) 2026-05-22 09:59:24 +02:00
María Valderrama
3527ffdc4d 🐛 Fix navigation to admin console 2026-05-22 09:49:26 +02:00
Marina López
1688741c21
Separate penpot from rest of organizations (#9753) 2026-05-22 08:50:07 +02:00
David Barragán Merino
d5bbfc43d3 🔧 Change the path to the cache directories in the custom runner 2026-05-21 19:02:42 +02:00
Juanfran
e6848170c8 🎉 Show dedicated screen when Nitrate is unavailable 2026-05-21 14:47:32 +02:00
Michael Panchenko
cec90416c2 Improve common JS test runner
Add focused common JavaScript test execution with log-level control and a quiet test wrapper matching the frontend workflow.

Update developer docs and testing memories to reflect the common/frontend test split, and document why runner helper extraction is deferred.

Signed-off-by: Codex <codex@openai.com>
2026-05-21 14:20:10 +02:00
Michael Panchenko
e252bcf901 Add --log-level flag to frontend test runner
Lets a caller pin `app.common.logging`'s level for the duration of a
test run via `--log-level <trace|debug|info|warn|error>`. The flag is
off by default, so when absent the runner doesn't touch logger state
and stdout looks exactly as before.

When passed, the runner calls `(l/setup! {:app level})` right before
dispatching to the test block, so production code exercised by tests
emits only at the requested level or higher.

  pnpm run test:quiet -- --focus frontend-tests.logic.groups-test \
                         --log-level warn

Composes with `--focus`; the two flags are independent.

Caveats worth knowing: top-level log calls fired at namespace load
time run before the runner parses CLI options and therefore slip past
this flag; direct `println` / `js/console.log` calls bypass the
logging system entirely and are unaffected.
2026-05-21 14:20:10 +02:00
Michael Panchenko
c29f32c7ae Add quiet variant of frontend test command
Introduces `pnpm run test:quiet` for non-interactive runs (CI, scripted
invocations, agent loops). It runs the same pipeline as `pnpm run test`
— `build:wasm`, then `build:test`, then `node target/tests/test.js` —
but buffers each build step's stdout and stderr and only replays them
when that step exits non-zero. Test-runner output streams through
unchanged, so failures and the summary are never hidden. Short progress
hints (`Building wasm...`, `Building test bundle...`, `Running tests...`)
are written to stderr, leaving stdout to carry only the test results
for clean capture and parsing.

Forwards arguments verbatim, so `pnpm run test:quiet -- --focus ...`
composes with the existing `--focus` flag. The default `pnpm run test`
script and its output are unchanged.

Also documents the new command in the developer guide and updates the
frontend testing memory to recommend it for agent runs.
2026-05-21 14:20:10 +02:00
Michael Panchenko
17041b53a7 Allow running a single frontend test via --focus
Previously `pnpm run test` always ran the full frontend-tests suite,
which made tight iteration on a single namespace or var painful. The
runner now accepts `--focus <ns>` or `--focus <ns>/<var>` and executes
only the matching tests, preserving each namespace's `:once` and `:each`
fixtures so behavior matches a full-suite run.

  pnpm run test -- --focus frontend-tests.logic.groups-test
  pnpm run test -- --focus frontend-tests.logic.groups-test/some-test

Also updates the developer guide and the testing memory so the flag is
discoverable from both docs and agent context.
2026-05-21 14:20:10 +02:00
Dominik Jain
63e7df5fda Add structured memories for agents
Memories use a system of progressive disclosure:
Starting from a root memory, memories reference other memories using explicit
references.

The new system of hierarchical memories replaces AGENTS.md files.

GitHub #9215

Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
Co-authored-by: Codex <codex@openai.com>
2026-05-21 14:20:10 +02:00
Dominik Jain
c7a4532838 Add Serena update mechanism for agentic devenv
Add SERENA_UPDATE_VERSION env var (in devenv docker-compose.yml)
to dynamically update Serena on agentic devenv without requiring
an image rebuild.
Apply for update to v1.5.0 (also changing initial installation
in Dockerfile to this version).
2026-05-21 14:20:10 +02:00
Eva Marco
2c453e4a00
🎉 Add dash and gap inputs for dashed strokes (#9765)
*  Add dash and gap customization for dashed strokes

Signed-off-by: eureka0928 <meobius123@gmail.com>

* ♻️ Change old numeric-inputs for new components

---------

Signed-off-by: eureka0928 <meobius123@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: eureka0928 <meobius123@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
2026-05-21 13:52:01 +02:00
Eva Marco
05fa8af479
🎉 Add search bar to prototype interaction destination dropdown (#9769)
*  Add search bar to prototype interaction destination dropdown

On pages with many boards the destination dropdown becomes hard to
navigate. Add an optional `searchable?` flag to the shared select
component that renders a case-insensitive filter input at the top of
the dropdown, and opt it in for the interaction destination select.

- Filtering reuses the already-computed option list (no extra queries).
- Arrow-key navigation tracks the filtered list.
- Search clears automatically when the dropdown closes, so reopening
  starts with the full list.
- New `labels.no-matches` i18n key renders when nothing matches.

Closes #8618

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>

*  Use trigger input as search field in destination dropdown

Per review on #9006, the searchable select now uses the visible trigger
input as the filter field itself rather than an extra sticky input
inside the dropdown. The trigger behaves like a filterable select:
typing filters the options without permitting free-text values.

Signed-off-by: moorsecopers99 <vadanamihai409@gmail.com>

* ♻️ Update css on old select component

---------

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Signed-off-by: moorsecopers99 <vadanamihai409@gmail.com>
Co-authored-by: moorsecopers99 <patellscott18@gmail.com>
Co-authored-by: moorsecopers99 <vadanamihai409@gmail.com>
Co-authored-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
2026-05-21 13:21:16 +02:00
María Valderrama
5c503591b4 🐛 Fix nitrate translation 2026-05-21 12:05:09 +02:00
Dexterity
5156866f20
♻️ Make ShapeImageIds byte conversion fallible (#9283)
Co-authored-by: Belén Albeza <belen.albeza@kaleidos.net>
2026-05-21 12:03:05 +02:00
María Valderrama
a157ecdc5b
Add nitrate advanced permissions for invite to teams
*  Add nitrate advanced permissions for invite to teams

* 📎 Code review
2026-05-20 16:02:37 +02:00
Eva Marco
371bd58878
👷 Fix develop CI (#9779) 2026-05-20 15:28:07 +02:00
Andrey Antukh
565ed042bc
🐛 Fix regression on shape rendering (#9762)
caused by previous merge
2026-05-20 11:58:34 +02:00
Pablo Alba
ead9bd9ccc 🐛 Make nitrate calls skip ssrf-check 2026-05-20 10:13:23 +02:00
Dr. Dominik Jain
14b53ecfec
Bound MCP memory consumption by limiting parallel exports & response size (#9748)
*  Bound the size of plugin task responses

When using the integrated remote MCP server, bound response size.
All responses are passed to LLMs, which themselves impose bounds.
This is a measure to bound memory usage in the centrally provided
MCP server.

GitHub #9493

*  Bound parallelism in ExportShapeTool

Use an integer semaphore to bound parallel requests to this
memory-intensive tool, thus bounding memory usage.

GitHub #9493

*  Add (manual) integration test script for ExportShapeTool parallelism

Add dependency tsx to facilitate executions.

GitHub #9493

*  Make number of parallel export requests configurable in ExportShapeTool

Use env var PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS to configure
the maximum number of requests in multi-user mode (default 0, no limit).
2026-05-19 19:37:29 +02:00
Dexterity
6be4f157d6
Avoid holding pool connection during font variant creation (#9287)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 17:38:55 +02:00
tmimmanuel
36c58287ae
♻️ Migrate debug playground to modern component syntax (#9367)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 17:38:30 +02:00
Dexterity
ade587968f
Cache OIDC provider records to skip per-login discovery (#9295)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 17:38:08 +02:00
Dexterity
bcc0b0d313
Validate shape on add-object to catch malformed inputs early (#9291)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 17:37:48 +02:00
wdeveloper16
83cc71e585
♻️ Migrate viewport snap, pixel-overlay and outline components to modern syntax (#9394)
Co-authored-by: wdeveloper16 <wdeveloer16@protonmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 17:37:31 +02:00
Alejandro Alonso
197c7c0f9a Merge remote-tracking branch 'origin/staging' into develop 2026-05-19 17:00:21 +02:00
Andrey Antukh
fd5ae84a9f 🚑 Fix syntax issue introduced in previous merges 2026-05-19 13:41:01 +02:00
Dexterity
408a9b033a
🐛 Fix conditional use-ctx hook violation in shape-wrapper (#9281)
* 🐛 Fix conditional use-ctx hook violation in shape-wrapper

*  Avoid subscribing non-root shapes to active-frames context

* 🐛 Wrap render-shape-content hiccup with mf/html

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 13:32:40 +02:00
tmimmanuel
54a866d0b5
♻️ Migrate workspace path-wrapper to modern component syntax (#9393)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 13:09:24 +02:00
Eva Marco
e854309049
🎉 Add typography token row to multiselected texts (#9128)
* 🐛 Fix text multiselection messages

* ♻️ Add tooltip to typography tooltip

* ♻️ Improve copy and add detach buttons
2026-05-19 09:47:04 +02:00
Andrey Antukh
595ec599c6 Merge remote-tracking branch 'origin/staging' into develop 2026-05-18 20:00:47 +02:00
Andrey Antukh
122a47359d Merge remote-tracking branch 'origin/staging' into develop 2026-05-18 16:22:16 +02:00
Juanfran
8e86416b0b Cascade owned organization deletion on account removal 2026-05-18 16:05:08 +02:00
Andrey Antukh
6f41a2b729 Merge remote-tracking branch 'origin/staging' into develop 2026-05-18 15:24:02 +02:00
Pablo Alba
ddfe2f7406 Remove nitrate teams with expired license from the teams list 2026-05-18 14:37:38 +02:00
Marina López
d26412740a
♻️ Rename control center to admin console (#9705) 2026-05-18 14:33:24 +02:00
María Valderrama
637ff3005a Add nitrate advanced permissions for move teams 2026-05-18 13:40:30 +02:00
Eva Marco
26e583c2a6
🎉 Add tooltip information on typography dropdown (#9375) 2026-05-18 09:42:28 +02:00
Marina López
83183e15c6
Adapt subscription page to selfhost (#9466) 2026-05-18 07:39:18 +02:00
Andrey Antukh
d620c86053 Merge remote-tracking branch 'origin/staging' into develop 2026-05-15 11:58:06 +02:00
Rene Arredondo
de1c942292
🐛 Use copia not copiar for Spanish duplicate-suffix (#9671) 2026-05-15 10:24:42 +02:00
Dominik Jain
7c42a1f9ac Catch serialisation issues in penpot.ui.sendMessage
This prevents timeouts in the MCP server in cases where there is an
issue with the serialisation.

GitHub #9634
2026-05-14 22:19:25 +02:00
Dominik Jain
94a5c6c4fd Add optional parameter throwOnError to penpot.ui.sendMessage
This provides more flexibility to callers, who may need to react
to a failure appropriately.
2026-05-14 22:19:25 +02:00
Dominik Jain
2a326ba23e 🎉 Add ReadTaigaIssueTool to Penpot MCP server
The tool is enabled in the agentic devenv to enable agents to
read Penpot issues on Taiga.

GitHub #9303
2026-05-14 22:18:31 +02:00
María Valderrama
e3df1d6f1f Restrict team delete to owners, prep org-owner flow 2026-05-14 19:30:03 +02:00
alonso.torres
46c642cf6d 🐛 Fix broken test 2026-05-14 17:14:31 +02:00
Alejandro Alonso
7429b97f86 Merge remote-tracking branch 'origin/staging' into develop 2026-05-14 13:27:38 +02:00
BitCompass
fbb1f9e634
🐛 Fix plugin API error message for nested malli validation paths (#9486)
When a plugin call fails malli validation, the frontend renders one
"plugins.validation.message" line per error via
`app.plugins.utils/error-messages`, which reduces the explain via
`csm/interpret-schema-problem` and then destructures each entry as
`[field {:keys [message]}]` for translation.

That works only when the underlying malli error path has a single
element. `interpret-schema-problem` calls `(assoc-in acc field ...)`
where `field` can be a multi-element vector (e.g. `[:sets 0 :name]`).
For single-element paths the resulting map is flat
(`{:group {:message "..."}}`); for multi-element paths it is nested
(`{:sets {0 {:name {:message "..."}}}}`). The destructure assumes the
flat shape, so for a nested error the consumer reads:

    field   -> :sets
    message -> nil (the nested entry has no :message at the top level)

and the produced i18n line resolves to `Field sets is invalid: ` --
or, when several errors are merged together at the same outer key,
to the user-facing `Field message is invalid` that the bug report
calls out, because `:message` then becomes the field name of the
deepest nested entry.

The original consumer carried a `#_(mapcat (comp seq val))` FIXME
that hinted at the missing flattening but did not implement one,
because the data shape produced by `interpret-schema-problem` is
not uniform.

Fix
---

Add a private `flatten-error-map` helper inside `app.plugins.utils`
that walks the error map produced by `interpret-schema-problem` and
yields `[path message]` pairs where `path` is the dot-joined field
path. Keywords use `(name k)`, strings pass through, anything else
(such as numeric indices from vector positions in the malli path)
is coerced via `str`. The recursion descends until it hits a leaf
that carries `:message`, which matches what
`interpret-schema-problem` produces in every branch.

The producer side (`csm/interpret-schema-problem` in
`common/src/app/common/schema/messages.cljc`) is left alone: it
already has another consumer (`collect-schema-errors` + the
form-validators pipeline) that depends on the keyed-by-field-path
shape, so normalising it at the source would require auditing every
validator. Flattening at the plugin consumer is the narrowest fix.

The FIXME comment is removed because the new helper supersedes it.

Tests
-----

`frontend-tests.plugins.utils-test` (new file, registered in
`runner.cljs`) covers:

- flat single-segment paths (`{:group {:message "..."}}`)
- nested multi-segment paths
  (`{:sets {0 {:name {:message "..."}}}}`) -- the case from #9417
- mixed single- and multi-segment paths at the same explain
- mixed key types (keyword / string / numeric index)
- empty explain (no validation errors)

Closes #9417

Signed-off-by: bitcompass <devwiz.sh@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-14 12:43:57 +02:00
Andrey Antukh
74ca40abd4 Merge remote-tracking branch 'origin/staging' into develop 2026-05-14 12:43:13 +02:00
Dexterity
8242015395
🐛 Log template download failures via console.error (#9363) 2026-05-14 12:40:30 +02:00
Dexterity
ee714adf5c
🐛 Remove stray println from onboarding team_choice success handler (#9366)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-14 12:28:13 +02:00
Marina López
08b30f76f3
♻️ Refactor nitrate copies (#9619) 2026-05-14 12:19:55 +02:00
Andrey Antukh
67e9c44b98 Merge remote-tracking branch 'origin/staging' into develop 2026-05-14 12:03:29 +02:00
Andrey Antukh
52588412c7 Merge remote-tracking branch 'origin/staging' into develop 2026-05-14 11:12:01 +02:00
Andrey Antukh
e9bec0a13b
🔧 Add cache to github tests CI worflow. (#9621)
*  Remove usage of RELEASE placeholder on deps.edn

* 🔧 Add Maven cache to CI

---------

Co-authored-by: Yamila Moreno <yamila.moreno@kaleidos.net>
2026-05-14 10:53:05 +02:00
Milos Milic
884b125cf5
🐛 Fix two plugin error i18n keys broken by leading whitespace in en.po (#9501) 2026-05-13 15:59:04 +02:00
Madalena Melo
f8744c8285
🔥 Remove issue template for new render bug reports (#9569)
Remove the template for new render bug reports; it was used for the open beta test but is no longer valuable
2026-05-13 15:52:29 +02:00
Andrey Antukh
1e746add31
Merge pull request #9299 from oraios/mcp-self-improvement
 Add agentic DevEnv (with extended MCP server for self-improvement)
2026-05-12 15:05:42 +02:00
Michael Panchenko
7a2ca6c08f 🎉 Add two new MCP tools for Clojure development
* CljsCompilerOutputTool: Checks compiler output and reports errors
* CljCheckParentheses: Precisely locates incorrect/unbalanced parentheses

GitHub #9214
2026-05-12 12:49:58 +02:00
Dominik Jain
b952783621 📚 Add documentation page on the agentic DevEnv #9216 2026-05-12 12:49:58 +02:00
Michael Panchenko
c2a1d5c6f7 Add run-devenv-agentic command, starting the Serena MCP server in the container
Serena provides useful tools for the agentic workflow for penpot.
The following additional extensions are added:

1. uv and Serena installation, including a suitable serena_config.yml, are added to the devenv docker image
2. Serena configuration options are set via env vars and flags in manage.sh
3. run-devenv can now take -e flags which it forwards to docker exec

GitHub #9315
2026-05-12 12:49:47 +02:00
Dominik Jain
85cf3fcc3c 📚 Improve/restructure critical-info memory, adding navigation memory 2026-05-12 12:36:55 +02:00
Dominik Jain
65fce36898 🎉 Add ImportPenpotFileTool for importing .penpot files via URL
Adds a new MCP tool (devenv-only) that imports .penpot files into the
running Penpot instance. The tool downloads the file from a given URL,
stages it in the frontend's static directory, and triggers the import
via the ClojureScript REPL using the frontend's web worker infrastructure.
The temporary file is cleaned up after the import completes or fails.

Registered alongside CljsReplTool, sharing the same NreplClient instance.

Github #9217

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-12 12:36:55 +02:00
Dominik Jain
1630561382 📚 Add memory on handling Penpot frontend crashes #9300
Documents how to detect, diagnose, and recover from frontend crashes
(the Internal Error page) when working through the Penpot Plugin API:

- Detect via (some? (:exception @app.main.store/state)) in the cljs REPL
- Read cause from the same map (:type, :code, :hint, :details, :uri, ...)
- Reload by listing/selecting the workspace tab in playwright and
  re-navigating to its URL, then re-checking the exception is gone

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-12 12:36:55 +02:00
Dominik Jain
fe23c731d4 📚 Add memory on PR creation 2026-05-12 12:36:55 +02:00
Dominik Jain
a25f43ff42 📚 Reorganise memories, introducing an entrypoint memory
Add `critical-info` memory as an entrypoint (bootstrap memory) for the LLM, which
points to critical tools and memories, allowing the LLM to dynamically build up
relevant context
2026-05-12 12:36:55 +02:00
Dominik Jain
af1c72df01 📚 Add memory on commit creation 2026-05-12 12:36:55 +02:00
Dominik Jain
eee8ee3103 📎 Exclude versioned .md files from .gitignore pattern
Exclude files like CONTRIBUTING.md or README.md from being ignored by /*.md pattern,
as this can influence agent behaviour (configurations that disallow ignored files
from being edited)
2026-05-12 12:36:55 +02:00
Dominik Jain
f1affdbadc Revamp cljs expression evaluation to full-blown REPL 2026-05-12 12:36:55 +02:00
Dominik Jain
66d518f15d 🎉 Add MCP tool for ClojureScript expression evaluation
New tool to evaluate ClojureScript expressions by connecting to the
nREPL service already provided in devenv.

Add dependency 'nrepl-client' and a corresponding client class
as well as types to support this.

Add a new environment variable for 'devenv mode', which enables
the new tool (PENPOT_MCP_DEVENV).
2026-05-12 12:36:44 +02:00
Dominik Jain
e1493de777 📎 Ignore .idea 2026-05-12 12:35:38 +02:00
Dominik Jain
6de41f072c Add initial Serena project 2026-05-12 12:35:38 +02:00
Dominik Jain
b7b31f6ee3 Start MCP server in devenv if PENPOT_FLAGS contains 'enable-mcp' 2026-05-12 12:35:38 +02:00
Pablo Alba
269edcd0ee
🐛 Fix restore saved version keeps view-only (#9514)
* 🐛 Fix restore saved verrsion keeps view-only

* 📎 Remove outdated note from CHANGES.md

Remove note about restoring saved version from Preview mode.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-12 10:32:12 +02:00
Jack Storment
c394a281c8
🐛 Revert blend-mode hover preview when dismissing dropdown (#9237)
* 🐛 Revert blend-mode hover preview when dismissing dropdown

When the blend-mode dropdown was dismissed by clicking outside instead
of selecting an option, the canvas kept rendering the last hovered
blend mode even though the inspector and data state had reverted. The
visible state of the shape no longer matched its stored state. Reset
the canvas render back to the shape's saved blend mode on dropdown
close so the preview never outlives the dropdown.

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>

* Fix blend-mode dropdown and various bugs

Fix blend-mode dropdown behavior and revert WASM render on pointer leave. Also, address multiple bugs related to user interactions and data handling.

Signed-off-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>

---------

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
Co-authored-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
2026-05-12 08:17:07 +02:00
Andrey Antukh
962bb1fa9b Merge remote-tracking branch 'origin/staging' into develop 2026-05-11 17:53:03 +02:00
Andrey Antukh
d670ba4bff 🐛 Fix mattermost and database logger related to the audit event change 2026-05-11 17:07:59 +02:00
Andrey Antukh
27e6c1e420 Merge remote-tracking branch 'origin/staging' into develop 2026-05-11 16:24:55 +02:00
Michael Panchenko
7fb19fc1a2
🐛 Fix float comparison #14070 (#9434)
* 📎 Add test that surfaces the bug described in #14070

The bug: alt-drag-duplicating a variant master into the variant container
auto-creates a new variant component cloned from the source
via duplicate-component, which preserves :touched on every cloned shape.
The resulting copy's children inherit those :geometry-group touched flags
through add-touched-from-ref-chain on switch.

variants-switch -> component-swap (keep-touched? true) -> generate-keep-touched
then runs update-attrs-on-switch for each touched child. The new shape's
:y is correctly skipped by the per-attr "different masters" guard, but
:selrect/:points fall through to a width/height-based safety check that
uses exact equality. In practice, the alt-drag modifier path leaves a
sub-pixel drift in :width on the copy, so equal-geometry? returns false,
the safety check is bypassed, and the :else branch copies the source
variant's :selrect verbatim onto the freshly instantiated target shape.
The shape ends up with :y from the target master but :selrect.y from the
source — the renderer reads :selrect, so the child appears at the source
position inside a parent that has resized to the target's dimensions:
the visible "button cut off" symptom.

The new test sets up a variant container whose children are themselves
component copies (matching production: variant masters' children carry
:touched), introduces the same kind of width drift on the copy that the
alt-drag path produces, and runs the swap directly via the existing
test harness. It asserts (a) :y matches the target, (b) :selrect.y
matches the target, and (c) :y and :selrect.y agree. With the current
code (a) passes and (b)/(c) fail — capturing both the wrong value and
the internal inconsistency that causes the visible regression.

* 🐛 Fix #14070 by no longer comparing floats for exact equality in equal-geometry?
2026-05-11 15:17:48 +02:00
Leona Lee
02bbbae0b0
🐛 Fix typography removal from plugin API (#9279)
* 🐛 Fix typography removal from plugin API

* 🔥 Remove unrelated delete-typography test

---------

Signed-off-by: Leona Lee <63717587+leonaIee@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-11 14:58:42 +02:00
dhgoal
3094d512f4
🐛 Expose Source Sans Pro 600 weight in builtin fonts list (#9247)
The font-family list at frontend/src/app/main/fonts.cljs registers
Source Sans Pro variants for weights 200, 300, 400, 700 and 900, but
omits the semibold (600) entries even though the font assets are
already bundled (frontend/resources/fonts/sourcesanspro-semibold.*)
and the CSS @font-face declarations that load them are present
(frontend/resources/styles/common/dependencies/fonts.scss:55-56).

Result: weight 600 cannot be selected from the font picker even
though the bytes are downloadable; users see a 400 -> 700 jump.

Add the two missing variant entries (600 and 600 italic) using the
same :suffix style as the other numeric-id entries (200, 300), since
the file-name component "semibold" doesn't match the weight number.

Issue mentions weights 500 and 800 as also missing, but no
sourcesanspro-medium or sourcesanspro-extrabold assets exist in the
repo, so this PR scopes to weight 600 only — the recoverable subset.

Closes #7378.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-11 14:55:25 +02:00
FairyPiggyDev
09bd7f96f6
Add author, relative timestamp and short identifier to history entries (#9132)
The workspace Actions history panel previously showed the operation
icon and a one-line message for each undo entry with no indication
of when the action happened, any stable way to refer to it, or who
made the change. The reporter of issue #7660 (and @Takhoffman's
follow-up comment) asked for a git-like display: `<hash> · <time> by
<name>`.

This change stamps each undo entry with its creation timestamp and
author at the moment it lands on the undo stack and surfaces three
extra pieces of information in the history sidebar:

- A short 7-character identifier derived from the entry's existing
  `:undo-group` UUID. Hovering shows the full UUID.
- A relative timestamp (e.g. `just now`, `5 minutes ago`, `2 hours
  ago`, `3 days ago`) rendered via `app.common.time/timeago` so it
  matches the formatting already used for comments and the dashboard.
- The display name of the profile that created the entry, rendered
  as `by <Name>` in the same metadata row.

The undo stack is client-side per profile, so every entry is always
the current user; the author is stored on the entry anyway so the UI
does not need to reach into profile state while rendering and so the
data stays correct if the stack shape ever changes.

Changes at a glance:

- `data/workspace/undo.cljs`: extend `schema:undo-entry` with an
  optional `:timestamp` and `:by`; new `profile-display-name` helper
  that falls back from full name to email to nil; `stamp-entry` now
  takes state and fills in both fields on entries that do not
  already carry them. Pre-stamped entries (e.g. coming out of an
  accumulated transaction) keep their original values.
- `ui/workspace/sidebar/history.cljs`: propagate `:timestamp`,
  `:undo-group`, and `:by` through `parse-entries`; add `short-id`
  helper; render the metadata row in `history-entry` using
  `app.common.time/timeago` against `:timestamp`; skip the author
  span entirely when `:by` is nil.
- `ui/workspace/sidebar/history.scss`: styling for the new metadata
  row (monospace hash, muted separator, truncated time/author).
- `translations/en.po`: 1 new string for `by %s`.

Existing undo entries created before this change have neither
timestamp nor author; the UI is defensive about both, so old entries
simply render with whatever data they have (and often the plain
title on its own).

Github #7660

Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Signed-off-by: FairyPiggyDev <luislee3108@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-11 14:48:32 +02:00
jony376
f7fbd3007e
🐛 Prevent viewers from overwriting file thumbnails (#9285)
* 🐛 Prevent viewers from overwriting file thumbnails

* 🐛 Fix message

---------

Co-authored-by: jony376 <jony376@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-11 14:38:40 +02:00
Andrey Antukh
06986e25a3 Merge remote-tracking branch 'origin/staging' into develop 2026-05-11 14:06:31 +02:00
bitloi
58ca0a16ba
🐛 Fix MCP SSE sessions leaking on zombie connections (#9432) (#9464)
SSE sessions were never included in the periodic inactivity timeout
checker, so a stale connection whose TCP close event never fired would
retain its SSEServerTransport and McpServer indefinitely.

Changes:
- Add lastActiveTime: number to the sseTransports entry type
- Initialise lastActiveTime at SSE session creation (GET /sse)
- Refresh lastActiveTime on every incoming message (POST /messages)
- Extend startSessionTimeoutChecker() to sweep and forcibly close SSE
  sessions idle for more than SESSION_TIMEOUT_MINUTES, mirroring the
  existing Streamable HTTP logic
- Update the checker log to count both transport maps

The existing res.on('close') cleanup path is preserved unchanged:
it remains the primary cleanup for normal disconnections; the timer
is a safety net for zombie sessions only.

Closes #9432

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-11 13:55:11 +02:00
Milos Milic
b54fa2f11c
🐛 Reject clipboard helpers gracefully on insecure origins (#9188)
* 🐛 Reject clipboard helpers gracefully on insecure origins

Closes #6514. Resolves the user-visible crash originally reported
in #4478.

`app.util.clipboard/to-clipboard` and `to-clipboard-promise` called
`(unchecked-get js/navigator "clipboard")` and then immediately
invoked `.writeText` / `.write` on the result, with no guard for the
case where `navigator.clipboard` is `undefined`. The W3C Clipboard
API spec requires a "secure context" (HTTPS or localhost), so a
Penpot instance served over plain HTTP - which the SSDP/LAN
self-hosted setup in #4478 was - throws

  TypeError: Cannot read properties of undefined (reading 'writeText')

synchronously the moment the user clicks any copy button. The error
escapes the consuming function before any error-handling rx/of arm
runs, so the whole UI ends up on the error screen instead of just
the affected control showing a "could not copy" message.

A third helper (`to-clipboard-multi`) already guards `clipboard` and
`clipboard.write`, but if both are missing it silently returns nil
which is also surprising for callers expecting a Promise.

## Fix

Add a small `get-clipboard` accessor and an `unavailable-error`
factory that returns `Promise.reject(Error(...))` with a clear
message ("Clipboard API is unavailable. This usually happens when
the page is served over plain HTTP; serve Penpot over HTTPS to
enable copy-to-clipboard."). Wire all three helpers through the
same defensive contract:

- `to-clipboard` - return the rejected Promise when
  `navigator.clipboard.writeText` is missing.
- `to-clipboard-promise` - return the rejected Promise when
  `navigator.clipboard.write` is missing.
- `to-clipboard-multi` - convert the existing `if` into a `cond`
  with three branches: prefer `clipboard.write` for true multi-MIME
  output, fall through to `writeText` with the text/plain payload
  when only the legacy text path is available, and finally reject
  with the unavailable error when neither path exists. Previously
  the no-API case fell off the `when-let` and silently returned
  nil.

The contract is now consistent: every helper either resolves or
rejects a Promise, never throws synchronously, and never returns
nil. Callers (which are already structured around rx streams that
call `rx/from` on the helper's return value) can chain `.catch` /
`rx/catch` to surface a status-bar message instead of crashing.

The two stale `;; FIXME` comments on `to-clipboard` (rename to
`write-text`) and `to-clipboard-promise` (this API is very confuse)
are removed - the rename remains an open follow-up across 13+ call
sites and is intentionally out of scope, but the API is no longer
"confuse" once the contract is documented and uniform.

CHANGES.md entry added under the 2.17.0 Bugs-fixed section
describing the user-visible behaviour change.

* 🐛 Reject paste-from-navigator gracefully on insecure origins

Symmetric companion to the to-clipboard / to-clipboard-promise /
to-clipboard-multi guards added earlier in this PR. The paste path
went through fromNavigator (frontend/src/app/util/clipboard.js) which
called `navigator.clipboard.read()` with no nil-check; on insecure
origins (plain HTTP / non-localhost) this raised an opaque
`TypeError: Cannot read properties of undefined (reading 'read')` and
the workspace surfaced a generic 'Something wrong has happened' toast
instead of the descriptive 'serve Penpot over HTTPS …' message users
get for the copy direction.

Mirror the get-clipboard pattern from clipboard.cljs:
- Read `navigator.clipboard` once into a local.
- If it's missing the `.read` method, throw a descriptive Error that
  matches the wording the copy direction already uses (only the verb
  swaps: 'paste-from-clipboard' instead of 'copy-to-clipboard').
- Otherwise, dispatch through the local handle.

The existing app.util.clipboard/from-navigator (clipboard.cljs:32)
already wraps impl/fromNavigator in rx/from, so a rejected Promise
from the async function propagates as an rx error event. Existing
callers that subscribe with .catch / on-error see the structured
Error and surface the toast, identical to how to-clipboard's
unavailable-error already flows.

Repro (matches niwinz's reproduction in the PR comment):

  Object.defineProperty(navigator, 'clipboard', { value: undefined });
  // … then attempt a paste action in the workspace …

Before: TypeError in console + 'Something wrong has happened' toast.
After: descriptive Error caught by the rx subscription and rendered
through the existing unavailable-Clipboard-API surface.

Refs #6514, #4478

* 🐛 Show user-facing toast when clipboard API is unavailable

Niwinz's review on penpot#9188 caught that the rejected Promise from
to-clipboard / to-clipboard-promise / to-clipboard-multi /
fromNavigator now surfaces the correct error to the console, but the
workspace UI still falls through to the generic "Something wrong has
happened" toast because the on-clipboard-permission-error and the
paste error-handler in paste-from-clipboard only branched on
clipboard-permission-error?.

Apply the patch he suggested in the review:

- Add clipboard-unavailable-error? predicate that matches the
  Promise.reject(Error("Clipboard API is unavailable. ...")) thrown
  by the get-clipboard / unavailable-error helpers added earlier in
  this PR. Uses str/starts-with? on the message prefix so the
  predicate stays stable even if the trailing "serve Penpot over
  HTTPS ..." advice text is reworded later.
- Convert on-clipboard-permission-error from `if` to `cond` and add
  a third arm that fires errors.clipboard-api-unavailable as a
  warning toast.
- Add the same arm in the second cond block inside
  paste-from-clipboard, before the :not-implemented and :else arms.
- Add the matching errors.clipboard-api-unavailable entry to
  frontend/translations/en.po with the wording niwinz suggested:
  "Clipboard API is unavailable. Serve Penpot over HTTPS to enable
  clipboard access".

Refs penpot#9188 review.

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-11 13:52:36 +02:00
Andrey Antukh
08bd53b6a1 📎 Update changelog 2026-05-11 09:49:28 +02:00
Andrey Antukh
a228b257e9 Merge remote-tracking branch 'origin/staging' into develop 2026-05-11 09:37:32 +02:00
Jack Storment
9dc607902b
🐛 Only fall back to anonymous on :not-found in get-profile (#9254)
* 🐛 Only fall back to anonymous on :not-found in get-profile

::get-profile caught Throwable and silently returned the anonymous
user payload for every error - contradicting the in-code comment that
states in all other cases we need to reraise the exception. Under
transient DB conditions (pool checkout timeout, replica lag, statement
timeout, network blip) this masked real DB outages as ordinary
anonymous responses, returning HTTP 200 instead of 5xx and leaving
logged-in users on the login screen with a valid session cookie.

Narrow the catch so only :type :not-found falls through; everything
else propagates and reaches the standard error pipeline.

Closes #9235

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>

* 🐛 Only fall back to anonymous on :not-found in get-profile

::get-profile caught Throwable and silently returned the anonymous
user payload for every error - contradicting the in-code comment that
states in all other cases we need to reraise the exception. Under
transient DB conditions (pool checkout timeout, replica lag, statement
timeout, network blip) this masked real DB outages as ordinary
anonymous responses, returning HTTP 200 instead of 5xx and leaving
logged-in users on the login screen with a valid session cookie.

Narrow the catch so only :type :not-found falls through; everything
else propagates and reaches the standard error pipeline.

Closes #9253

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>

---------

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
2026-05-10 19:40:29 +02:00
TinyClaw
7df53a46f2
🔥 Remove stray debug log in exporter upload-resource (#9272)
Signed-off-by: iot2edge <tylerprice830@gmail.com>
Co-authored-by: iot2edge <tylerprice830@gmail.com>
2026-05-10 19:36:55 +02:00
Dexterity
e30e5906c8
♻️ Remove unreachable try/catch in hex->hsl (#9245) 2026-05-10 19:28:12 +02:00
Andrey Antukh
49759021bf Merge remote-tracking branch 'origin/staging' into develop 2026-05-10 14:27:53 +02:00
tmimmanuel
f06a2ae4e3
♻️ Migrate inspect fill/stroke deprecated blocks to modern syntax (#9392)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
2026-05-10 14:16:43 +02:00
tmimmanuel
ef4f57c4a1
♻️ Migrate components/link to modern component syntax (#9383)
* ♻️ Migrate components/link to modern component syntax

Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>

* 📎 Fix cljfmt indent after link* rename

Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>

---------

Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-10 14:12:30 +02:00
Andrey Antukh
60c718eba1 Merge remote-tracking branch 'origin/staging' into develop 2026-05-10 09:20:27 +02:00
Dexterity
a53237ce9f
🐛 Route Google fonts fetch warning through project logger (#9422) 2026-05-08 17:41:09 +02:00
Jeff
f5b38a5025
🐛 Skip add-recent-color when colorpicker has no completed color (#9251)
Closing the fill dialog while an image-fill upload is still in flight
(or while a gradient is mid-edit) leaves the colorpicker's
current-color with only :opacity — no :image, :gradient, or :color.
update-colorpicker-color's WatchEvent then constructed
`(add-recent-color partial)`, which runs the value through
`clr/check-color` and threw "expected valid color". The user saw an
Internal Assertion Error toast and lost the in-flight upload.

The existing `ignore-color?` guard reads `:type` from the *output* of
`get-color-from-colorpicker-state` — but that helper strips :type from
its result, so the guard never actually fires. Add a schema-based gate
(same validator add-recent-color itself uses) right before `rx/of`, so
a partial selection is silently dropped instead of crashing the
workspace. Behaviour for fully-valid colors is unchanged.

Tests cover three cases: (1) a partial image-tab state with only
:opacity returns nil from watch (was: throws); (2) the same partial
shape on the color tab also returns nil — pinning down that the prior
:type guard wouldn't have caught it; (3) a fully-populated plain color
still produces a watch observable so the guard isn't over-eager.

Closes #8443

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-08 17:37:12 +02:00
Renzo
ea24445c2c
🐛 Toggle display-guides via physical key code so the shortcut works on non-US layouts (#9209)
* 🐛 Toggle display-guides via physical key code so the shortcut works on non-US layouts

Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>

* 🐛 Add tests for display-guides shortcut on non-US layout

---------

Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>
Signed-off-by: Renzo <170978465+RenzoMXD@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-08 17:36:52 +02:00
BitToby
6aeccb1208
🎉 Add selection size badge below bounding box (#9210)
* 🎉 Add selection size badge below bounding box

Signed-off-by: bittoby <218712309+bittoby@users.noreply.github.com>

* 💄 Address review comments

Signed-off-by: bittoby <218712309+bittoby@users.noreply.github.com>

* 💄 Move selection size badge text styles to SCSS class

---------

Signed-off-by: bittoby <218712309+bittoby@users.noreply.github.com>
Signed-off-by: BitToby <218712309+bittoby@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-08 14:20:24 +02:00
web-dev0521
bb93928099
🐛 Fix lost-update race on team.features during concurrent file cr… (#9198)
* 🐛 Fix lost-update race on team.features during concurrent file creation

* 📚 Add CHANGES.md entry for team.features race condition fix (#9197)
2026-05-08 14:12:20 +02:00
Yamila Moreno
be92e37af3 🔧 Add notification when a new devenv is published 2026-05-08 14:09:21 +02:00
María Valderrama
5a3d5f86af
🐛 Fix nitrate lookups to use nested organization
* 🐛 Fix nitrate lookups to use nested organization

* 📎 Code review
2026-05-08 13:33:31 +02:00
Pablo Alba
639a457c69 💄 Change error message on nitrate subscriptions 2026-05-08 12:27:58 +02:00
Marina López
175fb67afc 💄 Change margin for current plan 2026-05-08 11:59:09 +02:00
Pablo Alba
f3c2c0bee2 Change team organization structure on state 2026-05-08 11:18:26 +02:00
Andrey Antukh
18e289b15a
♻️ Migrate link-button component to rumext modern syntax (#9264)
Rename component from link-button to link-button* and remove the legacy
::mf/wrap-props false metadata. Update all callsites to use the modern
[:> lb/link-button* ...] syntax instead of [:& lb/link-button ...].

Part of the #9260 issue.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-08 09:53:12 +02:00
Andrés Moya
61cd757355
🐛 Detect duplicated token names in the whole library (#9034)
* 🐛 Detect duplicated token names in the whole library

* 🔧 Review comments

* 🐛 Prevent and repair token themes with inexistent sets

* 🔧 Convert tokens lib migration into file migration
2026-05-08 08:26:15 +02:00
María Valderrama
7c5fa038c1
Add Nitrate advanced permissions delete (#9416)
*  Add Nitrate advanced permissions delete

* 📎 Code review
2026-05-07 21:14:30 +02:00
Francis Santiago
d84685c0cb
Merge pull request #9426 from penpot/nginx-security-headers
🐳 Nginx security headers
2026-05-07 16:06:59 +02:00
FairyPiggyDev
fa06efa84d
♻️ Migrate fo-text and html-text renderers to modern component syntax (#9385)
Step toward issue #9260 (incremental migration of legacy UI
components to the modern `*`-suffixed syntax, removing the per-render
JS-to-Clojure props conversion overhead).

Twin namespaces with parallel structure: each defines six components
that drive a recursive text rendering pass over the editor's content
tree (root -> paragraph-set -> paragraph -> node -> text). Both files
were uniformly legacy: every component carried `::mf/wrap-props
false` and read its props with `(obj/get props "key")`. None had
`::mf/register`, `unchecked-get` or `obj/merge!`, so they qualify as
clean Case-A migrations.

frontend/src/app/main/ui/shapes/text/fo_text.cljs (6 components)
----------------------------------------------------------------

- `render-text`           -> `render-text*`
- `render-root`           -> `render-root*`
- `render-paragraph-set`  -> `render-paragraph-set*`
- `render-paragraph`      -> `render-paragraph*`
- `render-node`           -> `render-node*`     (forward-props case,
                                                 see below)
- `text-shape`            -> `text-shape*`      (`::mf/forward-ref`
                                                 preserved)

The four leaf components switch from `[props]` + per-key
`(obj/get props "key")` to standard destructuring. `text-shape`
already used destructuring under `::mf/props :obj`; that legacy
metadata is dropped because the modern `*` form handles props
automatically. Its single `::mf/forward-ref true` is kept per the
prompt's "preserve forward-ref" rule.

`render-node` is the recursive driver. It needs to forward all of
its incoming props to the matched paragraph-* / text component and
then to a child `render-node*` after overriding `:node`, `:index`
and `:key`. The migrated form uses `::mf/props :obj` together with
`{:keys [node] :as props}` to keep the JS-object props symbol
available, and `(mf/spread-props props {…})` replaces the previous
`obj/clone` + `obj/set!` chain.

`app.util.object` is no longer required by this namespace and the
`(:require ... [app.util.object :as obj] ...)` line is removed.

frontend/src/app/main/ui/shapes/text/html_text.cljs (6 components)
-----------------------------------------------------------------

Identical six-component shape as `fo_text.cljs`, plus a `code?`
flag threaded through every component to switch the rendering path
between regular shapes and code-style shapes.

- `render-text`           -> `render-text*`
- `render-root`           -> `render-root*`
- `render-paragraph-set`  -> `render-paragraph-set*`
- `render-paragraph`      -> `render-paragraph*`
- `render-node`           -> `render-node*`     (same forward-props
                                                 treatment as above,
                                                 plus `is-code` in
                                                 the spread)
- `text-shape`            -> `text-shape*`      (`::mf/forward-ref`
                                                 preserved)

The `code?` boolean prop is renamed to `is-code` per the migration
prompt's "?-suffixed boolean -> `is-` prefix" rule. The rename is
applied at every read site (5 components) and at the `text-shape*`
internal call to `render-node*`, so the prop is consistent inside
the namespace.

`app.util.object` is no longer required by this namespace either
and the corresponding `:require` line is dropped.

External call sites (3 files, 4 sites)
--------------------------------------

- `frontend/src/app/main/ui/shapes/text.cljs` - the legacy
  text-shape wrapper (intentionally kept legacy in this PR because
  it dispatches to `svg/text-shape`, which is still being touched by
  the in-flight PR #9016) now calls `[:> fo/text-shape* props]`.
  The `props` symbol is the wrapper's incoming JS-object; modern
  destructured components accept JS-object props at the call site
  via `[:>` so this works unchanged.

- `frontend/src/app/util/code_gen/markup_html.cljs` -
  `(mf/element text/text-shape #js {:shape shape :code? true})`
  becomes
  `(mf/element text/text-shape* #js {:shape shape :is-code true})`
  (component renamed and the `code?` JS key updated to match the
  renamed prop).

- `frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs`
  - `[:& html/text-shape {…}]` -> `[:> html/text-shape* {…}]`.

Behavior preserved verbatim
---------------------------

Same render output, same forward-ref forwarding semantics, same
recursive children-by-index keying, same default `:dir "auto"` on
`render-paragraph*`. The visible-prop changes are only the `code?`
-> `is-code` rename, all driven from this namespace and its single
caller in `markup_html.cljs`.

Github #9260

Signed-off-by: FairyPigDev <luislee3108@gmail.com>
2026-05-07 15:03:51 +02:00
Xaviju
ddad228849 📚 Update CONTRIBUTING (#9418) 2026-05-07 14:13:02 +02:00
Madalena Melo
3136b39404 Update issue templates to include the issue type (#9345)
*  Update issue templates to include the issue type

Added the type "bug" to the "New render bug report" and the "Bug report" templates and the type "feature" to the "Feature request template".

This will allow us to use the issue Type instead of labels to identify what kind of issue is being created.

*  Update bug_report.md to request screen recordings

Update the Screenshots section to also request screen recordings

Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>

---------

Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
2026-05-07 14:13:02 +02:00
Renzo
dd1ceae667 🐛 Fix plugin API fills/strokes arrays read-only (#9161)
* 🐛 Fix plugin API fills/strokes arrays read-only

Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>

* 🐛 Support mutable plugin fill and stroke gradients

---------

Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 14:13:02 +02:00
Juanfran
f79cfafae5 Show nitrate checkout error on subscription page
When the Stripe checkout fails to start, the subscription page now
  shows an inline error in the Business Nitrate card under the CTA
  instead of a toast. When the post-payment activation fails, the toast
  message is updated to point users to support@penpot.app.

  The nitrate-form modal also passed a URI object to
  build-nitrate-callback-urls while the underlying append-query-param
  relied on lambdaisland's u/parse, which only accepts strings. Switched
  to the local u/uri helper so both strings and URI records work, so
  failures opened from the modal land on the subscription page.
2026-05-07 14:13:02 +02:00
Xaviju
10a0e9e78c ♻️ Revert ESC keypress closes plugins (#9267) 2026-05-07 14:13:02 +02:00
Marina López
bc13dfcf9e Refactor subscriptions page 2026-05-07 14:13:02 +02:00
wdeveloper16
6e186143d5 ♻️ Migrate viewport debug and workspace shape debug components to modern syntax (#9395)
Co-authored-by: wdeveloper16 <wdeveloer16@protonmail.com>
2026-05-07 14:13:02 +02:00
Dexterity
a08f052da0 🐛 Remove stray println debug logs from dashboard team invitations (#9365) 2026-05-07 14:13:02 +02:00
tmimmanuel
4f1512186f ♻️ Migrate components/code-block to modern component syntax (#9384)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 14:13:02 +02:00
tmimmanuel
deb3085de5 ♻️ Migrate frame-preview to modern component syntax (#9382)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 14:13:02 +02:00
tmimmanuel
2ceddc3932 ♻️ Migrate debug icons-preview to modern component syntax (#9381)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 14:13:02 +02:00
Alejandro Alonso
173ef0dbb0 🐛 Avoid opaque fill check in drag crop cache hot path 2026-05-07 14:13:02 +02:00
Elena Torro
d457eb5e5c Translation-aware modifier propagation and lazy parent walks 2026-05-07 14:13:02 +02:00
Elena Torro
5c4d16fc2b Coalesce live drag preview state and reduce sidebar churn 2026-05-07 14:13:02 +02:00
BitCompass
55d085117b ♻️ Rename measurement and svg-defs components to defc* form (#9306)
Adopts the rumext * suffix convention for the following components,
invoking them with the [:> JS-style syntax to match the rest of the
codebase (see e.g. rea*, single-selection* in viewport/selection):

- measurements: size-display, distance-display-pill, selection-rect,
  distance-display, selection-guides, measurement
- shapes/svg-defs: svg-node, svg-defs (also drop the now-redundant
  {::mf/wrap-props false} annotations)

Updates all call sites in inspect/selection_feedback, shapes/shape,
workspace/viewport, and workspace/viewport_wasm. Pure rename — no
behavioral change.

Signed-off-by: bitcompass <devwiz.sh@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 14:13:02 +02:00
Dexterity
7e6e7baa71 🔥 Remove stray prn debug log in stroke-row* render (#9318) 2026-05-07 14:13:02 +02:00
Dexterity
2fc4f35cde 💄 Fix typos in comments and docstrings (#9362) 2026-05-07 14:13:02 +02:00
Dexterity
5fd758597e 🐛 Fix MCP "active in another tab" notification not clearing (#9321) 2026-05-07 14:13:02 +02:00
Dexterity
cc29334684 🐛 Fix swapped analytics event names on MCP tab-switch dialog (#9322) 2026-05-07 14:13:02 +02:00
Milos Milic
e61d512889 🐛 Fix missing labels.open i18n key surfacing raw key as aria-label (#9320) 2026-05-07 14:13:02 +02:00
Xaviju
defeeab054
📚 Update CONTRIBUTING (#9418) 2026-05-07 14:01:43 +02:00
Francis Santiago
4f172afce5 🐳 Reuse Nginx security headers config
Signed-off-by: Francis Santiago <francis.santiago@kaleidos.net>
2026-05-07 13:42:02 +02:00
Madalena Melo
df9cef1bb8
Update issue templates to include the issue type (#9345)
*  Update issue templates to include the issue type

Added the type "bug" to the "New render bug report" and the "Bug report" templates and the type "feature" to the "Feature request template".

This will allow us to use the issue Type instead of labels to identify what kind of issue is being created.

*  Update bug_report.md to request screen recordings

Update the Screenshots section to also request screen recordings

Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>

---------

Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
2026-05-07 13:29:25 +02:00
Renzo
691679d90b
🐛 Fix plugin API fills/strokes arrays read-only (#9161)
* 🐛 Fix plugin API fills/strokes arrays read-only

Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>

* 🐛 Support mutable plugin fill and stroke gradients

---------

Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 13:10:48 +02:00
Juanfran
bd91036b95 Show nitrate checkout error on subscription page
When the Stripe checkout fails to start, the subscription page now
  shows an inline error in the Business Nitrate card under the CTA
  instead of a toast. When the post-payment activation fails, the toast
  message is updated to point users to support@penpot.app.

  The nitrate-form modal also passed a URI object to
  build-nitrate-callback-urls while the underlying append-query-param
  relied on lambdaisland's u/parse, which only accepts strings. Switched
  to the local u/uri helper so both strings and URI records work, so
  failures opened from the modal land on the subscription page.
2026-05-07 12:48:43 +02:00
Xaviju
7b1f0eaaf0
♻️ Revert ESC keypress closes plugins (#9267) 2026-05-07 12:34:37 +02:00
Marina López
b2e3dbe558 Refactor subscriptions page 2026-05-07 12:06:46 +02:00
wdeveloper16
70e1a16bb8
♻️ Migrate viewport debug and workspace shape debug components to modern syntax (#9395)
Co-authored-by: wdeveloper16 <wdeveloer16@protonmail.com>
2026-05-07 08:44:09 +02:00
Dexterity
61b791368a
🐛 Remove stray println debug logs from dashboard team invitations (#9365) 2026-05-07 01:43:15 +02:00
tmimmanuel
f173fafb62
♻️ Migrate components/code-block to modern component syntax (#9384)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 01:41:10 +02:00
tmimmanuel
eca487afc5
♻️ Migrate frame-preview to modern component syntax (#9382)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 01:40:38 +02:00
tmimmanuel
bffec015d7
♻️ Migrate debug icons-preview to modern component syntax (#9381)
Signed-off-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: tmimmanuel <155203395+tmimmanuel@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 01:40:20 +02:00
Francis Santiago
50df7cb5c4 🐳 Harden Nginx security headers
Signed-off-by: Francis Santiago <francis.santiago@kaleidos.net>
2026-05-06 20:06:35 +02:00
Alejandro Alonso
0a0db15548 Merge remote-tracking branch 'origin/staging' into develop 2026-05-06 19:28:09 +02:00
BitCompass
3433b41aa8
♻️ Rename measurement and svg-defs components to defc* form (#9306)
Adopts the rumext * suffix convention for the following components,
invoking them with the [:> JS-style syntax to match the rest of the
codebase (see e.g. rea*, single-selection* in viewport/selection):

- measurements: size-display, distance-display-pill, selection-rect,
  distance-display, selection-guides, measurement
- shapes/svg-defs: svg-node, svg-defs (also drop the now-redundant
  {::mf/wrap-props false} annotations)

Updates all call sites in inspect/selection_feedback, shapes/shape,
workspace/viewport, and workspace/viewport_wasm. Pure rename — no
behavioral change.

Signed-off-by: bitcompass <devwiz.sh@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-06 19:11:45 +02:00
Dexterity
3885c9ee74
🔥 Remove stray prn debug log in stroke-row* render (#9318) 2026-05-06 17:43:58 +02:00
Dexterity
3226660812
💄 Fix typos in comments and docstrings (#9362) 2026-05-06 17:43:09 +02:00
Dexterity
a5b7bd90c7
🐛 Fix MCP "active in another tab" notification not clearing (#9321) 2026-05-06 17:42:06 +02:00
Dexterity
f4317d00e5
🐛 Fix swapped analytics event names on MCP tab-switch dialog (#9322) 2026-05-06 17:40:39 +02:00
Milos Milic
aa8f2ab80d
🐛 Fix missing labels.open i18n key surfacing raw key as aria-label (#9320) 2026-05-06 17:39:26 +02:00
Andrey Antukh
e07ad9cb53 Merge remote-tracking branch 'origin/staging' into develop 2026-05-06 15:07:45 +02:00
FairyPiggyDev
4892799cf6
♻️ Migrate fontfaces and viewer thumbnails components to modern syntax (#9293)
Step toward issue #9260 (incremental migration of legacy UI
components to the modern `*`-suffixed syntax, removing the per-render
JS-to-Clojure props conversion overhead).

Two unrelated namespaces, both clean Case-A migrations grouped in a
single PR for review efficiency.

frontend/src/app/main/ui/shapes/text/fontfaces.cljs
---------------------------------------------------

Three components, all previously using `::mf/wrap-props false` with a
custom memoizer (`#(mf/memo' % (mf/check-props ["fonts"]))`) and
reading `fonts` via `(obj/get props "fonts")`. The custom memoizer
existed because the legacy components received raw JS-object props,
where the default `mf/memo` Clojure-equality comparison would always
fail.

- `fontfaces-style-html`   → `fontfaces-style-html*`
- `fontfaces-style-render` → `fontfaces-style-render*`
- `fontfaces-style`        → `fontfaces-style*`

Migration:

- Standard destructuring `[{:keys [fonts]}]` replaces the
  `[props]` + `(obj/get props "fonts")` pattern.
- `::mf/wrap-props false` removed.
- Custom memoizer collapses to `::mf/wrap [mf/memo]`. With modern
  destructuring the props are Clojure data, so default `=`-based memo
  is structurally correct (and a stronger guarantee than the previous
  shallow JS-prop check).
- `app.util.object` require dropped from the namespace — it was only
  used for the `obj/get props "fonts"` reads that are now gone.
- Internal call site of `fontfaces-style-render*` (within
  `fontfaces-style*`) keeps its `[:>` form, just with the new name.

External call sites updated:

- `frontend/src/app/main/render.cljs` — three sites
  (`[:& ff/fontfaces-style {:fonts fonts}]` × 3) →
  `[:> ff/fontfaces-style* {:fonts fonts}]`.
- `frontend/src/app/main/ui/workspace/shapes.cljs` — one site,
  call signature unchanged. (Note: this caller passes `:shapes`
  rather than `:fonts`; the legacy component already ignored
  `:shapes` because it only read `(obj/get props "fonts")`, so the
  modern destructuring `{:keys [fonts]}` preserves the same
  behavior. Pre-existing bug, intentionally left out of scope.)

frontend/src/app/main/ui/viewer/thumbnails.cljs
-----------------------------------------------

Four components, all using standard destructuring already (no
`::mf/wrap-props false`, no `unchecked-get`). Migration is the
straight `*` rename plus `?`-prop renames per the prompt's mapping:

- `thumbnails-content`  → `thumbnails-content*`   (`expanded?` →
  `is-expanded`)
- `thumbnails-summary`  → `thumbnails-summary*`   (no `?`-props)
- `thumbnail-item`      → `thumbnail-item*`       (`selected?` →
  `is-selected`; `::mf/wrap [mf/memo #(mf/deferred …)]` preserved)
- `thumbnails-panel`    → `thumbnails-panel*`     (`show?` →
  `show` — no `is-` prefix needed, reads naturally as a verb)

Internal callsites of all three sub-components in `thumbnails-panel*`
updated to `[:> …*` with renamed kwargs (`:expanded?` →
`:is-expanded`, `:selected?` → `:is-selected`).

The `expanded?` symbol still appears in `thumbnails-panel*`'s body —
it's a local `let`-binding deref'd from the `expanded-state` atom,
not a component param, so the `?` suffix is preserved per the
prompt's "local bindings stay" rule.

External call sites updated:

- `frontend/src/app/main/ui/viewer.cljs` — one site, plus the
  `:refer [thumbnails-panel]` → `:refer [thumbnails-panel*]` require
  update.

Github #9260

Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-06 14:55:55 +02:00
Pablo Alba
e8ac5f26db 💄 Fix nitrate change org combo style 2026-05-06 13:37:37 +02:00
Alejandro Alonso
9dd7835815 Merge remote-tracking branch 'origin/staging' into develop 2026-05-06 12:43:13 +02:00
María Valderrama
7efeed1348 🐛 Fix nitrate activation modal not opening 2026-05-06 12:41:19 +02:00
Xaviju
0ea3ea332f
🎉 Display autocomplete combobox on token creation (#9109)
Co-authored-by: Eva Marco <evamarcod@gmail.com>
2026-05-06 12:40:23 +02:00
María Valderrama
e65ce8bdeb 🐛 Fix date issue in nitrate activation success modal 2026-05-06 11:58:00 +02:00
Dominik Jain
ed935e533f Expose variants retrieval via isVariant() type guard on LibraryComponent
Change isVariant() return type from boolean to 'this is LibraryVariantComponent',
enabling TypeScript users to directly access variants, variantProps, and
variantError after a type-narrowing check. Update MCP instructions with
improved variant navigation guidance.

Closes #9185

Co-authored-by: Claude (Anthropic) <noreply@anthropic.com>
2026-05-06 11:28:15 +02:00
Marina López
6ad83d24c9 Add nitrate manual cancel subscription 2026-05-06 11:04:35 +02:00
María Valderrama
4ddabaebff
Add Nitrate's advanced permissions
*  Add Nitrate's advanced permissions

* 📎 Code review
2026-05-06 10:13:17 +02:00
479 changed files with 67487 additions and 59582 deletions

133
.devenv/README.md Normal file
View File

@ -0,0 +1,133 @@
# `.devenv/` — Per-Workspace AI-Client MCP Configs
This directory carries the pieces needed to point an AI coding agent
(currently Claude Code, opencode, VS Code Copilot, and the OpenAI Codex CLI)
at the MCP servers running inside the parallel devenv instance the developer
is currently working in. Every parallel workspace (`ws0`, `ws1`, …) has its
own copy because the Penpot MCP and Serena MCP host ports are
workspace-specific.
## Layout
```
.devenv/
README.md
scripts/
merge-mcp-config.py # generator helper invoked by manage.sh
shared/ # committed; workspace-independent entries
claude-code.json # Playwright — same for every workspace
opencode.json
vscode.json
codex.toml
templates/ # committed; entries with ${...} port placeholders
claude-code.json # Penpot MCP, Serena MCP — port is the only diff
opencode.json
vscode.json
codex.toml
mcp/ # gitignored; written by manage.sh per workspace
claude-code.json # loaded via Claude Code's --mcp-config flag
opencode.json # loaded via OPENCODE_CONFIG env var
```
One more file is generated outside `.devenv/`, in the directory VS Code itself
auto-discovers (gitignored):
```
.vscode/mcp.json # auto-loaded by GitHub Copilot in VS Code
```
Codex is the exception: it has no way to load an MCP config from an arbitrary
path, and its only project-level config file (`.codex/config.toml`) is one a
developer may already own. So we do **not** write a file for Codex at all —
`start-coding-agent codex` injects our servers as `-c` command-line overrides
built fresh from `shared/codex.toml` + `templates/codex.toml` at launch.
* **`shared/`** holds MCP entries that don't depend on the workspace — the
browser-driving Playwright server today, plus any other workspace-independent
servers we add later. Same content in every workspace, so it's a static
checked-in file.
* **`templates/`** holds the workspace-specific entries (Penpot MCP, Serena
MCP) with `${PENPOT_MCP_PORT}` and `${SERENA_MCP_PORT}` placeholders. The
placeholders are resolved per-workspace from the port-base constants in
`manage.sh`.
* **`mcp/`** (Claude Code, opencode) is the result of merging `shared/` with
the port-substituted `templates/`. `manage.sh` writes these on every
`run-devenv-agentic` pass. Gitignored, dedicated paths with no developer
content — never edit by hand, your edits will be overwritten on the next
reconcile.
* **`.vscode/mcp.json`** is the same merge, but written to the path VS Code
auto-discovers. Because on `ws0` that path *is* the live repo's own file, the
reconcile **deep-merges** into it: any servers you added yourself are kept,
and only the entries we manage (`penpot`, `serena-devenv`, `playwright`) are
(re)written to the current ports. On `ws1+` the file doesn't exist yet, so it
is created from scratch.
* **`scripts/merge-mcp-config.py`** is the generator. Its `json` mode does the
JSON deep-merge (with `--merge-into-existing` for the VS Code path); its
`codex-args` mode prints the `-c` assignments for Codex. `manage.sh`'s
`_merge-mcp-config-json` helper is a thin shim over the former, and
`start-coding-agent` calls the latter directly. Run
`python3 .devenv/scripts/merge-mcp-config.py --help` for the CLI.
## Launching a coding agent
The easiest path is the wrapper command, which knows the right flags per
client, `cd`'s into the target workspace, and refuses to launch unless the
target instance is running and its MCP config has been generated:
```bash
# Default target is ws0 (the live repo).
./manage.sh start-coding-agent claude [...args to forward]
./manage.sh start-coding-agent opencode [...args to forward]
./manage.sh start-coding-agent vscode [...args to forward to 'code']
./manage.sh start-coding-agent codex [...args to forward]
# Target a parallel workspace with --ws N. N is an integer (non-negative);
# 'main', 'ws1' and similar spellings are rejected.
./manage.sh start-coding-agent claude --ws 1
./manage.sh start-coding-agent opencode --ws 2
```
Equivalents by hand (run from inside the workspace directory):
```bash
claude --mcp-config .devenv/mcp/claude-code.json
OPENCODE_CONFIG=.devenv/mcp/opencode.json opencode
code "$PWD" # VS Code auto-discovers .vscode/mcp.json
# Codex: pass our servers as -c overrides (no config file is written).
codex $(python3 .devenv/scripts/merge-mcp-config.py --format codex-args \
.devenv/shared/codex.toml .devenv/templates/codex.toml \
| sed 's/^/-c /')
```
`start-coding-agent codex` does the `-c` wiring for you (and resolves the
workspace's ports first). Because our servers arrive as command-line
overrides, no "trusted project" prompt is involved for them — that prompt only
gates Codex's own `.codex/config.toml`, which we never write.
## Overriding our entries
Both the auto-discovered configs and the launcher-loaded configs sit *on top
of* the developer's global config (with varying precedence rules). All four
clients offer escape hatches for shadowing entries we ship:
* **Claude Code**`claude mcp add --scope local …` installs a private entry
that overrides the one in `mcp/claude-code.json`. Local scope wins.
* **opencode** — drop an `opencode.json` at the repo root with the override
entries you need. opencode's precedence chain is *global → `OPENCODE_CONFIG`
→ project*, so the project file always wins. The root `opencode.json` is
gitignored on purpose, since these overrides are personal.
* **VS Code Copilot** — the reconcile deep-merges into `.vscode/mcp.json`, so
any servers you add there yourself are preserved (only `penpot`,
`serena-devenv` and `playwright` are rewritten). To shadow one of *ours*,
put an entry under the same name in your VS Code user-profile MCP config —
it is loaded alongside the workspace file and wins.
* **Codex CLI** — our servers arrive as `-c` overrides, which are Codex's
highest-precedence layer, so they win over a same-named `[mcp_servers.<name>]`
in your `~/.codex/config.toml` or a project `.codex/config.toml`. To override
one of ours, append your own `-c` after the client name — extra args are
forwarded after ours and the later `-c` wins, e.g.
`./manage.sh start-coding-agent codex -- -c 'mcp_servers.penpot.url="…"'`.
See `docs/technical-guide/developer/agentic-devenv.md` for the broader
client-configuration story (browser remote debugging, AI-client config
schemas, manual setup for unsupported clients).

View File

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Combine a shared MCP-server config with a port-substituted template for one
AI coding-agent client.
Invoked per workspace by manage.sh's `write-instance-mcp-configs` (JSON
clients) and by `start-coding-agent` (Codex). Each supported client ships a
`.devenv/shared/<tool>.{json,toml}` (workspace-independent entries, e.g.
Playwright) and a `.devenv/templates/<tool>.{json,toml}` (per-workspace entries
with `${PENPOT_MCP_PORT}` / `${SERENA_MCP_PORT}` placeholders). This script
combines the two for the target client.
Two output modes are supported:
json Deep-merge two JSON documents under a configurable top-level key
(`mcpServers` for Claude Code, `mcp` for opencode, `servers` for
VS Code Copilot) and write the result to <out>. Same-name
entries in the template override entries in shared. With
--merge-into-existing, any pre-existing <out> file is loaded as
the lowest-precedence layer first, so entries the developer
already had are preserved (ours win on name collision). This is
used for VS Code's auto-discovered `.vscode/mcp.json`, which on
ws0 IS the live repo's file and may hold the developer's own
servers; the Claude/opencode outputs live in a dedicated,
gitignored `.devenv/mcp/` path and are written without the flag
(a clean overwrite).
codex-args Deep-merge the two TOML chunks and print one
`dotted.key=<toml-value>` assignment per line to stdout (no
<out> file). The caller wraps each line in a `codex -c` flag.
Codex has no way to load an MCP config from an arbitrary file
path (CODEX_HOME would relocate auth/history too), so rather than
writing the auto-discovered `.codex/config.toml` we inject our
servers as ephemeral per-invocation overrides. This never
touches the developer's project- or user-level Codex config.
In both modes, `${VAR}` placeholders inside *either* chunk are resolved from
the current environment (only template chunks carry placeholders in practice,
but the substitution is uniform either way) using Python's
`os.path.expandvars`. Undefined placeholders are left as `${VAR}` literal text
-- callers (i.e. manage.sh) are responsible for exporting the variables before
invoking the script.
Usage:
merge-mcp-config.py --format json --key <key> [--merge-into-existing] \
<shared> <template> <out>
merge-mcp-config.py --format codex-args <shared> <template>
Exit codes:
0 success
2 argparse error (missing required option, bad value, unreadable input)
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tomllib
from pathlib import Path
def merge_json(
shared_path: Path,
tpl_path: Path,
out_path: Path,
key: str,
merge_into_existing: bool,
) -> None:
"""Deep-merge JSON documents under a single top-level dict key into out.
Precedence (lowest to highest): an existing <out> file (only when
merge_into_existing is set), then shared, then the template. Entries under
`key` are merged by name, so the template wins on a name collision while
every other entry the lower layers contributed is kept. Top-level keys
other than `key` come from the existing file and shared (shared wins).
"""
shared = json.loads(shared_path.read_text())
tpl = json.loads(os.path.expandvars(tpl_path.read_text()))
base: dict = {}
if merge_into_existing and out_path.exists():
base = json.loads(out_path.read_text())
merged: dict = {**base, **shared}
merged[key] = {**base.get(key, {}), **shared.get(key, {}), **tpl.get(key, {})}
out_path.write_text(json.dumps(merged, indent=2) + "\n")
def _deep_merge(base: dict, overlay: dict) -> dict:
"""Recursively merge overlay into base; overlay wins on scalar/list keys."""
out = dict(base)
for k, v in overlay.items():
if isinstance(out.get(k), dict) and isinstance(v, dict):
out[k] = _deep_merge(out[k], v)
else:
out[k] = v
return out
def _toml_value(value: object) -> str:
"""Serialize a scalar/list as a TOML literal for a `codex -c` value.
bool is checked before int because `isinstance(True, int)` is True. Strings
are emitted as JSON strings, which are valid TOML basic strings for the
ASCII values our configs carry (commands, args, URLs). Tables never reach
here -- they are flattened into dotted keys by _flatten.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, str):
return json.dumps(value)
if isinstance(value, list):
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
raise TypeError(f"unsupported TOML value type: {type(value).__name__}")
_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
def _key_segment(seg: str) -> str:
"""A dotted-key segment: bare if TOML-safe, else a quoted key."""
return seg if _BARE_KEY.match(seg) else json.dumps(seg)
def _flatten(obj: dict, prefix: list[str]):
"""Yield (dotted-path-segments, leaf-value) for every non-table leaf.
Lists are leaves (TOML arrays), so we do not recurse into them; nested
tables (e.g. an `env` table) are flattened into further dotted keys.
"""
for k, v in obj.items():
path = prefix + [k]
if isinstance(v, dict):
yield from _flatten(v, path)
else:
yield path, v
def emit_codex_args(shared_path: Path, tpl_path: Path) -> None:
"""Print `dotted.key=<toml-value>` lines from the merged TOML chunks."""
shared = tomllib.loads(os.path.expandvars(shared_path.read_text()))
tpl = tomllib.loads(os.path.expandvars(tpl_path.read_text()))
merged = _deep_merge(shared, tpl)
for path, value in _flatten(merged, []):
dotted = ".".join(_key_segment(s) for s in path)
sys.stdout.write(f"{dotted}={_toml_value(value)}\n")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description=__doc__.split("\n\n", 1)[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--format",
choices=("json", "codex-args"),
required=True,
help="Output mode: 'json' writes a merged file; 'codex-args' prints -c assignments.",
)
parser.add_argument(
"--key",
help="Top-level JSON key under which MCP entries live (required for --format json).",
)
parser.add_argument(
"--merge-into-existing",
action="store_true",
help="json only: layer the merge on top of an existing <out> file, "
"preserving entries already there (ours still win on name collision).",
)
parser.add_argument("shared", type=Path, help="Path to the shared chunk.")
parser.add_argument("template", type=Path, help="Path to the port-placeholder template chunk.")
parser.add_argument(
"out",
type=Path,
nargs="?",
help="Path the merged result is written to (json only; codex-args writes stdout).",
)
args = parser.parse_args(argv)
if args.format == "json":
if not args.key:
parser.error("--key is required when --format json")
if args.out is None:
parser.error("out is required when --format json")
merge_json(args.shared, args.template, args.out, args.key, args.merge_into_existing)
else: # codex-args
if args.key:
parser.error("--key is not accepted when --format codex-args")
if args.merge_into_existing:
parser.error("--merge-into-existing is not accepted when --format codex-args")
if args.out is not None:
parser.error("out path is not accepted when --format codex-args (result goes to stdout)")
emit_codex_args(args.shared, args.template)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
}
}
}

View File

@ -0,0 +1,8 @@
# Workspace-independent MCP servers for the OpenAI Codex CLI.
# This block is concatenated with the port-substituted templates/codex.toml
# by manage.sh's write-instance-mcp-configs to produce .codex/config.toml at
# the workspace root.
[mcp_servers.playwright]
command = "npx"
args = ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]

View File

@ -0,0 +1,9 @@
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"],
"enabled": true
}
}
}

View File

@ -0,0 +1,9 @@
{
"servers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
}
}
}

View File

@ -0,0 +1,12 @@
{
"mcpServers": {
"penpot": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:${PENPOT_MCP_PORT}/mcp", "--allow-http"]
},
"serena-devenv": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:${SERENA_MCP_PORT}/mcp", "--allow-http"]
}
}
}

View File

@ -0,0 +1,10 @@
# Workspace-specific MCP servers for the OpenAI Codex CLI. The PENPOT_MCP_PORT
# and SERENA_MCP_PORT placeholders below are filled in per workspace by
# manage.sh's write-instance-mcp-configs, then the result is concatenated
# with shared/codex.toml to produce .codex/config.toml.
[mcp_servers.penpot]
url = "http://localhost:${PENPOT_MCP_PORT}/mcp"
[mcp_servers.serena-devenv]
url = "http://localhost:${SERENA_MCP_PORT}/mcp"

View File

@ -0,0 +1,14 @@
{
"mcp": {
"penpot": {
"type": "remote",
"url": "http://localhost:${PENPOT_MCP_PORT}/mcp",
"enabled": true
},
"serena-devenv": {
"type": "remote",
"url": "http://localhost:${SERENA_MCP_PORT}/mcp",
"enabled": true
}
}
}

View File

@ -0,0 +1,12 @@
{
"servers": {
"penpot": {
"type": "http",
"url": "http://localhost:${PENPOT_MCP_PORT}/mcp"
},
"serena-devenv": {
"type": "http",
"url": "http://localhost:${SERENA_MCP_PORT}/mcp"
}
}
}

View File

@ -1,9 +1,8 @@
description: Create a report to help us improve description: Create a report to help us improve
name: Bug report name: Bug report
title: "bug: " title: ""
type: Bug type: Bug
labels: ["triage"] labels: ["needs triage"]
projects: ["penpot/8"]
body: body:
- type: markdown - type: markdown

View File

@ -1,9 +1,8 @@
description: Suggest an idea for this project. description: Suggest an idea for this project.
labels: ["needs triage"] labels: ["needs triage"]
name: "Feature request" name: "Feature request"
title: "feature: " title: ""
type: Enhancement type: Enhancement
projects: ["penpot/8"]
body: body:
- type: markdown - type: markdown

View File

@ -6,7 +6,6 @@ on:
jobs: jobs:
build-and-push: build-and-push:
name: Build and push DevEnv Docker image name: Build and push DevEnv Docker image
environment: release-admins
runs-on: penpot-runner-02 runs-on: penpot-runner-02
steps: steps:
@ -39,3 +38,13 @@ jobs:
tags: ${{ env.DOCKER_IMAGE }}:latest tags: ${{ env.DOCKER_IMAGE }}:latest
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🚀 *[PENPOT] New devenv available*
📄 You may want to update your devenv.
@alvaro

View File

@ -1,22 +0,0 @@
name: _MAIN-STAGING
on:
workflow_dispatch:
schedule:
- cron: '26 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "main-staging"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: "main-staging"

View File

@ -19,7 +19,6 @@ permissions:
jobs: jobs:
release: release:
environment: release-admins
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs: outputs:
version: ${{ steps.vars.outputs.gh_ref }} version: ${{ steps.vars.outputs.gh_ref }}

84
.github/workflows/tests-backend.yml vendored Normal file
View File

@ -0,0 +1,84 @@
name: "CI: Backend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'backend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'backend/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
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
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation

57
.github/workflows/tests-common.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: "CI: Common"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Tests
working-directory: ./common
run: |
./scripts/test

71
.github/workflows/tests-frontend.yml vendored Normal file
View File

@ -0,0 +1,71 @@
name: "CI: Frontend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frotend/**/*'
- 'common/**/*'
- 'render-wasm/**/*'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frotend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components

93
.github/workflows/tests-integration.yml vendored Normal file
View File

@ -0,0 +1,93 @@
name: "CI: Integration"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frotend/**'
- 'common/**'
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frotend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests"
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
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result
path: frontend/test-results/
overwrite: true
retention-days: 3

58
.github/workflows/tests-library.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: "CI: Library"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
- 'library/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
- 'library/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./library
run: |
./scripts/test

83
.github/workflows/tests-plugins.yml vendored Normal file
View File

@ -0,0 +1,83 @@
name: "CI: Plugins"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'plugins/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
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
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example

57
.github/workflows/tests-wasm.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: "CI: WASM"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test

View File

@ -1,411 +0,0 @@
name: "CI"
defaults:
run:
shell: bash
on:
pull_request:
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
concurrency:
group: ${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
if: ${{ !github.event.pull_request.draft }}
name: "Linter"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint Common
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Lint Frontend
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Lint Backend
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Exporter
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Library
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./common
run: |
./scripts/test
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
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
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
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
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./library
run: |
./scripts/test
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
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:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 1/3"
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
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="1/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-1
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-2:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 2/3"
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
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="2/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-2
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-3:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 3/3"
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
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="3/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-3
path: frontend/test-results/
overwrite: true
retention-days: 3

10
.gitignore vendored
View File

@ -15,6 +15,12 @@
.repl .repl
/*.jpg /*.jpg
/*.md /*.md
!CHANGES.md
!CONTRIBUTING.md
!README.md
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
/*.png /*.png
/*.svg /*.svg
/*.sql /*.sql
@ -87,6 +93,10 @@
/.pnpm-store /.pnpm-store
/.vscode /.vscode
/.idea /.idea
*.iml
/.claude /.claude
/.playwright-mcp /.playwright-mcp
/.devenv/mcp/
/opencode.json
/.codex/
/tools/__pycache__ /tools/__pycache__

View File

@ -9,7 +9,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow # Development workflow
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. - Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`.
- You have access to the GitHub CLI `gh` or corresponding MCP tools. - You have access to the GitHub CLI `gh` or corresponding MCP tools.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.

View File

@ -0,0 +1,160 @@
# Creating Issues
Create GitHub issues only on explicit request. Use `gh` CLI authenticated to `penpot/penpot`.
## Title Derivation
Derive the title from the source material (bug report, user feedback, feature request, etc.) — not from any pre-existing title which may be auto-generated or stale.
### Bug titles (descriptive present tense)
Describe the symptom as it appears to the user. Format: `[Where] [present-tense verb] when [condition]`.
- *"Plugin API crashes when setting text fills"*
- *"Canvas renders glitches when zooming quickly"*
- *"French Canada locale falls back to French (fr) translations"*
Do **not** start bug titles with "Fix" or any imperative verb — state what's broken, not command a fix.
### Feature / Enhancement titles (imperative mood)
Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
### Universal rules
- **Include the "where"** — specify the UI location or module (e.g. "in the sidebar", "on the stroke options")
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, `[PENPOT FEEDBACK]`, etc.
- **No emoji** — plain text only
- **Be specific** — prefer concrete detail over generality
- **Two problems → cover both** — if the description has two distinct but related issues, capture both joined by "and"
## Metadata
| Field | Rule |
|-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
## Issue Body Template
Write the body to a temp file to avoid shell quoting issues:
**Bug template:**
```markdown
### Description
<what breaks, what the user experiences>
### Steps to reproduce
1. <step 1>
2. <step 2>
### Expected behavior
<what should happen instead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<what the user can now do that they couldn't before>
### Use case
<why this is useful, who benefits>
### Affected versions
<version>
```
## Creating the Issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Derived title>" \
--label "<label>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
## Setting the Issue Type
`gh issue create` can't set Issue Type directly. Use GraphQL after creation.
**Issue Type IDs for penpot/penpot:**
| Type | ID |
|------|----|
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
| Task | `IT_kwDOAcyBPM4AX5NY` |
| Question | `IT_kwDOAcyBPM4B_IQj` |
| Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:**
- `bug` label → Bug
- `enhancement` label → Enhancement
- Feature/epic → Feature
- Docs → Docs
- None of the above → Task
**Set it:**
```bash
ISSUE_ID=$(gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { id }
}}' --jq '.data.repository.issue.id')
gh api graphql -f query='
mutation {
updateIssue(input: {
id: "'"$ISSUE_ID"'"
issueTypeId: "<TYPE_ID>"
}) {
issue { number issueType { name } }
}
}'
```
## Verification
```bash
gh issue view <NUMBER> --repo penpot/penpot \
--json title,labels,milestone,projectItems \
--jq '{title, milestone: .milestone.title, labels: [.labels[].name], projects: [.projectItems[].title]}'
gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { issueType { name } }
}}' --jq '.data.repository.issue.issueType.name'
```
## Cleanup
```bash
rm -f /tmp/issue-body.md
```
## See Also
- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs`

View File

@ -1,10 +1,123 @@
# CHANGELOG # CHANGELOG
## 2.17.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
### :sparkles: New features & Enhancements
- Show a read-only W × H size badge below the bounding box of the current selection (by @bittoby) [#9205](https://github.com/penpot/penpot/issues/9205)
- Expose `variants` retrieval on `LibraryComponent` via `isVariant()` type guard in plugin API (by @opcode81) [#9185](https://github.com/penpot/penpot/issues/9185) (PR: [#9302](https://github.com/penpot/penpot/pull/9302))
- Add search bar to prototype interaction destination dropdown (by @EvaMarco) [#8618](https://github.com/penpot/penpot/issues/8618) (PR: [#9769](https://github.com/penpot/penpot/pull/9769))
- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625))
- Add dashed stroke customization with dash and gap inputs (by @EvaMarco) [#3881](https://github.com/penpot/penpot/issues/3881) (PR: [#9765](https://github.com/penpot/penpot/pull/9765))
- Add author, relative timestamp and short identifier to history entries (by @FairyPigDev) [#7660](https://github.com/penpot/penpot/issues/7660) (PR: [#9132](https://github.com/penpot/penpot/pull/9132))
- Add typography token row to multiselected texts [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
- Optimize propagation of tokens [#9261](https://github.com/penpot/penpot/issues/9261) (PR: [#9144](https://github.com/penpot/penpot/pull/9144))
- Add typography information to token dropdown option [#9377](https://github.com/penpot/penpot/issues/9377) (PR: [#9375](https://github.com/penpot/penpot/pull/9375))
- Cache OIDC provider records to skip per-login discovery (by @Dexterity104) [#9294](https://github.com/penpot/penpot/issues/9294) (PR: [#9295](https://github.com/penpot/penpot/pull/9295))
- Validate shape on add-object to catch malformed inputs early (by @Dexterity104) [#9507](https://github.com/penpot/penpot/issues/9507) (PR: [#9291](https://github.com/penpot/penpot/pull/9291))
- Remove unreachable try/catch in hex->hsl (by @Dexterity104) [#9244](https://github.com/penpot/penpot/issues/9244) (PR: [#9245](https://github.com/penpot/penpot/pull/9245))
- Remove stray debug log in exporter upload-resource (by @iot2edge) [#9270](https://github.com/penpot/penpot/issues/9270) (PR: [#9272](https://github.com/penpot/penpot/pull/9272))
- Release pool connection during font variant creation (by @Dexterity104) [#9286](https://github.com/penpot/penpot/issues/9286) (PR: [#9287](https://github.com/penpot/penpot/pull/9287))
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109))
- Add list view mode to color picker UI [#4420](https://github.com/penpot/penpot/issues/4420) (PR: [#9953](https://github.com/penpot/penpot/pull/9953))
- Use Clipboard API consistently across the application (by @MilosM348) [#6514](https://github.com/penpot/penpot/issues/6514) (PR: [#9188](https://github.com/penpot/penpot/pull/9188))
- Use `$` as DTCG token/group discriminator and make `$description` optional [#8342](https://github.com/penpot/penpot/issues/8342) (PR: [#9912](https://github.com/penpot/penpot/pull/9912))
- Match version preview banner text to History sidebar labels (by @MilosM348) [#9503](https://github.com/penpot/penpot/issues/9503) (PR: [#9697](https://github.com/penpot/penpot/pull/9697))
- Use "copia" as duplicate suffix for Spanish (by @Rene0422) [#9623](https://github.com/penpot/penpot/issues/9623) (PR: [#9671](https://github.com/penpot/penpot/pull/9671))
- Harden CORS middleware to not reflect Origin with credentials enabled [#9659](https://github.com/penpot/penpot/issues/9659) (PR: [#9675](https://github.com/penpot/penpot/pull/9675))
- Revert token migrations on clashing names to prevent data loss [#9816](https://github.com/penpot/penpot/issues/9816) (PR: [#9950](https://github.com/penpot/penpot/pull/9950))
- Update contributing guidelines with current issue tags and CSS linting rules [#9900](https://github.com/penpot/penpot/issues/9900) (PR: [#9418](https://github.com/penpot/penpot/pull/9418))
- Add composite typography token input to the Design sidebar [#9932](https://github.com/penpot/penpot/issues/9932) (PR: [#9128](https://github.com/penpot/penpot/pull/9128), [#9375](https://github.com/penpot/penpot/pull/9375), [#8749](https://github.com/penpot/penpot/pull/8749))
- Avoid deduplicating temporary export files to prevent stale content (by @yong2bba) [#9970](https://github.com/penpot/penpot/issues/9970) (PR: [#9959](https://github.com/penpot/penpot/pull/9959))
### :bug: Bugs fixed
- Fix plugin API `fileVersion.restore()` promise hanging indefinitely on restore failure [Github #9092](https://github.com/penpot/penpot/issues/9092)
- Fix LDAP provider params schema typo (`bind-passwor``bind-password`) introduced during the `clojure.spec``malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide``:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored (`userinfo` / `token`) in the OIDC callback, causing "incomplete user info" failures during registration [Github #9108](https://github.com/penpot/penpot/issues/9108)
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled [Github #8877](https://github.com/penpot/penpot/issues/8877)
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947)
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
- Fix dashboard Recent/Deleted titles overlapped by scrolling content (by @rockchris099) [Github #8577](https://github.com/penpot/penpot/issues/8577)
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
- Fix hyphens stripped from export filenames (by @jamesrayammons) [Github #8901](https://github.com/penpot/penpot/issues/8901)
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
- Fix non-functional clear icon in change email modal inputs (by @Dexterity104) [Github #8977](https://github.com/penpot/penpot/issues/8977)
- Disable save button after saving account profile settings (by @Dexterity104) [Github #8979](https://github.com/penpot/penpot/issues/8979)
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
- Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067)
- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516)
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [Github #9137](https://github.com/penpot/penpot/issues/9137)
- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409)
- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479)
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
- Fix prototype interaction targets appearing in View Mode automatically when library component changes (by @jeffrey701) [#9049](https://github.com/penpot/penpot/issues/9049) (PR: [#9695](https://github.com/penpot/penpot/pull/9695))
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
- Prevent viewers from overwriting file thumbnails (by @jony376) [#9284](https://github.com/penpot/penpot/issues/9284) (PR: [#9285](https://github.com/penpot/penpot/pull/9285))
- Fix plugin API showing incorrect error messages for invalid operations (by @bitcompass) [#9417](https://github.com/penpot/penpot/issues/9417) (PR: [#9486](https://github.com/penpot/penpot/pull/9486))
- Add inactivity timeout to SSE sessions to match Streamable HTTP sessions [#9432](https://github.com/penpot/penpot/issues/9432) (PR: [#9464](https://github.com/penpot/penpot/pull/9464))
- Fix component variant switching behaving differently on two identical copies (by @MischaPanch) [#9498](https://github.com/penpot/penpot/issues/9498) (PR: [#9434](https://github.com/penpot/penpot/pull/9434))
- Populate is-indirect flag on file libraries from relation graph (by @Dexterity104) [#9506](https://github.com/penpot/penpot/issues/9506) (PR: [#9289](https://github.com/penpot/penpot/pull/9289))
- Add missing error message for invalid shadow token [#9583](https://github.com/penpot/penpot/issues/9583) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
- Fix moving a component in a library triggering stale update notification in dependent files [#9629](https://github.com/penpot/penpot/issues/9629) (PR: [#9616](https://github.com/penpot/penpot/pull/9616))
- Fix newly created token not visible when placed above existing tokens in the tree [#9711](https://github.com/penpot/penpot/issues/9711) (PR: [#9803](https://github.com/penpot/penpot/pull/9803))
- Fix B(V) input label misalignment in HSB color picker [#9731](https://github.com/penpot/penpot/issues/9731) (PR: [#9793](https://github.com/penpot/penpot/pull/9793))
- Fix text style name input appending font name instead of replacing it when edited [#9785](https://github.com/penpot/penpot/issues/9785) (PR: [#9784](https://github.com/penpot/penpot/pull/9784))
- Fix shadow token creation not allowing empty blur or spread value [#9808](https://github.com/penpot/penpot/issues/9808) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
- Fix thinner line in path when its stroke is deleted and added again [#9823](https://github.com/penpot/penpot/issues/9823) (PR: [#9836](https://github.com/penpot/penpot/pull/9836))
- Fix layers panel perceivable lag when displaying changes [#9834](https://github.com/penpot/penpot/issues/9834)
- Fix settings form visual layout broken after recent contribution [#9882](https://github.com/penpot/penpot/issues/9882) (PR: [#9883](https://github.com/penpot/penpot/pull/9883))
- Fix crash when duplicating shapes with fill/stroke properties [#9893](https://github.com/penpot/penpot/issues/9893) (PR: [#9647](https://github.com/penpot/penpot/pull/9647))
- Fix S3 storage failing with IRSA/Web Identity Token credentials (by @jpc2350) [#9927](https://github.com/penpot/penpot/issues/9927) (PR: [#9928](https://github.com/penpot/penpot/pull/9928))
- Fix onboarding template spinner stuck after failed template download (by @jeffrey701) [#9931](https://github.com/penpot/penpot/issues/9931) (PR: [#9504](https://github.com/penpot/penpot/pull/9504))
- Fix stroke caps not working correctly when there are other nodes in the middle of a path [#9987](https://github.com/penpot/penpot/issues/9987) (PR: [#9989](https://github.com/penpot/penpot/pull/9989))
- Fix missing three dots button for column and row edit menu in WebKit/Safari [#9993](https://github.com/penpot/penpot/issues/9993) (PR: [#9994](https://github.com/penpot/penpot/pull/9994))
- Fix exported path with strokes being cut off in SVG file [#9995](https://github.com/penpot/penpot/issues/9995) (PR: [#9996](https://github.com/penpot/penpot/pull/9996))
- Fix French Canada locale falling back to French translations instead of French Canadian (by @alexismo) [#10017](https://github.com/penpot/penpot/issues/10017) (PR: [#10027](https://github.com/penpot/penpot/pull/10027))
## 2.16.0 (Unreleased) ## 2.16.0 (Unreleased)
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights ### :rocket: Epics and highlights
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113)) - WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358) - Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
@ -62,6 +175,8 @@
- Add v2.16 release notes (What's new modal) [#9945](https://github.com/penpot/penpot/issues/9945) (PR: [#9940](https://github.com/penpot/penpot/pull/9940)) - Add v2.16 release notes (What's new modal) [#9945](https://github.com/penpot/penpot/issues/9945) (PR: [#9940](https://github.com/penpot/penpot/pull/9940))
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix plugin API `Board.addRulerGuide` attaching guides to the page instead of the board due to a shadowed `id` binding; also correct the `'content:write'` permission error message and the `RulerGuideProxy` name (by @girafic) [#8225](https://github.com/penpot/penpot/issues/8225) (PR: [#8632](https://github.com/penpot/penpot/pull/8632))
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063)) - Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652)) - Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652))
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949)) - Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
@ -175,7 +290,6 @@
- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565) - Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
## 2.15.1 ## 2.15.1
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements
@ -186,7 +300,6 @@
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138)) - Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138))
## 2.15.0 ## 2.15.0
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements

View File

@ -159,7 +159,7 @@ To save time on both sides, please avoid submitting PRs that:
### Good first issues ### Good first issues
We use the `easy fix` label to mark issues appropriate for newcomers. We use the `good first issue` label to mark issues appropriate for newcomers.
## Commit Guidelines ## Commit Guidelines
@ -175,26 +175,26 @@ Commit messages must follow this format:
### Commit types ### Commit types
| Emoji | Description | | Emoji | Description |
|-------|-------------| | ---------------------- | -------------------------- |
| :bug: | Bug fix | | :bug: | Bug fix |
| :sparkles: | Improvement or enhancement | | :sparkles: | Improvement or enhancement |
| :tada: | New feature | | :tada: | New feature |
| :recycle: | Refactor | | :recycle: | Refactor |
| :lipstick: | Cosmetic changes | | :lipstick: | Cosmetic changes |
| :ambulance: | Critical bug fix | | :ambulance: | Critical bug fix |
| :books: | Documentation | | :books: | Documentation |
| :construction: | Work in progress | | :construction: | Work in progress |
| :boom: | Breaking change | | :boom: | Breaking change |
| :wrench: | Configuration update | | :wrench: | Configuration update |
| :zap: | Performance improvement | | :zap: | Performance improvement |
| :whale: | Docker-related change | | :whale: | Docker-related change |
| :paperclip: | Other non-relevant changes | | :paperclip: | Other non-relevant changes |
| :arrow_up: | Dependency update | | :arrow_up: | Dependency update |
| :arrow_down: | Dependency downgrade | | :arrow_down: | Dependency downgrade |
| :fire: | Removal of code or files | | :fire: | Removal of code or files |
| :globe_with_meridians: | Add or update translations | | :globe_with_meridians: | Add or update translations |
| :rocket: | Epic or highlight | | :rocket: | Epic or highlight |
### Rules ### Rules
@ -231,6 +231,19 @@ We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
./scripts/lint ./scripts/lint
``` ```
For frontend SCSS, we use `stylelint` for linting and
`Prettier` for formatting:
```bash
cd frontend
# Lint SCSS
pnpm run lint:scss (does not modify files)
# Fix SCSS formatting (modifies files in place)
pnpm run fmt:scss
```
Ideally, run these as git pre-commit hooks. Ideally, run these as git pre-commit hooks.
[Husky](https://typicode.github.io/husky/#/) is a convenient option for [Husky](https://typicode.github.io/husky/#/) is a convenient option for
setting this up. setting this up.
@ -259,23 +272,23 @@ By submitting code you agree to and can certify the following:
> By making a contribution to this project, I certify that: > By making a contribution to this project, I certify that:
> >
> (a) The contribution was created in whole or in part by me and I have the > (a) The contribution was created in whole or in part by me and I have the
> right to submit it under the open source license indicated in the file; or > right to submit it under the open source license indicated in the file; or
> >
> (b) The contribution is based upon previous work that, to the best of my > (b) The contribution is based upon previous work that, to the best of my
> knowledge, is covered under an appropriate open source license and I have > knowledge, is covered under an appropriate open source license and I have
> the right under that license to submit that work with modifications, > the right under that license to submit that work with modifications,
> whether created in whole or in part by me, under the same open source > whether created in whole or in part by me, under the same open source
> license (unless I am permitted to submit under a different license), as > license (unless I am permitted to submit under a different license), as
> indicated in the file; or > indicated in the file; or
> >
> (c) The contribution was provided directly to me by some other person who > (c) The contribution was provided directly to me by some other person who
> certified (a), (b) or (c) and I have not modified it. > certified (a), (b) or (c) and I have not modified it.
> >
> (d) I understand and agree that this project and the contribution are public > (d) I understand and agree that this project and the contribution are public
> and that a record of the contribution (including all personal information > and that a record of the contribution (including all personal information
> I submit with it, including my sign-off) is maintained indefinitely and > I submit with it, including my sign-off) is maintained indefinitely and
> may be redistributed consistent with this project or the open source > may be redistributed consistent with this project or the open source
> license(s) involved. > license(s) involved.
### Signed-off-by ### Signed-off-by

View File

@ -5,7 +5,7 @@
<p align="center"> <p align="center">
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow"> <a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow">
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-blue.svg"> <img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==">
</a> </a>
<a href="https://community.penpot.app" rel="nofollow"> <a href="https://community.penpot.app" rel="nofollow">
<img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app"> <img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app">

View File

@ -5,8 +5,8 @@
We take the security of this project seriously. If you have discovered We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue. a security vulnerability, please do **not** open a public issue.
Please report vulnerabilities via email to: **[support@penpot.app]** Please report vulnerabilities through the [GitHub Security Advisories](https://github.com/penpot/penpot/security/advisories
) feature in the Penpot repository.
### What to include: ### What to include:

View File

@ -7,6 +7,7 @@ list.
## Security ## Security
* Alisher (@7megaumka7)
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD) * Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/) * [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
* Vaibhav Shukla * Vaibhav Shukla

View File

@ -67,7 +67,8 @@
;; Pretty Print specs ;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"} pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.44.4"}} software.amazon.awssdk/s3 {:mvn/version "2.44.4"}
software.amazon.awssdk/sts {:mvn/version "2.44.4"}}
:paths ["src" "resources" "target/classes"] :paths ["src" "resources" "target/classes"]
:aliases :aliases

View File

@ -198,14 +198,14 @@
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr> <tr>
<td width="20" height="20" align="center" valign="middle" <td width="20" height="20" align="center" valign="middle"
background="{{organization-logo}}" background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}"
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black"> style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
{% if organization-initials %}{{organization-initials}}{% endif %} {% if organization.initials %}{{organization.initials}}{% endif %}
</td> </td>
</tr> </tr>
</table> </table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;"> <span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
“{{ organization-name|abbreviate:25 }}” {{ organization.name|abbreviate:50 }}
</span> </span>
</div> </div>
</td> </td>

View File

@ -1 +1 @@
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}” {{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”

View File

@ -1,6 +1,6 @@
Hello! Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”. {{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”.
Accept invitation using this link: Accept invitation using this link:

View File

@ -0,0 +1,270 @@
<!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/uxbox-title.png"
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{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
</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;">
Your Enterprise subscription is coming up for renewal. Here's a summary of what's included.
</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin-top:20px">
<b>Renewal date:</b> {{ renewal-date }}
</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin-top:10px">
<b>Estimated amount:</b> {{ estimated-amount }}
</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin:10px 0">
<b>Organizations covered:</b> {% if organizations|empty? %}No organizations yet.{% endif %}
</div>
{% for org in organizations %}
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin-bottom:5px">
<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 org.logo %}{{org.logo}}{% else %}{{org.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 org.initials %}{{org.initials}}{% endif %}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ org.name|abbreviate:50 }}
</span>
</div>
{% endfor %}
</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;">
This amount is based on current member counts across your organizations. You can adjust members from the <a href="{{ public-uri }}/admin-console/" target="_blank">Admin Console</a>.</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;">
Check our <a href="https://penpot.app/terms" target="_blank">Terms and Conditions</a> and <a href="https://penpot.app/privacy" target="_blank">Privacy Policy.</a></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;">
Enjoy!</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1 @@
Your Enterprise subscription renews on {{ renewal-date }}

View File

@ -0,0 +1,17 @@
Hi {% if user-name %}{{ user-name }}{% endif %},
Your Enterprise subscription is coming up for renewal. Here's a summary of what's included.
Renewal date: {{ renewal-date }}
Estimated amount: {{ estimated-amount }}
Organizations covered: {% if organizations|empty? %}No organizations yet.{% endif %}
{% for org in organizations %}
- {{ org.name }}
{% endfor %}
This amount is based on current member counts across your organizations. You can adjust members from the Admin Console.
Check our Terms and Conditions and Privacy Policy.
Enjoy!
The Penpot team.

View File

@ -27,6 +27,7 @@
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.cache :as cache]
[app.util.inet :as inet] [app.util.inet :as inet]
[app.util.json :as json] [app.util.json :as json]
[buddy.sign.jwk :as jwk] [buddy.sign.jwk :as jwk]
@ -694,15 +695,24 @@
(db/pgarray? roles) (db/pgarray? roles)
(assoc :roles (db/decode-pgarray roles #{})))) (assoc :roles (db/decode-pgarray roles #{}))))
;; TODO: add cache layer for avoid build an discover each time ;; A short TTL avoids paying the OIDC discovery + JWKS fetch on every
;; login; Caffeine will not store the entry when the load fn throws,
;; so a transient failure at the provider's discovery endpoint does
;; not poison the cache.
(defonce ^:private provider-cache
(cache/create :expire "10m" :max-size 64))
(defn- load-provider
[cfg id]
(when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true})
(decode-row))]
(case (:type params)
"oidc" (prepare-oidc-provider cfg params))))
(defn get-provider (defn get-provider
[cfg id] [cfg id]
(try (try
(when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true}) (cache/get provider-cache id (partial load-provider cfg))
(decode-row))]
(case (:type params)
"oidc" (prepare-oidc-provider cfg params)))
(catch Throwable cause (catch Throwable cause
(l/err :hint "unable to configure custom SSO provider" (l/err :hint "unable to configure custom SSO provider"
:provider (str id) :provider (str id)

View File

@ -315,8 +315,8 @@
(defn get-file (defn get-file
"Get file, resolve all features and apply migrations. "Get file, resolve all features and apply migrations.
Usefull when you have plan to apply massive or not cirurgical Useful when you have plan to apply massive or not surgical
operations on file, because it removes the ovehead of lazy fetching operations on file, because it removes the overhead of lazy fetching
and decoding." and decoding."
[cfg file-id & {:as opts}] [cfg file-id & {:as opts}]
(db/run! cfg get-file* file-id opts)) (db/run! cfg get-file* file-id opts))
@ -843,7 +843,12 @@
l.vern, l.vern,
l.is_shared, l.is_shared,
l.version, l.version,
fls.synced_at fls.synced_at,
NOT EXISTS (
SELECT 1 FROM file_library_rel AS direct
WHERE direct.file_id = ?::uuid
AND direct.library_file_id = l.id
) AS is_indirect
FROM libs AS l FROM libs AS l
JOIN project AS p JOIN project AS p
ON p.id = l.project_id ON p.id = l.project_id
@ -855,12 +860,8 @@
(defn get-file-libraries (defn get-file-libraries
[conn file-id] [conn file-id]
(into [] (into []
(comp (map decode-row-features)
;; FIXME: :is-indirect set to false to all rows looks (db/exec! conn [sql:get-file-libraries file-id file-id file-id])))
;; completly useless
(map #(assoc % :is-indirect false))
(map decode-row-features))
(db/exec! conn [sql:get-file-libraries file-id file-id])))
(defn get-resolved-file-libraries (defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of "Get all file libraries including itself. Returns an instance of

View File

@ -109,6 +109,11 @@
[:http-server-io-threads {:optional true} ::sm/int] [:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int] [:http-server-max-worker-threads {:optional true} ::sm/int]
;; Explicit CORS allowlist used when the :cors flag is enabled.
;; Configured via PENPOT_ALLOWED_ORIGINS as a comma/whitespace
;; separated list of origins (e.g. "https://plugins.example.com").
[:allowed-origins {:optional true} [::sm/set :string]]
[:exporter-shared-key {:optional true} :string] [:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string] [:nitrate-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string] [:nexus-shared-key {:optional true} :string]
@ -295,7 +300,7 @@
(sm/explainer schema:config)) (sm/explainer schema:config))
(defn read-config (defn read-config
"Reads the configuration from enviroment variables and decodes all "Reads the configuration from environment variables and decodes all
known values." known values."
[& {:keys [prefix default] :or {prefix "penpot"}}] [& {:keys [prefix default] :or {prefix "penpot"}}]
(->> (read-env prefix) (->> (read-env prefix)

View File

@ -431,14 +431,19 @@
:id ::invite-to-team :id ::invite-to-team
:schema schema: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-org
[:map [:map
[:invited-by ::sm/text] [:invited-by ::sm/text]
[:organization-name ::sm/text]
[:organization-initials [:maybe :string]]
[:organization-logo ::sm/uri]
[:user-name [:maybe ::sm/text]] [:user-name [:maybe ::sm/text]]
[:token ::sm/text]]) [:token ::sm/text]
[:organization schema:organization-data]])
(def invite-to-org (def invite-to-org
"Org member invitation email." "Org member invitation email."
@ -446,6 +451,21 @@
:id ::invite-to-org :id ::invite-to-org
:schema schema:invite-to-org)) :schema schema:invite-to-org))
(def ^:private schema:renewal-notice
[:map
[:user-name [:maybe ::sm/text]]
[:renewal-date ::sm/text]
[:estimated-amount ::sm/text]
[:organizations [:vector schema:organization-data]]])
(def renewal-notice
"Enterprise subscription renewal notice email."
(template-factory
:id ::renewal-notice
:schema schema:renewal-notice))
(def ^:private schema:join-team (def ^:private schema:join-team
[:map [:map
[:invited-by ::sm/text] [:invited-by ::sm/text]

View File

@ -22,7 +22,8 @@
(and (= "unlimited" type) (not (contains? canceled-status status))) (and (= "unlimited" type) (not (contains? canceled-status status)))
(ct/duration {:days 30}) (ct/duration {:days 30})
(and (= "enterprise" type) (not (contains? canceled-status status))) (and (contains? #{"enterprise" "nitrate"} type)
(not (contains? canceled-status status)))
(ct/duration {:days 90}) (ct/duration {:days 90})
:else :else

View File

@ -144,6 +144,15 @@
{::yres/status 404 {::yres/status 404
::yres/body (ex-data err)}) ::yres/body (ex-data err)})
(defmethod handle-error :nitrate-unavailable
[err request _]
(binding [l/*context* (request->context request)]
(l/warn :hint "nitrate is unreachable; blocking request" :cause err)
;; Do not leak Nitrate's internal URL/status to the client; the
;; full context is already logged above for operators.
{::yres/status 503
::yres/body {:type :nitrate-unavailable}}))
(defmethod handle-error :internal (defmethod handle-error :internal
[error request parent-cause] [error request parent-cause]
(binding [l/*context* (request->context request)] (binding [l/*context* (request->context request)]

View File

@ -208,28 +208,40 @@
:compile (constantly wrap-errors)}) :compile (constantly wrap-errors)})
(defn- with-cors-headers (defn- with-cors-headers
[headers origin] "Build CORS response headers. Only emits permissive headers when the
(-> headers request `origin` is present on the configured `allowed` allowlist;
(assoc "access-control-allow-origin" origin) otherwise returns the headers unchanged except for `Vary: Origin` so
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH") shared caches don't leak per-origin responses."
(assoc "access-control-allow-credentials" "true") [headers origin allowed]
(assoc "access-control-expose-headers" "content-type, set-cookie") (cond-> (assoc headers "vary" "Origin")
(assoc "access-control-allow-headers" "x-frontend-version, x-client, x-requested-width, content-type, accept, cookie"))) (and (some? origin) (contains? allowed origin))
(-> (assoc "access-control-allow-origin" origin)
(assoc "access-control-allow-credentials" "true")
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
(assoc "access-control-expose-headers" "content-type")
(assoc "access-control-allow-headers" "x-frontend-version, x-client, content-type, accept"))))
(defn wrap-cors (defn wrap-cors
[handler] [handler allowed]
(fn [request] (fn [request]
(let [response (if (= (yreq/method request) :options) (let [response (if (= (yreq/method request) :options)
{::yres/status 204} {::yres/status 204}
(handler request)) (handler request))
origin (yreq/get-header request "origin")] origin (yreq/get-header request "origin")]
(update response ::yres/headers with-cors-headers origin)))) (update response ::yres/headers with-cors-headers origin allowed))))
(def cors (def cors
{:name ::cors {:name ::cors
:compile (fn [& _] :compile (fn [& _]
(when (contains? cf/flags :cors) (when (contains? cf/flags :cors)
wrap-cors))}) (let [allowed (not-empty (cf/get :allowed-origins))]
(if allowed
(fn [handler] (wrap-cors handler allowed))
(do
(l/wrn :hint (str "cors flag is enabled but :allowed-origins is empty; "
"CORS middleware disabled (fail-closed). "
"Configure PENPOT_ALLOWED_ORIGINS with a comma-separated list of trusted origins."))
nil)))))})
(def restrict-methods (def restrict-methods
{:name ::restrict-methods {:name ::restrict-methods

View File

@ -21,7 +21,7 @@
(defn- write! (defn- write!
[^OutputStream output ^bytes data] [^OutputStream output ^bytes data]
(l/trc :hint "writting data" :data data :length (alength data)) (l/trc :hint "writing data" :data data :length (alength data))
(.write output data) (.write output data)
(.flush output)) (.flush output))

View File

@ -7,6 +7,7 @@
(ns app.nitrate (ns app.nitrate
"Module that make calls to the external nitrate aplication" "Module that make calls to the external nitrate aplication"
(:require (:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.json :as json] [app.common.json :as json]
[app.common.logging :as l] [app.common.logging :as l]
@ -28,14 +29,16 @@
(defn- request-builder (defn- request-builder
[cfg method uri shared-key profile-id request-params] [cfg method uri shared-key profile-id request-params]
(fn [] (fn []
(http/req cfg (cond-> {:method method (http/req cfg
:headers {"content-type" "application/json" (cond-> {:method method
"accept" "application/json" :headers {"content-type" "application/json"
"x-shared-key" shared-key "accept" "application/json"
"x-profile-id" (str profile-id)} "x-shared-key" shared-key
:uri uri "x-profile-id" (str profile-id)}
:version :http1.1} :uri uri
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))))) :version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))
{:skip-ssrf-check? true})))
(defn- with-retries (defn- with-retries
[handler max-retries] [handler max-retries]
@ -59,14 +62,29 @@
(fn [] (fn []
(let [response (handler) (let [response (handler)
status (:status response)] status (:status response)]
(when-not status
(l/error :hint "could't do the nitrate request, it is probably down"
:uri uri)
;; TODO decide what to do when Nitrate is inaccesible
nil)
(cond (cond
(nil? status)
(do
(l/error :hint "couldn't do the nitrate request, it is probably down"
:uri uri)
(ex/raise :type :nitrate-unavailable
:hint (str "nitrate is unreachable at " uri)))
(>= status 500)
;; Nitrate is up enough to answer (or the proxy is) but the
;; service itself is failing; treat as unavailable so callers
;; surface the static error page.
(do
(l/error :hint "nitrate request failed with server error status"
:uri uri
:status status
:body (:body response))
(ex/raise :type :nitrate-unavailable
:status status
:hint (str "nitrate is unavailable, HTTP " status " at " uri)))
(>= status 400) (>= status 400)
;; For error status codes (4xx, 5xx), fail immediately without validation ;; For client error status codes (4xx), fail immediately without validation
(do (do
(when (not= status 404) ;; Don't need to log 404 (when (not= status 404) ;; Don't need to log 404
(l/error :hint "nitrate request failed with error status" (l/error :hint "nitrate request failed with error status"
@ -171,6 +189,7 @@
"day" "day"
"week" "week"
"year"]] "year"]]
[:manual :boolean]
[:quantity :int] [:quantity :int]
[:description [:maybe ::sm/text]] [:description [:maybe ::sm/text]]
[:created-at schema:timestamp] [:created-at schema:timestamp]
@ -256,6 +275,42 @@
[:vector schema:org-summary] [:vector schema:org-summary]
params))) params)))
(def ^:private schema:org-summary-counts
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]
[:team-count ::sm/int]
[:member-count ::sm/int]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
(defn- get-owned-orgs-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)))
(defn- delete-owned-orgs-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
"/delete-owned-organizations")
nil params)))
(defn- set-team-org-api (defn- set-team-org-api
[cfg {:keys [organization-id team-id is-default] :as params}] [cfg {:keys [organization-id team-id is-default] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [baseuri (cf/get :nitrate-backend-uri)
@ -267,7 +322,7 @@
organization-id organization-id
"/add-team") "/add-team")
cto/schema:team-with-organization params) cto/schema:team-with-organization params)
custom-photo (when-let [logo-id (get-in team [:organization :logo-id])] custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))] (str (cf/get :public-uri) "/assets/by-id/" logo-id))]
(cond-> team (cond-> team
custom-photo custom-photo
@ -336,6 +391,24 @@
profile-id) profile-id)
schema:subscription params))) schema:subscription params)))
(def ^:private schema:subscription-warning
[:maybe
[:map {:title "SubscriptionWarning"}
[:type {:optional true} ::sm/text]
[:days-from-expiry {:optional true} ::sm/int]
[:days-until-expiry {:optional true} ::sm/int]
[:expiration-date {:optional true} schema:timestamp]]])
(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)]
(request-to-nitrate cfg :get
(str baseuri
"/api/subscription-warning/"
penpot-id)
schema:subscription-warning params)))
(defn- get-connectivity-api (defn- get-connectivity-api
[cfg params] [cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
@ -348,6 +421,31 @@
[:map [:map
[:cancel-at [:maybe schema:timestamp]]]) [:cancel-at [:maybe schema:timestamp]]])
(defn- get-org-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)))
(defn- get-org-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)))
(defn- redeem-activation-code-api (defn- redeem-activation-code-api
[cfg params] [cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
@ -369,12 +467,17 @@
:get-org-membership-by-team (partial get-org-membership-by-team-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-org-summary (partial get-org-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-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)
:delete-owned-orgs (partial delete-owned-orgs-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg) :add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg) :remove-profile-from-org (partial remove-profile-from-org-api cfg)
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg) :remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
:get-org-permissions (partial get-org-permissions-api cfg)
:delete-team (partial delete-team-api cfg) :delete-team (partial delete-team-api cfg)
:remove-team-from-org (partial remove-team-from-org-api cfg) :remove-team-from-org (partial remove-team-from-org-api cfg)
:get-subscription (partial get-subscription-api cfg) :get-subscription (partial get-subscription-api cfg)
:get-subscription-warning (partial get-subscription-warning-api cfg)
:connectivity (partial get-connectivity-api cfg) :connectivity (partial get-connectivity-api cfg)
:redeem-activation-code (partial redeem-activation-code-api cfg)})) :redeem-activation-code (partial redeem-activation-code-api cfg)}))
@ -386,21 +489,27 @@
(defn add-nitrate-licence-to-profile (defn add-nitrate-licence-to-profile
"Enriches a profile map with subscription information from Nitrate. "Enriches a profile map with subscription information from Nitrate.
Adds a :subscription field containing the user's license details. Adds a :subscription field containing the user's license details.
Returns the original profile unchanged if the request fails." Returns the original profile unchanged if the request fails for a reason
other than Nitrate being unreachable. When Nitrate is unreachable the
`:nitrate-unavailable` exception propagates so the request is rejected."
[cfg profile] [cfg profile]
(try (try
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})] (let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
(assoc profile :subscription subscription)) (assoc profile :subscription subscription))
(catch Throwable cause (catch Throwable cause
(l/error :hint "failed to get nitrate licence" (if (= :nitrate-unavailable (-> cause ex-data :type))
:profile-id (:id profile) (throw cause)
:cause cause) (do
profile))) (l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))))
(defn add-org-info-to-team (defn add-org-info-to-team
"Enriches a team map with organization information from Nitrate. "Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or org data is nil." Returns the original team unchanged if the request fails or org data is nil.
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
[cfg team params] [cfg team params]
(try (try
(let [params (assoc (or params {}) :team-id (:id team)) (let [params (assoc (or params {}) :team-id (:id team))
@ -413,10 +522,13 @@
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org))))) (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
team)) team))
(catch Throwable cause (catch Throwable cause
(l/error :hint "failed to get team organization info" (if (= :nitrate-unavailable (-> cause ex-data :type))
:team-id (:id team) (throw cause)
:cause cause) (do
team))) (l/error :hint "failed to get team organization info"
:team-id (:id team)
:cause cause)
team)))))
(defn set-team-organization (defn set-team-organization
"Associates a team with an organization in Nitrate. "Associates a team with an organization in Nitrate.
@ -434,7 +546,3 @@
:context {:team-id (:id team) :context {:team-id (:id team)
:organization-id (:organization-id params)})) :organization-id (:organization-id params)}))
team)) team))

View File

@ -112,22 +112,30 @@
::quotes/profile-id profile-id ::quotes/profile-id profile-id
::quotes/project-id project-id}) ::quotes/project-id project-id})
;; FIXME: IMPORTANT: this code can have race conditions, because ;; Acquire a row-level lock on the team and re-read its features
;; we have no locks for updating team so, creating two files ;; inside the same transaction before the read-modify-write below.
;; concurrently can lead to lost team features updating ;; Without the lock, two concurrent create-file calls on the same
(when-let [features (-> features ;; team can both observe the same team.features value, each
(set/difference (:features team)) ;; compute a different union, and the second UPDATE silently
(set/difference cfeat/no-team-inheritable-features) ;; overwrites the first (lost update under READ COMMITTED).
(not-empty))] (let [team-features (-> (db/exec-one! conn
(let [features (-> features ["SELECT features FROM team WHERE id = ? FOR UPDATE"
(set/union (:features team)) team-id])
(set/difference cfeat/no-team-inheritable-features) :features
(into-array))] (db/decode-pgarray #{}))]
(when-let [new-features (-> features
(set/difference team-features)
(set/difference cfeat/no-team-inheritable-features)
(not-empty))]
(let [features (-> new-features
(set/union team-features)
(set/difference cfeat/no-team-inheritable-features)
(into-array))]
(db/update! conn :team (db/update! conn :team
{:features features} {:features features}
{:id (:id team)} {:id team-id}
{::db/return-keys false}))) {::db/return-keys false}))))
(-> (create-file cfg params) (-> (create-file cfg params)
(vary-meta assoc ::audit/props {:team-id team-id})))) (vary-meta assoc ::audit/props {:team-id team-id}))))

View File

@ -409,10 +409,7 @@
[cfg {:keys [::rpc/profile-id file-id] :as params}] [cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; TODO For now we check read permissions instead of write, (files/check-edition-permissions! conn profile-id file-id)
;; to allow viewer users to update thumbnails. We might
;; review this approach on the future.
(files/check-read-permissions! conn profile-id file-id)
(when-not (db/read-only? conn) (when-not (db/read-only? conn)
(let [media (create-file-thumbnail cfg params)] (let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media)) {:uri (files/resolve-public-uri (:id media))

View File

@ -109,9 +109,6 @@
(fn [{:keys [data uploads]}] (fn [{:keys [data uploads]}]
(or (seq data) (seq uploads)))]]) (or (seq data) (seq uploads)))]])
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
;; connection around the font creation
(defn- prepare-font-data-from-uploads (defn- prepare-font-data-from-uploads
"Assembles each chunked-upload session in `uploads` (a `{mtype "Assembles each chunked-upload session in `uploads` (a `{mtype
session-id}` map) into a temp file, validates the media type and session-id}` map) into a temp file, validates the media type and
@ -171,20 +168,18 @@
[:process-font/global]] [:process-font/global]]
::webhooks/event? true ::webhooks/event? true
::sm/params schema:create-font-variant} ::sm/params schema:create-font-variant}
[cfg {:keys [::rpc/profile-id team-id uploads] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
(db/tx-run! cfg (teams/check-edition-permissions! pool profile-id team-id)
(fn [{:keys [::db/conn] :as cfg}] (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
(teams/check-edition-permissions! conn profile-id team-id) ::quotes/profile-id profile-id
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team ::quotes/team-id team-id})
::quotes/profile-id profile-id (let [params (if (some? uploads)
::quotes/team-id team-id}) (db/tx-run! cfg prepare-font-data-from-uploads params)
(let [params (if (some? uploads) (prepare-font-data-from-legacy params))]
(prepare-font-data-from-uploads cfg params) (create-font-variant cfg (assoc params :profile-id profile-id))))
(prepare-font-data-from-legacy params))]
(create-font-variant cfg (assoc params :profile-id profile-id))))))
(defn create-font-variant (defn create-font-variant
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}] [{:keys [::sto/storage] :as cfg} {:keys [data] :as params}]
(letfn [(generate-missing [data] (letfn [(generate-missing [data]
(let [data (media/run {:cmd :generate-fonts :input data})] (let [data (media/run {:cmd :generate-fonts :input data})]
(when (and (not (contains? data "font/otf")) (when (and (not (contains? data "font/otf"))
@ -209,22 +204,15 @@
:bucket "team-font-variant"}))) :bucket "team-font-variant"})))
(persist-fonts-files! [data] (persist-fonts-files! [data]
(let [otf-params (prepare-font data "font/otf") (into {} (keep (fn [[kind mtype]]
ttf-params (prepare-font data "font/ttf") (when-let [params (prepare-font data mtype)]
wf1-params (prepare-font data "font/woff") [kind (sto/put-object! storage params)])))
wf2-params (prepare-font data "font/woff2")] [[:otf "font/otf"]
[:ttf "font/ttf"]
[:woff1 "font/woff"]
[:woff2 "font/woff2"]]))
(cond-> {} (insert-font-variant! [conn {:keys [woff1 woff2 otf ttf]}]
(some? otf-params)
(assoc :otf (sto/put-object! storage otf-params))
(some? ttf-params)
(assoc :ttf (sto/put-object! storage ttf-params))
(some? wf1-params)
(assoc :woff1 (sto/put-object! storage wf1-params))
(some? wf2-params)
(assoc :woff2 (sto/put-object! storage wf2-params)))))
(insert-font-variant! [{:keys [woff1 woff2 otf ttf]}]
(db/insert! conn :team-font-variant (db/insert! conn :team-font-variant
{:id (uuid/next) {:id (uuid/next)
:team-id (:team-id params) :team-id (:team-id params)
@ -238,14 +226,14 @@
:otf-file-id (:id otf) :otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))] :ttf-file-id (:id ttf)}))]
(let [tpoint (ct/tpoint) (let [tpoint (ct/tpoint)
mtypes (vec (keys data)) mtypes (vec (keys data))
total-size (reduce-kv (fn [acc _ content] total-size (reduce-kv (fn [acc _ content]
(+ acc (if (bytes? content) (+ acc (if (bytes? content)
(alength ^bytes content) (alength ^bytes content)
(fs/size content)))) (fs/size content))))
0 0
data)] data)]
(l/dbg :hint "create-font-variant" (l/dbg :hint "create-font-variant"
:step "init" :step "init"
@ -257,7 +245,7 @@
(let [data (generate-missing data) (let [data (generate-missing data)
assets (persist-fonts-files! data) assets (persist-fonts-files! data)
result (insert-font-variant! assets) result (db/tx-run! cfg #(insert-font-variant! (::db/conn %) assets))
elapsed (tpoint)] elapsed (tpoint)]
(l/dbg :hint "create-font-variant" (l/dbg :hint "create-font-variant"

View File

@ -12,6 +12,8 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -57,6 +59,22 @@
[cfg _params] [cfg _params]
(nitrate/call cfg :connectivity {})) (nitrate/call cfg :connectivity {}))
(def ^:private schema:subscription-warning
[:maybe
[:map {:title "SubscriptionWarning"}
[:type {:optional true} ::sm/text]
[:days-from-expiry {:optional true} ::sm/int]
[:days-until-expiry {:optional true} ::sm/int]
[:expiration-date {:optional true} ct/schema:inst]]])
(sv/defmethod ::get-subscription-warning
{::rpc/auth true
::doc/added "2.14"
::sm/params [:map]
::sm/result schema:subscription-warning}
[cfg {:keys [::rpc/profile-id]}]
(nitrate/call cfg :get-subscription-warning {:profile-id profile-id}))
(def ^:private schema:redeem-activation-code-params (def ^:private schema:redeem-activation-code-params
[:map {:title "RedeemActivationCodeParams"} [:map {:title "RedeemActivationCodeParams"}
[:activation-code ::sm/text]]) [:activation-code ::sm/text]])
@ -110,12 +128,47 @@
AND t.id = ANY(?) AND t.id = ANY(?)
AND t.deleted_at IS NULL") AND t.deleted_at IS NULL")
(def sql:get-team-files-count (def ^:private sql:get-teams-files-counts
"SELECT count(*) AS total "SELECT p.team_id, count(*) AS total
FROM file AS f FROM file AS f
JOIN project AS p ON (p.id = f.project_id) JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ? WHERE p.team_id = ANY(?)
AND f.deleted_at IS NULL") AND f.deleted_at IS NULL
GROUP BY p.team_id")
(defn- get-team-files-counts
[conn team-ids]
(if (seq team-ids)
(let [ids-array (db/create-array conn "uuid" team-ids)]
(->> (db/exec! conn [sql:get-teams-files-counts ids-array])
(reduce (fn [acc {:keys [team-id total]}]
(assoc acc team-id (long total)))
{})))
{}))
(defn- build-leave-org-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)
has-files? (fn [id] (pos? (long (get files-counts id 0))))
deletable (remove has-files? teams-to-delete)
keep-default? (or keep-default-team-requested?
(and default-team-id (has-files? default-team-id)))
to-detach (cond-> (into [] (remove (set deletable) teams-to-delete))
(and default-team-id keep-default?) (conj default-team-id))]
{: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}))
(defn get-leave-org-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)]
{: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)}))
(def ^:private schema:leave-org (def ^:private schema:leave-org
[:map [:map
@ -130,6 +183,18 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:reassign-to {:optional true} ::sm/uuid]]]]]) [:reassign-to {:optional true} ::sm/uuid]]]]])
(def ^:private schema:get-leave-org-summary-result
[:map
[:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]])
(def ^:private schema:get-leave-org-summary
[:map
[:id ::sm/uuid]
[:default-team-id ::sm/uuid]])
(defn- get-organization-teams-for-user (defn- get-organization-teams-for-user
[{:keys [::db/conn] :as cfg} org-summary profile-id] [{:keys [::db/conn] :as cfg} org-summary profile-id]
@ -219,16 +284,14 @@
:code :not-valid-teams)))) :code :not-valid-teams))))
(defn leave-org (defn leave-org
[{:keys [::db/conn] :as cfg} {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}] [{:keys [::db/conn] :as cfg}
(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?]}]
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id]) {:keys [deletable-team-ids
:total) keep-default-team?
delete-default-team? (= default-team-files-count 0)] detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
;; assert that the received teams are valid, checking the different constraints ;; assert that the received teams are valid, checking the different constraints
(when-not skip-validation (when-not skip-validation
@ -236,20 +299,27 @@
(assert-membership cfg profile-id id) (assert-membership cfg profile-id id)
;; delete the teams-to-delete ;; delete only eligible teams (non-protected and without files)
(doseq [id teams-to-delete] (doseq [id deletable-team-ids]
(teams/delete-team cfg {:profile-id profile-id :team-id id})) (teams/delete-team cfg {:profile-id profile-id
:team-id id}))
;; leave the teams-to-leave ;; leave the teams-to-leave
(doseq [{:keys [id reassign-to]} teams-to-leave] (doseq [{:keys [id reassign-to]} teams-to-leave]
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to})) (teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
;; Delete default-team-id if empty; otherwise keep it and prefix the name. ;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
(if delete-default-team? (when default-team-id
(do (if keep-default-team?
(db/update! conn :team {:is-default false} {:id default-team-id}) (db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])
(teams/delete-team cfg {:profile-id profile-id :team-id default-team-id})) (teams/delete-team cfg {:profile-id profile-id
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-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}))
;; Api call to nitrate ;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id}) (nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
@ -266,6 +336,25 @@
(leave-org cfg (assoc params :profile-id profile-id))) (leave-org cfg (assoc params :profile-id profile-id)))
(sv/defmethod ::get-leave-org-summary
{::rpc/auth true
::doc/added "2.18"
::sm/params schema:get-leave-org-summary
::sm/result schema:get-leave-org-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)
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)))
(def ^:private schema:remove-team-from-org (def ^:private schema:remove-team-from-org
[:map [:map
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
@ -280,6 +369,20 @@
(assert-is-owner cfg profile-id team-id) (assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id) (assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id) (assert-membership cfg profile-id organization-id)
;; 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)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(when-not (nitrate-perms/allowed? :move-team
{:org-perms org-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 ;; 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-org {:team-id team-id :organization-id organization-id})
@ -288,6 +391,45 @@
(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-org")
nil) nil)
(def ^:private sql:get-team-invitation-emails
"SELECT email_to
FROM team_invitation
WHERE team_id = ?
AND valid_until > now()")
(def ^:private sql:delete-team-external-invitations
"DELETE FROM team_invitation
WHERE team_id = ?
AND email_to = ANY(?)
AND valid_until > now()")
(def ^:private sql:get-profiles-by-emails
"SELECT id, email
FROM profile
WHERE email = ANY(?)
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 {: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})]
(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)]
(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}))
external-emails (->> profiles
(remove #(contains? org-member-ids (:id %)))
(map :email)
(vec))]
{:allows-anybody false :external-emails external-emails}))))))
(def ^:private schema:add-team-to-organization (def ^:private schema:add-team-to-organization
[:map [:map
@ -305,15 +447,173 @@
(assert-not-default-team cfg team-id) (assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id) (assert-membership cfg profile-id organization-id)
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] (when (contains? cf/flags :nitrate)
;; Add teammates to the org if needed (let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id})
(doseq [{member-id :profile-id} team-members source-org-id (get-in team-with-org [:organization :id])
:when (not= member-id profile-id)] source-org-perms (when source-org-id
(teams/initialize-user-in-nitrate-org cfg member-id organization-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)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions"))
;; Api call to nitrate ;; Team already belongs to an organization: check move-teams on source org.
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] (when (some? source-org-id)
(when (nil? source-org-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
:profile-id profile-id
:target-org-same-owner? target-org-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
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
: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)))
;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
;; 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])))))
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
nil) nil)
(def ^:private schema:check-org-members-params
[:map {:title "CheckOrgMembersParams"}
[:organization-id ::sm/uuid]
[:emails [:vector ::sm/email]]])
(sv/defmethod ::check-org-members
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:check-org-members-params
::sm/result [:map-of :string :boolean]
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
(or (when (contains? cf/flags :nitrate)
(assert-membership cfg profile-id organization-id)
(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}))]
(into {}
(map (fn [email]
(let [pid (get email->id email)]
[email (boolean (and pid (contains? org-member-ids pid)))])))
emails)))
{}))
(def ^:private schema:all-org-members-in-team-params
[:map {:title "CheckOrgMembersInTeamParams"}
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]])
(sv/defmethod ::all-org-members-in-team
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-org-members-in-team-params
::sm/result ::sm/boolean}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(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)
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)))
false))
(def ^:private schema:all-team-members-in-orgs-params
[:map {:title "CheckTeamMembersInOrgsParams"}
[:team-id ::sm/uuid]
[:organization-ids [:vector ::sm/uuid]]])
(sv/defmethod ::all-team-members-in-orgs
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-team-members-in-orgs-params
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(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.
(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)]
[organization-id
(every? #(contains? org-member-ids %) team-member-ids)])))
organization-ids)))
{}))
(def ^:private schema:check-team-external-invitations-params
[:map {:title "CheckTeamExternalInvitationsParams"}
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]])
(def ^:private schema:check-team-external-invitations-result
[:map {:title "CheckTeamExternalInvitationsResult"}
[:has-external-invitations ::sm/boolean]
[:allows-anybody ::sm/boolean]])
(sv/defmethod ::check-team-external-invitations
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:check-team-external-invitations-params
::sm/result schema:check-team-external-invitations-result
::db/transaction true}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
{:has-external-invitations (boolean (seq external-emails))
:allows-anybody allows-anybody}))
{:has-external-invitations false
:allows-anybody false}))

View File

@ -110,8 +110,10 @@
(nitrate/add-nitrate-licence-to-profile cfg profile) (nitrate/add-nitrate-licence-to-profile cfg profile)
profile)) profile))
(catch Throwable _ (catch Throwable cause
{:id uuid/zero :fullname "Anonymous User"}))) (if (= :not-found (-> cause ex-data :type))
{:id uuid/zero :fullname "Anonymous User"}
(throw cause)))))
(defn get-profile (defn get-profile
"Get profile by id. Throws not-found exception if no profile found." "Get profile by id. Throws not-found exception if no profile found."
@ -483,8 +485,16 @@
{:deleted-at deleted-at} {:deleted-at deleted-at}
{:id profile-id}) {:id profile-id})
;; Api call to nitrate ;; Delete owned organizations on the fly (no grace period).
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id}) ;; Nitrate iterates the user's owned orgs and, per org, 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
;; imported "Your Penpot" teams according to whether they still have files.
(when (contains? cf/flags :nitrate)
(nitrate/call cfg :delete-owned-orgs {:profile-id profile-id})
;; Remove the user from any remaining org memberships.
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id}))
;; Schedule cascade deletion to a worker ;; Schedule cascade deletion to a worker
(wrk/submit! {::db/conn conn (wrk/submit! {::db/conn conn
@ -493,7 +503,6 @@
:deleted-at deleted-at :deleted-at deleted-at
:id profile-id}}) :id profile-id}})
(-> (rph/wrap nil) (-> (rph/wrap nil)
(rph/with-transform (session/delete-fn cfg))))) (rph/with-transform (session/delete-fn cfg)))))
@ -520,6 +529,32 @@
(let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])] (let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])]
{:editors editors})) {:editors editors}))
;; --- QUERY: Owned Organizations Summary (for delete-account modal)
(def ^:private schema:owned-organization-summary
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]
[:team-count ::sm/int]
[:member-count ::sm/int]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:custom-photo {:optional true} [:maybe ::sm/text]]])
(def ^:private schema:get-owned-organizations-summary-result
[:vector schema:owned-organization-summary])
(sv/defmethod ::get-owned-organizations-summary
"List organizations owned by the current profile with team and member counts.
Used by the delete-account modal to warn the user about cascading deletion."
{::doc/added "2.18"
::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}) [])
[]))
;; --- HELPERS ;; --- HELPERS
(def sql:owned-teams (def sql:owned-teams

View File

@ -12,6 +12,7 @@
[app.common.features :as cfeat] [app.common.features :as cfeat]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team] [app.common.types.team :as types.team]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
@ -193,7 +194,9 @@
(dm/with-open [conn (db/open pool)] (dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id) (cond->> (get-teams conn profile-id)
(contains? cf/flags :nitrate) (contains? cf/flags :nitrate)
(map #(nitrate/add-org-info-to-team cfg % params))))) (map #(nitrate/add-org-info-to-team cfg % params))
(contains? cf/flags :nitrate)
(remove #(get-in % [:organization :expired-license])))))
(def ^:private sql:get-owned-teams (def ^:private sql:get-owned-teams
"SELECT t.id, t.name, "SELECT t.id, t.name,
@ -506,11 +509,27 @@
(sv/defmethod ::create-team (sv/defmethod ::create-team
{::doc/added "1.17" {::doc/added "1.17"
::sm/params schema:create-team} ::sm/params schema:create-team}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id organization-id] :as params}]
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id}) ::quotes/profile-id profile-id})
;; When creating inside an org, verify the user has permission to do so.
;; Fail closed: if org 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)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(when-not (nitrate-perms/allowed? :create-team
{:org-perms org-perms
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to create teams in this organization")))))
(let [features (-> (cfeat/get-enabled-features cf/flags) (let [features (-> (cfeat/get-enabled-features cf/flags)
(set/difference cfeat/frontend-only-features) (set/difference cfeat/frontend-only-features)
(set/difference cfeat/no-team-inheritable-features)) (set/difference cfeat/no-team-inheritable-features))
@ -757,16 +776,31 @@
(defn delete-team (defn delete-team
"Mark a team for deletion" "Mark a team for deletion"
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}] [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
(let [team (get-team conn :profile-id profile-id :team-id team-id) (let [team (get-team conn :profile-id profile-id :team-id team-id)
perms (get team :permissions)] team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team params)
team)
perms (get team :permissions)
org (:organization team)
in-org? (and (contains? cf/flags :nitrate) org)
can-delete?
(if in-org?
(nitrate-perms/allowed? :delete-team
{:org-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)))]
(when-not (:is-owner perms) (when-not can-delete?
(ex/raise :type :validation (ex/raise :type :validation
:code :only-owner-can-delete-team)) :code :only-owner-can-delete-team))
(when (:is-default 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?))
(ex/raise :type :validation (ex/raise :type :validation
:code :non-deletable-team :code :non-deletable-team
:hint "impossible to delete default team")) :hint "impossible to delete default team"))

View File

@ -14,6 +14,7 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team] [app.common.types.team :as types.team]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
@ -112,8 +113,19 @@
(let [notifications (dm/get-in member [:props :notifications])] (let [notifications (dm/get-in member [:props :notifications])]
(not= :none (:email-invites notifications)))) (not= :none (:email-invites notifications))))
(defn- assert-email-can-be-invited
"Asserts that member is an org member when the org
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)))]
(when-not is-member?
(ex/raise :type :validation
:code :email-not-org-member
:hint "The invited email is not a member of the organization")))))
(defn- create-invitation (defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}] [{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}]
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
@ -130,6 +142,13 @@
:code :email-domain-is-not-allowed :code :email-domain-is-not-allowed
:hint "email domain is in the blacklist")) :hint "email domain is in the blacklist"))
;; When nitrate is active and the team belongs to an org, check that
;; the email is already an org member unless the org explicitly allows adding anybody.
(when (and (contains? cf/flags :nitrate)
(:organization team))
(assert-email-can-be-invited member org-member-ids))
;; When we have email verification disabled and invitation user is ;; When we have email verification disabled and invitation user is
;; already present in the database, we proceed to add it to the ;; already present in the database, we proceed to add it to the
;; team as-is, without email roundtrip. ;; team as-is, without email roundtrip.
@ -218,32 +237,26 @@
:to email :to email
:invited-by (:fullname profile) :invited-by (:fullname profile)
:user-name (:fullname member) :user-name (:fullname member)
:organization-name (:name organization) :organization organization
:organization-logo (:logo organization)
:organization-initials (:initials organization)
:token itoken :token itoken
:extra-data ptoken})) :extra-data ptoken}))
(let [team (if (contains? cf/flags :nitrate) (eml/send! {::eml/conn conn
(nitrate/add-org-info-to-team cfg team {}) ::eml/factory eml/invite-to-team
team)] :public-uri (cf/get :public-uri)
(eml/send! {::eml/conn conn :to email
::eml/factory eml/invite-to-team :invited-by (:fullname profile)
:public-uri (cf/get :public-uri) :team (:name team)
:to email :organization (dm/get-in team [:organization :name])
:invited-by (:fullname profile) :token itoken
:team (:name team) :extra-data ptoken})))
:organization (:organization-name team)
:token itoken
:extra-data ptoken}))))
itoken))))) itoken)))))
(defn create-org-invitation (defn create-org-invitation
[cfg {:keys [::rpc/profile-id id name initials logo] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (db/get-by-id cfg :profile profile-id)] (let [profile (db/get-by-id cfg :profile profile-id)]
(create-invitation cfg (create-invitation cfg
(assoc params (assoc params
:organization {:id id :name name :initials initials :logo logo}
:profile profile :profile profile
:role :editor)))) :role :editor))))
@ -309,7 +322,18 @@
- emails (set) + role (single role for all emails) - emails (set) + role (single role for all emails)
- invitations (vector of {:email :role} maps)" - invitations (vector of {:email :role} maps)"
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}] [{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
(let [;; Normalize input to a consistent format: [{:email :role}] (let [;; Enrich team with org info once for all invitations when nitrate is active
team (if (contains? cf/flags :nitrate)
(nitrate/add-org-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)
;; Normalize input to a consistent format: [{:email :role}]
invitation-data (cond invitation-data (cond
;; Case 1: emails + single role (create invitations style) ;; Case 1: emails + single role (create invitations style)
(and emails role) (and emails role)

View File

@ -19,7 +19,7 @@
of the object. This function can be applied to the object returned by the of the object. This function can be applied to the object returned by the
`get-object` but also to the RPC return value (in case you don't provide `get-object` but also to the RPC return value (in case you don't provide
the return value calculated key under `::key` metadata prop. the return value calculated key under `::key` metadata prop.
- `::reuse-key?` enables reusing the key calculated on first time; usefull - `::reuse-key?` enables reusing the key calculated on first time; useful
when the target object is not retrieved on the RPC (typical on retrieving when the target object is not retrieved on the RPC (typical on retrieving
dependent objects). dependent objects).
" "

View File

@ -37,7 +37,7 @@
data (-> (sto/content (:path content)) data (-> (sto/content (:path content))
(sto/wrap-with-hash hash)) (sto/wrap-with-hash hash))
content {::sto/content data content {::sto/content data
::sto/deduplicate? true ::sto/deduplicate? false
::sto/touched-at (ct/in-future {:minutes 10}) ::sto/touched-at (ct/in-future {:minutes 10})
:profile-id profile-id :profile-id profile-id
:content-type (:mtype content) :content-type (:mtype content)

View File

@ -12,11 +12,13 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.types.organization :refer [schema:team-with-organization]] [app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar]]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]] [app.common.types.team :refer [schema:team]]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.email :as eml]
[app.loggers.audit :as audit]
[app.media :as media] [app.media :as media]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -29,7 +31,8 @@
[app.rpc.notifications :as notifications] [app.rpc.notifications :as notifications]
[app.storage :as sto] [app.storage :as sto]
[app.util.services :as sv] [app.util.services :as sv]
[app.worker :as wrk])) [app.worker :as wrk]
[cuerdas.core :as str]))
(defn- profile-to-map [profile] (defn- profile-to-map [profile]
@ -48,7 +51,8 @@
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)] (let [profile (profile/get-profile cfg profile-id)]
(-> (profile-to-map profile) (-> (profile-to-map profile)
(assoc :theme (:theme profile))))) (assoc :theme (:theme profile))
(assoc :lang (:lang profile)))))
;; ---- API: get-teams ;; ---- API: get-teams
@ -296,46 +300,61 @@ RETURNING id, deleted_at;")
nil) nil)
(defn manage-deleted-organization-teams (defn manage-deleted-organization-teams
"For a list of teams, rename those with files and delete those without, then notify users." "For a deleted organization, preserve org teams unchanged and only prefix or
[cfg {:keys [teams organization-name]}] delete member Your Penpot teams depending on whether they still contain files."
(let [teams (->> teams (filter uuid?) distinct (into []))] [cfg {:keys [organization-id organization-name teams]}]
(when (seq teams) (let [all-team-ids (->> teams
(map :id)
(filter uuid?)
distinct
(into []))
your-penpot-team-ids (->> teams
(filter :is-your-penpot)
(map :id)
(filter uuid?)
distinct
(into []))]
(when (seq all-team-ids)
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")] (let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
(db/tx-run! (db/tx-run!
cfg cfg
(fn [{:keys [::db/conn] :as cfg}] (fn [{:keys [::db/conn] :as cfg}]
(let [teams-array (db/create-array conn "uuid" teams) (let [teams-with-files (if (seq your-penpot-team-ids)
teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array]) (->> (db/exec! conn [sql:get-teams-files-counts
(filter (fn [{:keys [total]}] (pos? total))) (db/create-array conn "uuid" your-penpot-team-ids)])
(map :team-id) (filter (fn [{:keys [total]}] (pos? total)))
(into #{})) (map :team-id)
teams-to-keep (->> teams (filter teams-with-files) (into [])) (into #{}))
teams-to-delete (->> teams (remove teams-with-files) (into []))] #{})
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
;; Rename teams that have files in one go ;; Org teams move to the fallback org unchanged. Only imported
(when (seq teams-to-keep) ;; Your Penpot teams keep the org prefix when they still have files.
(when (seq teams-to-prefix)
(db/exec! conn [sql:prefix-teams-name-and-unset-default (db/exec! conn [sql:prefix-teams-name-and-unset-default
org-prefix org-prefix
(db/create-array conn "uuid" teams-to-keep)])) (db/create-array conn "uuid" teams-to-prefix)]))
;; Soft-delete empty teams in one go ;; Empty imported Your Penpot teams disappear entirely.
(soft-delete-teams! cfg teams-to-delete) (soft-delete-teams! cfg teams-to-delete)
(notifications/notify-organization-deletion cfg organization-name teams teams-to-delete) (notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete)
nil))))))) nil)))))))
(sv/defmethod ::notify-organization-deletion (sv/defmethod ::notify-organization-deletion
"For a list of teams, rename them with the name of the deleted org, and notify "For a deleted organization, preserve org teams and only prefix or delete
of the deletion to the connected users" imported Your Penpot teams before notifying connected users."
{::doc/added "2.15" {::doc/added "2.15"
::sm/params schema:notify-organization-deletion ::sm/params schema:notify-organization-deletion
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [organization-id]}] [cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
teams (->> (:teams org-summary) teams (:teams org-summary)]
(map :id))] (manage-deleted-organization-teams cfg {:organization-name (:name org-summary)
(manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)}) :organization-id (:id org-summary)
:teams teams})
nil)) nil))
;; ---- API: notify-user-organizations-deletion ;; ---- API: notify-user-organizations-deletion
@ -345,15 +364,18 @@ RETURNING id, deleted_at;")
[:profile-id ::sm/uuid]]) [:profile-id ::sm/uuid]])
(sv/defmethod ::notify-user-organizations-deletion (sv/defmethod ::notify-user-organizations-deletion
"For a given user, find all owned organizations and rename or delete their teams." "For a given user, find all owned organizations and apply the deleted-org
transfer rules to their imported Your Penpot teams."
{::doc/added "2.18" {::doc/added "2.18"
::sm/params schema:notify-user-organizations-deletion} ::sm/params schema:notify-user-organizations-deletion}
[cfg {:keys [profile-id]}] [cfg {:keys [profile-id]}]
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})] (let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
(doseq [org owned-orgs] (doseq [org owned-orgs]
(let [organization-name (:name org) (let [organization-name (:name org)
teams (map :id (:teams org))] teams (:teams org)]
(manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name})))) (manage-deleted-organization-teams cfg {:organization-name organization-name
:organization-id (:id org)
:teams teams}))))
nil) nil)
@ -454,10 +476,7 @@ RETURNING id, deleted_at;")
{::doc/added "2.15" {::doc/added "2.15"
::sm/params [:map ::sm/params [:map
[:email ::sm/email] [:email ::sm/email]
[:id ::sm/uuid] [:organization schema:organization-with-avatar]]}
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo ::sm/uri]]}
[cfg params] [cfg params]
(db/tx-run! cfg ti/create-org-invitation params) (db/tx-run! cfg ti/create-org-invitation params)
nil) nil)
@ -472,6 +491,7 @@ RETURNING id, deleted_at;")
ti.email_to AS email, ti.email_to AS email,
ti.created_at AS sent_at, ti.created_at AS sent_at,
p.fullname AS name, p.fullname AS name,
p.id AS profile_id,
p.photo_id p.photo_id
FROM team_invitation AS ti FROM team_invitation AS ti
LEFT JOIN profile AS p LEFT JOIN profile AS p
@ -493,6 +513,7 @@ LEFT JOIN profile AS p
[:email ::sm/email] [:email ::sm/email]
[:sent-at ::sm/inst] [:sent-at ::sm/inst]
[:name {:optional true} [:maybe ::sm/text]] [:name {:optional true} [:maybe ::sm/text]]
[:profile-id {:optional true} [:maybe ::sm/uuid]]
[:photo-url {:optional true} ::sm/uri]]]) [:photo-url {:optional true} ::sm/uri]]])
(sv/defmethod ::get-org-invitations (sv/defmethod ::get-org-invitations
@ -544,6 +565,33 @@ LEFT JOIN profile AS p
nil)) nil))
;; API: delete-all-org-invitations
(def ^:private sql:delete-all-org-invitations
"DELETE FROM team_invitation AS ti
WHERE ti.org_id = ?
OR ti.team_id = ANY(?);")
(def ^:private schema:delete-all-org-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).
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
::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))]
(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]))))
nil))
;; API: remove-from-org ;; API: remove-from-org
@ -603,7 +651,8 @@ LEFT JOIN profile AS p
[:map [:map
[:teams-to-delete ::sm/int] [:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int] [:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]]) [:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]])
(sv/defmethod ::get-remove-from-org-summary (sv/defmethod ::get-remove-from-org-summary
"Get a summary of the teams that would be deleted, transferred, or exited "Get a summary of the teams that would be deleted, transferred, or exited
@ -623,7 +672,154 @@ LEFT JOIN profile AS p
(when-not valid-default-team (when-not valid-default-team
(ex/raise :type :validation (ex/raise :type :validation
:code :not-valid-teams)) :code :not-valid-teams))
{:teams-to-delete (count valid-teams-to-delete-ids) (cnit/get-leave-org-summary cfg
:teams-to-transfer (count valid-teams-to-transfer) default-team-id
:teams-to-exit (count valid-teams-to-exit)})) valid-teams-to-delete-ids
(count valid-teams-to-transfer)
(count valid-teams-to-exit))))
;; API: send-renewal-email
(def ^:private schema:send-renewal-email-params
[:map
[:profile-id ::sm/uuid]
[:user-email ::sm/email]
[:user-name [:maybe ::sm/text]]
[:renewal-date :string]
[:estimated-amount :double]
[:organizations [:vector schema:organization-with-avatar]]])
(sv/defmethod ::send-renewal-email
"Send an Enterprise subscription renewal notice email to a user."
{::doc/added "2.17"
::sm/params schema:send-renewal-email-params
::rpc/auth false}
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
(let [amount-str (format "$%.2f" estimated-amount)
user-name (if (str/empty? user-name)
(:fullname (profile/get-profile cfg profile-id))
user-name)]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(eml/send! {::eml/conn conn
::eml/factory eml/renewal-notice
:public-uri (cf/get :public-uri)
:to user-email
:user-name user-name
:renewal-date renewal-date
:estimated-amount amount-str
:organizations organizations}))))
nil)
;; API: exists-org-team-invitations-for-non-members /
;; delete-org-team-invitations-for-non-members
(def ^:private sql:get-profile-emails-by-ids
"SELECT email
FROM profile
WHERE id = ANY(?)
AND deleted_at IS NULL")
(def ^:private sql:exists-non-member-org-team-invitations
"SELECT EXISTS (
SELECT 1
FROM team_invitation
WHERE team_id = ANY(?)
AND email_to <> ALL(?)
) AS non_member")
(def ^:private sql:delete-non-member-org-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
[:map
[:team-ids [:vector ::sm/uuid]]
[:member-ids [:vector ::sm/uuid]]])
(def ^:private schema:exists-org-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."
[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])
(map :email)
(into #{}))]
{: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?
[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
teams-array
emails-array])
:non-member)))
(sv/defmethod ::exists-org-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}
[cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}]
{:exists (boolean (non-member-org-team-invitations-exist? conn params))})))
(sv/defmethod ::delete-org-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}
[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
teams-array
emails-array])
nil))))
;; ---- API: push-audit-events
(def ^:private schema:nitrate-audit-event
[:map {:title "NitrateAuditEvent"}
[:name [:and [:string {:max 250}]
[:re #"[\d\w-]{1,50}"]]]
[:profile-id ::sm/uuid]
[:props {: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"
{::doc/added "2.19"
::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))
nil))

View File

@ -34,11 +34,12 @@
(defn notify-organization-deletion (defn notify-organization-deletion
[cfg organization-name teams deleted-teams] [cfg organization-id organization-name teams deleted-teams]
(let [msgbus (::mbus/msgbus cfg)] (let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus (mbus/pub! msgbus
:topic uuid/zero :topic uuid/zero
:message {:type :organization-deleted :message {:type :organization-deleted
:organization-id organization-id
:organization-name organization-name :organization-name organization-name
:teams teams :teams teams
:deleted-teams deleted-teams}))) :deleted-teams deleted-teams})))

View File

@ -135,7 +135,8 @@
;; still not deleted. ;; still not deleted.
result (when (and (::deduplicate? params) result (when (and (::deduplicate? params)
(:hash mdata) (:hash mdata)
(:bucket mdata)) (:bucket mdata)
(not= "tempfile" (:bucket mdata)))
(let [result (get-database-object-by-hash connectable backend (let [result (get-database-object-by-hash connectable backend
(:bucket mdata) (:bucket mdata)
(:hash mdata))] (:hash mdata))]

View File

@ -12,7 +12,6 @@
[app.common.time :as ct] [app.common.time :as ct]
[promesa.exec :as px]) [promesa.exec :as px])
(:import (:import
com.github.benmanes.caffeine.cache.AsyncCache
com.github.benmanes.caffeine.cache.Cache com.github.benmanes.caffeine.cache.Cache
com.github.benmanes.caffeine.cache.Caffeine com.github.benmanes.caffeine.cache.Caffeine
com.github.benmanes.caffeine.cache.RemovalListener com.github.benmanes.caffeine.cache.RemovalListener
@ -47,15 +46,18 @@
:miss-rate (.missRate stats)})) :miss-rate (.missRate stats)}))
(defn create (defn create
[& {:keys [executor on-remove max-size keepalive]}] "Build an in-memory cache. Loads run synchronously on the calling
thread, so when a load fn throws or returns nil the entry is not
stored concurrent loads for the same key still deduplicate."
[& {:keys [executor on-remove max-size keepalive expire]}]
(let [cache (as-> (Caffeine/newBuilder) builder (let [cache (as-> (Caffeine/newBuilder) builder
(if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder) (if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder)
(if executor (.executor builder ^Executor (px/resolve-executor executor)) builder) (if executor (.executor builder ^Executor (px/resolve-executor executor)) builder)
(if keepalive (.expireAfterAccess builder ^Duration (ct/duration keepalive)) builder) (if keepalive (.expireAfterAccess builder ^Duration (ct/duration keepalive)) builder)
(if expire (.expireAfterWrite builder ^Duration (ct/duration expire)) builder)
(if (int? max-size) (.maximumSize builder (long max-size)) builder) (if (int? max-size) (.maximumSize builder (long max-size)) builder)
(.recordStats builder) (.recordStats builder)
(.buildAsync builder)) (.build builder))]
cache (.synchronous ^AsyncCache cache)]
(reify (reify
ICache ICache
(get [_ k] (get [_ k]
@ -69,7 +71,7 @@
(invalidate! [_] (invalidate! [_]
(.invalidateAll ^Cache cache)) (.invalidateAll ^Cache cache))
(invalidate! [_ k] (invalidate! [_ k]
(.invalidateAll ^Cache cache ^Object k)) (.invalidate ^Cache cache ^Object k))
ICacheStats ICacheStats
(stats [_] (stats [_]

View File

@ -17,6 +17,7 @@
[app.rpc.commands.access-token] [app.rpc.commands.access-token]
[app.tokens :as tokens] [app.tokens :as tokens]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.string :as str]
[clojure.test :as t] [clojure.test :as t]
[mockery.core :refer [with-mocks]] [mockery.core :refer [with-mocks]]
[yetti.request :as yreq] [yetti.request :as yreq]
@ -112,6 +113,74 @@
(t/is (= #{} (:app.http.access-token/perms response))) (t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response)))))) (t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
(defrecord MethodAwareDummyRequest [req-method headers]
yreq/IRequest
(method [_] req-method)
(get-header [_ name] (get headers name)))
(t/deftest cors-middleware-allowlisted-origin
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://trusted.example"}))
headers (::yres/headers resp)]
(t/is (= 200 (::yres/status resp)))
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
(t/is (= "true" (get headers "access-control-allow-credentials")))
(t/is (= "Origin" (get headers "vary")))
(t/is (= "content-type" (get headers "access-control-expose-headers")))
(t/is (not (str/includes?
(get headers "access-control-allow-headers" "")
"cookie")))))
(t/deftest cors-middleware-non-allowlisted-origin
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://attacker.example"}))
headers (::yres/headers resp)]
(t/is (= 200 (::yres/status resp)))
(t/is (nil? (get headers "access-control-allow-origin")))
(t/is (nil? (get headers "access-control-allow-credentials")))
(t/is (nil? (get headers "access-control-allow-headers")))
(t/is (nil? (get headers "access-control-expose-headers")))
(t/is (= "Origin" (get headers "vary")))))
(t/deftest cors-middleware-preflight-allowlisted
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://trusted.example"}))
headers (::yres/headers resp)]
(t/is (= 204 (::yres/status resp)))
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
(t/is (= "true" (get headers "access-control-allow-credentials")))))
(t/deftest cors-middleware-preflight-non-allowlisted
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://attacker.example"}))
headers (::yres/headers resp)]
(t/is (= 204 (::yres/status resp)))
(t/is (nil? (get headers "access-control-allow-origin")))
(t/is (nil? (get headers "access-control-allow-credentials")))))
(t/deftest cors-middleware-missing-origin
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :get {}))
headers (::yres/headers resp)]
(t/is (= 200 (::yres/status resp)))
(t/is (nil? (get headers "access-control-allow-origin")))
(t/is (nil? (get headers "access-control-allow-credentials")))))
(t/deftest session-authz (t/deftest session-authz
(let [cfg th/*system* (let [cfg th/*system*
manager (session/inmemory-manager) manager (session/inmemory-manager)

View File

@ -0,0 +1,36 @@
;; 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 backend-tests.logical-deletion-test
(:require
[app.common.time :as ct]
[app.config :as cf]
[app.features.logical-deletion :as ldel]
[clojure.test :as t]))
(t/deftest get-deletion-delay-for-active-subscriptions
(t/is (= (ct/duration {:days 30})
(ldel/get-deletion-delay {:subscription {:type "unlimited"
:status "active"}})))
(t/is (= (ct/duration {:days 90})
(ldel/get-deletion-delay {:subscription {:type "enterprise"
:status "active"}})))
(t/is (= (ct/duration {:days 90})
(ldel/get-deletion-delay {:subscription {:type "nitrate"
:status "active"}}))))
(t/deftest get-deletion-delay-for-canceled-subscriptions
(let [fallback (ct/duration {:days 5})]
(with-redefs [cf/get-deletion-delay (fn [] fallback)]
(t/is (= fallback
(ldel/get-deletion-delay {:subscription {:type "nitrate"
:status "canceled"}})))
(t/is (= fallback
(ldel/get-deletion-delay {:subscription {:type "enterprise"
:status "unpaid"}}))))))

View File

@ -154,7 +154,7 @@
(t/is (nil? (sto/get-object storage (:media-id row1)))) (t/is (nil? (sto/get-object storage (:media-id row1))))
(t/is (some? (sto/get-object storage (:media-id row2)))) (t/is (some? (sto/get-object storage (:media-id row2))))
;; check that storage object is still exists but is marked as deleted ;; check that storage object is still exists but is marked as deleted.
(let [row (th/db-get :storage-object {:id (:media-id row1)} {::db/remove-deleted false})] (let [row (th/db-get :storage-object {:id (:media-id row1)} {::db/remove-deleted false})]
(t/is (nil? row)))))) (t/is (nil? row))))))
@ -254,6 +254,32 @@
(t/is (some? (sto/get-object storage (:media-id row2))))))) (t/is (some? (sto/get-object storage (:media-id row2)))))))
(t/deftest create-file-thumbnail-requires-edit-permissions
(let [owner (th/create-profile* 1)
viewer (th/create-profile* 2)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)
:is-shared false
:revn 1})
_ (th/create-file-role* {:file-id (:id file)
:profile-id (:id viewer)
:role :viewer})
data {::th/type :create-file-thumbnail
::rpc/profile-id (:id viewer)
:file-id (:id file)
:revn 1
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)
error (:error out)]
(t/is (nil? (:result out)))
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))
(t/is (= 0 (count (th/db-query :file-thumbnail {:file-id (:id file)}))))))
(t/deftest error-on-direct-storage-obj-deletion (t/deftest error-on-direct-storage-obj-deletion
(let [storage (::sto/storage th/*system*) (let [storage (::sto/storage th/*system*)
profile (th/create-profile* 1) profile (th/create-profile* 1)

View File

@ -186,8 +186,10 @@
expected-start (str "[" (d/sanitize-string organization-name) "] ") expected-start (str "[" (d/sanitize-string organization-name) "] ")
org-summary {:id organization-id org-summary {:id organization-id
:name organization-name :name organization-name
:teams [{:id (:id team-with-files)} :teams [{:id (:id team-with-files)
{:id (:id empty-team)}]} :is-your-penpot true}
{:id (:id empty-team)
:is-your-penpot true}]}
calls (atom []) calls (atom [])
submitted (atom []) submitted (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params] out (with-redefs [nitrate/call (fn [_cfg method params]
@ -222,6 +224,7 @@
(let [{:keys [topic message]} (first @calls)] (let [{:keys [topic message]} (first @calls)]
(t/is (= uuid/zero topic)) (t/is (= uuid/zero topic))
(t/is (= :organization-deleted (:type message))) (t/is (= :organization-deleted (:type message)))
(t/is (= organization-id (:organization-id message)))
(t/is (= organization-name (:organization-name message))) (t/is (= organization-name (:organization-name message)))
(t/is (= #{(:id team-with-files) (:id empty-team)} (t/is (= #{(:id team-with-files) (:id empty-team)}
(set (:teams message)))) (set (:teams message))))
@ -254,12 +257,16 @@
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ") org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
owned-orgs [{:id org-1-id owned-orgs [{:id org-1-id
:name org-1-name :name org-1-name
:teams [{:id (:id org-1-team-files)} :teams [{:id (:id org-1-team-files)
{:id (:id org-1-team-empty)}]} :is-your-penpot true}
{:id (:id org-1-team-empty)
:is-your-penpot true}]}
{:id org-2-id {:id org-2-id
:name org-2-name :name org-2-name
:teams [{:id (:id org-2-team-files)} :teams [{:id (:id org-2-team-files)
{:id (:id org-2-team-empty)}]}] :is-your-penpot true}
{:id (:id org-2-team-empty)
:is-your-penpot true}]}]
calls (atom []) calls (atom [])
submitted (atom []) submitted (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params] out (with-redefs [nitrate/call (fn [_cfg method params]
@ -313,6 +320,8 @@
m2 (org-msg org-2-name)] m2 (org-msg org-2-name)]
(t/is (some? m1)) (t/is (some? m1))
(t/is (some? m2)) (t/is (some? m2))
(t/is (= org-1-id (:organization-id m1)))
(t/is (= org-2-id (:organization-id m2)))
(t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)} (t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
(set (:teams m1)))) (set (:teams m1))))
(t/is (= #{(:id org-1-team-empty)} (t/is (= #{(:id org-1-team-empty)}
@ -561,6 +570,263 @@
(t/is (= (:id outside-team) (:team-id (first remaining-target)))) (t/is (= (:id outside-team) (:team-id (first remaining-target))))
(t/is (= 1 (count remaining-other)))))) (t/is (= 1 (count remaining-other))))))
(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations
(let [profile (th/create-profile* 1 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
org-summary {:id org-id
:teams [{:id (:id team-1)}
{:id (:id team-2)}]}
params {::th/type :delete-all-org-invitations
:organization-id org-id}]
;; Should be deleted: org-level invitation.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "alice@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: team-level invitation in team-1 (belongs to org).
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "bob@example.com"
:created-by (:id profile)
:role "admin"
:valid-until (ct/in-future "48h")})
;; Should be deleted: team-level invitation in team-2 (belongs to org),
;; even if expired.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "carol@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-past "1h")})
;; Should remain: invitation to a team outside the org.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to "dan@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should remain: invitation to a different organization.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id (uuid/random)
:team-id nil
:email-to "erin@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [calls (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params]
(swap! calls conj {:method method :params params})
(case method
:get-org-summary org-summary
nil))]
(management-command-with-nitrate! params))
present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))]
(t/is (th/success? out))
(t/is (nil? (:result out)))
;; get-org-summary was called with the right organization-id.
(t/is (= 1 (count @calls)))
(t/is (= :get-org-summary (-> @calls first :method)))
(t/is (= {:organization-id org-id} (-> @calls first :params)))
;; Org-level + team-in-org invitations are deleted.
(t/is (not (present? "alice@example.com")))
(t/is (not (present? "bob@example.com")))
(t/is (not (present? "carol@example.com")))
;; Invitations outside the org survive.
(t/is (present? "dan@example.com"))
(t/is (present? "erin@example.com")))))
(t/deftest delete-all-org-invitations-handles-org-with-no-teams
(let [profile (th/create-profile* 1 {:is-active true})
org-id (uuid/random)
params {::th/type :delete-all-org-invitations
:organization-id org-id}]
;; Org-level invitation should still be deleted.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "alice@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-summary {:id org-id :teams []}
nil))]
(management-command-with-nitrate! params))
remaining (th/db-query :team-invitation {:org-id org-id})]
(t/is (th/success? out))
(t/is (nil? (:result out)))
(t/is (empty? remaining)))))
(t/deftest exists-org-team-invitations-for-non-members-reports-invitations-to-delete
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
profile (th/create-profile* 4 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
base-params {::th/type :exists-org-team-invitations-for-non-members
::rpc/profile-id (:id profile)
:organization-id org-id
:team-ids [(:id team-1) (:id team-2)]
:member-ids [(:id member1)]}
exist! (fn [] (-> (management-command-with-nitrate! base-params)
:result
:exists))]
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "member1@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to "outsider@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "orphan@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (true? (exist!)))))
(t/deftest delete-org-team-invitations-for-non-members-removes-non-member-invitations
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
profile (th/create-profile* 4 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
params {::th/type :delete-org-team-invitations-for-non-members
::rpc/profile-id (:id profile)
:organization-id org-id
:team-ids [(:id team-1) (:id team-2)]
:member-ids [(:id member1)]}]
;; Should remain: member1 is an org member.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "member1@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Org-level invitation remains (out of team cleanup scope).
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: team invitation for non-member
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: orphaned invitation
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "orphan@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: expired invitation.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "expired@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-past "1h")})
;; Should remain: outside org scope.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to "outsider@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (management-command-with-nitrate! params)]
(t/is (th/success? out))
(t/is (nil? (:result out)))
;; Verify remaining invitations.
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"}))))
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "pending@example.com"}))))
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"}))))
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "expired@example.com"}))))
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests: remove-from-org ;; Tests: remove-from-org
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -808,7 +1074,8 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 0 (t/is (= {:teams-to-delete 0
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 0} :teams-to-exit 0
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-delete (t/deftest get-remove-from-org-summary-with-teams-to-delete
@ -834,7 +1101,8 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 1 (t/is (= {:teams-to-delete 1
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 0} :teams-to-exit 0
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-transfer (t/deftest get-remove-from-org-summary-with-teams-to-transfer
@ -864,7 +1132,8 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 0 (t/is (= {:teams-to-delete 0
:teams-to-transfer 1 :teams-to-transfer 1
:teams-to-exit 0} :teams-to-exit 0
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-exit (t/deftest get-remove-from-org-summary-with-teams-to-exit
@ -893,7 +1162,8 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 0 (t/is (= {:teams-to-delete 0
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 1} :teams-to-exit 1
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-does-not-mutate (t/deftest get-remove-from-org-summary-does-not-mutate

View File

@ -19,7 +19,8 @@
[backend-tests.storage-test :refer [configure-storage-backend]] [backend-tests.storage-test :refer [configure-storage-backend]]
[buddy.core.bytes :as b] [buddy.core.bytes :as b]
[clojure.test :as t] [clojure.test :as t]
[datoteka.fs :as fs])) [datoteka.fs :as fs]
[datoteka.io :as io]))
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
@ -39,6 +40,23 @@
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(:result out))) (:result out)))
(t/deftest upload-tempfile-returns-fresh-object-for-same-content
(let [profile (th/create-profile* 1 {:is-active true})
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
_ (io/write* path "content")
params {::th/type :upload-tempfile
::rpc/profile-id (:id profile)
:content {:filename "export.png"
:path path
:mtype "image/png"
:size 7}}
out1 (th/management-command! params)
out2 (th/management-command! params)]
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
(t/is (not= (get-in out1 [:result :id])
(get-in out2 [:result :id])))))
(t/deftest duplicate-file (t/deftest duplicate-file
(let [storage (-> (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend)) (configure-storage-backend))

View File

@ -7,6 +7,7 @@
(ns backend-tests.rpc-nitrate-test (ns backend-tests.rpc-nitrate-test
(:require (:require
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as-alias db] [app.db :as-alias db]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -44,6 +45,13 @@
:organization-id (:id org-summary)} :organization-id (:id org-summary)}
nil))) nil)))
(defn- nitrate-org-summary-only-mock
[org-summary]
(fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests ;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -279,6 +287,64 @@
(let [team (th/db-get :team {:id (:id team1)})] (let [team (th/db-get :team {:id (:id team1)})]
(t/is (nil? (:deleted-at team)))))))) (t/is (nil? (:deleted-at team))))))))
(t/deftest get-leave-org-summary-counts-default-team-as-delete-when-empty
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 97 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [])]
(with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)]
(let [out (th/command! {::th/type :get-leave-org-summary
::rpc/profile-id (:id profile-user)
:id organization-id
:default-team-id your-penpot-id})]
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 0
:teams-to-exit 0
:teams-to-detach 0}
(:result out)))))))
(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 96 {:profile-id (:id profile-user)})
project (th/create-project* 96 {:profile-id (:id profile-user)
:team-id (:id org-default-team)})
_ (th/create-file* 96 {:profile-id (:id profile-user)
:project-id (:id project)})
extra-team (th/create-team* 95 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id extra-team)])]
(with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)]
(let [out (th/command! {::th/type :get-leave-org-summary
::rpc/profile-id (:id profile-user)
:id organization-id
:default-team-id your-penpot-id})]
(t/is (th/success? out))
;; extra-team is deletable, default team has files and is preserved.
(t/is (= {:teams-to-delete 1
:teams-to-transfer 0
:teams-to-exit 0
:teams-to-detach 1}
(:result out)))))))
(t/deftest leave-org-error-org-owner-cannot-leave (t/deftest leave-org-error-org-owner-cannot-leave
(let [profile-owner (th/create-profile* 1 {:is-active true}) (let [profile-owner (th/create-profile* 1 {:is-active true})
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)}) org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
@ -650,6 +716,71 @@
(t/is (= :validation (th/ex-type (:error out)))) (t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out)))))))) (t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest all-team-members-in-orgs-returns-org-id->boolean-map
(let [profile-user (th/create-profile* 201 {:is-active true})
profile-other (th/create-profile* 202 {:is-active true})
team (th/create-team* 201 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id profile-other)
:role :editor})
team-member-ids (->> (th/db-query :team-profile-rel {:team-id (:id team)})
(map :profile-id)
(into #{}))
org-id-1 (uuid/random)
org-id-2 (uuid/random)
calls (atom [])]
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (fn [_cfg method params]
(swap! calls conj [method params])
(case method
:get-org-membership {:is-member true
:organization-id (:organization-id params)}
:get-org-members (get {org-id-1 (vec team-member-ids)
org-id-2 [(:id profile-user)]}
(:organization-id params)
[])
nil))]
(let [out (th/command! {::th/type :all-team-members-in-orgs
::rpc/profile-id (:id profile-user)
:team-id (:id team)
:organization-ids [org-id-1 org-id-2]})
methods (map first @calls)
membership-calls (count (filter #(= :get-org-membership %) methods))
get-members-calls (count (filter #(= :get-org-members %) methods))]
(t/is (th/success? out))
(t/is (= {org-id-1 true
org-id-2 false}
(:result out)))
(t/is (= 2 membership-calls))
(t/is (= 2 get-members-calls))))))
(t/deftest all-team-members-in-orgs-fails-before-fetching-org-members
(let [profile-user (th/create-profile* 203 {:is-active true})
team (th/create-team* 203 {:profile-id (:id profile-user)})
org-id-1 (uuid/random)
org-id-2 (uuid/random)
calls (atom [])]
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (fn [_cfg method params]
(swap! calls conj [method params])
(case method
:get-org-membership (if (= (:organization-id params) org-id-2)
{:is-member false
:organization-id (:organization-id params)}
{:is-member true
:organization-id (:organization-id params)})
:get-org-members []
nil))]
(let [out (th/command! {::th/type :all-team-members-in-orgs
::rpc/profile-id (:id profile-user)
:team-id (:id team)
:organization-ids [org-id-1 org-id-2]})
methods (map first @calls)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :user-doesnt-belong-organization (th/ex-code (:error out))))
(t/is (= 0 (count (filter #(= :get-org-members %) methods))))))))
(t/deftest leave-org-error-reassign-on-non-owned-team (t/deftest leave-org-error-reassign-on-non-owned-team
(let [profile-owner (th/create-profile* 1 {:is-active true}) (let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true})

View File

@ -48,6 +48,23 @@
(t/is (= "content" (slurp (sto/get-object-data storage object)))) (t/is (= "content" (slurp (sto/get-object-data storage object))))
(t/is (= "content" (slurp (sto/get-object-path storage object)))))) (t/is (= "content" (slurp (sto/get-object-path storage object))))))
(t/deftest tempfile-objects-are-not-deduplicated
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content (-> (sto/content "content")
(sto/wrap-with-hash "same-hash"))
object1 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 10})
:bucket "tempfile"
:content-type "text/plain"})
object2 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 10})
:bucket "tempfile"
:content-type "text/plain"})]
(t/is (not= (:id object1) (:id object2)))))
(t/deftest put-and-retrieve-expired-object (t/deftest put-and-retrieve-expired-object
(let [storage (-> (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend)) (configure-storage-backend))

View File

@ -30,6 +30,7 @@
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test", "build:test": "clojure -M:dev:shadow-cljs compile test",
"test:js": "pnpm run build:test && node target/tests/test.js", "test:js": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js",
"test:jvm": "clojure -M:dev:test" "test:jvm": "clojure -M:dev:test"
} }
} }

View File

@ -0,0 +1,25 @@
import { spawnSync } from "node:child_process";
const progress = (msg) => process.stderr.write(`${msg}\n`);
progress("Building test bundle...");
const build = spawnSync("pnpm", ["run", "build:test"], {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (build.status !== 0) {
progress("Building test bundle failed");
if (build.stdout?.length) process.stdout.write(build.stdout);
if (build.stderr?.length) process.stderr.write(build.stderr);
process.exit(build.status ?? 1);
}
progress("Running tests...");
const result = spawnSync(
"node",
["target/tests/test.js", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);

View File

@ -332,10 +332,7 @@
(conj opacity))) (conj opacity)))
(defn hex->hsl [hex] (defn hex->hsl [hex]
(try (-> hex hex->rgb rgb->hsl))
(-> hex hex->rgb rgb->hsl)
(catch #?(:clj Throwable :cljs :default) _e
[0 0 0])))
(defn hex->hsla (defn hex->hsla
[data opacity] [data opacity]

View File

@ -17,7 +17,7 @@
(defmacro select-keys (defmacro select-keys
"A macro version of `select-keys`. Useful when keys vector is known "A macro version of `select-keys`. Useful when keys vector is known
at compile time (aprox 600% performance boost). at compile time (approx 600% performance boost).
It is not 100% equivalent, this macro does not removes not existing It is not 100% equivalent, this macro does not removes not existing
keys in contrast to clojure.core/select-keys" keys in contrast to clojure.core/select-keys"

View File

@ -1194,7 +1194,7 @@
;; frames. Return the ids of the frames affected ;; frames. Return the ids of the frames affected
(defn- parents-frames (defn- parents-frames
"Go trough the parents and get all of them that are a frame." "Go through the parents and get all of them that are a frame."
[id objects] [id objects]
(->> (cfh/get-parents-with-self objects id) (->> (cfh/get-parents-with-self objects id)
(filter cfh/frame-shape?))) (filter cfh/frame-shape?)))

View File

@ -19,6 +19,7 @@
[app.common.types.component :as ctk] [app.common.types.component :as ctk]
[app.common.types.file :as ctf] [app.common.types.file :as ctf]
[app.common.types.path :as path] [app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.common.types.tokens-lib :as ctob] [app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
@ -412,12 +413,9 @@
(add-object changes obj nil)) (add-object changes obj nil))
([changes obj {:keys [index ignore-touched] :or {index ::undefined ignore-touched false}}] ([changes obj {:keys [index ignore-touched] :or {index ::undefined ignore-touched false}}]
;; FIXME: add shape validation
(assert-page-id! changes) (assert-page-id! changes)
(assert-objects! changes) (assert-objects! changes)
(let [obj (cond-> obj (let [obj (cond-> (cts/check-shape obj)
(not= index ::undefined) (not= index ::undefined)
(assoc ::index index)) (assoc ::index index))

View File

@ -13,7 +13,7 @@
(defn- generate-index (defn- generate-index
"An optimized algorithm for calculate parents index that walk from top "An optimized algorithm for calculate parents index that walk from top
to down starting from a provided shape-id. Usefull when you want to to down starting from a provided shape-id. Useful when you want to
create an index for the whole objects or subpart of the tree." create an index for the whole objects or subpart of the tree."
[index objects shape-id parents] [index objects shape-id parents]
(let [shape (get objects shape-id) (let [shape (get objects shape-id)

View File

@ -34,7 +34,7 @@
[app.common.types.shape.shadow :as ctss] [app.common.types.shape.shadow :as ctss]
[app.common.types.shape.text :as ctst] [app.common.types.shape.text :as ctst]
[app.common.types.text :as types.text] [app.common.types.text :as types.text]
[app.common.types.tokens-lib :as types.tokens-lib] [app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[clojure.set :as set] [clojure.set :as set]
[cuerdas.core :as str])) [cuerdas.core :as str]))
@ -1599,7 +1599,7 @@
(defmethod migrate-data "0014-fix-tokens-lib-duplicate-ids" (defmethod migrate-data "0014-fix-tokens-lib-duplicate-ids"
[data _] [data _]
(d/update-when data :tokens-lib types.tokens-lib/fix-duplicate-token-set-ids)) (d/update-when data :tokens-lib ctob/fix-duplicate-token-set-ids))
(defmethod migrate-data "0014-clear-components-nil-objects" (defmethod migrate-data "0014-clear-components-nil-objects"
[data _] [data _]
@ -1833,6 +1833,47 @@
(cfcp/fix-missing-swap-slots libraries) (cfcp/fix-missing-swap-slots libraries)
(cfcp/sync-component-id-with-ref-shape libraries)))) (cfcp/sync-component-id-with-ref-shape libraries))))
(defmethod migrate-data "0023-repair-token-themes-with-inexistent-sets"
[data _]
(d/update-when data :tokens-lib ctob/fix-missing-sets-in-themes))
;; This will fix incorrectly created strokes from SVG imports
;; that have the stroke-cap at the shape level instead of at the stroke level
(defmethod migrate-data "0024b-fix-stroke-cap-placement"
[data _]
(letfn [(check-strokes [strokes]
(->> strokes
(mapv (fn [stroke]
(cond-> stroke
(string? (:stroke-cap-start stroke))
(update :stroke-cap-start keyword)
(string? (:stroke-cap-end stroke))
(update :stroke-cap-end keyword))))))
(fix-shape [shape]
(let [cap-start (keyword (get shape :stroke-cap-start))
cap-end (keyword (get shape :stroke-cap-end))]
(if (or (some? cap-start) (some? cap-end))
(-> shape
(dissoc :stroke-cap-start :stroke-cap-end)
(cond-> (seq (:strokes shape))
(update :strokes check-strokes)
(and (some? cap-start) (seq (:strokes shape)))
(assoc-in [:strokes 0 :stroke-cap-start] cap-start)
(and (some? cap-end) (seq (:strokes shape)))
(assoc-in [:strokes 0 :stroke-cap-end] cap-end)))
shape)))
(update-container [container]
(d/update-when container :objects d/update-vals fix-shape))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(def available-migrations (def available-migrations
(into (d/ordered-set) (into (d/ordered-set)
["legacy-2" ["legacy-2"
@ -1912,4 +1953,6 @@
"0019-fix-missing-swap-slots" "0019-fix-missing-swap-slots"
"0020-sync-component-id-with-near-main" "0020-sync-component-id-with-near-main"
"0021-fix-shape-svg-attrs" "0021-fix-shape-svg-attrs"
"0022-normalize-component-root-and-resync"])) "0022-normalize-component-root-and-resync"
"0023-repair-token-themes-with-inexistent-sets"
"0024b-fix-stroke-cap-placement"]))

View File

@ -543,7 +543,7 @@
(update :svg-attrs dissoc :fill) (update :svg-attrs dissoc :fill)
(assoc-in [:fills 0 :fill-color] (clr/parse color-style))) (assoc-in [:fills 0 :fill-color] (clr/parse color-style)))
;; Only create an opacity if the color is setted. Othewise can create problems down the line ;; Only create an opacity if the color is set. Otherwise can create problems down the line
(and (or (clr/color-string? color-attr) (clr/color-string? color-style)) (and (or (clr/color-string? color-attr) (clr/color-string? color-style))
(dm/get-in shape [:svg-attrs :fillOpacity])) (dm/get-in shape [:svg-attrs :fillOpacity]))
(-> (update :svg-attrs dissoc :fillOpacity) (-> (update :svg-attrs dissoc :fillOpacity)
@ -609,17 +609,13 @@
(and (some? color) (some? width)) (and (some? color) (some? width))
(assoc-in [:strokes 0 :stroke-width] width) (assoc-in [:strokes 0 :stroke-width] width)
(and (some? linecap) (cfh/path-shape? shape) (and (some? color) (some? linecap) (cfh/path-shape? shape)
(or (= linecap :round) (= linecap :square))) (or (= linecap :round) (= linecap :square)))
(assoc-in [:strokes 0 :stroke-cap-start] linecap)
(assoc :stroke-cap-start linecap (and (some? color) (some? linecap) (cfh/path-shape? shape)
:stroke-cap-end linecap (or (= linecap :round) (= linecap :square)))
:stroke-linecap linecap) (assoc-in [:strokes 0 :stroke-cap-end] linecap))))
(d/any-key? (dm/get-in shape [:strokes 0])
:strokeColor :strokeOpacity :strokeWidth
:strokeLinecap :strokeCapStart :strokeCapEnd)
(assoc-in [:strokes 0 :stroke-style] :svg))))
(defn setup-opacity [shape] (defn setup-opacity [shape]
(cond-> shape (cond-> shape

View File

@ -148,11 +148,9 @@
(not (ctob/token-name-path-exists? % tokens-tree)))]]) (not (ctob/token-name-path-exists? % tokens-tree)))]])
(defn make-node-token-name-schema (defn make-node-token-name-schema
"Dynamically generates a schema to check a token node name, adding translated error messages "Dynamically generates a schema to check the name of a token node, that may be a final token or a group.
and two additional validations: This runs same checks as make-token-name-schema, but for all tokens that will be renamed by this change,
- Min and max length. if the group already contains tokens."
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
[active-tokens tokens-tree node] [active-tokens tokens-tree node]
[:and [:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
@ -287,12 +285,18 @@
(defn make-token-theme-schema (defn make-token-theme-schema
[tokens-lib group name theme-id] [tokens-lib group name theme-id]
(sm/merge [:and
ctob/schema:token-theme-attrs (sm/merge
[:map ctob/schema:token-theme-attrs
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here? [:map
[:name (make-token-theme-name-schema tokens-lib group theme-id)] [:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[:description {:optional true} schema:token-theme-description]])) [:name (make-token-theme-name-schema tokens-lib group theme-id)]
[:description {:optional true} schema:token-theme-description]])
[:fn {:error/field :sets
:error/fn #(tr "errors.token-theme-not-existing-sets" (str/join ", " (:sets (:value %))))}
(fn [{:keys [sets]}]
(or (nil? tokens-lib)
(every? #(ctob/get-set-by-name tokens-lib %) sets)))]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; HELPERS

View File

@ -591,7 +591,7 @@
-it should be a main component -it should be a main component
-its parent should be a variant-container -its parent should be a variant-container
-its variant-name is derived from the properties -its variant-name is derived from the properties
-its name should be tha same as its parent's -its name should be the same as its parent's
" "
[shape file page] [shape file page]
(let [parent (ctst/get-shape page (:parent-id shape)) (let [parent (ctst/get-shape page (:parent-id shape))
@ -707,7 +707,7 @@
(if (#{:main-top :main-nested :main-any} context) (if (#{:main-top :main-nested :main-any} context)
(report-error :not-component-not-allowed (report-error :not-component-not-allowed
"Not compoments are not allowed inside a main" "Not components are not allowed inside a main"
shape file page) shape file page)
(check-shape-not-component shape file page libraries))))))))) (check-shape-not-component shape file page libraries)))))))))

View File

@ -11,7 +11,7 @@
[app.common.types.variant :as ctv])) [app.common.types.variant :as ctv]))
(defn find-variant-components (defn find-variant-components
"Find a list of the components thet belongs to this variant-id" "Find a list of the components that belongs to this variant-id"
([data variant-id] ([data variant-id]
(let [page-id (->> data (let [page-id (->> data
:components :components

View File

@ -72,7 +72,11 @@
:backend-worker :backend-worker
;; Only for development ;; Only for development
:component-thumbnails :component-thumbnails
;; enables the default cors configuration that allows all domains (currently this configuration is only used for development). ;; Enables CORS support for the RPC API. Requires an explicit
;; allowlist of origins via PENPOT_ALLOWED_ORIGINS; if no allowlist
;; is configured the middleware fails closed (a warning is logged
;; and CORS headers are not emitted) to avoid CSRF / data
;; exfiltration via origin reflection.
:cors :cors
;; Enables the templates dialog on Penpot dashboard. ;; Enables the templates dialog on Penpot dashboard.
:dashboard-templates-section :dashboard-templates-section
@ -165,6 +169,7 @@
:mcp :mcp
:background-blur :background-blur
:available-viewer-wasm
:stroke-path}) :stroke-path})
(def all-flags (def all-flags

View File

@ -34,7 +34,7 @@
;; modif-tree)))) ;; modif-tree))))
(defn- set-children-modifiers (defn- set-children-modifiers
"Propagates the modifiers from a parent too its children applying constraints if necesary" "Propagates the modifiers from a parent too its children applying constraints if necessary"
[modif-tree children objects bounds parent transformed-parent-bounds ignore-constraints] [modif-tree children objects bounds parent transformed-parent-bounds ignore-constraints]
(let [modifiers (dm/get-in modif-tree [(:id parent) :modifiers])] (let [modifiers (dm/get-in modif-tree [(:id parent) :modifiers])]
;; Move modifiers don't need to calculate constraints ;; Move modifiers don't need to calculate constraints

View File

@ -11,7 +11,8 @@
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.types.path :as path])) [app.common.types.path :as path]
[app.common.types.stroke :as cts]))
(defn shape-stroke-margin (defn shape-stroke-margin
[shape stroke-width] [shape stroke-width]
@ -88,14 +89,23 @@
([shape] ([shape]
(get-shape-filter-bounds shape false)) (get-shape-filter-bounds shape false))
([shape ignore-shadow-margin?] ([shape ignore-shadow-margin?]
(if (or (and (cfh/svg-raw-shape? shape) (cond
(not= :svg (dm/get-in shape [:content :tag]))) ;; SVG raw elements (non-root) don't have proper rotated points; use selrect
;; If no shadows or blur, we return the selrect as is (and (cfh/svg-raw-shape? shape)
(and (empty? (-> shape :shadow)) (not= :svg (dm/get-in shape [:content :tag])))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0))))))
(dm/get-prop shape :selrect) (dm/get-prop shape :selrect)
;; No shadows or blur: use the axis-aligned bounding box from the actual
;; (possibly rotated) points. Using selrect here would be wrong for rotated
;; shapes because selrect stores the unrotated rectangle, not the screen-space bbox.
(and (empty? (-> shape :shadow))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0)))))
(-> (dm/get-prop shape :points)
(grc/points->rect))
:else
(let [filters (shape->filters shape) (let [filters (shape->filters shape)
blur-value (case (-> shape :blur :type) blur-value (case (-> shape :blur :type)
:layer-blur (or (-> shape :blur :value) 0) :layer-blur (or (-> shape :blur :value) 0)
@ -105,6 +115,19 @@
(grc/points->rect))] (grc/points->rect))]
(get-rect-filter-bounds srect filters blur-value ignore-shadow-margin?))))) (get-rect-filter-bounds srect filters blur-value ignore-shadow-margin?)))))
(def ^:private stroke-margin-multiplier 4.25)
(defn- stroke-cap-marker-margin
[strokes open-path?]
(if open-path?
(->> strokes
(filter (fn [s]
(or (cts/stroke-caps-marker (:stroke-cap-start s))
(cts/stroke-caps-marker (:stroke-cap-end s)))))
(map #(* stroke-margin-multiplier (:stroke-width % 0)))
(reduce d/max 0))
0))
(defn calculate-padding (defn calculate-padding
([shape] ([shape]
(calculate-padding shape false false)) (calculate-padding shape false false))
@ -127,6 +150,11 @@
0 0
(shape-stroke-margin shape stroke-width)) (shape-stroke-margin shape stroke-width))
stroke-cap-margin
(if ignore-margin?
0
(stroke-cap-marker-margin strokes open-path?))
shadow-width shadow-width
(->> (:shadow shape) (->> (:shadow shape)
(remove :hidden) (remove :hidden)
@ -149,8 +177,8 @@
shadow-width shadow-width
(if ignore-shadow-margin? 0 shadow-width)] (if ignore-shadow-margin? 0 shadow-width)]
{:horizontal (mth/ceil (+ stroke-margin shadow-width)) {:horizontal (mth/ceil (+ stroke-margin stroke-cap-margin shadow-width))
:vertical (mth/ceil (+ stroke-margin shadow-height))}))) :vertical (mth/ceil (+ stroke-margin stroke-cap-margin shadow-height))})))
(defn- add-padding (defn- add-padding
[bounds padding] [bounds padding]

View File

@ -264,7 +264,7 @@
:scale))) :scale)))
(defn normalize-modifiers (defn normalize-modifiers
"Before aplying constraints we need to remove the deformation caused by the resizing of the parent" "Before applying constraints we need to remove the deformation caused by the resizing of the parent"
[constraints-h constraints-v modifiers [constraints-h constraints-v modifiers
child-bounds transformed-child-bounds parent-bounds transformed-parent-bounds] child-bounds transformed-child-bounds parent-bounds transformed-parent-bounds]

View File

@ -12,7 +12,7 @@
[app.common.geom.shapes.points :as gpo] [app.common.geom.shapes.points :as gpo]
[app.common.types.shape.layout :as ctl])) [app.common.types.shape.layout :as ctl]))
;; Setted in app.common.geom.shapes.common-layout ;; Set in app.common.geom.shapes.common-layout
;; We do it this way because circular dependencies ;; We do it this way because circular dependencies
(def -child-min-width nil) (def -child-min-width nil)

View File

@ -14,7 +14,7 @@
(def conjv (fnil conj [])) (def conjv (fnil conj []))
;; Setted in app.common.geom.shapes.min-size-layout ;; Set in app.common.geom.shapes.min-size-layout
;; We do it this way because circular dependencies ;; We do it this way because circular dependencies
(def -child-min-width nil) (def -child-min-width nil)

View File

@ -39,7 +39,7 @@
;; ;;
;; 5. If any track still has an infinite growth limit set its growth limit to its base size. ;; 5. If any track still has an infinite growth limit set its growth limit to its base size.
;; - Distribute extra space accross spaned tracks ;; - Distribute extra space across spanned tracks
;; - Maximize tracks ;; - Maximize tracks
;; ;;
;; - Expand flexible tracks ;; - Expand flexible tracks
@ -55,7 +55,7 @@
[app.common.math :as mth] [app.common.math :as mth]
[app.common.types.shape.layout :as ctl])) [app.common.types.shape.layout :as ctl]))
;; Setted in app.common.geom.shapes.common-layout ;; Set in app.common.geom.shapes.common-layout
;; We do it this way because circular dependencies ;; We do it this way because circular dependencies
(def -child-min-width nil) (def -child-min-width nil)
@ -449,7 +449,7 @@
column-tracks (set-auto-base-size column-tracks children shape-cells bounds objects :column) column-tracks (set-auto-base-size column-tracks children shape-cells bounds objects :column)
row-tracks (set-auto-base-size row-tracks children shape-cells bounds objects :row) row-tracks (set-auto-base-size row-tracks children shape-cells bounds objects :row)
;; Adjust multi-spaned cells with no flex columns ;; Adjust multi-spanned cells with no flex columns
column-tracks (set-auto-multi-span parent column-tracks children-map shape-cells bounds objects :column) column-tracks (set-auto-multi-span parent column-tracks children-map shape-cells bounds objects :column)
row-tracks (set-auto-multi-span parent row-tracks children-map shape-cells bounds objects :row) row-tracks (set-auto-multi-span parent row-tracks children-map shape-cells bounds objects :row)

View File

@ -369,7 +369,7 @@
(defn line-line-intersect (defn line-line-intersect
"Calculates the interesection point for two lines given by the points a-b and b-c" "Calculates the intersection point for two lines given by the points a-b and b-c"
[a b c d] [a b c d]
(let [;; Line equation representation: ax + by + c = 0 (let [;; Line equation representation: ax + by + c = 0

View File

@ -31,21 +31,21 @@
(gpt/scale val))) (gpt/scale val)))
(defn end-hv (defn end-hv
"Horizontal vector from the oposite to the origin in the x axis with a magnitude `val`" "Horizontal vector from the opposite to the origin in the x axis with a magnitude `val`"
[[p0 p1 _ _] val] [[p0 p1 _ _] val]
(-> (gpt/to-vec p1 p0) (-> (gpt/to-vec p1 p0)
(gpt/unit) (gpt/unit)
(gpt/scale val))) (gpt/scale val)))
(defn start-vv (defn start-vv
"Vertical vector from the oposite to the origin in the x axis with a magnitude `val`" "Vertical vector from the opposite to the origin in the x axis with a magnitude `val`"
[[p0 _ _ p3] val] [[p0 _ _ p3] val]
(-> (gpt/to-vec p0 p3) (-> (gpt/to-vec p0 p3)
(gpt/unit) (gpt/unit)
(gpt/scale val))) (gpt/scale val)))
(defn end-vv (defn end-vv
"Vertical vector from the oposite to the origin in the x axis with a magnitude `val`" "Vertical vector from the opposite to the origin in the x axis with a magnitude `val`"
[[p0 _ _ p3] val] [[p0 _ _ p3] val]
(-> (gpt/to-vec p3 p0) (-> (gpt/to-vec p3 p0)
(gpt/unit) (gpt/unit)

View File

@ -283,7 +283,7 @@
[selrect transform (when (some? transform) (gmt/inverse transform))])) [selrect transform (when (some? transform) (gmt/inverse transform))]))
(defn- adjust-shape-flips (defn- adjust-shape-flips
"After some tranformations the flip-x/flip-y flags can change we need "After some transformations the flip-x/flip-y flags can change we need
to check this before adjusting the selrect" to check this before adjusting the selrect"
[shape points] [shape points]
(let [points' (dm/get-prop shape :points) (let [points' (dm/get-prop shape :points)

View File

@ -90,7 +90,7 @@
child-seq))) child-seq)))
(defn resolve-subtree (defn resolve-subtree
"Resolves the subtree but only partialy from-to the parameters" "Resolves the subtree but only partially from-to the parameters"
[from-id to-id objects] [from-id to-id objects]
(concat (concat
(->> (get-children-seq from-id objects) (->> (get-children-seq from-id objects)

View File

@ -486,36 +486,41 @@
that use assets of the given type in the given library. that use assets of the given type in the given library.
If an asset id is given, only shapes linked to this particular asset will If an asset id is given, only shapes linked to this particular asset will
be synchronized." be synchronized.
[changes file-id asset-type asset-id library-id libraries current-file-id]
(assert (contains? #{:colors :components :typographies} asset-type))
(assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
(container-log :info asset-id If early-return? is true, stops as soon as the first change is generated."
:msg "Sync file with library" ([changes file-id asset-type asset-id library-id libraries current-file-id]
:asset-type asset-type (generate-sync-file changes file-id asset-type asset-id library-id libraries current-file-id false))
:asset-id asset-id ([changes file-id asset-type asset-id library-id libraries current-file-id early-return?]
:file (pretty-file file-id libraries current-file-id) (assert (contains? #{:colors :components :typographies} asset-type))
:library (pretty-file library-id libraries current-file-id)) (assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
(let [file (get-in libraries [file-id :data])] (container-log :info asset-id
(loop [containers (ctf/object-containers-seq file) :msg "Sync file with library"
changes changes] :asset-type asset-type
(if-let [container (first containers)] :asset-id asset-id
(do :file (pretty-file file-id libraries current-file-id)
(recur (next containers) :library (pretty-file library-id libraries current-file-id))
(pcb/concat-changes ;;TODO Remove concat changes
changes (let [file (get-in libraries [file-id :data])]
(generate-sync-container (pcb/empty-changes nil) (loop [containers (ctf/object-containers-seq file)
asset-type changes changes]
asset-id (let [container (first containers)]
library-id (if (or (nil? container)
container (and early-return? (seq (:redo-changes changes))))
libraries changes
current-file-id)))) (recur (next containers)
changes)))) (pcb/concat-changes ;;TODO Remove concat changes
changes
(generate-sync-container (pcb/empty-changes nil)
asset-type
asset-id
library-id
container
libraries
current-file-id)))))))))
(defn generate-sync-library (defn generate-sync-library
"Generate changes to synchronize all shapes in all components of the "Generate changes to synchronize all shapes in all components of the
@ -523,35 +528,41 @@
the given library. the given library.
If an asset id is given, only shapes linked to this particular asset will If an asset id is given, only shapes linked to this particular asset will
be synchronized." be synchronized.
[changes file-id asset-type asset-id library-id libraries current-file-id]
(assert (contains? #{:colors :components :typographies} asset-type))
(assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
(container-log :info asset-id If early-return? is true, stops as soon as the first change is generated."
:msg "Sync local components with library" ([changes file-id asset-type asset-id library-id libraries current-file-id]
:asset-type asset-type (generate-sync-library changes file-id asset-type asset-id library-id libraries current-file-id false))
:asset-id asset-id ([changes file-id asset-type asset-id library-id libraries current-file-id early-return?]
:file (pretty-file file-id libraries current-file-id) (assert (contains? #{:colors :components :typographies} asset-type))
:library (pretty-file library-id libraries current-file-id)) (assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
(let [file (get-in libraries [file-id :data])] (container-log :info asset-id
(loop [local-components (ctkl/components-seq file) :msg "Sync local components with library"
changes changes] :asset-type asset-type
(if-let [local-component (first local-components)] :asset-id asset-id
(recur (next local-components) :file (pretty-file file-id libraries current-file-id)
(pcb/concat-changes ;;TODO Remove concat changes :library (pretty-file library-id libraries current-file-id))
changes
(generate-sync-container (pcb/empty-changes nil) (let [file (get-in libraries [file-id :data])]
asset-type (loop [local-components (ctkl/components-seq file)
asset-id changes changes]
library-id (let [local-component (first local-components)]
(cfh/make-container local-component :component) (if (or (nil? local-component)
libraries (and early-return? (seq (:redo-changes changes))))
current-file-id))) changes
changes)))) (recur (next local-components)
(pcb/concat-changes ;;TODO Remove concat changes
changes
(generate-sync-container (pcb/empty-changes nil)
asset-type
asset-id
library-id
(cfh/make-container local-component :component)
libraries
current-file-id)))))))))
(defn- generate-sync-container (defn- generate-sync-container
"Generate changes to synchronize all shapes in a particular container (a page "Generate changes to synchronize all shapes in a particular container (a page
@ -1851,7 +1862,7 @@
;; On texts, when we want to omit the touched attrs, both text (the actual letters) ;; On texts, when we want to omit the touched attrs, both text (the actual letters)
;; and attrs (bold, font, etc) are in the same attr :content. ;; and attrs (bold, font, etc) are in the same attr :content.
;; If only one of them is touched, we want to adress this case and ;; If only one of them is touched, we want to address this case and
;; only update the untouched one ;; only update the untouched one
text-content-change? text-content-change?
(and omit-touched? (and omit-touched?
@ -2091,6 +2102,38 @@
(or (:transform current-shape) (gmt/matrix))))))) (or (:transform current-shape) (gmt/matrix)))))))
(defn- switch-geom-change-value
[prev-shape current-shape attr]
;; Composite geometry stores absolute coordinates. When preserving a size
;; override across variants, keep the target variant's position and only carry
;; the previous dimensions; otherwise :x/:y can disagree with :selrect/:points.
(let [prev-selrect (:selrect prev-shape)
current-selrect (:selrect current-shape)
final-width (:width prev-selrect)
final-height (:height prev-selrect)
x (:x current-selrect)
y (:y current-selrect)
selrect (assoc current-selrect
:width final-width
:height final-height
:x x
:y y
:x1 x
:y1 y
:x2 (+ x final-width)
:y2 (+ y final-height))]
(case attr
:selrect
selrect
:points
(-> selrect
(grc/rect->points)
(gsh/transform-points
(grc/rect->center selrect)
(or (:transform current-shape) (gmt/matrix)))))))
(defn- equal-geometry? (defn- equal-geometry?
"Returns true when the value of `attr` in `shape` is considered equal "Returns true when the value of `attr` in `shape` is considered equal
to the corresponding value in `origin-shape`, ignoring positional to the corresponding value in `origin-shape`, ignoring positional
@ -2195,7 +2238,7 @@
;; On texts, both text (the actual letters) ;; On texts, both text (the actual letters)
;; and attrs (bold, font, etc) are in the same attr :content. ;; and attrs (bold, font, etc) are in the same attr :content.
;; If only one of them is touched, we want to adress this case and ;; If only one of them is touched, we want to address this case and
;; only update the untouched one ;; only update the untouched one
text-change? text-change?
(and (not skip-operations?) (and (not skip-operations?)
@ -2260,6 +2303,10 @@
(contains? #{:points :selrect :width :height} attr)) (contains? #{:points :selrect :width :height} attr))
(switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr) (switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr)
(and (contains? #{:points :selrect} attr)
(not path-change?))
(switch-geom-change-value previous-shape current-shape attr)
:else :else
(get previous-shape attr))) (get previous-shape attr)))
@ -2667,29 +2714,30 @@
(generate-new-shape-for-swap shape file page libraries id-new-component index target-cell keep-props-values))] (generate-new-shape-for-swap shape file page libraries id-new-component index target-cell keep-props-values))]
[new-shape all-parents changes])) [new-shape all-parents changes]))
(defn generate-sync-file-changes (defn- maybe-sync
[changes undo-group asset-type file-id asset-id library-id libraries current-file-id] [c enabled? done? f]
(let [sync-components? (or (nil? asset-type) (= asset-type :components)) (if (and enabled? (not (done? c)))
sync-colors? (or (nil? asset-type) (= asset-type :colors)) (f c)
sync-typographies? (or (nil? asset-type) (= asset-type :typographies))] c))
(cond-> changes
:always (defn generate-sync-file-changes
(pcb/set-undo-group undo-group) ([changes undo-group asset-type file-id asset-id library-id libraries current-file-id]
;; library-changes (generate-sync-file-changes changes undo-group asset-type file-id asset-id library-id libraries current-file-id false))
sync-components? ([changes undo-group asset-type file-id asset-id library-id libraries current-file-id early-return?]
(generate-sync-library file-id :components asset-id library-id libraries current-file-id) (let [sync-components? (or (nil? asset-type) (= asset-type :components))
sync-colors? sync-colors? (or (nil? asset-type) (= asset-type :colors))
(generate-sync-library file-id :colors asset-id library-id libraries current-file-id) sync-typographies? (or (nil? asset-type) (= asset-type :typographies))
sync-typographies? done? (fn [c] (and early-return? (seq (:redo-changes c))))]
(generate-sync-library file-id :typographies asset-id library-id libraries current-file-id) (-> (pcb/set-undo-group changes undo-group)
;; library-changes
(maybe-sync sync-components? done? #(generate-sync-library % file-id :components asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-colors? done? #(generate-sync-library % file-id :colors asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-typographies? done? #(generate-sync-library % file-id :typographies asset-id library-id libraries current-file-id early-return?))
;; file-changes
(maybe-sync sync-components? done? #(generate-sync-file % file-id :components asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-colors? done? #(generate-sync-file % file-id :colors asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-typographies? done? #(generate-sync-file % file-id :typographies asset-id library-id libraries current-file-id early-return?))))))
;; file-changes
sync-components?
(generate-sync-file file-id :components asset-id library-id libraries current-file-id)
sync-colors?
(generate-sync-file file-id :colors asset-id library-id libraries current-file-id)
sync-typographies?
(generate-sync-file file-id :typographies asset-id library-id libraries current-file-id))))
(defn generate-sync-head (defn generate-sync-head
[changes file-full libraries container id reset?] [changes file-full libraries container id reset?]

View File

@ -539,5 +539,12 @@
(update shape :interactions ctsi/add-interaction interaction)) (update shape :interactions ctsi/add-interaction interaction))
(defn show-in-viewer (defn show-in-viewer
"Auto-unhide the shape in viewer when it becomes an interaction
destination, but only when the user has not explicitly hidden it.
Preserves explicit `:hide-in-viewer true` so that adding or updating
an interaction whose destination has been deliberately hidden does not
silently flip the viewer-visibility flag the user set. See #9049."
[shape] [shape]
(dissoc shape :hide-in-viewer)) (if (true? (:hide-in-viewer shape))
shape
(dissoc shape :hide-in-viewer)))

View File

@ -202,6 +202,24 @@
(zero? result) false (zero? result) false
:else false))) :else false)))
(defn is-after-or-equal?
"Analgous to: da >= db"
[da db]
(let [result (compare da db)]
(cond
(neg? result) false
(zero? result) true
:else true)))
(defn is-before-or-equal?
"Analgous to: da <= db"
[da db]
(let [result (compare da db)]
(cond
(neg? result) true
(zero? result) true
:else false)))
(defn inst? (defn inst?
[o] [o]
#?(:clj (instance? Instant o) #?(:clj (instance? Instant o)

View File

@ -159,7 +159,7 @@
group))) group)))
(defn component-attr? (defn component-attr?
"Check if some attribute is one that is involved in component syncrhonization. "Check if some attribute is one that is involved in component synchronization.
Note that design tokens also are involved, although they go by an alternate Note that design tokens also are involved, although they go by an alternate
route and thus they are not part of :sync-attrs. route and thus they are not part of :sync-attrs.
Also when detaching a nested copy it also needs to trigger a synchronization, Also when detaching a nested copy it also needs to trigger a synchronization,

View File

@ -259,7 +259,7 @@
(some? (find-component-main objects shape only-direct-child?)))) (some? (find-component-main objects shape only-direct-child?))))
(defn in-any-component? (defn in-any-component?
"Check if the shape is part of any component (main or copy), wether it's "Check if the shape is part of any component (main or copy), whether it's
head or not." head or not."
[objects shape] [objects shape]
(or (ctk/in-component-copy? shape) (or (ctk/in-component-copy? shape)
@ -405,7 +405,7 @@
(map remap-ids new-shapes)]))) (map remap-ids new-shapes)])))
(defn get-first-valid-parent (defn get-first-valid-parent
"Go trough the parents until we find a shape that is not a copy of a component nor "Go through the parents until we find a shape that is not a copy of a component nor
a variant container." a variant container."
[objects id] [objects id]
(let [shape (get objects id)] (let [shape (get objects id)]
@ -517,7 +517,7 @@
:any-main-descendant any-main-descendant})) :any-main-descendant any-main-descendant}))
(defn find-valid-parent-and-frame-ids (defn find-valid-parent-and-frame-ids
"Navigate trough the ancestors until find one that is valid. Returns [ parent-id frame-id ]" "Navigate through the ancestors until find one that is valid. Returns [ parent-id frame-id ]"
([parent-id objects children] ([parent-id objects children]
(find-valid-parent-and-frame-ids parent-id objects children false nil nil)) (find-valid-parent-and-frame-ids parent-id objects children false nil nil))
([parent-id objects children pasting? libraries] ([parent-id objects children pasting? libraries]

View File

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

View File

@ -8,10 +8,10 @@
"Implements a specialized map-like data structure for store an UUID => "Implements a specialized map-like data structure for store an UUID =>
OBJECT mappings. The main purpose of this data structure is be able OBJECT mappings. The main purpose of this data structure is be able
to serialize it on fressian as byte-array and have the ability to to serialize it on fressian as byte-array and have the ability to
decode each field separatelly without the need to decode the whole decode each field separately without the need to decode the whole
map from the byte-array. map from the byte-array.
It works transparently, so no aditional dynamic vars are needed. It It works transparently, so no additional dynamic vars are needed. It
only works by reference equality and the hash-code is calculated only works by reference equality and the hash-code is calculated
properly from each value." properly from each value."

View File

@ -15,7 +15,14 @@
[:slug ::sm/text] [:slug ::sm/text]
[:owner-id ::sm/uuid] [:owner-id ::sm/uuid]
[:avatar-bg-url ::sm/uri] [:avatar-bg-url ::sm/uri]
[:logo-id {:optional true} [:maybe ::sm/uuid]]]) [:logo-id {:optional true} [:maybe ::sm/uuid]]
[:expired-license {:optional true} [:maybe :boolean]]
[:permissions {:optional true}
[:maybe [:map
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]
[:delete-teams {:optional true} [:maybe [:enum "onlyMe" "onlyOwners"]]]
[:move-teams {:optional true} [:maybe [:enum "always" "myOrganizations" "never"]]]
[:new-team-members {:optional true} [:maybe [:enum "anyone" "members"]]]]]]])
(def schema:team-with-organization (def schema:team-with-organization
@ -25,26 +32,32 @@
[:organization schema:organization]]) [:organization schema:organization]])
(def organization->team-keys (def organization->team-keys
"Mapping from organization field keys to their corresponding :organization-* team keys." "Organization field keys to include in the nested :organization map."
[[:id :organization-id] [:id :name :custom-photo :slug :avatar-bg-url :owner-id :expired-license :permissions])
[:name :organization-name]
[:custom-photo :organization-custom-photo]
[:slug :organization-slug]
[:avatar-bg-url :organization-avatar-bg-url]
[:owner-id :organization-owner-id]])
(defn apply-organization (defn apply-organization
"Updates a team map with organization fields sourced from org. "Updates a team map with organization fields in a nested :organization map.
Associates each org field to the corresponding :organization-* team key when Associates each org field within :organization when the value is non-nil;
the value is non-nil; dissociates the key otherwise. This correctly handles dissociates the field otherwise. This correctly handles both attaching an org
both attaching an org (all values present) and detaching one (org is nil or (all values present) and detaching one (org is nil or all fields absent)."
all fields absent)."
[team organization] [team organization]
(let [id (:id organization)] (let [id (:id organization)]
(reduce (fn [acc [org-k team-k]] (if id
(let [v (get organization org-k)] (assoc team :organization
(if (and id (some? v)) (reduce (fn [acc k]
(assoc acc team-k v) (let [v (get organization k)]
(dissoc acc team-k)))) (if (some? v)
team (assoc acc k v)
organization->team-keys))) (dissoc acc k))))
(or (:organization team) {})
organization->team-keys))
(dissoc team :organization))))
(def schema:organization-with-avatar
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]]
[:avatar-bg-url [:maybe ::sm/uri]]])

View File

@ -10,7 +10,7 @@
This NS allows separate context-less/dependency-less helpers from This NS allows separate context-less/dependency-less helpers from
other path related namespaces and make proper domain-specific other path related namespaces and make proper domain-specific
namespaces without incurrying on circular depedency cycles." namespaces without incurrying on circular dependency cycles."
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
@ -192,7 +192,7 @@
(defn solve-roots* (defn solve-roots*
"Solvers a quadratic or cubic equation given by the parameters a b c d. "Solvers a quadratic or cubic equation given by the parameters a b c d.
Implemented as reduction algorithm (this helps implemement Implemented as reduction algorithm (this helps implement
derivative algorithms that does not require intermediate results derivative algorithms that does not require intermediate results
thanks to transducers." thanks to transducers."
[result conj a b c d] [result conj a b c d]
@ -794,5 +794,3 @@
#_:else false))] #_:else false))]
(some inside-border? content))) (some inside-border? content)))

View File

@ -30,6 +30,7 @@
[app.common.types.shape.layout :as ctsl] [app.common.types.shape.layout :as ctsl]
[app.common.types.shape.shadow :as ctss] [app.common.types.shape.shadow :as ctss]
[app.common.types.shape.text :as ctsx] [app.common.types.shape.text :as ctsx]
[app.common.types.stroke :as stroke]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.types.token :as cto] [app.common.types.token :as cto]
[app.common.types.variant :as ctv] [app.common.types.variant :as ctv]
@ -61,8 +62,8 @@
(map->Shape attrs)) (map->Shape attrs))
:clj (map->Shape attrs))) :clj (map->Shape attrs)))
(def stroke-caps-line #{:round :square}) (def stroke-caps-line stroke/stroke-caps-line)
(def stroke-caps-marker #{:line-arrow :triangle-arrow :square-marker :circle-marker :diamond-marker}) (def stroke-caps-marker stroke/stroke-caps-marker)
(def stroke-caps (conj (set/union stroke-caps-line stroke-caps-marker) nil)) (def stroke-caps (conj (set/union stroke-caps-line stroke-caps-marker) nil))
(def shape-types (def shape-types
@ -137,6 +138,8 @@
[:stroke-style {:optional true} [:stroke-style {:optional true}
[::sm/one-of #{:solid :dotted :dashed :mixed}]] [::sm/one-of #{:solid :dotted :dashed :mixed}]]
[:stroke-width {:optional true} ::sm/safe-number] [:stroke-width {:optional true} ::sm/safe-number]
[:stroke-dash {:optional true} ::sm/safe-number]
[:stroke-gap {:optional true} ::sm/safe-number]
[:stroke-alignment {:optional true} [:stroke-alignment {:optional true}
[::sm/one-of #{:center :inner :outer}]] [::sm/one-of #{:center :inner :outer}]]
[:stroke-cap-start {:optional true} [:stroke-cap-start {:optional true}
@ -523,7 +526,7 @@
:fills [] :fills []
:strokes [{:stroke-style :solid :strokes [{:stroke-style :solid
:stroke-alignment :inner :stroke-alignment :inner
:stroke-width 2 :stroke-width 1
:stroke-color clr/black :stroke-color clr/black
:stroke-opacity 1}]}) :stroke-opacity 1}]})
@ -727,7 +730,7 @@
(cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape)))))) (cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape))))))
(defn patch-props (defn patch-props
"Given the object of `extract-props` applies it to a shape. Adapt the shape if necesary" "Given the object of `extract-props` applies it to a shape. Adapt the shape if necessary"
[shape props objects] [shape props objects]
(letfn [(patch-text-props [shape props] (letfn [(patch-text-props [shape props]

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