Compare commits

..

16 Commits

Author SHA1 Message Date
Pablo Alba
7b174c4af3 Add feature flag check 2026-01-16 10:49:01 +01:00
Pablo Alba
71deb19c76 Revert " Add nitrate to tmux devenv"
This reverts commit dc6f6c4589.
2026-01-16 10:12:23 +01:00
Pablo Alba
cb9746c716 🎉 Add nitrate licence check 2026-01-16 10:12:23 +01:00
Juanfran
f38b68da97 🐛 Fix control-center proxy pass 2026-01-16 10:12:23 +01:00
Yamila Moreno
b44ebeda9b 🔧 Fix nginx entrypoint 2026-01-16 10:12:23 +01:00
Marina López
1825e74327 Add create org link 2026-01-16 10:12:23 +01:00
Yamila Moreno
9869176fa6 🔧 Add control-center to nginx 2026-01-16 10:12:23 +01:00
Pablo Alba
af5d3e0a98 ♻️ Cleanup unused imports 2026-01-16 10:12:23 +01:00
Juanfran
c2eef086dd ♻️ Change Nitrate organization-id schema to text 2026-01-16 10:12:23 +01:00
Pablo Alba
d163751296 Move nitrate url to an env variable 2026-01-16 10:12:23 +01:00
Pablo Alba
cb55be2378 Add photoUrl to profile on nitrate authenticate 2026-01-16 10:12:23 +01:00
Pablo Alba
916771ec47 Add retry and validation to nitrate module 2026-01-16 10:12:23 +01:00
Pablo Alba
8aa18dbf6a Add nitrate to tmux devenv 2026-01-16 10:12:23 +01:00
Pablo Alba
8821bfd271 🐛 Fix nitrate get-teams returns deleted teams 2026-01-16 10:12:23 +01:00
Pablo Alba
9d53e6c0f9 🎉 Integration with nitrate platform 2026-01-16 10:12:23 +01:00
Xaviju
db70edecff 🐛 Display missing selected tokens set info (#8098)
* 🐛 Display missing selected tokens set info

*  Add integration tests to verify current active set
2026-01-16 09:57:31 +01:00
33 changed files with 585 additions and 196 deletions

2
.gitignore vendored
View File

@@ -21,6 +21,7 @@
.rebel_readline_history
.repl
.shadow-cljs
.pnpm-store/
/*.jpg
/*.md
/*.png
@@ -72,6 +73,7 @@
/library/target/
/library/*.zip
/external
/penpot-nitrate
clj-profiler/
node_modules

View File

@@ -36,7 +36,8 @@ export PENPOT_FLAGS="\
enable-file-validation \
enable-file-schema-validation \
enable-redis-cache \
enable-subscriptions";
enable-subscriptions \
enable-nitrate";
# Default deletion delay for devenv
export PENPOT_DELETION_DELAY="24h"
@@ -55,6 +56,8 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/control-center
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-Djdk.attach.allowAttachSelf \

View File

@@ -225,6 +225,8 @@
[:netty-io-threads {:optional true} ::sm/int]
[:executor-threads {:optional true} ::sm/int]
[:nitrate-backend-uri {:optional true} ::sm/uri]
;; DEPRECATED
[:assets-storage-backend {:optional true} :keyword]
[:storage-assets-fs-directory {:optional true} :string]

View File

@@ -323,6 +323,7 @@
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
:app.nitrate/client (ig/ref :app.nitrate/client)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
@@ -339,6 +340,9 @@
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)}
:app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
@@ -348,6 +352,7 @@
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
:app.nitrate/client (ig/ref :app.nitrate/client)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}

133
backend/src/app/nitrate.clj Normal file
View File

@@ -0,0 +1,133 @@
;; 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.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.config :as cf]
[app.http.client :as http]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[app.util.json :as json]
[clojure.core :as c]
[integrant.core :as ig]))
(def baseuri (cf/get :nitrate-backend-uri))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- coercer
[schema & {:as opts}]
(let [decode-fn (sm/decoder schema sm/json-transformer)
check-fn (sm/check-fn schema opts)]
(fn [data]
(-> data decode-fn check-fn))))
(defn- request-builder
[cfg method uri management-key profile-id]
(fn []
(http/req! cfg {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" management-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1})))
(defn- with-retries
[handler max-retries]
(fn []
(loop [attempt 1]
(let [result (try
(handler)
(catch Exception e
(if (< attempt max-retries)
::retry
(do
;; TODO Error handling
(l/error :hint "request fail after multiple retries" :cause e)
nil))))]
(if (= result ::retry)
(recur (inc attempt))
result)))))
(defn- with-validate [handler uri schema]
(fn []
(let [coercer-http (coercer schema
:type :validation
:hint (str "invalid data received calling " uri))]
(try
(coercer-http (-> (handler) :body json/decode))
(catch Exception e
;; TODO Error handling
(l/error :hint "error validating json response" :cause e)
nil)))))
(defn- request-to-nitrate
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}]
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id)
(with-retries 3)
(with-validate uri schema))]
(full-http-call)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn call
[cfg method params]
(when (contains? cf/flags :nitrate)
(let [client (get cfg ::client)
method (get client method)]
(method params))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:organization
[:map
[:id ::sm/text]
[:name ::sm/text]])
(def ^:private schema:user
[:map
[:valid ::sm/boolean]])
(defn- get-team-org
[cfg {:keys [team-id] :as params}]
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params))
(defn- is-valid-user
[cfg {:keys [profile-id] :as params}]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client
[_ {:keys [::setup/props] :as cfg}]
(if (contains? cf/flags :nitrate)
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))
cfg (assoc cfg ::management-key management-key)]
{:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)})
{}))
(defmethod ig/halt-key! ::client
[_ {:keys []}]
(do :stuff))

View File

@@ -301,6 +301,7 @@
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription
'app.rpc.management.nitrate
'app.rpc.management.exporter)
(map (partial process-method cfg "management" wrap-management))
(into {}))))

View File

@@ -21,6 +21,7 @@
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.climit :as climit]
[app.rpc.doc :as-alias doc]
@@ -88,6 +89,17 @@
;; --- QUERY: Get profile (own)
(defn- add-nitrate-licence-to-profile
[cfg profile]
(try
(let [nitrate-licence (nitrate/call cfg :is-valid-user {:profile-id (:id profile)})]
(assoc profile :nitrate-licence (:valid nitrate-licence)))
(catch Throwable cause
(prn :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))
(sv/defmethod ::get-profile
{::rpc/auth false
::doc/added "1.18"
@@ -98,9 +110,13 @@
;; no profile-id is in session, and when db call raises not found. In all other
;; cases we need to reraise the exception.
(try
(-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))
(let [profile (-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))]
(if (contains? cf/flags :nitrate)
(add-nitrate-licence-to-profile cfg profile)
profile))
(catch Throwable _
{:id uuid/zero :fullname "Anonymous User"})))

View File

@@ -23,6 +23,7 @@
[app.main :as-alias main]
[app.media :as media]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
@@ -172,6 +173,12 @@
(map decode-row)
(map process-permissions)))
(defn- add-org-to-team
[cfg team params]
(let [params (assoc (or params {}) :team-id (:id team))
org (nitrate/call cfg :get-team-org params)]
(assoc team :organization-id (:id org) :organization-name (:name org))))
(defn get-teams
[conn profile-id]
(let [profile (profile/get-profile conn profile-id)
@@ -190,7 +197,9 @@
::sm/params schema:get-teams}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(dm/with-open [conn (db/open pool)]
(get-teams conn profile-id)))
(cond->> (get-teams conn profile-id)
(contains? cf/flags :nitrate)
(map #(add-org-to-team cfg % params)))))
(def ^:private sql:get-owned-teams
"SELECT t.id, t.name,

View File

@@ -0,0 +1,112 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate
"Internal Nitrate HTTP API.
Provides authenticated access to organization management and token validation endpoints.
All requests must include a valid shared key token in the `x-shared-key` header, and
a cookie `auth-token` with the user token.
They will return `401 Unauthorized` if the shared key or user token are invalid."
(:require
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- API: authenticate
(def ^:private schema:profile
[:map
[:id ::sm/uuid]
[:name :string]
[:email :string]
[:photo-url :string]])
(sv/defmethod ::authenticate
"Authenticate an user
@api GET /authenticate
@returns
200 OK: Returns the authenticated user."
{::doc/added "2.12"
::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:photo-url (files/resolve-public-uri (get profile :photo-id))}))
;; ---- API: get-teams
(def ^:private sql:get-teams
"SELECT t.*
FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ?
AND tpr.is_owner = 't'
AND t.is_default = 'f'
AND t.deleted_at is null;")
(def ^:private schema:team
[:map
[:id ::sm/uuid]
[:name :string]])
(def ^:private schema:get-teams-result
[:vector schema:team])
(sv/defmethod ::get-teams
"List teams for which current user is owner.
@api GET /get-teams
@returns
200 OK: Returns the list of teams for the user."
{::doc/added "2.12"
::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}]
(when (contains? cf/flags :nitrate)
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name]))))))
;; ---- API: notify-team-change
(def ^:private schema:notify-team-change
[:map
[:id ::sm/uuid]
[:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate
@api POST /notify-team-change
@returns
200 OK"
{::doc/added "2.12"
::sm/params schema:notify-team-change
::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}]
(when (contains? cf/flags :nitrate)
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team-id id
:organization-id organization-id
:organization-name organization-name}))))

View File

@@ -145,7 +145,10 @@
;; A temporal flag, enables backend code use more extensivelly
;; redis for caching data
:redis-cache})
:redis-cache
;; Activates the nitrate module
:nitrate})
(def all-flags
(set/union email login varia))

View File

@@ -141,8 +141,14 @@ http {
proxy_pass http://127.0.0.1:5000;
}
location /nitrate/ {
proxy_pass http://127.0.0.1:3000/;
location /control-center {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /wasm-playground {

View File

@@ -130,6 +130,12 @@ services:
environment:
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key]
## The PREPL host. Mainly used for external programatic access to penpot backend
## (example: admin). By default it will listen on `localhost` but if you are going to use
## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`.
# PENPOT_PREPL_HOST: 0.0.0.0
## Database connection parameters. Don't touch them unless you are using custom
## postgresql connection parameters.
@@ -145,8 +151,8 @@ services:
## Default configuration for assets storage: using filesystem based with all files
## stored in a docker volume.
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage.

View File

@@ -29,8 +29,9 @@ update_flags /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@@ -139,12 +139,20 @@ http {
proxy_pass $PENPOT_BACKEND_URI/ws/notifications;
}
location /control-center {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass $PENPOT_NITRATE_URI$request_uri;
}
include /etc/nginx/overrides/server.d/*.conf;
location / {
include /etc/nginx/overrides/location.d/*.conf;
location ~* \.(js|css|wasm|jpg|png|map|svg|ttf|woff|woff2|)$ {
location ~* \.(js|css|jpg|png|svg|ttf|woff|woff2|wasm)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}
@@ -152,10 +160,8 @@ http {
return 301 " /404";
}
add_header X-Frame-Options SAMEORIGIN always;
add_header Cache-Control "no-store, no-cache, max-age=0" always;
try_files $uri /index.html$is_args$args /index.html =404;
}
}
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -19,12 +19,6 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Create and manage your teams</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/projects-files">
<h2>Projects and Files →</h2>
<p>Organize your work with projects and files</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/comments/">
<h2>Comments →</h2>

View File

@@ -1,107 +0,0 @@
---
title: Projects and Files
order: 3
desc: Learn how to organize your work in Penpot. Create, manage and organize projects and files, work with drafts, and handle deleted items.
---
<h1 id="projects-files">Projects and Files</h1>
<p class="main-paragraph">Projects and files are the core organizational structure in Penpot. Projects work like folders that contain multiple design files, helping you organize your work efficiently. Files are your actual design documents where you create boards, pages, and all your design elements.</p>
<p class="main-paragraph">Understanding how to manage projects and files will help you keep your workspace organized and make it easier to collaborate with your team.</p>
<h2 id="projects-management">Projects</h2>
<p>Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.</p>
<p>If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Your Penpot") remain private to you.</p>
<figure>
<img src="/img/files-projects/01-projects.webp" alt="Projects view in dashboard" />
</figure>
<h3 id="create-project">Create a project</h3>
<p>To create a new project, use the <strong>+ New project</strong> button in the dashboard. You can also use the keyboard shortcut <kbd>+</kbd> when you're on the dashboard. A dialog will appear where you can enter the project name. Once created, the project will appear in your projects list.</p>
<p>When you create a project, you can immediately start adding files to it, or create files first and move them into the project later.</p>
<h3 id="edit-project">Edit a project</h3>
<p>To edit a project's name, right-click on the project in the sidebar or click the three-dot menu next to the project name. Select <strong>Edit</strong> or <strong>Rename</strong> to change the project name. You can also update the project's profile picture from the same menu.</p>
<h3 id="pin-project">Pin a project</h3>
<p>Projects can be pinned to the sidebar for quick access. Right-click on a project and select <strong>Pin</strong> to keep it visible in the sidebar even when you have many projects. Pinned projects appear at the top of your projects list for easy access.</p>
<p>To unpin a project, right-click on it and select <strong>Unpin</strong>. The project will remain in your list but won't be pinned to the sidebar anymore.</p>
<figure>
<img src="/img/files-projects/04-pin-project.webp" alt="Pin project option" />
</figure>
<h3 id="move-project">Move a project</h3>
<p>Projects can be moved between teams. To move a project, right-click on it and select <strong>Move to</strong> from the context menu. A dialog will appear showing all available teams where you can move the project. Select the destination team and confirm the move.</p>
<figure>
<img src="/img/files-projects/06-move-project.webp" alt="Move project to another team" />
</figure>
<p>When you move a project to another team, all files within the project are moved along with it. Team members of the destination team will gain access to the project and its files according to their permissions.</p>
<p class="advice">Moving a project to another team changes its ownership and access permissions. Make sure the destination team has the appropriate members and permissions for the work contained in the project.</p>
<h3 id="delete-project">Delete a project</h3>
<p>To delete a project, right-click on it and select <strong>Delete</strong> from the menu. You'll be asked to confirm the deletion. Keep in mind that deleting a project will also delete all files within it. Make sure you have backed up any important files before deleting a project.</p>
<p class="advice">Deleted projects and their files are moved to the trash area where they can be restored or permanently deleted.</p>
<h2 id="files-management">Files</h2>
<p>Files are your design documents in Penpot. Each file contains pages, boards, and all the design elements you create. Files can be created within a project or in the drafts section, and you can move them between projects as needed.</p>
<h3 id="create-file">Create a file</h3>
<p>To create a new file, you have several options:</p>
<ul>
<li>Click the <strong>+</strong> button in a project to create a file inside that project</li>
<li>Use the keyboard shortcut <kbd>+</kbd> when you have a project selected</li>
<li>Create a file directly in the drafts section if you're not ready to organize it into a project yet</li>
</ul>
<figure>
<img src="/img/files-projects/05-create-file.webp" alt="Create a new file" />
</figure>
<p>When creating a file, you'll be asked to give it a name. The file will open in the workspace where you can start designing immediately.</p>
<h3 id="edit-file">Edit a file</h3>
<p>To rename a file, right-click on the file card in the dashboard and select <strong>Rename</strong>, or click on the three-dot menu on the file card. Enter the new name and confirm the change. You can also access file settings and other options from the file's context menu.</p>
<h3 id="move-file">Move a file</h3>
<p>Files can be moved between projects, from drafts to a project, or even to projects in other teams. To move a file, right-click on the file card and select <strong>Move to</strong> from the context menu. A dialog will appear showing all available projects across your teams where you can choose the destination. Select the project where you want to move the file and confirm.</p>
<p>You can also drag and drop files between projects in the dashboard for a quick way to reorganize your files within the same team.</p>
<p>When moving a file to a project in another team, the file becomes accessible to members of that team according to their permissions. Moving a file doesn't affect its content or any shared libraries it might be using. Only its location in your project structure changes.</p>
<p class="advice">When moving files between teams, be aware that this changes who has access to the file. Make sure the destination team has the appropriate members and permissions for the work contained in the file.</p>
<h3 id="duplicate-file">Duplicate a file</h3>
<p>To create a copy of an existing file, right-click on the file card and select <strong>Duplicate</strong>. The duplicated file will be created in the same location (project or drafts) with the same name plus "Copy" added to it. You can then rename or move it as needed.</p>
<p>Duplicating a file creates a complete copy including all pages, boards, and design elements. This is useful when you want to create variations of a design or use a file as a starting point for a new project.</p>
<h3 id="delete-file">Delete a file</h3>
<p>To delete a file, right-click on the file card and select <strong>Delete</strong>. You'll be asked to confirm the deletion. The file will be moved to the trash area where it can be restored or permanently deleted later.</p>
<p class="advice">Deleting a file doesn't immediately remove it permanently. You can recover deleted files from the trash area within a certain time period.</p>
<h2 id="drafts">Drafts</h2>
<p>The drafts section is a fixed, non-deletable space in your dashboard where you can create and store files that aren't part of any specific project yet. This is useful for quick sketches, experimental designs, or files you're not ready to organize into projects.</p>
<figure>
<img src="/img/files-projects/02-drafts.webp" alt="Drafts section" />
</figure>
<p>Drafts appear in a dedicated section in the dashboard sidebar, separate from your projects. All team members can see and access files in the drafts section, depending on their permissions.</p>
<p>You can create files directly in drafts, or move existing files from projects into drafts if you want to temporarily remove them from a project's organization. Files in drafts work exactly like files in projects - they have the same functionality and features.</p>
<p>When you're ready to organize a file from drafts, you can move it into an appropriate project using the move option in the file's context menu.</p>
<h2 id="trash-area">Trash area</h2>
<p>When you delete projects or files, they are not removed permanently. Instead, they are moved to a trash area, a dedicated space for deleted content. This allows you to recover mistakenly deleted content or permanently remove items when you're sure you don't need them anymore.</p>
<p>The trash applies to both files and projects. Items in the trash remain there for a certain period depending on your Penpot subscription plan before being automatically deleted permanently.</p>
<h3 id="access-trash">Access the trash</h3>
<p>A <strong>Trash</strong> section is accessible from the dashboard navigation. When you access it, you'll see all your deleted files and projects, each clearly labeled so you can easily identify what you want to restore or permanently delete.</p>
<figure>
<img src="/img/files-projects/03-trash.webp" alt="Trash area" />
</figure>
<h3 id="trash-permissions">Trash permissions</h3>
<p>Access to the trash and the actions you can perform depend on your role in the team:</p>
<ul>
<li><strong>Owner, Admin, and Editor:</strong> Can view the trash, restore deleted items, and permanently delete items from the trash.</li>
<li><strong>Viewer:</strong> Cannot access the trash or manage deleted content.</li>
</ul>
<h3 id="restore-items">Restore items</h3>
<p>To restore a deleted file or project, access the trash area and find the item you want to recover. Select the item and choose <strong>Restore</strong>. The item will be restored to its original location (the project it belonged to, or the drafts section if it wasn't in a project).</p>
<h3 id="permanently-delete">Permanently delete items</h3>
<p>If you're sure you don't need an item anymore, you can permanently delete it from the trash. Select the item and choose <strong>Permanently delete</strong>. This action cannot be undone, so make sure you really want to remove the item permanently.</p>
<p class="advice">Items in the trash are automatically deleted after a certain period depending on your subscription plan. If you want to keep something, restore it before the auto-deletion period expires.</p>

View File

@@ -455,43 +455,6 @@ ExtraBold Italic
<p>A <strong>Typography composite token</strong> can be applied to a full text layer to set all typography properties at once. This lets you manage complete text styles using a single token instead of combining multiple individual ones.</p>
<p>When applying a Typography composite token to a layer, any previously applied <em>Typography composite token</em> or <em>style</em> will be detached. The same happens in reverse. Only one of them can be active at a time.</p>
<h3 id="design-tokens-shadow">Shadow</h3>
<p>Shadow tokens are composite entities that encapsulate the properties of one or more shadows into a single token definition. This token can contain a single shadow or an array of multiple shadows that can be reordered.</p>
<p>Shadow tokens support both <strong>Drop Shadow</strong> and <strong>Inner Shadow</strong> types. When creating or editing a shadow token, you can select the type of shadow you want to use. The default selection is Drop Shadow.</p>
<figure>
<img src="/img/design-tokens/37-tokens-shadow-individual.webp" alt="Shadow token creation with individual values" />
</figure>
<h4 id="design-tokens-shadow-properties">Shadow properties</h4>
<p>Each shadow within a shadow token contains a set of properties that define how the shadow appears:</p>
<ul>
<li><strong>Color:</strong> The color of the shadow. Accepts the same values as <a href="#design-tokens-color">color tokens</a> (Hex, RGB, RGBA, ARGB, HSL, HSLA), and you can reference existing color tokens. The color picker is available when defining the value.</li>
<li><strong>X offset:</strong> The horizontal offset of the shadow. Can be unit or unitless, and accepts negative values. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Y offset:</strong> The vertical offset of the shadow. Can be unit or unitless, and accepts negative values. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Blur:</strong> The blur radius of the shadow. Can be unit or unitless. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Spread:</strong> The spread radius of the shadow. Can be unit or unitless. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Type:</strong> Whether the shadow is a drop shadow or an inner shadow. Selected via a dropdown menu, with Drop Shadow as the default.</li>
</ul>
<p>Each property within a shadow token can reference existing tokens or be assigned hardcoded values. Shadows can also reference other shadow tokens (the type of shadow must match when using references).</p>
<p class="advice">Not all properties are mandatory to save a shadow token. Some can be empty (and will be computed as 0). Only the color property is mandatory. In an array of shadows, if any shadow does not have the color set, the form cannot be saved.</p>
<h4 id="design-tokens-shadow-create">Creating shadow tokens</h4>
<p>To create a shadow token, click on the <strong>+</strong> next to <strong>Shadow</strong> in the Tokens panel. Shadow tokens can be created in two ways:</p>
<ul>
<li><strong>Individual values:</strong> You can create one shadow or multiple shadows with individual property values. Click the <strong>+</strong> button to add more shadows to the array. New shadows are added at the top of the list.</li>
<li><strong>Single reference:</strong> You can reference another existing shadow token. When using a single reference, you cannot add more than one shadow. The resolved value will display the shadow or list of shadows that the referenced token contains.</li>
</ul>
<figure>
<img src="/img/design-tokens/38-tokens-shadow-reference.webp" alt="Shadow token creation with reference" />
</figure>
<p>When creating a shadow with individual values, the color value starts empty, but the other inputs have default values (X: 4, Y: 4, Blur: 4, Spread: 0). You can reorder shadows by hovering over a shadow form and using the reorder button to drag it to a different position.</p>
<p>You can also reference another existing shadow token instead of defining each property manually. When doing so, Penpot resolves all shadow properties from the referenced token.</p>
<h4 id="design-tokens-shadow-apply">Applying shadow tokens</h4>
<p>Shadow tokens can be applied to any layer type. Clicking on a shadow token will apply it to the selected layer. Right-clicking on a shadow token shows the context menu with the <strong>Shadow</strong> option to apply it.</p>
<p class="advice">Text elements in CSS do not support inner shadows, but Penpot does, since it uses the filter property internally instead of the box-shadow property.</p>
<p>When applying a shadow token, any existing shadow on the layer will be overridden (whether it's a raw shadow or an applied token shadow). If the token contains an array of shadows, each shadow will be added in the same order as in the creation form.</p>
<p class="advice">In Penpot, an element can have multiple shadows, but only one token of the same type can be applied. This means that applying a second shadow token would override the first one, regardless of how many shadows the shape currently has.</p>

View File

@@ -216,4 +216,32 @@ test.describe("Tokens: Sets Tab", () => {
await expect(tokenSetItems.nth(1)).toHaveAttribute("aria-checked", "false");
await expect(tokenSetItems.nth(2)).toHaveAttribute("aria-checked", "true");
});
test("Display active set and verify if is enabled", async ({ page }) => {
const { tokenThemesSetsSidebar, tokensSidebar, tokenSetItems } =
await setupTokensFile(page);
// Create set
await tokenThemesSetsSidebar
.getByRole("button", { name: "Add set" })
.click();
await changeSetInput(tokenThemesSetsSidebar, "Inactive set");
await tokenThemesSetsSidebar
.getByRole("button", { name: "Inactive set" })
.click();
let activeSetTitle = await tokensSidebar.getByTestId(
"active-token-set-title",
);
await expect(activeSetTitle).toHaveText("TOKENS - Inactive set");
const inactiveSetInfo = await tokensSidebar.getByTitle(
"This set is not active.",
);
await expect(inactiveSetInfo).toBeVisible();
// Switch active set
await tokenThemesSetsSidebar.getByRole("button", { name: "theme" }).click();
await expect(activeSetTitle).toHaveText("TOKENS - theme");
await expect(inactiveSetInfo).not.toBeVisible();
});
});

View File

@@ -15,6 +15,7 @@
[app.common.time :as ct]
[app.common.types.project :refer [valid-project?]]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.constants :as mconst]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
@@ -683,12 +684,25 @@
(rx/of (dcm/change-team-role params)
(modal/hide)))))
(defn handle-change-team-org
[{:keys [team-id organization-id organization-name] :as message}]
(ptk/reify ::handle-change-team-org
ptk/UpdateEvent
(update [_ state]
(if (and (contains? cf/flags :nitrate)
(contains? (:teams state) team-id))
(-> state
(assoc-in [:teams team-id :organization-id] organization-id)
(assoc-in [:teams team-id :organization-name] organization-name))
state))))
(defn- process-message
[{:keys [type] :as msg}]
(case type
:notification (dcm/handle-notification msg)
:team-role-change (handle-change-team-role msg)
:team-membership-change (dcm/team-membership-change msg)
:team-org-change (handle-change-team-org msg)
nil))

View File

@@ -0,0 +1,46 @@
;; 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.main.ui.dashboard.nitrate-form
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.modal :as modal]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[rumext.v2 :as mf]))
(mf/defc nitrate-form-modal
{::mf/register modal/components
::mf/register-as :nitrate-form}
[]
(let [on-click
(mf/use-fn
(fn []
(dom/open-new-window "/control-center/licences/start")))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :nitrate-form)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
"BUY NITRATE"]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
"Nitrate is so cool! You should buy it!"]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:> button* {:variant "primary"
:on-click on-click}
"BUY NOW!"]]]]]]))

View File

@@ -0,0 +1,52 @@
// 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
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
}
.modal-header {
margin-bottom: deprecated.$s-24;
}
.modal-title {
@include deprecated.uppercaseTitleTipography;
color: var(--modal-title-foreground-color);
}
.modal-close-btn {
@extend .modal-close-btn-base;
}
.modal-content {
margin-bottom: deprecated.$s-24;
}
.nitrate-form {
min-width: deprecated.$s-400;
}
.action-buttons {
@extend .modal-action-btns;
}
.cancel-button {
@extend .modal-cancel-btn;
}
.accept-btn {
@extend .modal-accept-btn;
&.danger {
@extend .modal-danger-btn;
}
}

View File

@@ -26,6 +26,7 @@
[app.main.ui.components.link :refer [link]]
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.nitrate-form]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
get-subscription-type
@@ -280,8 +281,8 @@
(mf/defc teams-selector-dropdown*
{::mf/private true}
[{:keys [team profile teams] :rest props}]
(let [on-create-click
[{:keys [team profile teams show-default-team allow-create-teams allow-create-org] :rest props}]
(let [on-create-team-click
(mf/use-fn #(st/emit! (modal/show :team-form {})))
on-team-click
@@ -290,18 +291,27 @@
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
on-create-org-click
(mf/use-fn
(fn []
(if (:nitrate-licence profile)
;; TODO update when org creation route is ready
(dom/open-new-window "/control-center/org/create")
(st/emit! (modal/show :nitrate-form {})))))]
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
(when show-default-team
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")]
(when (= (:default-team-id profile) (:id team))
tick-icon)]
[:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")]
(when (= (:default-team-id profile) (:id team))
tick-icon)])
(for [team-item (remove :is-default (vals teams))]
[:> dropdown-menu-item* {:on-click on-team-click
@@ -322,11 +332,19 @@
(when (= (:id team-item) (:id team))
tick-icon)])
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]]))
(when allow-create-teams
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-team-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]])
(when allow-create-org
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-org-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-org")]])]))
(mf/defc team-options-dropdown*
{::mf/private true}
@@ -476,9 +494,80 @@
:data-testid "delete-team"}
(tr "dashboard.delete-team")])]))
(mf/defc sidebar-org-switch*
[{:keys [team profile]}]
(let [teams (->> (mf/deref refs/teams)
vals
(group-by :organization-id)
(map (fn [[_group entries]] (first entries)))
vec
(d/index-by :id))
teams (update-vals teams
(fn [t]
(assoc t :name (str "ORG: " (:organization-name t)))))
team (assoc team :name (str "ORG: " (:organization-name team)))
show-teams-menu*
(mf/use-state false)
show-teams-menu?
(deref show-teams-menu*)
on-show-teams-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-teams-menu* not)))
on-show-teams-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click!)))))
close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))]
[:div {:class (stl/css :sidebar-team-switch)}
[:div {:class (stl/css :switch-content)}
[:button {:class (stl/css :current-team)
:on-click on-show-teams-click
:on-key-down on-show-teams-keydown}
[:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}]
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]]
arrow-icon]]
;; Teams Dropdown
[:> teams-selector-dropdown* {:show show-teams-menu?
:on-close close-teams-menu
:id "organizations-list"
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams
:show-default-team false
:allow-create-teams false
:allow-create-org true}]]))
(mf/defc sidebar-team-switch*
[{:keys [team profile]}]
(let [teams (mf/deref refs/teams)
(let [nitrate? (contains? cf/flags :nitrate)
org-id (when nitrate? (:organization-id team))
teams (cond->> (mf/deref refs/teams)
nitrate?
(filter #(= (-> % val :organization-id) org-id)))
subscription
(get team :subscription)
@@ -586,7 +675,10 @@
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams}]
:teams teams
:show-default-team true
:allow-create-teams true
:allow-create-org false}]
[:> team-options-dropdown* {:show show-team-options-menu?
:on-close close-team-options-menu
@@ -703,6 +795,8 @@
[:*
[:div {:class (stl/css-case :sidebar-content true)
:ref container}
(when (contains? cf/flags :nitrate)
[:> sidebar-org-switch* {:team team :profile profile}])
[:> sidebar-team-switch* {:team team :profile profile}]
[:> sidebar-search* {:search-term search-term

View File

@@ -44,7 +44,7 @@
{::mf/private true}
[{:keys [tokens-lib selected-token-set-id]}]
(let [selected-token-set
(mf/with-memo [tokens-lib]
(mf/with-memo [tokens-lib selected-token-set-id]
(when selected-token-set-id
(some-> tokens-lib (ctob/get-set selected-token-set-id))))
@@ -60,18 +60,20 @@
[:div {:class (stl/css :sets-header-container)}
[:> text* {:as "span"
:typography "headline-small"
:class (stl/css :sets-header)}
:class (stl/css :sets-header)
:data-testid "active-token-set-title"}
(tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
(when (and (some? selected-token-set-id)
(not (token-set-active? (ctob/get-name selected-token-set))))
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
;; NOTE: when no set in tokens-lib, the selected-token-set-id
;; will be `nil`, so for properly hide the inactive message we
;; check that at least `selected-token-set-id` has a value
(when (and (some? selected-token-set-id)
(not (token-set-active? (ctob/get-name selected-token-set))))
[:*
[:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}]
[:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)}
(tr "workspace.tokens.inactive-set")]])]]))
(tr "workspace.tokens.inactive-set")]]])]))
(mf/defc tokens-section*
{::mf/private true}
@@ -135,7 +137,7 @@
[:*
[:& token-context-menu]
[:& selected-set-info* {:tokens-lib tokens-lib
[:> selected-set-info* {:tokens-lib tokens-lib
:selected-token-set-id selected-token-set-id}]
(for [type filled-group]

View File

@@ -433,6 +433,9 @@ msgstr "(copy)"
msgid "dashboard.create-new-team"
msgstr "Create new team"
msgid "dashboard.create-new-org"
msgstr "Create new org"
#: src/app/main/ui/workspace/main_menu.cljs:661
msgid "dashboard.create-version-menu"
msgstr "Pin this version"
@@ -7797,10 +7800,6 @@ msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved va
msgid "workspace.tokens.create-new-theme"
msgstr "Create your first theme now."
#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs
msgid "workspace.tokens.warning-name-change"
msgstr "Renaming this token will break any reference to its old name"
#: src/app/main/ui/workspace/tokens/sets/lists.cljs:96, src/app/main/ui/workspace/tokens/themes.cljs:44
msgid "workspace.tokens.create-one"
msgstr "Create one."

View File

@@ -442,6 +442,9 @@ msgstr "(copia)"
msgid "dashboard.create-new-team"
msgstr "Crear nuevo equipo"
msgid "dashboard.create-new-org"
msgstr "Crear nueva organización"
#: src/app/main/ui/workspace/main_menu.cljs:661
msgid "dashboard.create-version-menu"
msgstr "Guardar esta versión"
@@ -4575,10 +4578,6 @@ msgstr "Mostrar/ocultar recursos"
msgid "shortcuts.toggle-colorpalette"
msgstr "Mostrar/ocultar paleta de colores"
#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs
msgid "workspace.tokens.warning-name-change"
msgstr "Cambiar el nombre de este token romperá cualquier referencia a su nombre anterior."
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:185
msgid "shortcuts.toggle-focus-mode"
msgstr "Mostrar/ocultar focus mode"