mirror of
https://github.com/penpot/penpot.git
synced 2026-06-09 17:02:05 +00:00
Compare commits
359 Commits
2.16.0-RC1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd4e35203 | ||
|
|
1a4cee7e5a | ||
|
|
4b03cfd20c | ||
|
|
f7c5ce7ac9 | ||
|
|
e0a44eede0 | ||
|
|
4df399ab5a | ||
|
|
6671037ff7 | ||
|
|
c37cff7687 | ||
|
|
82acec1191 | ||
|
|
11a8d08f95 | ||
|
|
9ae84dbfe9 | ||
|
|
62c85467f9 | ||
|
|
06c83553fd | ||
|
|
744c1b98c0 | ||
|
|
d249fd106a | ||
|
|
facea16444 | ||
|
|
70e8dbb38a | ||
|
|
3444c0589f | ||
|
|
f450e09e08 | ||
|
|
25ee47a2d4 | ||
|
|
7aa720f150 | ||
|
|
8a2274cbc0 | ||
|
|
67ee0b0625 | ||
|
|
2a48747cf6 | ||
|
|
03c02d5adf | ||
|
|
c183380e0d | ||
|
|
3da7e7eb77 | ||
|
|
9f5e89d5f8 | ||
|
|
f9f4d7e2cd | ||
|
|
15f469becb | ||
|
|
adcc2ebd1a | ||
|
|
e2f96a6ba0 | ||
|
|
fc038e72fc | ||
|
|
60d3c81450 | ||
|
|
8d3516d06d | ||
|
|
9911ff7959 | ||
|
|
cb2994fc3b | ||
|
|
3a44e291f4 | ||
|
|
945c44c505 | ||
|
|
ccc734055f | ||
|
|
785b07313b | ||
|
|
c3f107e830 | ||
|
|
892869b039 | ||
|
|
6a0f24e691 | ||
|
|
e90b14eb37 | ||
|
|
16dc83616a | ||
|
|
88f50b6ddd | ||
|
|
7ddc93a4df | ||
|
|
bb89ca526b | ||
|
|
ff3587ca2d | ||
|
|
ea0e248d4b | ||
|
|
8b9a7b257f | ||
|
|
cb5f59533d | ||
|
|
feca7cef41 | ||
|
|
ba9d225c2b | ||
|
|
d9fea603f8 | ||
|
|
e6f5b270de | ||
|
|
9df1e99c08 | ||
|
|
1f2f1bdaf4 | ||
|
|
7517ba1559 | ||
|
|
fc3a95765d | ||
|
|
e12e5f8373 | ||
|
|
d0f6d5b3a1 | ||
|
|
7bf519a127 | ||
|
|
06c9a18ab0 | ||
|
|
53a4d2a18a | ||
|
|
5d80f7f5b2 | ||
|
|
d91f5be12f | ||
|
|
78609f776d | ||
|
|
badf38922c | ||
|
|
d9257d8187 | ||
|
|
2163d40c8c | ||
|
|
4c18837c12 | ||
|
|
92cfe174e8 | ||
|
|
68aa5c0ce7 | ||
|
|
0ea1b6d95a | ||
|
|
ab7ce12785 | ||
|
|
948936116a | ||
|
|
095ab6d822 | ||
|
|
e64a83e995 | ||
|
|
b3ffb63434 | ||
|
|
b8c7954f98 | ||
|
|
57477f203e | ||
|
|
4e6eb83829 | ||
|
|
79880593f5 | ||
|
|
d85576d6ee | ||
|
|
50d2af9930 | ||
|
|
a0c1e519ba | ||
|
|
db98140d3a | ||
|
|
879d9df47e | ||
|
|
11ee30d05a | ||
|
|
ef07cb8f7c | ||
|
|
9c3c1fafec | ||
|
|
0c99a64cf0 | ||
|
|
50d8c581f2 | ||
|
|
197808c2af | ||
|
|
a9712ab77e | ||
|
|
fe0bc1f0b8 | ||
|
|
b566e6df01 | ||
|
|
c74750664d | ||
|
|
cbbefa3410 | ||
|
|
0aa7c06309 | ||
|
|
f5ac88d43e | ||
|
|
f6b883b44b | ||
|
|
25e4597d1a | ||
|
|
62e840d6c0 | ||
|
|
e64840e788 | ||
|
|
8185fe51ea | ||
|
|
d69ba665ac | ||
|
|
13afcb372d | ||
|
|
89af02da96 | ||
|
|
3ca98e34df | ||
|
|
c69a834412 | ||
|
|
75ca626f55 | ||
|
|
01ea3be262 | ||
|
|
d6a0fac9ab | ||
|
|
1e66f8d637 | ||
|
|
e472304d64 | ||
|
|
19faebf292 | ||
|
|
af36428a29 | ||
|
|
901ffe0c09 | ||
|
|
1d4e4aa7df | ||
|
|
6690803559 | ||
|
|
2b3a256461 | ||
|
|
490a7bc046 | ||
|
|
6974dfdd4d | ||
|
|
6e985a460f | ||
|
|
47d6601e13 | ||
|
|
dadab03891 | ||
|
|
adc0c967f3 | ||
|
|
ed6e4db749 | ||
|
|
8a9e2722ab | ||
|
|
582dd3beef | ||
|
|
5a7a8aa83d | ||
|
|
a77147a22b | ||
|
|
c753506039 | ||
|
|
4a8fb5af53 | ||
|
|
9e12e413ca | ||
|
|
a9e88b8fa8 | ||
|
|
67f6786809 | ||
|
|
4b2ddfd7b2 | ||
|
|
a9c0b5394c | ||
|
|
156c888e2d | ||
|
|
61d44a374a | ||
|
|
c156559f2c | ||
|
|
d7c155ac4f | ||
|
|
3d7dbbe6fc | ||
|
|
300be392f6 | ||
|
|
bec21e69e6 | ||
|
|
c51a137ca9 | ||
|
|
ac3950e36c | ||
|
|
b08ceca81d | ||
|
|
3b6cefbb85 | ||
|
|
c4a5f0098e | ||
|
|
53b1837b11 | ||
|
|
01ac1529e1 | ||
|
|
ede1cd86f4 | ||
|
|
9c6e3f5ec3 | ||
|
|
78597374ab | ||
|
|
09c274bd92 | ||
|
|
bda977202a | ||
|
|
1f35f57258 | ||
|
|
4c8b33691a | ||
|
|
dd7d5bb113 | ||
|
|
5c5ee73f2d | ||
|
|
8430358621 | ||
|
|
bee1a89698 | ||
|
|
50ec6ad777 | ||
|
|
3ba70337ea | ||
|
|
1a0e497c84 | ||
|
|
0dd40776f8 | ||
|
|
0fe59cac94 | ||
|
|
763ec4c4fe | ||
|
|
7d76a1caa3 | ||
|
|
1a1c7355e2 | ||
|
|
3858993a57 | ||
|
|
15d6df48f5 | ||
|
|
5ffec3e5e9 | ||
|
|
3cecc29276 | ||
|
|
56d8dc678c | ||
|
|
0eb8cabd39 | ||
|
|
02ab41f420 | ||
|
|
04d4abc766 | ||
|
|
8542d44aaa | ||
|
|
a637dda554 | ||
|
|
5c93ad0ab3 | ||
|
|
10074bc700 | ||
|
|
40b1757c6e | ||
|
|
b9e13c12f2 | ||
|
|
0b84ada977 | ||
|
|
81f1668e3d | ||
|
|
87384aaccd | ||
|
|
6b3d4e38b0 | ||
|
|
57d47f8e5e | ||
|
|
f3f697b4a2 | ||
|
|
841ad69d26 | ||
|
|
dac98c0625 | ||
|
|
3e733bb762 | ||
|
|
ee1b61914e | ||
|
|
1d8da08144 | ||
|
|
3527ffdc4d | ||
|
|
1688741c21 | ||
|
|
d5bbfc43d3 | ||
|
|
e6848170c8 | ||
|
|
cec90416c2 | ||
|
|
e252bcf901 | ||
|
|
c29f32c7ae | ||
|
|
17041b53a7 | ||
|
|
63e7df5fda | ||
|
|
c7a4532838 | ||
|
|
2c453e4a00 | ||
|
|
05fa8af479 | ||
|
|
5c503591b4 | ||
|
|
5156866f20 | ||
|
|
a157ecdc5b | ||
|
|
371bd58878 | ||
|
|
565ed042bc | ||
|
|
ead9bd9ccc | ||
|
|
14b53ecfec | ||
|
|
6be4f157d6 | ||
|
|
36c58287ae | ||
|
|
ade587968f | ||
|
|
bcc0b0d313 | ||
|
|
83cc71e585 | ||
|
|
197c7c0f9a | ||
|
|
fd5ae84a9f | ||
|
|
408a9b033a | ||
|
|
54a866d0b5 | ||
|
|
e854309049 | ||
|
|
595ec599c6 | ||
|
|
122a47359d | ||
|
|
8e86416b0b | ||
|
|
6f41a2b729 | ||
|
|
ddfe2f7406 | ||
|
|
d26412740a | ||
|
|
637ff3005a | ||
|
|
26e583c2a6 | ||
|
|
83183e15c6 | ||
|
|
d620c86053 | ||
|
|
de1c942292 | ||
|
|
7c42a1f9ac | ||
|
|
94a5c6c4fd | ||
|
|
2a326ba23e | ||
|
|
e3df1d6f1f | ||
|
|
46c642cf6d | ||
|
|
7429b97f86 | ||
|
|
fbb1f9e634 | ||
|
|
74ca40abd4 | ||
|
|
8242015395 | ||
|
|
ee714adf5c | ||
|
|
08b30f76f3 | ||
|
|
67e9c44b98 | ||
|
|
52588412c7 | ||
|
|
e9bec0a13b | ||
|
|
884b125cf5 | ||
|
|
f8744c8285 | ||
|
|
1e746add31 | ||
|
|
7a2ca6c08f | ||
|
|
b952783621 | ||
|
|
c2a1d5c6f7 | ||
|
|
85cf3fcc3c | ||
|
|
65fce36898 | ||
|
|
1630561382 | ||
|
|
fe23c731d4 | ||
|
|
a25f43ff42 | ||
|
|
af1c72df01 | ||
|
|
eee8ee3103 | ||
|
|
f1affdbadc | ||
|
|
66d518f15d | ||
|
|
e1493de777 | ||
|
|
6de41f072c | ||
|
|
b7b31f6ee3 | ||
|
|
269edcd0ee | ||
|
|
c394a281c8 | ||
|
|
962bb1fa9b | ||
|
|
d670ba4bff | ||
|
|
27e6c1e420 | ||
|
|
7fb19fc1a2 | ||
|
|
02bbbae0b0 | ||
|
|
3094d512f4 | ||
|
|
09bd7f96f6 | ||
|
|
f7fbd3007e | ||
|
|
06986e25a3 | ||
|
|
58ca0a16ba | ||
|
|
b54fa2f11c | ||
|
|
08bd53b6a1 | ||
|
|
a228b257e9 | ||
|
|
9dc607902b | ||
|
|
7df53a46f2 | ||
|
|
e30e5906c8 | ||
|
|
49759021bf | ||
|
|
f06a2ae4e3 | ||
|
|
ef4f57c4a1 | ||
|
|
60c718eba1 | ||
|
|
a53237ce9f | ||
|
|
f5b38a5025 | ||
|
|
ea24445c2c | ||
|
|
6aeccb1208 | ||
|
|
bb93928099 | ||
|
|
be92e37af3 | ||
|
|
5a3d5f86af | ||
|
|
639a457c69 | ||
|
|
175fb67afc | ||
|
|
f3c2c0bee2 | ||
|
|
18e289b15a | ||
|
|
61cd757355 | ||
|
|
7c5fa038c1 | ||
|
|
d84685c0cb | ||
|
|
fa06efa84d | ||
|
|
ddad228849 | ||
|
|
3136b39404 | ||
|
|
dd1ceae667 | ||
|
|
f79cfafae5 | ||
|
|
10a0e9e78c | ||
|
|
bc13dfcf9e | ||
|
|
6e186143d5 | ||
|
|
a08f052da0 | ||
|
|
4f1512186f | ||
|
|
deb3085de5 | ||
|
|
2ceddc3932 | ||
|
|
173ef0dbb0 | ||
|
|
d457eb5e5c | ||
|
|
5c4d16fc2b | ||
|
|
55d085117b | ||
|
|
7e6e7baa71 | ||
|
|
2fc4f35cde | ||
|
|
5fd758597e | ||
|
|
cc29334684 | ||
|
|
e61d512889 | ||
|
|
defeeab054 | ||
|
|
4f172afce5 | ||
|
|
df9cef1bb8 | ||
|
|
691679d90b | ||
|
|
bd91036b95 | ||
|
|
7b1f0eaaf0 | ||
|
|
b2e3dbe558 | ||
|
|
70e1a16bb8 | ||
|
|
61b791368a | ||
|
|
f173fafb62 | ||
|
|
eca487afc5 | ||
|
|
bffec015d7 | ||
|
|
50df7cb5c4 | ||
|
|
0a0db15548 | ||
|
|
3433b41aa8 | ||
|
|
3885c9ee74 | ||
|
|
3226660812 | ||
|
|
a5b7bd90c7 | ||
|
|
f4317d00e5 | ||
|
|
aa8f2ab80d | ||
|
|
e07ad9cb53 | ||
|
|
4892799cf6 | ||
|
|
e8ac5f26db | ||
|
|
9dd7835815 | ||
|
|
7efeed1348 | ||
|
|
0ea3ea332f | ||
|
|
e65ce8bdeb | ||
|
|
ed935e533f | ||
|
|
6ad83d24c9 | ||
|
|
4ddabaebff |
133
.devenv/README.md
Normal file
133
.devenv/README.md
Normal 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).
|
||||
204
.devenv/scripts/merge-mcp-config.py
Executable file
204
.devenv/scripts/merge-mcp-config.py
Executable 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:]))
|
||||
8
.devenv/shared/claude-code.json
Normal file
8
.devenv/shared/claude-code.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
|
||||
}
|
||||
}
|
||||
}
|
||||
8
.devenv/shared/codex.toml
Normal file
8
.devenv/shared/codex.toml
Normal 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"]
|
||||
9
.devenv/shared/opencode.json
Normal file
9
.devenv/shared/opencode.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
9
.devenv/shared/vscode.json
Normal file
9
.devenv/shared/vscode.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"servers": {
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
|
||||
}
|
||||
}
|
||||
}
|
||||
12
.devenv/templates/claude-code.json
Normal file
12
.devenv/templates/claude-code.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
.devenv/templates/codex.toml
Normal file
10
.devenv/templates/codex.toml
Normal 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"
|
||||
14
.devenv/templates/opencode.json
Normal file
14
.devenv/templates/opencode.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
.devenv/templates/vscode.json
Normal file
12
.devenv/templates/vscode.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -1,9 +1,8 @@
|
||||
description: Create a report to help us improve
|
||||
name: Bug report
|
||||
title: "bug: "
|
||||
title: ""
|
||||
type: Bug
|
||||
labels: ["triage"]
|
||||
projects: ["penpot/8"]
|
||||
labels: ["needs triage"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
3
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -1,9 +1,8 @@
|
||||
description: Suggest an idea for this project.
|
||||
labels: ["needs triage"]
|
||||
name: "Feature request"
|
||||
title: "feature: "
|
||||
title: ""
|
||||
type: Enhancement
|
||||
projects: ["penpot/8"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
11
.github/workflows/build-docker-devenv.yml
vendored
11
.github/workflows/build-docker-devenv.yml
vendored
@ -6,7 +6,6 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
environment: release-admins
|
||||
runs-on: penpot-runner-02
|
||||
|
||||
steps:
|
||||
@ -39,3 +38,13 @@ jobs:
|
||||
tags: ${{ env.DOCKER_IMAGE }}:latest
|
||||
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Notify Mattermost
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
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
|
||||
|
||||
22
.github/workflows/build-main-staging.yml
vendored
22
.github/workflows/build-main-staging.yml
vendored
@ -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"
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@ -19,7 +19,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
environment: release-admins
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
version: ${{ steps.vars.outputs.gh_ref }}
|
||||
|
||||
84
.github/workflows/tests-backend.yml
vendored
Normal file
84
.github/workflows/tests-backend.yml
vendored
Normal 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
57
.github/workflows/tests-common.yml
vendored
Normal 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
71
.github/workflows/tests-frontend.yml
vendored
Normal 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
93
.github/workflows/tests-integration.yml
vendored
Normal 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
58
.github/workflows/tests-library.yml
vendored
Normal 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
83
.github/workflows/tests-plugins.yml
vendored
Normal 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
57
.github/workflows/tests-wasm.yml
vendored
Normal 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
|
||||
411
.github/workflows/tests.yml
vendored
411
.github/workflows/tests.yml
vendored
@ -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
10
.gitignore
vendored
@ -15,6 +15,12 @@
|
||||
.repl
|
||||
/*.jpg
|
||||
/*.md
|
||||
!CHANGES.md
|
||||
!CONTRIBUTING.md
|
||||
!README.md
|
||||
!AGENTS.md
|
||||
!CODE_OF_CONDUCT.md
|
||||
!SECURITY.md
|
||||
/*.png
|
||||
/*.svg
|
||||
/*.sql
|
||||
@ -87,6 +93,10 @@
|
||||
/.pnpm-store
|
||||
/.vscode
|
||||
/.idea
|
||||
*.iml
|
||||
/.claude
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
/.codex/
|
||||
/tools/__pycache__
|
||||
|
||||
@ -9,7 +9,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
|
||||
# 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.
|
||||
- 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.
|
||||
|
||||
160
.serena/memories/workflow/creating-issues.md
Normal file
160
.serena/memories/workflow/creating-issues.md
Normal 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`
|
||||
117
CHANGES.md
117
CHANGES.md
@ -1,10 +1,123 @@
|
||||
# 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)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
### :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))
|
||||
- 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))
|
||||
|
||||
### :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))
|
||||
- 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))
|
||||
@ -175,7 +290,6 @@
|
||||
|
||||
- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
|
||||
|
||||
|
||||
## 2.15.1
|
||||
|
||||
### :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))
|
||||
|
||||
|
||||
## 2.15.0
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@ -159,7 +159,7 @@ To save time on both sides, please avoid submitting PRs that:
|
||||
|
||||
### 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
|
||||
|
||||
@ -175,26 +175,26 @@ Commit messages must follow this format:
|
||||
|
||||
### Commit types
|
||||
|
||||
| Emoji | Description |
|
||||
|-------|-------------|
|
||||
| :bug: | Bug fix |
|
||||
| :sparkles: | Improvement or enhancement |
|
||||
| :tada: | New feature |
|
||||
| :recycle: | Refactor |
|
||||
| :lipstick: | Cosmetic changes |
|
||||
| :ambulance: | Critical bug fix |
|
||||
| :books: | Documentation |
|
||||
| :construction: | Work in progress |
|
||||
| :boom: | Breaking change |
|
||||
| :wrench: | Configuration update |
|
||||
| :zap: | Performance improvement |
|
||||
| :whale: | Docker-related change |
|
||||
| :paperclip: | Other non-relevant changes |
|
||||
| :arrow_up: | Dependency update |
|
||||
| :arrow_down: | Dependency downgrade |
|
||||
| :fire: | Removal of code or files |
|
||||
| Emoji | Description |
|
||||
| ---------------------- | -------------------------- |
|
||||
| :bug: | Bug fix |
|
||||
| :sparkles: | Improvement or enhancement |
|
||||
| :tada: | New feature |
|
||||
| :recycle: | Refactor |
|
||||
| :lipstick: | Cosmetic changes |
|
||||
| :ambulance: | Critical bug fix |
|
||||
| :books: | Documentation |
|
||||
| :construction: | Work in progress |
|
||||
| :boom: | Breaking change |
|
||||
| :wrench: | Configuration update |
|
||||
| :zap: | Performance improvement |
|
||||
| :whale: | Docker-related change |
|
||||
| :paperclip: | Other non-relevant changes |
|
||||
| :arrow_up: | Dependency update |
|
||||
| :arrow_down: | Dependency downgrade |
|
||||
| :fire: | Removal of code or files |
|
||||
| :globe_with_meridians: | Add or update translations |
|
||||
| :rocket: | Epic or highlight |
|
||||
| :rocket: | Epic or highlight |
|
||||
|
||||
### Rules
|
||||
|
||||
@ -231,6 +231,19 @@ We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
|
||||
./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.
|
||||
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
||||
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:
|
||||
>
|
||||
> (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
|
||||
> knowledge, is covered under an appropriate open source license and I have
|
||||
> 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
|
||||
> license (unless I am permitted to submit under a different license), as
|
||||
> indicated in the file; or
|
||||
> knowledge, is covered under an appropriate open source license and I have
|
||||
> 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
|
||||
> license (unless I am permitted to submit under a different license), as
|
||||
> indicated in the file; or
|
||||
>
|
||||
> (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
|
||||
> and that a record of the contribution (including all personal information
|
||||
> I submit with it, including my sign-off) is maintained indefinitely and
|
||||
> may be redistributed consistent with this project or the open source
|
||||
> license(s) involved.
|
||||
> and that a record of the contribution (including all personal information
|
||||
> I submit with it, including my sign-off) is maintained indefinitely and
|
||||
> may be redistributed consistent with this project or the open source
|
||||
> license(s) involved.
|
||||
|
||||
### Signed-off-by
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
<p align="center">
|
||||
<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 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">
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
We take the security of this project seriously. If you have discovered
|
||||
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:
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ list.
|
||||
|
||||
## Security
|
||||
|
||||
* Alisher (@7megaumka7)
|
||||
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
|
||||
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
|
||||
* Vaibhav Shukla
|
||||
|
||||
@ -67,7 +67,8 @@
|
||||
|
||||
;; Pretty Print specs
|
||||
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"]
|
||||
:aliases
|
||||
|
||||
@ -198,14 +198,14 @@
|
||||
<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="{{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">
|
||||
{% if organization-initials %}{{organization-initials}}{% endif %}
|
||||
{% if organization.initials %}{{organization.initials}}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
|
||||
“{{ organization-name|abbreviate:25 }}”
|
||||
{{ organization.name|abbreviate:50 }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@ -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 }}”
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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:
|
||||
|
||||
|
||||
270
backend/resources/app/email/renewal-notice/en.html
Normal file
270
backend/resources/app/email/renewal-notice/en.html
Normal 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>
|
||||
1
backend/resources/app/email/renewal-notice/en.subj
Normal file
1
backend/resources/app/email/renewal-notice/en.subj
Normal file
@ -0,0 +1 @@
|
||||
Your Enterprise subscription renews on {{ renewal-date }}
|
||||
17
backend/resources/app/email/renewal-notice/en.txt
Normal file
17
backend/resources/app/email/renewal-notice/en.txt
Normal 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.
|
||||
@ -27,6 +27,7 @@
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.cache :as cache]
|
||||
[app.util.inet :as inet]
|
||||
[app.util.json :as json]
|
||||
[buddy.sign.jwk :as jwk]
|
||||
@ -694,15 +695,24 @@
|
||||
(db/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
|
||||
[cfg id]
|
||||
(try
|
||||
(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)))
|
||||
(cache/get provider-cache id (partial load-provider cfg))
|
||||
(catch Throwable cause
|
||||
(l/err :hint "unable to configure custom SSO provider"
|
||||
:provider (str id)
|
||||
|
||||
@ -315,8 +315,8 @@
|
||||
(defn get-file
|
||||
"Get file, resolve all features and apply migrations.
|
||||
|
||||
Usefull when you have plan to apply massive or not cirurgical
|
||||
operations on file, because it removes the ovehead of lazy fetching
|
||||
Useful when you have plan to apply massive or not surgical
|
||||
operations on file, because it removes the overhead of lazy fetching
|
||||
and decoding."
|
||||
[cfg file-id & {:as opts}]
|
||||
(db/run! cfg get-file* file-id opts))
|
||||
@ -843,7 +843,12 @@
|
||||
l.vern,
|
||||
l.is_shared,
|
||||
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
|
||||
JOIN project AS p
|
||||
ON p.id = l.project_id
|
||||
@ -855,12 +860,8 @@
|
||||
(defn get-file-libraries
|
||||
[conn file-id]
|
||||
(into []
|
||||
(comp
|
||||
;; FIXME: :is-indirect set to false to all rows looks
|
||||
;; completly useless
|
||||
(map #(assoc % :is-indirect false))
|
||||
(map decode-row-features))
|
||||
(db/exec! conn [sql:get-file-libraries file-id file-id])))
|
||||
(map decode-row-features)
|
||||
(db/exec! conn [sql:get-file-libraries file-id file-id file-id])))
|
||||
|
||||
(defn get-resolved-file-libraries
|
||||
"Get all file libraries including itself. Returns an instance of
|
||||
|
||||
@ -109,6 +109,11 @@
|
||||
[:http-server-io-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]
|
||||
[:nitrate-shared-key {:optional true} :string]
|
||||
[:nexus-shared-key {:optional true} :string]
|
||||
@ -295,7 +300,7 @@
|
||||
(sm/explainer schema:config))
|
||||
|
||||
(defn read-config
|
||||
"Reads the configuration from enviroment variables and decodes all
|
||||
"Reads the configuration from environment variables and decodes all
|
||||
known values."
|
||||
[& {:keys [prefix default] :or {prefix "penpot"}}]
|
||||
(->> (read-env prefix)
|
||||
|
||||
@ -431,14 +431,19 @@
|
||||
:id ::invite-to-team
|
||||
:schema schema:invite-to-team))
|
||||
|
||||
(def ^:private schema:organization-data
|
||||
[:map
|
||||
[:name ::sm/text]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo [:maybe ::sm/uri]]
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]])
|
||||
|
||||
(def ^:private schema:invite-to-org
|
||||
[:map
|
||||
[:invited-by ::sm/text]
|
||||
[:organization-name ::sm/text]
|
||||
[:organization-initials [:maybe :string]]
|
||||
[:organization-logo ::sm/uri]
|
||||
[:user-name [:maybe ::sm/text]]
|
||||
[:token ::sm/text]])
|
||||
[:token ::sm/text]
|
||||
[:organization schema:organization-data]])
|
||||
|
||||
(def invite-to-org
|
||||
"Org member invitation email."
|
||||
@ -446,6 +451,21 @@
|
||||
:id ::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
|
||||
[:map
|
||||
[:invited-by ::sm/text]
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
(and (= "unlimited" type) (not (contains? canceled-status status)))
|
||||
(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})
|
||||
|
||||
:else
|
||||
|
||||
@ -144,6 +144,15 @@
|
||||
{::yres/status 404
|
||||
::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
|
||||
[error request parent-cause]
|
||||
(binding [l/*context* (request->context request)]
|
||||
|
||||
@ -208,28 +208,40 @@
|
||||
:compile (constantly wrap-errors)})
|
||||
|
||||
(defn- with-cors-headers
|
||||
[headers origin]
|
||||
(-> headers
|
||||
(assoc "access-control-allow-origin" origin)
|
||||
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
|
||||
(assoc "access-control-allow-credentials" "true")
|
||||
(assoc "access-control-expose-headers" "content-type, set-cookie")
|
||||
(assoc "access-control-allow-headers" "x-frontend-version, x-client, x-requested-width, content-type, accept, cookie")))
|
||||
"Build CORS response headers. Only emits permissive headers when the
|
||||
request `origin` is present on the configured `allowed` allowlist;
|
||||
otherwise returns the headers unchanged except for `Vary: Origin` so
|
||||
shared caches don't leak per-origin responses."
|
||||
[headers origin allowed]
|
||||
(cond-> (assoc headers "vary" "Origin")
|
||||
(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
|
||||
[handler]
|
||||
[handler allowed]
|
||||
(fn [request]
|
||||
(let [response (if (= (yreq/method request) :options)
|
||||
{::yres/status 204}
|
||||
(handler request))
|
||||
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
|
||||
{:name ::cors
|
||||
:compile (fn [& _]
|
||||
(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
|
||||
{:name ::restrict-methods
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
(defn- write!
|
||||
[^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)
|
||||
(.flush output))
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.nitrate
|
||||
"Module that make calls to the external nitrate aplication"
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.json :as json]
|
||||
[app.common.logging :as l]
|
||||
@ -28,14 +29,16 @@
|
||||
(defn- request-builder
|
||||
[cfg method uri shared-key profile-id request-params]
|
||||
(fn []
|
||||
(http/req cfg (cond-> {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" shared-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1}
|
||||
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
|
||||
(http/req cfg
|
||||
(cond-> {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" shared-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1}
|
||||
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))
|
||||
{:skip-ssrf-check? true})))
|
||||
|
||||
(defn- with-retries
|
||||
[handler max-retries]
|
||||
@ -59,14 +62,29 @@
|
||||
(fn []
|
||||
(let [response (handler)
|
||||
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
|
||||
(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)
|
||||
;; For error status codes (4xx, 5xx), fail immediately without validation
|
||||
;; For client error status codes (4xx), fail immediately without validation
|
||||
(do
|
||||
(when (not= status 404) ;; Don't need to log 404
|
||||
(l/error :hint "nitrate request failed with error status"
|
||||
@ -171,6 +189,7 @@
|
||||
"day"
|
||||
"week"
|
||||
"year"]]
|
||||
[:manual :boolean]
|
||||
[:quantity :int]
|
||||
[:description [:maybe ::sm/text]]
|
||||
[:created-at schema:timestamp]
|
||||
@ -256,6 +275,42 @@
|
||||
[:vector schema:org-summary]
|
||||
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
|
||||
[cfg {:keys [organization-id team-id is-default] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||
@ -267,7 +322,7 @@
|
||||
organization-id
|
||||
"/add-team")
|
||||
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))]
|
||||
(cond-> team
|
||||
custom-photo
|
||||
@ -336,6 +391,24 @@
|
||||
profile-id)
|
||||
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
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
@ -348,6 +421,31 @@
|
||||
[:map
|
||||
[: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
|
||||
[cfg params]
|
||||
(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-summary (partial get-org-summary-api cfg)
|
||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
||||
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
|
||||
:get-org-members (partial get-org-members-api cfg)
|
||||
:delete-owned-orgs (partial delete-owned-orgs-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-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)
|
||||
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
||||
:get-subscription (partial get-subscription-api cfg)
|
||||
:get-subscription-warning (partial get-subscription-warning-api cfg)
|
||||
:connectivity (partial get-connectivity-api cfg)
|
||||
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
|
||||
|
||||
@ -386,21 +489,27 @@
|
||||
(defn add-nitrate-licence-to-profile
|
||||
"Enriches a profile map with subscription information from Nitrate.
|
||||
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]
|
||||
(try
|
||||
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
|
||||
(assoc profile :subscription subscription))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "failed to get nitrate licence"
|
||||
:profile-id (:id profile)
|
||||
:cause cause)
|
||||
profile)))
|
||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||
(throw cause)
|
||||
(do
|
||||
(l/error :hint "failed to get nitrate licence"
|
||||
:profile-id (:id profile)
|
||||
:cause cause)
|
||||
profile)))))
|
||||
|
||||
(defn add-org-info-to-team
|
||||
"Enriches a team map with organization information from Nitrate.
|
||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||
Returns the original team unchanged if the request fails or org data is nil."
|
||||
Returns the original team unchanged if the request fails or org data is nil.
|
||||
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
||||
[cfg team params]
|
||||
(try
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
@ -413,10 +522,13 @@
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
|
||||
team))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "failed to get team organization info"
|
||||
:team-id (:id team)
|
||||
:cause cause)
|
||||
team)))
|
||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||
(throw cause)
|
||||
(do
|
||||
(l/error :hint "failed to get team organization info"
|
||||
:team-id (:id team)
|
||||
:cause cause)
|
||||
team)))))
|
||||
|
||||
(defn set-team-organization
|
||||
"Associates a team with an organization in Nitrate.
|
||||
@ -434,7 +546,3 @@
|
||||
:context {:team-id (:id team)
|
||||
:organization-id (:organization-id params)}))
|
||||
team))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -112,22 +112,30 @@
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/project-id project-id})
|
||||
|
||||
;; FIXME: IMPORTANT: this code can have race conditions, because
|
||||
;; we have no locks for updating team so, creating two files
|
||||
;; concurrently can lead to lost team features updating
|
||||
(when-let [features (-> features
|
||||
(set/difference (:features team))
|
||||
(set/difference cfeat/no-team-inheritable-features)
|
||||
(not-empty))]
|
||||
(let [features (-> features
|
||||
(set/union (:features team))
|
||||
(set/difference cfeat/no-team-inheritable-features)
|
||||
(into-array))]
|
||||
;; Acquire a row-level lock on the team and re-read its features
|
||||
;; inside the same transaction before the read-modify-write below.
|
||||
;; Without the lock, two concurrent create-file calls on the same
|
||||
;; team can both observe the same team.features value, each
|
||||
;; compute a different union, and the second UPDATE silently
|
||||
;; overwrites the first (lost update under READ COMMITTED).
|
||||
(let [team-features (-> (db/exec-one! conn
|
||||
["SELECT features FROM team WHERE id = ? FOR UPDATE"
|
||||
team-id])
|
||||
:features
|
||||
(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
|
||||
{:features features}
|
||||
{:id (:id team)}
|
||||
{::db/return-keys false})))
|
||||
(db/update! conn :team
|
||||
{:features features}
|
||||
{:id team-id}
|
||||
{::db/return-keys false}))))
|
||||
|
||||
(-> (create-file cfg params)
|
||||
(vary-meta assoc ::audit/props {:team-id team-id}))))
|
||||
|
||||
@ -409,10 +409,7 @@
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
;; TODO For now we check read permissions instead of write,
|
||||
;; to allow viewer users to update thumbnails. We might
|
||||
;; review this approach on the future.
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(when-not (db/read-only? conn)
|
||||
(let [media (create-file-thumbnail cfg params)]
|
||||
{:uri (files/resolve-public-uri (:id media))
|
||||
|
||||
@ -109,9 +109,6 @@
|
||||
(fn [{:keys [data 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
|
||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||
session-id}` map) into a temp file, validates the media type and
|
||||
@ -171,20 +168,18 @@
|
||||
[:process-font/global]]
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-font-variant}
|
||||
[cfg {:keys [::rpc/profile-id team-id uploads] :as params}]
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
(let [params (if (some? uploads)
|
||||
(prepare-font-data-from-uploads cfg params)
|
||||
(prepare-font-data-from-legacy params))]
|
||||
(create-font-variant cfg (assoc params :profile-id profile-id))))))
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
|
||||
(teams/check-edition-permissions! pool profile-id team-id)
|
||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
(let [params (if (some? uploads)
|
||||
(db/tx-run! cfg prepare-font-data-from-uploads params)
|
||||
(prepare-font-data-from-legacy params))]
|
||||
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
||||
|
||||
(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]
|
||||
(let [data (media/run {:cmd :generate-fonts :input data})]
|
||||
(when (and (not (contains? data "font/otf"))
|
||||
@ -209,22 +204,15 @@
|
||||
:bucket "team-font-variant"})))
|
||||
|
||||
(persist-fonts-files! [data]
|
||||
(let [otf-params (prepare-font data "font/otf")
|
||||
ttf-params (prepare-font data "font/ttf")
|
||||
wf1-params (prepare-font data "font/woff")
|
||||
wf2-params (prepare-font data "font/woff2")]
|
||||
(into {} (keep (fn [[kind mtype]]
|
||||
(when-let [params (prepare-font data mtype)]
|
||||
[kind (sto/put-object! storage params)])))
|
||||
[[:otf "font/otf"]
|
||||
[:ttf "font/ttf"]
|
||||
[:woff1 "font/woff"]
|
||||
[:woff2 "font/woff2"]]))
|
||||
|
||||
(cond-> {}
|
||||
(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]}]
|
||||
(insert-font-variant! [conn {:keys [woff1 woff2 otf ttf]}]
|
||||
(db/insert! conn :team-font-variant
|
||||
{:id (uuid/next)
|
||||
:team-id (:team-id params)
|
||||
@ -238,14 +226,14 @@
|
||||
:otf-file-id (:id otf)
|
||||
:ttf-file-id (:id ttf)}))]
|
||||
|
||||
(let [tpoint (ct/tpoint)
|
||||
mtypes (vec (keys data))
|
||||
total-size (reduce-kv (fn [acc _ content]
|
||||
(+ acc (if (bytes? content)
|
||||
(alength ^bytes content)
|
||||
(fs/size content))))
|
||||
0
|
||||
data)]
|
||||
(let [tpoint (ct/tpoint)
|
||||
mtypes (vec (keys data))
|
||||
total-size (reduce-kv (fn [acc _ content]
|
||||
(+ acc (if (bytes? content)
|
||||
(alength ^bytes content)
|
||||
(fs/size content))))
|
||||
0
|
||||
data)]
|
||||
|
||||
(l/dbg :hint "create-font-variant"
|
||||
:step "init"
|
||||
@ -257,7 +245,7 @@
|
||||
|
||||
(let [data (generate-missing data)
|
||||
assets (persist-fonts-files! data)
|
||||
result (insert-font-variant! assets)
|
||||
result (db/tx-run! cfg #(insert-font-variant! (::db/conn %) assets))
|
||||
elapsed (tpoint)]
|
||||
|
||||
(l/dbg :hint "create-font-variant"
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
@ -57,6 +59,22 @@
|
||||
[cfg _params]
|
||||
(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
|
||||
[:map {:title "RedeemActivationCodeParams"}
|
||||
[:activation-code ::sm/text]])
|
||||
@ -110,12 +128,47 @@
|
||||
AND t.id = ANY(?)
|
||||
AND t.deleted_at IS NULL")
|
||||
|
||||
(def sql:get-team-files-count
|
||||
"SELECT count(*) AS total
|
||||
(def ^:private sql:get-teams-files-counts
|
||||
"SELECT p.team_id, count(*) AS total
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND f.deleted_at IS NULL")
|
||||
WHERE p.team_id = ANY(?)
|
||||
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
|
||||
[:map
|
||||
@ -130,6 +183,18 @@
|
||||
[:id ::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
|
||||
[{:keys [::db/conn] :as cfg} org-summary profile-id]
|
||||
@ -219,16 +284,14 @@
|
||||
:code :not-valid-teams))))
|
||||
|
||||
|
||||
|
||||
(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}]
|
||||
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
||||
|
||||
default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id])
|
||||
:total)
|
||||
delete-default-team? (= default-team-files-count 0)]
|
||||
|
||||
|
||||
|
||||
[{:keys [::db/conn] :as cfg}
|
||||
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?]}]
|
||||
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
||||
{:keys [deletable-team-ids
|
||||
keep-default-team?
|
||||
detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
|
||||
|
||||
;; assert that the received teams are valid, checking the different constraints
|
||||
(when-not skip-validation
|
||||
@ -236,20 +299,27 @@
|
||||
|
||||
(assert-membership cfg profile-id id)
|
||||
|
||||
;; delete the teams-to-delete
|
||||
(doseq [id teams-to-delete]
|
||||
(teams/delete-team cfg {:profile-id profile-id :team-id id}))
|
||||
;; delete only eligible teams (non-protected and without files)
|
||||
(doseq [id deletable-team-ids]
|
||||
(teams/delete-team cfg {:profile-id profile-id
|
||||
:team-id id}))
|
||||
|
||||
;; leave the 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}))
|
||||
|
||||
;; Delete default-team-id if empty; otherwise keep it and prefix the name.
|
||||
(if delete-default-team?
|
||||
(do
|
||||
(db/update! conn :team {:is-default false} {:id default-team-id})
|
||||
(teams/delete-team cfg {:profile-id profile-id :team-id default-team-id}))
|
||||
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id]))
|
||||
;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||
(when default-team-id
|
||||
(if keep-default-team?
|
||||
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])
|
||||
(teams/delete-team cfg {:profile-id profile-id
|
||||
:team-id default-team-id})))
|
||||
|
||||
;; Detach retained owned teams from the organization in Nitrate.
|
||||
;; Nitrate will rehome them to its fallback/default org.
|
||||
(doseq [team-id detach-from-org-team-ids]
|
||||
(nitrate/call cfg :remove-team-from-org {:team-id team-id
|
||||
:organization-id id}))
|
||||
|
||||
;; Api call to nitrate
|
||||
(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)))
|
||||
|
||||
|
||||
(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
|
||||
[:map
|
||||
[:team-id ::sm/uuid]
|
||||
@ -280,6 +369,20 @@
|
||||
(assert-is-owner cfg profile-id team-id)
|
||||
(assert-not-default-team cfg team-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
|
||||
(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")
|
||||
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
|
||||
[:map
|
||||
@ -305,15 +447,173 @@
|
||||
(assert-not-default-team cfg team-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
|
||||
(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)))
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id})
|
||||
source-org-id (get-in team-with-org [:organization :id])
|
||||
source-org-perms (when source-org-id
|
||||
(nitrate/call cfg :get-org-permissions
|
||||
{:organization-id source-org-id}))
|
||||
target-org-perms (nitrate/call cfg :get-org-permissions
|
||||
{:organization-id organization-id})
|
||||
target-org-same-owner? (and (some? source-org-perms)
|
||||
(some? target-org-perms)
|
||||
(= (:owner-id source-org-perms)
|
||||
(:owner-id target-org-perms)))]
|
||||
(when (nil? target-org-perms)
|
||||
(ex/raise :type :validation
|
||||
:code :not-allowed
|
||||
:hint "Unable to verify organization permissions"))
|
||||
|
||||
;; Api call to nitrate
|
||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
||||
;; Team already belongs to an organization: check move-teams on source org.
|
||||
(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)
|
||||
|
||||
(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}))
|
||||
|
||||
|
||||
|
||||
@ -110,8 +110,10 @@
|
||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
||||
profile))
|
||||
|
||||
(catch Throwable _
|
||||
{:id uuid/zero :fullname "Anonymous User"})))
|
||||
(catch Throwable cause
|
||||
(if (= :not-found (-> cause ex-data :type))
|
||||
{:id uuid/zero :fullname "Anonymous User"}
|
||||
(throw cause)))))
|
||||
|
||||
(defn get-profile
|
||||
"Get profile by id. Throws not-found exception if no profile found."
|
||||
@ -483,8 +485,16 @@
|
||||
{:deleted-at deleted-at}
|
||||
{:id profile-id})
|
||||
|
||||
;; Api call to nitrate
|
||||
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})
|
||||
;; Delete owned organizations on the fly (no grace period).
|
||||
;; 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
|
||||
(wrk/submit! {::db/conn conn
|
||||
@ -493,7 +503,6 @@
|
||||
:deleted-at deleted-at
|
||||
:id profile-id}})
|
||||
|
||||
|
||||
(-> (rph/wrap nil)
|
||||
(rph/with-transform (session/delete-fn cfg)))))
|
||||
|
||||
@ -520,6 +529,32 @@
|
||||
(let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])]
|
||||
{: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
|
||||
|
||||
(def sql:owned-teams
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||
[app.common.types.team :as types.team]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
@ -193,7 +194,9 @@
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(cond->> (get-teams conn profile-id)
|
||||
(contains? cf/flags :nitrate)
|
||||
(map #(nitrate/add-org-info-to-team cfg % params)))))
|
||||
(map #(nitrate/add-org-info-to-team cfg % params))
|
||||
(contains? cf/flags :nitrate)
|
||||
(remove #(get-in % [:organization :expired-license])))))
|
||||
|
||||
(def ^:private sql:get-owned-teams
|
||||
"SELECT t.id, t.name,
|
||||
@ -506,11 +509,27 @@
|
||||
(sv/defmethod ::create-team
|
||||
{::doc/added "1.17"
|
||||
::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/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)
|
||||
(set/difference cfeat/frontend-only-features)
|
||||
(set/difference cfeat/no-team-inheritable-features))
|
||||
@ -757,16 +776,31 @@
|
||||
|
||||
(defn delete-team
|
||||
"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)
|
||||
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
|
||||
: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
|
||||
:code :non-deletable-team
|
||||
:hint "impossible to delete default team"))
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||
[app.common.types.team :as types.team]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
@ -112,8 +113,19 @@
|
||||
(let [notifications (dm/get-in member [:props :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
|
||||
[{: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)
|
||||
"expected cfg with valid connection")
|
||||
@ -130,6 +142,13 @@
|
||||
:code :email-domain-is-not-allowed
|
||||
:hint "email domain is in the blacklist"))
|
||||
|
||||
;; When nitrate is active and the team belongs to an org, check that
|
||||
;; the email is already an org member unless the org explicitly allows adding anybody.
|
||||
(when (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
|
||||
;; already present in the database, we proceed to add it to the
|
||||
;; team as-is, without email roundtrip.
|
||||
@ -218,32 +237,26 @@
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:user-name (:fullname member)
|
||||
:organization-name (:name organization)
|
||||
:organization-logo (:logo organization)
|
||||
:organization-initials (:initials organization)
|
||||
:organization organization
|
||||
:token itoken
|
||||
:extra-data ptoken}))
|
||||
(let [team (if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-org-info-to-team cfg team {})
|
||||
team)]
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-team
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:organization (:organization-name team)
|
||||
:token itoken
|
||||
:extra-data ptoken}))))
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-team
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:organization (dm/get-in team [:organization :name])
|
||||
:token itoken
|
||||
:extra-data ptoken})))
|
||||
|
||||
itoken)))))
|
||||
|
||||
(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)]
|
||||
(create-invitation cfg
|
||||
(assoc params
|
||||
:organization {:id id :name name :initials initials :logo logo}
|
||||
:profile profile
|
||||
:role :editor))))
|
||||
|
||||
@ -309,7 +322,18 @@
|
||||
- emails (set) + role (single role for all emails)
|
||||
- invitations (vector of {:email :role} maps)"
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
||||
(let [;; 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
|
||||
;; Case 1: emails + single role (create invitations style)
|
||||
(and emails role)
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
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
|
||||
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
|
||||
dependent objects).
|
||||
"
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
data (-> (sto/content (:path content))
|
||||
(sto/wrap-with-hash hash))
|
||||
content {::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/deduplicate? false
|
||||
::sto/touched-at (ct/in-future {:minutes 10})
|
||||
:profile-id profile-id
|
||||
:content-type (:mtype content)
|
||||
|
||||
@ -12,11 +12,13 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[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.team :refer [schema:team]]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.media :as media]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
@ -29,7 +31,8 @@
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as wrk]))
|
||||
[app.worker :as wrk]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
|
||||
(defn- profile-to-map [profile]
|
||||
@ -48,7 +51,8 @@
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
(let [profile (profile/get-profile cfg profile-id)]
|
||||
(-> (profile-to-map profile)
|
||||
(assoc :theme (:theme profile)))))
|
||||
(assoc :theme (:theme profile))
|
||||
(assoc :lang (:lang profile)))))
|
||||
|
||||
;; ---- API: get-teams
|
||||
|
||||
@ -296,46 +300,61 @@ RETURNING id, deleted_at;")
|
||||
nil)
|
||||
|
||||
(defn manage-deleted-organization-teams
|
||||
"For a list of teams, rename those with files and delete those without, then notify users."
|
||||
[cfg {:keys [teams organization-name]}]
|
||||
(let [teams (->> teams (filter uuid?) distinct (into []))]
|
||||
(when (seq teams)
|
||||
"For a deleted organization, preserve org teams unchanged and only prefix or
|
||||
delete member Your Penpot teams depending on whether they still contain files."
|
||||
[cfg {:keys [organization-id organization-name teams]}]
|
||||
(let [all-team-ids (->> teams
|
||||
(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) "] ")]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [teams-array (db/create-array conn "uuid" teams)
|
||||
teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array])
|
||||
(filter (fn [{:keys [total]}] (pos? total)))
|
||||
(map :team-id)
|
||||
(into #{}))
|
||||
teams-to-keep (->> teams (filter teams-with-files) (into []))
|
||||
teams-to-delete (->> teams (remove teams-with-files) (into []))]
|
||||
(let [teams-with-files (if (seq your-penpot-team-ids)
|
||||
(->> (db/exec! conn [sql:get-teams-files-counts
|
||||
(db/create-array conn "uuid" your-penpot-team-ids)])
|
||||
(filter (fn [{:keys [total]}] (pos? total)))
|
||||
(map :team-id)
|
||||
(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
|
||||
(when (seq teams-to-keep)
|
||||
;; Org teams move to the fallback org unchanged. Only imported
|
||||
;; 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
|
||||
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)
|
||||
|
||||
(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)))))))
|
||||
|
||||
|
||||
(sv/defmethod ::notify-organization-deletion
|
||||
"For a list of teams, rename them with the name of the deleted org, and notify
|
||||
of the deletion to the connected users"
|
||||
"For a deleted organization, preserve org teams and only prefix or delete
|
||||
imported Your Penpot teams before notifying connected users."
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:notify-organization-deletion
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
teams (->> (:teams org-summary)
|
||||
(map :id))]
|
||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)})
|
||||
teams (:teams org-summary)]
|
||||
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary)
|
||||
:organization-id (:id org-summary)
|
||||
:teams teams})
|
||||
nil))
|
||||
|
||||
;; ---- API: notify-user-organizations-deletion
|
||||
@ -345,15 +364,18 @@ RETURNING id, deleted_at;")
|
||||
[:profile-id ::sm/uuid]])
|
||||
|
||||
(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"
|
||||
::sm/params schema:notify-user-organizations-deletion}
|
||||
[cfg {:keys [profile-id]}]
|
||||
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
|
||||
(doseq [org owned-orgs]
|
||||
(let [organization-name (:name org)
|
||||
teams (map :id (:teams org))]
|
||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name}))))
|
||||
teams (:teams org)]
|
||||
(manage-deleted-organization-teams cfg {:organization-name organization-name
|
||||
:organization-id (:id org)
|
||||
:teams teams}))))
|
||||
nil)
|
||||
|
||||
|
||||
@ -454,10 +476,7 @@ RETURNING id, deleted_at;")
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map
|
||||
[:email ::sm/email]
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo ::sm/uri]]}
|
||||
[:organization schema:organization-with-avatar]]}
|
||||
[cfg params]
|
||||
(db/tx-run! cfg ti/create-org-invitation params)
|
||||
nil)
|
||||
@ -472,6 +491,7 @@ RETURNING id, deleted_at;")
|
||||
ti.email_to AS email,
|
||||
ti.created_at AS sent_at,
|
||||
p.fullname AS name,
|
||||
p.id AS profile_id,
|
||||
p.photo_id
|
||||
FROM team_invitation AS ti
|
||||
LEFT JOIN profile AS p
|
||||
@ -493,6 +513,7 @@ LEFT JOIN profile AS p
|
||||
[:email ::sm/email]
|
||||
[:sent-at ::sm/inst]
|
||||
[:name {:optional true} [:maybe ::sm/text]]
|
||||
[:profile-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:photo-url {:optional true} ::sm/uri]]])
|
||||
|
||||
(sv/defmethod ::get-org-invitations
|
||||
@ -544,6 +565,33 @@ LEFT JOIN profile AS p
|
||||
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
|
||||
|
||||
@ -603,7 +651,8 @@ LEFT JOIN profile AS p
|
||||
[:map
|
||||
[:teams-to-delete ::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
|
||||
"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
|
||||
(ex/raise :type :validation
|
||||
:code :not-valid-teams))
|
||||
{:teams-to-delete (count valid-teams-to-delete-ids)
|
||||
:teams-to-transfer (count valid-teams-to-transfer)
|
||||
:teams-to-exit (count valid-teams-to-exit)}))
|
||||
(cnit/get-leave-org-summary cfg
|
||||
default-team-id
|
||||
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))
|
||||
|
||||
|
||||
@ -34,11 +34,12 @@
|
||||
|
||||
|
||||
(defn notify-organization-deletion
|
||||
[cfg organization-name teams deleted-teams]
|
||||
[cfg organization-id organization-name teams deleted-teams]
|
||||
(let [msgbus (::mbus/msgbus cfg)]
|
||||
(mbus/pub! msgbus
|
||||
:topic uuid/zero
|
||||
:message {:type :organization-deleted
|
||||
:organization-id organization-id
|
||||
:organization-name organization-name
|
||||
:teams teams
|
||||
:deleted-teams deleted-teams})))
|
||||
@ -135,7 +135,8 @@
|
||||
;; still not deleted.
|
||||
result (when (and (::deduplicate? params)
|
||||
(:hash mdata)
|
||||
(:bucket mdata))
|
||||
(:bucket mdata)
|
||||
(not= "tempfile" (:bucket mdata)))
|
||||
(let [result (get-database-object-by-hash connectable backend
|
||||
(:bucket mdata)
|
||||
(:hash mdata))]
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
[app.common.time :as ct]
|
||||
[promesa.exec :as px])
|
||||
(:import
|
||||
com.github.benmanes.caffeine.cache.AsyncCache
|
||||
com.github.benmanes.caffeine.cache.Cache
|
||||
com.github.benmanes.caffeine.cache.Caffeine
|
||||
com.github.benmanes.caffeine.cache.RemovalListener
|
||||
@ -47,15 +46,18 @@
|
||||
:miss-rate (.missRate stats)}))
|
||||
|
||||
(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
|
||||
(if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder)
|
||||
(if executor (.executor builder ^Executor (px/resolve-executor executor)) 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)
|
||||
(.recordStats builder)
|
||||
(.buildAsync builder))
|
||||
cache (.synchronous ^AsyncCache cache)]
|
||||
(.build builder))]
|
||||
(reify
|
||||
ICache
|
||||
(get [_ k]
|
||||
@ -69,7 +71,7 @@
|
||||
(invalidate! [_]
|
||||
(.invalidateAll ^Cache cache))
|
||||
(invalidate! [_ k]
|
||||
(.invalidateAll ^Cache cache ^Object k))
|
||||
(.invalidate ^Cache cache ^Object k))
|
||||
|
||||
ICacheStats
|
||||
(stats [_]
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
[app.rpc.commands.access-token]
|
||||
[app.tokens :as tokens]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[yetti.request :as yreq]
|
||||
@ -112,6 +113,74 @@
|
||||
(t/is (= #{} (:app.http.access-token/perms 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
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
|
||||
36
backend/test/backend_tests/logical_deletion_test.clj
Normal file
36
backend/test/backend_tests/logical_deletion_test.clj
Normal 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"}}))))))
|
||||
@ -154,7 +154,7 @@
|
||||
(t/is (nil? (sto/get-object storage (:media-id row1))))
|
||||
(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})]
|
||||
(t/is (nil? row))))))
|
||||
|
||||
@ -254,6 +254,32 @@
|
||||
|
||||
(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
|
||||
(let [storage (::sto/storage th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
|
||||
@ -186,8 +186,10 @@
|
||||
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
||||
org-summary {:id organization-id
|
||||
:name organization-name
|
||||
:teams [{:id (:id team-with-files)}
|
||||
{:id (:id empty-team)}]}
|
||||
:teams [{:id (:id team-with-files)
|
||||
:is-your-penpot true}
|
||||
{:id (:id empty-team)
|
||||
:is-your-penpot true}]}
|
||||
calls (atom [])
|
||||
submitted (atom [])
|
||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||
@ -222,6 +224,7 @@
|
||||
(let [{:keys [topic message]} (first @calls)]
|
||||
(t/is (= uuid/zero topic))
|
||||
(t/is (= :organization-deleted (:type message)))
|
||||
(t/is (= organization-id (:organization-id message)))
|
||||
(t/is (= organization-name (:organization-name message)))
|
||||
(t/is (= #{(:id team-with-files) (:id empty-team)}
|
||||
(set (:teams message))))
|
||||
@ -254,12 +257,16 @@
|
||||
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
|
||||
owned-orgs [{:id org-1-id
|
||||
:name org-1-name
|
||||
:teams [{:id (:id org-1-team-files)}
|
||||
{:id (:id org-1-team-empty)}]}
|
||||
:teams [{:id (:id org-1-team-files)
|
||||
:is-your-penpot true}
|
||||
{:id (:id org-1-team-empty)
|
||||
:is-your-penpot true}]}
|
||||
{:id org-2-id
|
||||
:name org-2-name
|
||||
:teams [{:id (:id org-2-team-files)}
|
||||
{:id (:id org-2-team-empty)}]}]
|
||||
:teams [{:id (:id org-2-team-files)
|
||||
:is-your-penpot true}
|
||||
{:id (:id org-2-team-empty)
|
||||
:is-your-penpot true}]}]
|
||||
calls (atom [])
|
||||
submitted (atom [])
|
||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||
@ -313,6 +320,8 @@
|
||||
m2 (org-msg org-2-name)]
|
||||
(t/is (some? m1))
|
||||
(t/is (some? m2))
|
||||
(t/is (= org-1-id (:organization-id m1)))
|
||||
(t/is (= org-2-id (:organization-id m2)))
|
||||
(t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
|
||||
(set (:teams m1))))
|
||||
(t/is (= #{(:id org-1-team-empty)}
|
||||
@ -561,6 +570,263 @@
|
||||
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
|
||||
(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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -808,7 +1074,8 @@
|
||||
(t/is (th/success? out))
|
||||
(t/is (= {:teams-to-delete 0
|
||||
:teams-to-transfer 0
|
||||
:teams-to-exit 0}
|
||||
:teams-to-exit 0
|
||||
:teams-to-detach 0}
|
||||
(:result out)))))
|
||||
|
||||
(t/deftest get-remove-from-org-summary-with-teams-to-delete
|
||||
@ -834,7 +1101,8 @@
|
||||
(t/is (th/success? out))
|
||||
(t/is (= {:teams-to-delete 1
|
||||
:teams-to-transfer 0
|
||||
:teams-to-exit 0}
|
||||
:teams-to-exit 0
|
||||
:teams-to-detach 0}
|
||||
(:result out)))))
|
||||
|
||||
(t/deftest get-remove-from-org-summary-with-teams-to-transfer
|
||||
@ -864,7 +1132,8 @@
|
||||
(t/is (th/success? out))
|
||||
(t/is (= {:teams-to-delete 0
|
||||
:teams-to-transfer 1
|
||||
:teams-to-exit 0}
|
||||
:teams-to-exit 0
|
||||
:teams-to-detach 0}
|
||||
(:result out)))))
|
||||
|
||||
(t/deftest get-remove-from-org-summary-with-teams-to-exit
|
||||
@ -893,7 +1162,8 @@
|
||||
(t/is (th/success? out))
|
||||
(t/is (= {:teams-to-delete 0
|
||||
:teams-to-transfer 0
|
||||
:teams-to-exit 1}
|
||||
:teams-to-exit 1
|
||||
:teams-to-detach 0}
|
||||
(:result out)))))
|
||||
|
||||
(t/deftest get-remove-from-org-summary-does-not-mutate
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
[backend-tests.storage-test :refer [configure-storage-backend]]
|
||||
[buddy.core.bytes :as b]
|
||||
[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 :each th/database-reset)
|
||||
@ -39,6 +40,23 @@
|
||||
(t/is (nil? (:error 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
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns backend-tests.rpc-nitrate-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
@ -44,6 +45,13 @@
|
||||
:organization-id (:id org-summary)}
|
||||
nil)))
|
||||
|
||||
(defn- nitrate-org-summary-only-mock
|
||||
[org-summary]
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
:get-org-summary org-summary
|
||||
nil)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Tests
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -279,6 +287,64 @@
|
||||
(let [team (th/db-get :team {:id (:id team1)})]
|
||||
(t/is (nil? (:deleted-at team))))))))
|
||||
|
||||
(t/deftest get-leave-org-summary-counts-default-team-as-delete-when-empty
|
||||
(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
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
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 (= :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
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
|
||||
@ -48,6 +48,23 @@
|
||||
(t/is (= "content" (slurp (sto/get-object-data 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
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
|
||||
@ -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'\"",
|
||||
"build:test": "clojure -M:dev:shadow-cljs compile test",
|
||||
"test:js": "pnpm run build:test && node target/tests/test.js",
|
||||
"test:quiet": "node ./scripts/test-quiet.js",
|
||||
"test:jvm": "clojure -M:dev:test"
|
||||
}
|
||||
}
|
||||
|
||||
25
common/scripts/test-quiet.js
Normal file
25
common/scripts/test-quiet.js
Normal 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);
|
||||
@ -332,10 +332,7 @@
|
||||
(conj opacity)))
|
||||
|
||||
(defn hex->hsl [hex]
|
||||
(try
|
||||
(-> hex hex->rgb rgb->hsl)
|
||||
(catch #?(:clj Throwable :cljs :default) _e
|
||||
[0 0 0])))
|
||||
(-> hex hex->rgb rgb->hsl))
|
||||
|
||||
(defn hex->hsla
|
||||
[data opacity]
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
(defmacro select-keys
|
||||
"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
|
||||
keys in contrast to clojure.core/select-keys"
|
||||
|
||||
@ -1194,7 +1194,7 @@
|
||||
;; frames. Return the ids of the frames affected
|
||||
|
||||
(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]
|
||||
(->> (cfh/get-parents-with-self objects id)
|
||||
(filter cfh/frame-shape?)))
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.path :as path]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.common.uuid :as uuid]
|
||||
@ -412,12 +413,9 @@
|
||||
(add-object changes obj nil))
|
||||
|
||||
([changes obj {:keys [index ignore-touched] :or {index ::undefined ignore-touched false}}]
|
||||
|
||||
;; FIXME: add shape validation
|
||||
|
||||
(assert-page-id! changes)
|
||||
(assert-objects! changes)
|
||||
(let [obj (cond-> obj
|
||||
(let [obj (cond-> (cts/check-shape obj)
|
||||
(not= index ::undefined)
|
||||
(assoc ::index index))
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
(defn- generate-index
|
||||
"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."
|
||||
[index objects shape-id parents]
|
||||
(let [shape (get objects shape-id)
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
[app.common.types.shape.shadow :as ctss]
|
||||
[app.common.types.shape.text :as ctst]
|
||||
[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]
|
||||
[clojure.set :as set]
|
||||
[cuerdas.core :as str]))
|
||||
@ -1599,7 +1599,7 @@
|
||||
|
||||
(defmethod migrate-data "0014-fix-tokens-lib-duplicate-ids"
|
||||
[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"
|
||||
[data _]
|
||||
@ -1833,6 +1833,47 @@
|
||||
(cfcp/fix-missing-swap-slots 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
|
||||
(into (d/ordered-set)
|
||||
["legacy-2"
|
||||
@ -1912,4 +1953,6 @@
|
||||
"0019-fix-missing-swap-slots"
|
||||
"0020-sync-component-id-with-near-main"
|
||||
"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"]))
|
||||
|
||||
@ -543,7 +543,7 @@
|
||||
(update :svg-attrs dissoc :fill)
|
||||
(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))
|
||||
(dm/get-in shape [:svg-attrs :fillOpacity]))
|
||||
(-> (update :svg-attrs dissoc :fillOpacity)
|
||||
@ -609,17 +609,13 @@
|
||||
(and (some? color) (some? 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)))
|
||||
(assoc-in [:strokes 0 :stroke-cap-start] linecap)
|
||||
|
||||
(assoc :stroke-cap-start linecap
|
||||
:stroke-cap-end linecap
|
||||
:stroke-linecap linecap)
|
||||
|
||||
(d/any-key? (dm/get-in shape [:strokes 0])
|
||||
:strokeColor :strokeOpacity :strokeWidth
|
||||
:strokeLinecap :strokeCapStart :strokeCapEnd)
|
||||
(assoc-in [:strokes 0 :stroke-style] :svg))))
|
||||
(and (some? color) (some? linecap) (cfh/path-shape? shape)
|
||||
(or (= linecap :round) (= linecap :square)))
|
||||
(assoc-in [:strokes 0 :stroke-cap-end] linecap))))
|
||||
|
||||
(defn setup-opacity [shape]
|
||||
(cond-> shape
|
||||
|
||||
@ -148,11 +148,9 @@
|
||||
(not (ctob/token-name-path-exists? % tokens-tree)))]])
|
||||
|
||||
(defn make-node-token-name-schema
|
||||
"Dynamically generates a schema to check a token node name, adding translated error messages
|
||||
and two additional validations:
|
||||
- Min and max length.
|
||||
- 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."
|
||||
"Dynamically generates a schema to check the name of a token node, that may be a final token or a group.
|
||||
This runs same checks as make-token-name-schema, but for all tokens that will be renamed by this change,
|
||||
if the group already contains tokens."
|
||||
[active-tokens tokens-tree node]
|
||||
[:and
|
||||
[: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
|
||||
[tokens-lib group name theme-id]
|
||||
(sm/merge
|
||||
ctob/schema:token-theme-attrs
|
||||
[:map
|
||||
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
|
||||
[:name (make-token-theme-name-schema tokens-lib group theme-id)]
|
||||
[:description {:optional true} schema:token-theme-description]]))
|
||||
[:and
|
||||
(sm/merge
|
||||
ctob/schema:token-theme-attrs
|
||||
[:map
|
||||
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
|
||||
[: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
|
||||
|
||||
@ -591,7 +591,7 @@
|
||||
-it should be a main component
|
||||
-its parent should be a variant-container
|
||||
-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]
|
||||
(let [parent (ctst/get-shape page (:parent-id shape))
|
||||
@ -707,7 +707,7 @@
|
||||
|
||||
(if (#{:main-top :main-nested :main-any} context)
|
||||
(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)
|
||||
(check-shape-not-component shape file page libraries)))))))))
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
[app.common.types.variant :as ctv]))
|
||||
|
||||
(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]
|
||||
(let [page-id (->> data
|
||||
:components
|
||||
|
||||
@ -72,7 +72,11 @@
|
||||
:backend-worker
|
||||
;; Only for development
|
||||
: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
|
||||
;; Enables the templates dialog on Penpot dashboard.
|
||||
:dashboard-templates-section
|
||||
@ -165,6 +169,7 @@
|
||||
|
||||
:mcp
|
||||
:background-blur
|
||||
:available-viewer-wasm
|
||||
:stroke-path})
|
||||
|
||||
(def all-flags
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
;; modif-tree))))
|
||||
|
||||
(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]
|
||||
(let [modifiers (dm/get-in modif-tree [(:id parent) :modifiers])]
|
||||
;; Move modifiers don't need to calculate constraints
|
||||
|
||||
@ -11,7 +11,8 @@
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.rect :as grc]
|
||||
[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
|
||||
[shape stroke-width]
|
||||
@ -88,14 +89,23 @@
|
||||
([shape]
|
||||
(get-shape-filter-bounds shape false))
|
||||
([shape ignore-shadow-margin?]
|
||||
(if (or (and (cfh/svg-raw-shape? shape)
|
||||
(not= :svg (dm/get-in shape [:content :tag])))
|
||||
;; If no shadows or blur, we return the selrect as is
|
||||
(and (empty? (-> shape :shadow))
|
||||
(or (nil? (:blur shape))
|
||||
(not= :layer-blur (-> shape :blur :type))
|
||||
(zero? (-> shape :blur :value (or 0))))))
|
||||
(cond
|
||||
;; SVG raw elements (non-root) don't have proper rotated points; use selrect
|
||||
(and (cfh/svg-raw-shape? shape)
|
||||
(not= :svg (dm/get-in shape [:content :tag])))
|
||||
(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)
|
||||
blur-value (case (-> shape :blur :type)
|
||||
:layer-blur (or (-> shape :blur :value) 0)
|
||||
@ -105,6 +115,19 @@
|
||||
(grc/points->rect))]
|
||||
(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
|
||||
([shape]
|
||||
(calculate-padding shape false false))
|
||||
@ -127,6 +150,11 @@
|
||||
0
|
||||
(shape-stroke-margin shape stroke-width))
|
||||
|
||||
stroke-cap-margin
|
||||
(if ignore-margin?
|
||||
0
|
||||
(stroke-cap-marker-margin strokes open-path?))
|
||||
|
||||
shadow-width
|
||||
(->> (:shadow shape)
|
||||
(remove :hidden)
|
||||
@ -149,8 +177,8 @@
|
||||
shadow-width
|
||||
(if ignore-shadow-margin? 0 shadow-width)]
|
||||
|
||||
{:horizontal (mth/ceil (+ stroke-margin shadow-width))
|
||||
:vertical (mth/ceil (+ stroke-margin shadow-height))})))
|
||||
{:horizontal (mth/ceil (+ stroke-margin stroke-cap-margin shadow-width))
|
||||
:vertical (mth/ceil (+ stroke-margin stroke-cap-margin shadow-height))})))
|
||||
|
||||
(defn- add-padding
|
||||
[bounds padding]
|
||||
|
||||
@ -264,7 +264,7 @@
|
||||
:scale)))
|
||||
|
||||
(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
|
||||
child-bounds transformed-child-bounds parent-bounds transformed-parent-bounds]
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
[app.common.geom.shapes.points :as gpo]
|
||||
[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
|
||||
(def -child-min-width nil)
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
(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
|
||||
(def -child-min-width nil)
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
;;
|
||||
;; 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
|
||||
;;
|
||||
;; - Expand flexible tracks
|
||||
@ -55,7 +55,7 @@
|
||||
[app.common.math :as mth]
|
||||
[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
|
||||
(def -child-min-width nil)
|
||||
|
||||
@ -449,7 +449,7 @@
|
||||
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)
|
||||
|
||||
;; 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)
|
||||
row-tracks (set-auto-multi-span parent row-tracks children-map shape-cells bounds objects :row)
|
||||
|
||||
|
||||
@ -369,7 +369,7 @@
|
||||
|
||||
|
||||
(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]
|
||||
|
||||
(let [;; Line equation representation: ax + by + c = 0
|
||||
|
||||
@ -31,21 +31,21 @@
|
||||
(gpt/scale val)))
|
||||
|
||||
(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]
|
||||
(-> (gpt/to-vec p1 p0)
|
||||
(gpt/unit)
|
||||
(gpt/scale val)))
|
||||
|
||||
(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]
|
||||
(-> (gpt/to-vec p0 p3)
|
||||
(gpt/unit)
|
||||
(gpt/scale val)))
|
||||
|
||||
(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]
|
||||
(-> (gpt/to-vec p3 p0)
|
||||
(gpt/unit)
|
||||
|
||||
@ -283,7 +283,7 @@
|
||||
[selrect transform (when (some? transform) (gmt/inverse transform))]))
|
||||
|
||||
(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"
|
||||
[shape points]
|
||||
(let [points' (dm/get-prop shape :points)
|
||||
|
||||
@ -90,7 +90,7 @@
|
||||
child-seq)))
|
||||
|
||||
(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]
|
||||
(concat
|
||||
(->> (get-children-seq from-id objects)
|
||||
|
||||
@ -486,36 +486,41 @@
|
||||
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
|
||||
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))
|
||||
be synchronized.
|
||||
|
||||
(container-log :info asset-id
|
||||
:msg "Sync file with library"
|
||||
:asset-type asset-type
|
||||
:asset-id asset-id
|
||||
:file (pretty-file file-id libraries current-file-id)
|
||||
:library (pretty-file library-id libraries current-file-id))
|
||||
If early-return? is true, stops as soon as the first change is generated."
|
||||
([changes file-id asset-type asset-id library-id libraries current-file-id]
|
||||
(generate-sync-file changes file-id asset-type asset-id library-id libraries current-file-id false))
|
||||
([changes file-id asset-type asset-id library-id libraries current-file-id early-return?]
|
||||
(assert (contains? #{:colors :components :typographies} asset-type))
|
||||
(assert (or (nil? asset-id) (uuid? asset-id)))
|
||||
(assert (uuid? file-id))
|
||||
(assert (uuid? library-id))
|
||||
|
||||
(let [file (get-in libraries [file-id :data])]
|
||||
(loop [containers (ctf/object-containers-seq file)
|
||||
changes changes]
|
||||
(if-let [container (first containers)]
|
||||
(do
|
||||
(recur (next containers)
|
||||
(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))))
|
||||
changes))))
|
||||
(container-log :info asset-id
|
||||
:msg "Sync file with library"
|
||||
:asset-type asset-type
|
||||
:asset-id asset-id
|
||||
:file (pretty-file file-id libraries current-file-id)
|
||||
:library (pretty-file library-id libraries current-file-id))
|
||||
|
||||
(let [file (get-in libraries [file-id :data])]
|
||||
(loop [containers (ctf/object-containers-seq file)
|
||||
changes changes]
|
||||
(let [container (first containers)]
|
||||
(if (or (nil? container)
|
||||
(and early-return? (seq (:redo-changes changes))))
|
||||
changes
|
||||
(recur (next containers)
|
||||
(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
|
||||
"Generate changes to synchronize all shapes in all components of the
|
||||
@ -523,35 +528,41 @@
|
||||
the given library.
|
||||
|
||||
If an asset id is given, only shapes linked to this particular asset will
|
||||
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))
|
||||
be synchronized.
|
||||
|
||||
(container-log :info asset-id
|
||||
:msg "Sync local components with library"
|
||||
:asset-type asset-type
|
||||
:asset-id asset-id
|
||||
:file (pretty-file file-id libraries current-file-id)
|
||||
:library (pretty-file library-id libraries current-file-id))
|
||||
If early-return? is true, stops as soon as the first change is generated."
|
||||
([changes file-id asset-type asset-id library-id libraries current-file-id]
|
||||
(generate-sync-library changes file-id asset-type asset-id library-id libraries current-file-id false))
|
||||
([changes file-id asset-type asset-id library-id libraries current-file-id early-return?]
|
||||
(assert (contains? #{:colors :components :typographies} asset-type))
|
||||
(assert (or (nil? asset-id) (uuid? asset-id)))
|
||||
(assert (uuid? file-id))
|
||||
(assert (uuid? library-id))
|
||||
|
||||
(let [file (get-in libraries [file-id :data])]
|
||||
(loop [local-components (ctkl/components-seq file)
|
||||
changes changes]
|
||||
(if-let [local-component (first local-components)]
|
||||
(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)))
|
||||
changes))))
|
||||
(container-log :info asset-id
|
||||
:msg "Sync local components with library"
|
||||
:asset-type asset-type
|
||||
:asset-id asset-id
|
||||
:file (pretty-file file-id libraries current-file-id)
|
||||
:library (pretty-file library-id libraries current-file-id))
|
||||
|
||||
(let [file (get-in libraries [file-id :data])]
|
||||
(loop [local-components (ctkl/components-seq file)
|
||||
changes changes]
|
||||
(let [local-component (first local-components)]
|
||||
(if (or (nil? local-component)
|
||||
(and early-return? (seq (:redo-changes 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
|
||||
"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)
|
||||
;; 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
|
||||
text-content-change?
|
||||
(and omit-touched?
|
||||
@ -2091,6 +2102,38 @@
|
||||
(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?
|
||||
"Returns true when the value of `attr` in `shape` is considered equal
|
||||
to the corresponding value in `origin-shape`, ignoring positional
|
||||
@ -2195,7 +2238,7 @@
|
||||
|
||||
;; On texts, both text (the actual letters)
|
||||
;; 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
|
||||
text-change?
|
||||
(and (not skip-operations?)
|
||||
@ -2260,6 +2303,10 @@
|
||||
(contains? #{:points :selrect :width :height} 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
|
||||
(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))]
|
||||
[new-shape all-parents changes]))
|
||||
|
||||
(defn generate-sync-file-changes
|
||||
[changes undo-group asset-type file-id asset-id library-id libraries current-file-id]
|
||||
(let [sync-components? (or (nil? asset-type) (= asset-type :components))
|
||||
sync-colors? (or (nil? asset-type) (= asset-type :colors))
|
||||
sync-typographies? (or (nil? asset-type) (= asset-type :typographies))]
|
||||
(cond-> changes
|
||||
:always
|
||||
(pcb/set-undo-group undo-group)
|
||||
;; library-changes
|
||||
sync-components?
|
||||
(generate-sync-library file-id :components asset-id library-id libraries current-file-id)
|
||||
sync-colors?
|
||||
(generate-sync-library file-id :colors asset-id library-id libraries current-file-id)
|
||||
sync-typographies?
|
||||
(generate-sync-library file-id :typographies asset-id library-id libraries current-file-id)
|
||||
(defn- maybe-sync
|
||||
[c enabled? done? f]
|
||||
(if (and enabled? (not (done? c)))
|
||||
(f c)
|
||||
c))
|
||||
|
||||
(defn generate-sync-file-changes
|
||||
([changes undo-group asset-type file-id asset-id library-id libraries current-file-id]
|
||||
(generate-sync-file-changes changes undo-group asset-type file-id asset-id library-id libraries current-file-id false))
|
||||
([changes undo-group asset-type file-id asset-id library-id libraries current-file-id early-return?]
|
||||
(let [sync-components? (or (nil? asset-type) (= asset-type :components))
|
||||
sync-colors? (or (nil? asset-type) (= asset-type :colors))
|
||||
sync-typographies? (or (nil? asset-type) (= asset-type :typographies))
|
||||
done? (fn [c] (and early-return? (seq (:redo-changes c))))]
|
||||
(-> (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
|
||||
[changes file-full libraries container id reset?]
|
||||
|
||||
@ -539,5 +539,12 @@
|
||||
(update shape :interactions ctsi/add-interaction interaction))
|
||||
|
||||
(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]
|
||||
(dissoc shape :hide-in-viewer))
|
||||
(if (true? (:hide-in-viewer shape))
|
||||
shape
|
||||
(dissoc shape :hide-in-viewer)))
|
||||
|
||||
@ -202,6 +202,24 @@
|
||||
(zero? result) 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?
|
||||
[o]
|
||||
#?(:clj (instance? Instant o)
|
||||
|
||||
@ -159,7 +159,7 @@
|
||||
group)))
|
||||
|
||||
(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
|
||||
route and thus they are not part of :sync-attrs.
|
||||
Also when detaching a nested copy it also needs to trigger a synchronization,
|
||||
|
||||
@ -259,7 +259,7 @@
|
||||
(some? (find-component-main objects shape only-direct-child?))))
|
||||
|
||||
(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."
|
||||
[objects shape]
|
||||
(or (ctk/in-component-copy? shape)
|
||||
@ -405,7 +405,7 @@
|
||||
(map remap-ids new-shapes)])))
|
||||
|
||||
(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."
|
||||
[objects id]
|
||||
(let [shape (get objects id)]
|
||||
@ -517,7 +517,7 @@
|
||||
:any-main-descendant any-main-descendant}))
|
||||
|
||||
(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]
|
||||
(find-valid-parent-and-frame-ids parent-id objects children false nil nil))
|
||||
([parent-id objects children pasting? libraries]
|
||||
|
||||
103
common/src/app/common/types/nitrate_permissions.cljc
Normal file
103
common/src/app/common/types/nitrate_permissions.cljc
Normal 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))))))
|
||||
@ -8,10 +8,10 @@
|
||||
"Implements a specialized map-like data structure for store an UUID =>
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
properly from each value."
|
||||
|
||||
|
||||
@ -15,7 +15,14 @@
|
||||
[:slug ::sm/text]
|
||||
[:owner-id ::sm/uuid]
|
||||
[: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
|
||||
@ -25,26 +32,32 @@
|
||||
[:organization schema:organization]])
|
||||
|
||||
(def organization->team-keys
|
||||
"Mapping from organization field keys to their corresponding :organization-* team keys."
|
||||
[[:id :organization-id]
|
||||
[:name :organization-name]
|
||||
[:custom-photo :organization-custom-photo]
|
||||
[:slug :organization-slug]
|
||||
[:avatar-bg-url :organization-avatar-bg-url]
|
||||
[:owner-id :organization-owner-id]])
|
||||
"Organization field keys to include in the nested :organization map."
|
||||
[:id :name :custom-photo :slug :avatar-bg-url :owner-id :expired-license :permissions])
|
||||
|
||||
(defn apply-organization
|
||||
"Updates a team map with organization fields sourced from org.
|
||||
Associates each org field to the corresponding :organization-* team key when
|
||||
the value is non-nil; dissociates the key otherwise. This correctly handles
|
||||
both attaching an org (all values present) and detaching one (org is nil or
|
||||
all fields absent)."
|
||||
"Updates a team map with organization fields in a nested :organization map.
|
||||
Associates each org field within :organization when the value is non-nil;
|
||||
dissociates the field otherwise. This correctly handles both attaching an org
|
||||
(all values present) and detaching one (org is nil or all fields absent)."
|
||||
[team organization]
|
||||
(let [id (:id organization)]
|
||||
(reduce (fn [acc [org-k team-k]]
|
||||
(let [v (get organization org-k)]
|
||||
(if (and id (some? v))
|
||||
(assoc acc team-k v)
|
||||
(dissoc acc team-k))))
|
||||
team
|
||||
organization->team-keys)))
|
||||
(if id
|
||||
(assoc team :organization
|
||||
(reduce (fn [acc k]
|
||||
(let [v (get organization k)]
|
||||
(if (some? v)
|
||||
(assoc acc k v)
|
||||
(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]]])
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
This NS allows separate context-less/dependency-less helpers from
|
||||
other path related namespaces and make proper domain-specific
|
||||
namespaces without incurrying on circular depedency cycles."
|
||||
namespaces without incurrying on circular dependency cycles."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
@ -192,7 +192,7 @@
|
||||
(defn solve-roots*
|
||||
"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
|
||||
thanks to transducers."
|
||||
[result conj a b c d]
|
||||
@ -794,5 +794,3 @@
|
||||
#_:else false))]
|
||||
|
||||
(some inside-border? content)))
|
||||
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
[app.common.types.shape.layout :as ctsl]
|
||||
[app.common.types.shape.shadow :as ctss]
|
||||
[app.common.types.shape.text :as ctsx]
|
||||
[app.common.types.stroke :as stroke]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.variant :as ctv]
|
||||
@ -61,8 +62,8 @@
|
||||
(map->Shape attrs))
|
||||
:clj (map->Shape attrs)))
|
||||
|
||||
(def stroke-caps-line #{:round :square})
|
||||
(def stroke-caps-marker #{:line-arrow :triangle-arrow :square-marker :circle-marker :diamond-marker})
|
||||
(def stroke-caps-line stroke/stroke-caps-line)
|
||||
(def stroke-caps-marker stroke/stroke-caps-marker)
|
||||
(def stroke-caps (conj (set/union stroke-caps-line stroke-caps-marker) nil))
|
||||
|
||||
(def shape-types
|
||||
@ -137,6 +138,8 @@
|
||||
[:stroke-style {:optional true}
|
||||
[::sm/one-of #{:solid :dotted :dashed :mixed}]]
|
||||
[: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}
|
||||
[::sm/one-of #{:center :inner :outer}]]
|
||||
[:stroke-cap-start {:optional true}
|
||||
@ -523,7 +526,7 @@
|
||||
:fills []
|
||||
:strokes [{:stroke-style :solid
|
||||
:stroke-alignment :inner
|
||||
:stroke-width 2
|
||||
:stroke-width 1
|
||||
:stroke-color clr/black
|
||||
:stroke-opacity 1}]})
|
||||
|
||||
@ -727,7 +730,7 @@
|
||||
(cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape))))))
|
||||
|
||||
(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]
|
||||
|
||||
(letfn [(patch-text-props [shape props]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user