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

This commit is contained in:
Alejandro Alonso 2026-08-11 12:47:36 +02:00
commit 0de47302a6
44 changed files with 1292 additions and 210 deletions

View File

@ -1,11 +1,308 @@
;; Example rlimit.edn file
^{:refresh "30s"}
{:default
[[:default :window "200000/h"]]
;; #{:main/get-teams}
;; [[:burst :bucket "5/5/5s"]]
;; ═══════════════════════════════════════════════
;; Auth & Identity — public, unauthenticated
;; ═══════════════════════════════════════════════
#{:main/login-with-password}
[[:auth-password :bucket "100/50/1m"]]
;; #{:main/get-profile}
;; [[:burst :bucket "60/60/1m"]]
}
#{:main/login-with-ldap}
[[:auth-ldap :bucket "20/10/5m"]]
#{:main/register-profile}
[[:auth-register :bucket "20/10/15m"]]
#{:main/request-profile-recovery
:main/prepare-register-profile}
[[:auth-recovery :bucket "100/50/5m"]]
#{:main/recover-profile
:main/verify-token}
[[:auth-token :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; SSRF vectors — URL fetch endpoints
;; ═══════════════════════════════════════════════
#{:main/create-file-media-object-from-url}
[[:url-fetch :bucket "100/50/5m"]]
#{:main/create-webhook
:main/update-webhook}
[[:webhook-validation :bucket "20/10/5m"]]
;; ═══════════════════════════════════════════════
;; Search — full sequential scan risk
;; ═══════════════════════════════════════════════
#{:main/search-files}
[[:search :bucket "60/30/1m"]]
;; ═══════════════════════════════════════════════
;; Feedback & Invitations — email-sending
;; ═══════════════════════════════════════════════
#{:main/send-user-feedback
:main/create-team-invitations}
[[:email-send :bucket "30/15/5m"]]
;; ═══════════════════════════════════════════════
;; Media & File heavy ops
;; ═══════════════════════════════════════════════
#{:main/upload-file-media-object}
[[:image-upload :bucket "200/100/1m"]]
#{:main/create-file-object-thumbnail
:main/delete-file-object-thumbnails
:main/get-file-object-thumbnails}
[[:thumbnail-ops :bucket "5000/3000/1m"]]
#{:main/get-file-data-for-thumbnail
:main/create-file-thumbnail}
[[:thumbnail-data :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; UI navigation reads — high frequency
;; ═══════════════════════════════════════════════
#{:main/get-teams}
[[:get-teams :bucket "5000/2500/30s"]]
#{:main/get-team-members}
[[:get-team-members :bucket "4000/2000/30s"]]
#{:main/get-profile}
[[:get-profile :bucket "500/250/30s"]]
#{:main/get-font-variants}
[[:get-font-variants :bucket "250/125/30s"]]
#{:main/get-comment-threads}
[[:get-comment-threads :bucket "500/250/30s"]]
#{:main/get-profiles-for-file-comments}
[[:get-profiles-for-file-comments :bucket "300/150/30s"]]
#{:main/get-file-libraries}
[[:get-file-libraries :bucket "200/100/30s"]]
#{:main/get-projects}
[[:get-projects :bucket "120/60/30s"]]
#{:main/get-team-recent-files
:main/get-unread-comment-threads}
[[:get-team-recent :bucket "120/60/30s"]]
#{:main/get-page}
[[:get-page :bucket "150/75/30s"]]
#{:main/get-access-tokens
:main/get-subscription-usage}
[[:get-access-tokens :bucket "150/75/30s"]]
#{:main/get-enabled-flags}
[[:get-enabled-flags :bucket "250/125/30s"]]
#{:main/get-builtin-templates}
[[:get-builtin-templates :bucket "200/100/30s"]]
#{:main/get-project
:main/get-project-files}
[[:get-project-info :bucket "80/40/30s"]]
#{:main/get-file}
[[:get-file :bucket "180/90/1m"]]
#{:main/get-team-shared-files
:main/get-team-info
:main/get-team-users
:main/get-team-invitations
:main/get-team-deleted-files
:main/get-sso-provider}
[[:get-team-info :bucket "60/30/30s"]]
#{:main/get-comments
:main/get-file-snapshots
:main/get-library-usage
:main/has-file-libraries}
[[:get-misc-list :bucket "300/150/30s"]]
#{:main/get-comment-thread
:main/get-library-file-references}
[[:get-misc-single :bucket "60/30/30s"]]
#{:main/get-file-info
:main/get-view-only-bundle
:main/get-all-projects
:main/get-owned-teams
:main/get-team-stats
:main/get-file-summary
:main/get-file-stats
:main/get-file-fragment}
[[:get-light :bucket "60/30/30s"]]
;; ═══════════════════════════════════════════════
;; File mutations — editing active
;; ═══════════════════════════════════════════════
#{:main/update-file}
[[:update-file :bucket "1000/500/1m"]]
#{:main/create-file
:main/rename-file
:main/duplicate-file
:main/move-files}
[[:file-create :bucket "60/30/1m"]]
#{:main/delete-file}
[[:file-delete :bucket "80/40/1m"]]
#{:main/set-file-shared
:main/update-file-library-sync-status
:main/ignore-file-library-sync-status
:main/link-file-to-library
:main/unlink-file-from-library
:main/create-file-snapshot
:main/restore-file-snapshot
:main/update-file-snapshot
:main/delete-file-snapshot
:main/lock-file-snapshot
:main/unlock-file-snapshot}
[[:file-mutations :bucket "80/40/1m"]]
;; ═══════════════════════════════════════════════
;; Project mutations
;; ═══════════════════════════════════════════════
#{:main/create-project}
[[:project-create :bucket "100/50/1m"]]
#{:main/delete-project
:main/rename-project
:main/duplicate-project
:main/move-project
:main/update-project-pin}
[[:project-mutations :bucket "40/20/1m"]]
;; ═══════════════════════════════════════════════
;; Team mutations
;; ═══════════════════════════════════════════════
#{:main/create-team
:main/update-team
:main/delete-team
:main/update-team-photo
:main/update-team-member-role
:main/delete-team-member
:main/leave-team
:main/create-team-with-invitations
:main/create-team-access-request
:main/permanently-delete-team-files
:main/restore-deleted-team-files}
[[:team-mutations :bucket "60/30/1m"]]
;; ═══════════════════════════════════════════════
;; Comment operations
;; ═══════════════════════════════════════════════
#{:main/create-comment-thread
:main/create-comment
:main/update-comment
:main/delete-comment
:main/mark-all-threads-as-read}
[[:comment-basic :bucket "30/15/1m"]]
#{:main/update-comment-thread
:main/update-comment-thread-status
:main/update-comment-thread-position
:main/update-comment-thread-frame
:main/delete-comment-thread}
[[:comment-thread :bucket "80/40/1m"]]
;; ═══════════════════════════════════════════════
;; Profile operations
;; ═══════════════════════════════════════════════
#{:main/update-profile
:main/update-profile-props
:main/update-profile-photo
:main/update-profile-password
:main/update-profile-notifications
:main/delete-profile
:main/delete-profile-photo
:main/request-email-change}
[[:profile-mutations :bucket "30/15/1m"]]
;; ═══════════════════════════════════════════════
;; Font operations
;; ═══════════════════════════════════════════════
#{:main/create-font-variant
:main/delete-font
:main/delete-font-variant
:main/update-font
:main/download-font
:main/download-font-family}
[[:font-ops :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; Access tokens
;; ═══════════════════════════════════════════════
#{:main/create-access-token
:main/delete-access-token}
[[:access-token :bucket "60/30/1m"]]
;; ═══════════════════════════════════════════════
;; Export / Import
;; ═══════════════════════════════════════════════
#{:main/export-binfile
:main/import-binfile
:main/clone-template}
[[:export-import :bucket "80/40/1m"]]
;; ═══════════════════════════════════════════════
;; Upload sessions
;; ═══════════════════════════════════════════════
#{:main/create-upload-session
:main/upload-chunk
:main/assemble-file-media-object}
[[:upload-session :bucket "100/50/1m"]]
;; ═══════════════════════════════════════════════
;; Webhooks
;; ═══════════════════════════════════════════════
#{:main/get-webhooks
:main/delete-webhook}
[[:webhook-read :bucket "20/10/1m"]]
;; ═══════════════════════════════════════════════
;; Share links
;; ═══════════════════════════════════════════════
#{:main/create-share-link
:main/delete-share-link}
[[:share-link :bucket "10/5/1m"]]
;; ═══════════════════════════════════════════════
;; Organization operations
;; ═══════════════════════════════════════════════
#{:main/add-team-to-organization
:main/remove-team-from-org
:main/all-org-members-in-team
:main/all-team-members-in-orgs
:main/get-owned-organizations-summary
:main/get-leave-org-summary
:main/leave-org
:main/check-org-members
:main/get-team-invitation-token
:main/delete-team-invitation
:main/check-team-external-invitations}
[[:org-ops :bucket "20/10/1m"]]
;; ═══════════════════════════════════════════════
;; Audit & stats
;; ═══════════════════════════════════════════════
#{:main/push-audit-events}
[[:audit-events :bucket "1000/500/1m"]]
#{:main/logout
:main/get-error-report
:main/get-error-reports
:main/get-current-mcp-token
:main/get-nitrate-connectivity
:main/check-nitrate-sso
:main/redeem-nitrate-activation-code
:main/create-demo-profile
:main/get-subscription-warning}
[[:misc-light :bucket "100/50/1m"]]}

View File

@ -650,6 +650,13 @@
(assoc :query (u/map->query-string params)))]
(redirect-response uri))))
(defn- redirect-with-organization-sso-error
[{:keys [dest-url organization-id]}]
(-> (str (or dest-url (cf/get :public-uri)))
(u/append-query-param :sso-error true)
(u/append-query-param :organization-id organization-id)
(redirect-response)))
(defn- redirect-to-register
[cfg info provider]
(let [info (assoc info
@ -887,6 +894,39 @@
{::yres/status 200
::yres/body {:redirect-uri uri}}))
(defn- organization-sso-callback-handler
"Handle the organization-SSO branch of the OIDC callback: state carries
:dest-url exchange the authorization code with the OIDC provider to
verify authentication actually occurred, then redirect back to dest-url."
[cfg request state code]
(let [dest-url (:dest-url state)]
(try
(let [organization-id (:organization-id state)
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
provider (prepare-organization-sso-provider cfg sso)
info (get-info cfg provider state code)
session (session/get-session request)
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(redirect-response dest-url))
(catch Throwable cause
(let [{:keys [code]} (ex-data cause)]
(binding [l/*context* (errors/request->context request)]
(if (some? code)
(l/warn :hint "organization sso callback failed"
:code code
:message (ex-message cause)
:organization-id (:organization-id state))
(l/err :hint "unexpected error on organization sso callback"
:organization-id (:organization-id state)
:cause cause))))
(redirect-with-organization-sso-error
{:dest-url dest-url
:organization-id (:organization-id state)})))))
(defn- callback-handler
[cfg {:keys [params] :as request}]
(if-let [error (get params :error)]
@ -898,18 +938,8 @@
;; Organization SSO flow: state carries :dest-url — exchange the authorization
;; code with the OIDC provider to verify authentication actually occurred.
(if-let [dest-url (:dest-url state)]
(let [organization-id (:organization-id state)
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
provider (prepare-organization-sso-provider cfg sso)
info (get-info cfg provider state code)
session (session/get-session request)
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(redirect-response dest-url))
(if (:dest-url state)
(organization-sso-callback-handler cfg request state code)
(let [provider (resolve-provider cfg state)
info (get-info cfg provider state code)

View File

@ -24,7 +24,8 @@
(:import
io.undertow.server.RequestTooBigException
java.io.InputStream
java.io.OutputStream))
java.io.OutputStream
java.security.MessageDigest))
(set! *warn-on-reflection* true)
@ -329,6 +330,11 @@
{:name ::auth
:compile (constantly wrap-auth)})
(defn- constant-time-eq?
"Compare strings in constant time to prevent timing attacks."
[^String a ^String b]
(MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8")))
(defn- wrap-shared-key-auth
[handler keys]
(if (seq keys)
@ -338,7 +344,7 @@
(let [key-id (-> key-id str/lower keyword)]
(if (and (string? key)
(contains? keys key-id)
(= key (get keys key-id)))
(constant-time-eq? key (get keys key-id)))
(-> request
(assoc ::http/auth-key-id key-id)
(handler))

View File

@ -226,6 +226,14 @@
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count))))
(defn invalidate-all
"Delete all sessions for a given profile. Used when a profile is deleted
to ensure immediate access revocation across all devices."
[cfg profile-id]
(let [sql "delete from http_session_v2 where profile_id = ?"]
(-> (db/exec-one! cfg [sql profile-id])
(db/get-update-count))))
(def ^:private sql:clear-organization-sso-sessions
(str "UPDATE http_session_v2 "
"SET props = props #- ARRAY['~:sso', ?]::text[] "

View File

@ -7,6 +7,7 @@
(ns app.http.websocket
"A penpot notification service for file cooperative edition."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@ -17,6 +18,8 @@
[app.http.session :as session]
[app.metrics :as mtx]
[app.msgbus :as mbus]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.util.websocket :as ws]
[integrant.core :as ig]
[promesa.exec.csp :as sp]
@ -131,8 +134,9 @@
(mbus/pub! msgbus :topic topic :message msg))))
(defmethod handle-message :subscribe-team
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
(teams/check-read-permissions! pool profile-id team-id)
(let [prev-subs (get @state ::team-subscription)
channel (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
@ -150,8 +154,10 @@
(defmethod handle-message :subscribe-file
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
(bfc/check-file-exists pool file-id)
(files/check-read-permissions! pool profile-id file-id)
(let [psub (::file-subscription @state)
fch (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]

View File

@ -167,6 +167,9 @@
[:id ::sm/uuid]
[:name ::sm/text]
[:owner-id ::sm/uuid]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:sso-active {:optional true} [:maybe ::sm/boolean]]
[:teams
[:vector
[:map
@ -259,6 +262,14 @@
(generate-nitrate-uri "api/teams/" team-id)
cto/schema:team-with-organization params))
(defn- get-teams-organizations-api
[cfg {:keys [team-ids] :as params}]
(let [params (assoc params :request-params {:team-ids team-ids})]
(request-to-nitrate cfg :post
(generate-nitrate-uri "api/teams/organizations")
[:vector cto/schema:team-with-organization]
params)))
(defn- get-organization-membership-api
[cfg {:keys [profile-id organization-id] :as params}]
(request-to-nitrate cfg :get
@ -489,6 +500,7 @@
[_ cfg]
(when (contains? cf/flags :admin-console)
{:get-team-organization (partial get-team-organization-api cfg)
:get-teams-organizations (partial get-teams-organizations-api cfg)
:set-team-organization (partial set-team-organization-api cfg)
:get-organization-membership (partial get-organization-membership-api cfg)
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
@ -596,22 +608,25 @@
:cause cause)
profile)))))
(defn- apply-organization-info-to-team
[team team-with-organization]
(let [organization (:organization team-with-organization)]
(if (some? organization)
(-> (cto/apply-organization team (assoc organization :custom-photo
(when-let [logo-id (:logo-id organization)]
(generate-public-uri "assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
team)))
(defn add-organization-info-to-team
"Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or organization data is nil.
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
[cfg team params]
(try
(let [params (assoc (or params {}) :team-id (:id team))
team-with-organization (call cfg :get-team-organization params)
organization (:organization team-with-organization)]
(if (some? organization)
(-> (cto/apply-organization team (assoc organization :custom-photo
(when-let [logo-id (:logo-id organization)]
(generate-public-uri "assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
team))
(let [params (assoc (or params {}) :team-id (:id team))
team-with-organization (call cfg :get-team-organization params)]
(apply-organization-info-to-team team team-with-organization))
(catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type))
(throw cause)
@ -621,6 +636,23 @@
:cause cause)
team)))))
(defn add-organization-info-to-teams
"Enriches teams with organization information using one batched Nitrate request.
Teams absent from the Nitrate response are returned unchanged.
Rejects the request when Nitrate does not return a valid batch response."
[cfg teams params]
(let [request-params (assoc (or params {}) :team-ids (mapv :id teams))
teams-with-organization (call cfg :get-teams-organizations request-params)]
(when (nil? teams-with-organization)
(ex/raise :type :nitrate-unavailable
:hint "nitrate did not return a valid teams organization response"))
(let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)]
(mapv (fn [{:keys [id] :as team}]
(if-let [team-with-organization (get organizations-by-team id)]
(apply-organization-info-to-team team team-with-organization)
team))
teams))))
(defn set-team-organization
"Associates a team with an organization in Nitrate.
Requires organization-id and is-default in params.
@ -637,3 +669,17 @@
:context {:team-id (:id team)
:organization-id (:organization-id params)}))
team))
(defn assert-membership
"Verifies that the user is a member of the organization.
Raises an exception if the organization doesn't exist or the user is not a member."
[cfg profile-id organization-id]
(let [membership (call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})]
(when-not (:organization-id membership)
(ex/raise :type :validation
:code :organization-does-not-exist))
(when-not (:is-member membership)
(ex/raise :type :validation
:code :user-doesnt-belong-organization))))

View File

@ -43,7 +43,7 @@
[conn {:keys [profile-id file-id pages who-comment who-inspect]}]
(let [pages (db/create-array conn "uuid" pages)
slink (db/insert! conn :share-link
{:id (uuid/next)
{:id (uuid/random)
:file-id file-id
:who-comment who-comment
:who-inspect who-inspect

View File

@ -41,17 +41,6 @@
(ex/raise :type :validation
:code :cant-move-default-team))))
(defn assert-membership [cfg profile-id organization-id]
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
:organization-id organization-id})]
(when-not (:organization-id membership)
(ex/raise :type :validation
:code :organization-does-not-exist))
(when-not (:is-member membership)
(ex/raise :type :validation
:code :user-doesnt-belong-organization))))
(def schema:connectivity
[:map {:title "nitrate-connectivity"}
@ -335,7 +324,7 @@
(when-not skip-validation
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
(assert-membership cfg profile-id id)
(nitrate/assert-membership cfg profile-id id)
;; delete only eligible teams (non-protected and without files)
(doseq [id deletable-team-ids]
@ -421,7 +410,7 @@
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
;; Check moveTeams permission on the source organization
(when (contains? cf/flags :admin-console)
(let [organization-perms (nitrate/call cfg :get-organization-permissions
@ -491,7 +480,7 @@
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :admin-console)
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
@ -575,7 +564,7 @@
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
(or (when (contains? cf/flags :admin-console)
(assert-membership cfg profile-id organization-id)
(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)
@ -603,7 +592,7 @@
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(nitrate/assert-membership cfg profile-id organization-id)
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
organization-member-ids (into #{} organization-members)
team-members (db/query cfg :team-profile-rel {:team-id team-id})
@ -631,7 +620,7 @@
(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 organizations before fetching members.
(run! #(assert-membership cfg profile-id %) organization-ids)
(run! #(nitrate/assert-membership cfg profile-id %) organization-ids)
(into {}
(map (fn [organization-id]
@ -664,7 +653,7 @@
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(nitrate/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}))

View File

@ -534,6 +534,10 @@
:deleted-at deleted-at
:id profile-id}})
;; Invalidate all sessions for this profile to ensure immediate
;; access revocation across all devices
(session/invalidate-all cfg profile-id)
(-> (rph/wrap nil)
(rph/with-transform (session/delete-fn cfg)))))

View File

@ -196,11 +196,11 @@
::sm/params schema:get-teams}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
(dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id)
(contains? cf/flags :admin-console)
(map #(nitrate/add-organization-info-to-team cfg % params))
(contains? cf/flags :admin-console)
(remove #(get-in % [:organization :expired-license])))))
(let [teams (get-teams conn profile-id)]
(if (contains? cf/flags :admin-console)
(->> (nitrate/add-organization-info-to-teams cfg teams params)
(remove #(get-in % [:organization :expired-license])))
teams))))
(def ^:private sql:get-owned-teams
"SELECT t.id, t.name,
@ -538,6 +538,9 @@
;; When creating inside an organization, verify the user has permission to do so.
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
(when (and organization-id (contains? cf/flags :admin-console))
;; Verify caller is a member of the organization
(nitrate/assert-membership cfg profile-id organization-id)
(let [organization-perms (nitrate/call cfg :get-organization-permissions
{:organization-id organization-id})]
(if (nil? organization-perms)

View File

@ -488,6 +488,21 @@ RETURNING id, deleted_at;")
;; API: invite-to-organization
(defn- get-invitation-organization
[cfg profile-id organization-id]
(let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]}
(nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
(when-not (= profile-id owner-id)
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))
{:id id
:name name
:initials (if logo-id "" (d/get-initials name))
:logo (when logo-id (files/resolve-public-uri logo-id))
:avatar-bg-url (when-not logo-id avatar-bg-url)
:sso-active (true? sso-active)}))
(sv/defmethod ::invite-to-organization
"Invite to organization"
{::doc/added "2.15"
@ -495,8 +510,11 @@ RETURNING id, deleted_at;")
[:email ::sm/email]
[:organization cto/schema:organization-with-avatar]]
::nitrate/sso false}
[cfg params]
(db/tx-run! cfg ti/create-organization-invitation params)
[cfg {profile-id ::rpc/profile-id
:keys [organization]
:as params}]
(let [organization (get-invitation-organization cfg profile-id (:id organization))]
(db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization)))
nil)

View File

@ -190,6 +190,7 @@
:allowed allowed?
:remaining remaining)
(-> limit
(assoc ::lresult/now now)
(assoc ::lresult/allowed allowed?)
(assoc ::lresult/reset (ct/plus now reset))
(assoc ::lresult/remaining remaining))))
@ -212,6 +213,7 @@
:allowed allowed?
:remaining remaining)
(-> limit
(assoc ::lresult/now now)
(assoc ::lresult/allowed allowed?)
(assoc ::lresult/timestamp ts)
(assoc ::lresult/remaining remaining)

View File

@ -733,7 +733,7 @@
(t/is (= 2 (count rows)))
(t/is (= 1 (count (remove (comp some? :deleted-at) rows))))
(t/is (= (thc/fmt-object-id file-id page-id frame-id-1 "frame")
(-> rows first :object-id))))
(->> rows (remove (comp some? :deleted-at)) first :object-id))))
;; Now that file-gc have marked for deletion the object
;; thumbnail lets execute the objects-gc task which remove
@ -2377,8 +2377,6 @@
(let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata))))))
;; --- Security Fix Tests ---
(t/deftest link-file-to-library-circular-reference
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:profile-id (:id profile)
@ -2448,3 +2446,24 @@
(t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata))))))
(t/deftest get-file-libraries-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-file-libraries
::rpc/profile-id (:id prof)
:file-id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-file-libraries-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})
out (th/command! {::th/type :get-file-libraries
::rpc/profile-id (:id other)
:file-id (:id file)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))

View File

@ -650,3 +650,24 @@
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code)))))))
(t/deftest get-font-variants-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id prof)
:file-id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-font-variants-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id other)
:file-id (:id file)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))

View File

@ -50,45 +50,138 @@
(t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest create-and-update-organization-invitations-audit-props
(let [owner-id-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method params]
(when (= method :get-organization-summary)
{:id (:organization-id params)
:name "Acme"
:owner-id @owner-id-ref
:teams []}))}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
organization {:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}
_ (reset! owner-id-ref (:id owner))
params {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email (:email invitee)
:organization organization}
create-out (th/management-command! params)
update-out (th/management-command! params)
external-out (th/management-command! (assoc params :email "external@example.com"))
events (mapv second (:call-args-list @audit-mock))
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
external-event
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
(t/is (th/success? create-out))
(t/is (th/success? update-out))
(t/is (th/success? external-out))
(doseq [event [create-event update-event]]
(t/is (not (contains? (:props event) :event-origin)))
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (= (:id organization)
(get-in event [:props :organization-id])))
(t/is (= (:email invitee)
(get-in event [:props :member-email])))
(t/is (= (:id invitee)
(get-in event [:props :member-id]))))
(t/is (not (contains? (:props external-event) :member-id))))))))
(t/deftest invite-to-organization-rejects-non-owner
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(let [owner (th/create-profile* 103 {:is-active true})
attacker (th/create-profile* 104 {:is-active true})
organization-id (uuid/random)
organization {:id organization-id
:name "Trusted Organization"
:initials "TO"
:logo nil
:avatar-bg-url nil}
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id attacker)
:email "victim@example.com"
:organization organization})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock)))))))
(t/deftest invite-to-organization-rejects-unknown-organization
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
organization {:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}
params {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email (:email invitee)
:organization organization}
create-out (th/management-command! params)
update-out (th/management-command! params)
external-out (th/management-command! (assoc params :email "external@example.com"))
events (mapv second (:call-args-list @audit-mock))
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
external-event
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
(t/is (th/success? create-out))
(t/is (th/success? update-out))
(t/is (th/success? external-out))
(let [profile (th/create-profile* 105 {:is-active true})
organization-id (uuid/random)
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id profile)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Organization"
:initials "FO"
:logo "https://evil.example/logo.png"
:avatar-bg-url nil}})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :object-not-found (th/ex-code (:error out))))
(t/is (not (:called? @email-mock))))))
(doseq [event [create-event update-event]]
(t/is (not (contains? (:props event) :event-origin)))
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (= (:id organization)
(get-in event [:props :organization-id])))
(t/is (= (:email invitee)
(get-in event [:props :member-email])))
(t/is (= (:id invitee)
(get-in event [:props :member-id]))))
(t/is (not (contains? (:props external-event) :member-id)))))))
(t/deftest invite-to-organization-uses-authoritative-branding
(let [organization-summary-ref (atom nil)]
(with-mocks [email-mock {:target 'app.email/send! :return nil}
nitrate-mock {:target 'app.nitrate/call
:return (fn [_cfg method _params]
(when (= method :get-organization-summary)
@organization-summary-ref))}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 106 {:is-active true})
organization-id (uuid/random)
logo-id (uuid/random)
_ (reset! organization-summary-ref
{:id organization-id
:name "Trusted Organization"
:owner-id (:id owner)
:logo-id logo-id
:avatar-bg-url "https://trusted.example/avatar.svg"
:sso-active true
:teams []})
out (th/management-command! {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email "victim@example.com"
:organization {:id organization-id
:name "Fabricated Bank"
:initials "FB"
:logo "https://evil.example/logo.png"
:avatar-bg-url "https://evil.example/avatar.svg"
:sso-active false}})
email-params (first (:call-args @email-mock))
organization (:organization email-params)]
(t/is (th/success? out))
(t/is (= "Trusted Organization" (:name organization)))
(t/is (= "" (:initials organization)))
(t/is (str/ends-with? (:logo organization)
(str "/assets/by-id/" logo-id)))
(t/is (nil? (:avatar-bg-url organization)))
(t/is (true? (:sso-active organization))))))))
(t/deftest get-penpot-version
(let [out (th/management-command! {::th/type :get-penpot-version})

View File

@ -50,7 +50,16 @@
:organization (organization-data organization-id organization-owner-id)}
{:id (:team-id params)
:is-your-penpot false
:organization nil})))]
:organization nil})
:get-teams-organizations
(->> (:team-ids params)
(keep (fn [candidate-team-id]
(when (= team-id candidate-team-id)
{:id team-id
:is-your-penpot false
:organization (organization-data organization-id organization-owner-id)})))
vec)))]
(f)))
(defn- with-captured-messages

View File

@ -388,6 +388,31 @@
(let [result (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 10 (:processed result))))))
(t/deftest profile-deletion-invalidates-all-sessions
(let [prof (th/create-profile* 1)
;; Insert 3 sessions for this profile directly into the database
session-ids (doall
(for [i (range 3)]
(let [sid (uuid/random)]
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
sid (:id prof) (str "user-agent-" i)])
sid)))]
;; Verify sessions exist
(let [count-before (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
(t/is (= 3 count-before)))
;; Request profile to be deleted
(let [params {::th/type :delete-profile
::rpc/profile-id (:id prof)}
out (th/command! params)]
(t/is (nil? (:error out))))
;; Verify ALL sessions were invalidated (not just one)
(let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
(t/is (= 0 count-after)))))
(t/deftest email-blacklist-1
(t/is (false? (email.blacklist/enabled? th/*system*)))

View File

@ -241,3 +241,24 @@
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))))
(t/deftest get-project-nonexistent
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-project
::rpc/profile-id (:id prof)
:id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-project-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
proj (th/create-project* 1 {:profile-id (:id owner)
:team-id (:default-team-id owner)})
out (th/command! {::th/type :get-project
::rpc/profile-id (:id other)
:id (:id proj)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))

View File

@ -719,6 +719,67 @@
(t/is (not= (:default-team-id profile1) (:id item1))))))
(t/deftest get-teams-fetches-organizations-in-one-batch
(let [profile (th/create-profile* 1 {:is-active true})
organization-team (th/create-team* 1 {:profile-id (:id profile)})
plain-team (th/create-team* 2 {:profile-id (:id profile)})
expired-team (th/create-team* 3 {:profile-id (:id profile)})
organization-id (uuid/random)
calls (atom [])
organization {:id organization-id
:name "Acme"
:slug "acme"
:owner-id (:id profile)
:avatar-bg-url "https://example.com/avatar.svg"}
nitrate-call (fn [_cfg method params]
(swap! calls conj [method params])
[{:id (:id organization-team)
:is-your-penpot false
:organization organization}
{:id (:id expired-team)
:is-your-penpot false
:organization (assoc organization :expired-license true)}])
params {::th/type :get-teams
::rpc/profile-id (:id profile)}]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call nitrate-call]
(let [out (th/command! params)
teams (:result out)]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= :get-teams-organizations (ffirst @calls)))
(t/is (= #{(:default-team-id profile)
(:id organization-team)
(:id plain-team)
(:id expired-team)}
(-> @calls first second :team-ids set)))
(t/is (= #{(:default-team-id profile)
(:id organization-team)
(:id plain-team)}
(into #{} (map :id) teams)))
(t/is (= organization
(->> teams
(filter #(= (:id organization-team) (:id %)))
first
:organization)))))))
(t/deftest get-teams-rejects-invalid-organization-batch-response
(let [profile (th/create-profile* 1 {:is-active true})
calls (atom [])
params {::th/type :get-teams
::rpc/profile-id (:id profile)}]
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call (fn [_cfg method call-params]
(swap! calls conj [method call-params])
nil)]
(let [out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (= :nitrate-unavailable (th/ex-type (:error out))))
(t/is (= 1 (count @calls)))
(t/is (= :get-teams-organizations (ffirst @calls)))))))
(t/deftest team-deletion-1
(let [profile1 (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
@ -1096,3 +1157,53 @@
:name "My Valid Team"}
out (th/command! data)]
(t/is (th/success? out)))))
(t/deftest create-team-in-organization-regression
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
(let [owner (th/create-profile* 401 {:is-active true})
non-member (th/create-profile* 402 {:is-active true})
organization-id (uuid/random)
params {::th/type :create-team
::rpc/profile-id (:id owner)
:name "Test Team"
:organization-id organization-id}
nitrate-call-fn
(fn [_cfg method p]
(case method
:get-organization-membership
(if (= (:profile-id p) (:id non-member))
{:organization-id organization-id :is-member false}
{:organization-id organization-id :is-member true})
:get-organization-permissions
{:owner-id (:id owner)
:permissions {:create-teams "any"}}
:set-team-organization
(let [team-id (:team-id p)]
{:id team-id
:name "Test Team"
:organization-id organization-id
:default-project-id (uuid/random)})
nil))]
;; Non-member should be denied with :user-doesnt-belong-organization
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call nitrate-call-fn]
(let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))]
(t/is (not (th/success? out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata)))
(t/is (= :user-doesnt-belong-organization (:code edata))))))
;; Authorized member should succeed
(th/reset-mock! audit-mock)
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call nitrate-call-fn]
(let [out (th/command! params)]
(t/is (th/success? out))
(let [team (:result out)]
(t/is (uuid? (:id team)))
(t/is (= "Test Team" (:name team)))))))))

View File

@ -147,7 +147,6 @@
:render-switch
:hide-release-modal
:subscriptions
:subscriptions-old
:inspect-styles
;; Enable performance logs in devconsole (disabled by default)
:perf-logs

View File

@ -2345,7 +2345,12 @@
updated-sync-groups (into #{}
(keep #(ctk/resolve-sync-group (:type previous-shape) %))
updated-attrs)
new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups)
text-sub-touched #{:text-content-text :text-content-attribute :text-content-structure}
new-touched (set/union (or (:touched current-shape) #{})
updated-sync-groups
(when (contains? updated-sync-groups :content-group)
(set/intersection (or (:touched previous-shape) #{})
text-sub-touched)))
roperations (into [{:type :set-touched :touched new-touched}] roperations)
uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)]
(cond-> changes

View File

@ -67,6 +67,36 @@
path
(str path "/")))))
(defn- update-query-params
"Apply `f` to the query-params map of `url`, returning the updated URL string.
Handles both plain query strings and fragment-based (hash) URLs."
[url f]
(let [transform (fn [parsed]
(update parsed :query
(fn [q]
(-> (query-string->map (or q ""))
f
map->query-string))))
parsed (uri url)
fragment (:fragment parsed)]
(if (str/blank? fragment)
(str (transform parsed))
(-> parsed
(assoc :fragment (str (transform (parse fragment))))
str))))
(defn append-query-param
"Return a new URL string with the given query parameter added or replaced.
Handles both plain query strings and fragment-based (hash) URLs."
[url key value]
(update-query-params url #(assoc % key value)))
(defn remove-query-param
"Return a new URL string with the given query parameter removed.
Handles both plain query strings and fragment-based (hash) URLs."
[url key]
(update-query-params url #(dissoc % key)))
#?(:clj
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
(.write writer "#")

View File

@ -10,6 +10,7 @@
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
@ -3101,3 +3102,82 @@
(t/is (= 150 (:width rect02')))
(t/is (= (+ (:y copy02') 70) (:y rect02')))
(t/is (= (:y rect02') (get-in rect02' [:selrect :y])))))
;; ============================================================
;; PRESERVE TEXT SUB-TOUCHED FLAGS ACROSS VARIANT SWITCH
;; ============================================================
(t/deftest test-switch-preserves-text-sub-touched-flags
;; 1. Creates a component with text "hello world" + font-size "14", variant with font-size "20"
;; 2. Overrides only text on the copy → verifies :text-content-text in touched
;; 3. Switches to variant → verifies text override preserved, font-size updated, :text-content-text preserved
;; 4. Updates main font-size to "30" and syncs → verifies font-size synced but text override preserved
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
;; c01 has text "hello world" font-size "14"
;; c02 has text "hello world" font-size "20" (same text, different font-size)
(thv/add-variant-with-text
:v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world")
(update-attr :t02 font-size-path-0 "20")
(thc/instantiate-component :c01
:copy01
:children-labels [:copy-t01]))
;; Override only the TEXT on the copy (not font-size)
file (update-attr file :copy-t01 text-path-0 "custom text")
copy-t01 (ths/get-shape file :copy-t01)]
;; Verify the copy has the text override and correct touched flags
(t/is (= (get-in copy-t01 text-path-0) "custom text"))
(t/is (= (get-in copy-t01 font-size-path-0) "14"))
(t/is (contains? (:touched copy-t01) :content-group))
(t/is (contains? (:touched copy-t01) :text-content-text))
(t/is (not (contains? (:touched copy-t01) :text-content-attribute)))
(t/is (not (contains? (:touched copy-t01) :text-content-structure)))
;; ==== Action: Switch copy to c02 variant (same text, different font-size)
(let [file' (tho/swap-component-in-shape file :copy01 :c02
{:new-shape-label :copy02
:keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
copy-t02' (get-in page' [:objects (-> copy02' :shapes first)])]
;; After switch: text override preserved (same text between variants),
;; font-size updated from variant, touched preserves text-content-text
(t/is (= (get-in copy-t02' text-path-0) "custom text"))
(t/is (= (get-in copy-t02' font-size-path-0) "20"))
(t/is (contains? (:touched copy-t02') :content-group))
(t/is (contains? (:touched copy-t02') :text-content-text))
(t/is (not (contains? (:touched copy-t02') :text-content-attribute)))
;; ==== Now test subsequent component sync
;; Modify the main component's font-size to "30" (keeping text "hello world")
(let [main-text (ths/get-shape file' :t02)
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page'))
#{(:id main-text)}
(fn [shape]
(assoc-in shape font-size-path-0 "30"))
(:objects page')
{})
updated-file (thf/apply-changes file' changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :c02)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
synced-file (thf/apply-changes updated-file changes2)
synced-copy (ths/get-shape synced-file :copy02)
synced-t (get-in (thf/current-page synced-file)
[:objects (-> synced-copy :shapes first)])]
;; The text override is preserved and font-size is synced
(t/is (= (get-in synced-t text-path-0) "custom text"))
(t/is (= (get-in synced-t font-size-path-0) "30"))
(t/is (contains? (:touched synced-t) :content-group))
(t/is (contains? (:touched synced-t) :text-content-text))))))

View File

@ -1,16 +1,19 @@
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass $PENPOT_MCP_URI_WS;
set $mcp_ws_backend $PENPOT_MCP_URI_WS;
proxy_pass $mcp_ws_backend;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass $PENPOT_MCP_URI/mcp;
set $mcp_stream_backend $PENPOT_MCP_URI/mcp$is_args$args;
proxy_pass $mcp_stream_backend;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass $PENPOT_MCP_URI/sse;
set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args;
proxy_pass $mcp_sse_backend;
proxy_http_version 1.1;
}

View File

@ -0,0 +1,110 @@
import { test, expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
// ---------------------------------------------------------------------------
// BUG 10925 - Font family typography asset must not persist across files in
// newly created text layers.
//
// `save-font` writes the current font (plus the typography refs of the edited
// shape, when it uses one) into the session-global `:workspace-global
// :default-font`. That state is what seeds the content of brand-new text
// shapes via `v2-default-text-content`. Because it is session-global it
// survives a file switch, so a text created in file B could end up referencing
// a typography asset that only exists in file A (see workspace/texts.cljs
// save-font and workspace.cljs initialize/finalize-workspace).
//
// This E2E reproduces the leak faithfully in a single SPA session:
// 1. Open file A (has a text shape linked to a typography asset).
// 2. Change a font attribute on that shape (triggers `emit-update!` ->
// `save-font` with the current text-node attrs, typography refs included).
// 3. Switch to file B (same session, fragment navigation keeps JS state).
// 4. Create a brand-new text layer in file B.
// 5. Assert the new text uses the DEFAULT Penpot font ("Source Sans Pro"),
// not the typography font-family carried over from file A.
// ---------------------------------------------------------------------------
const FILE_A = {
id: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef",
pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0",
// "Text with typography asset one" carries a ref to in-file typography whose
// font-family is "IM Fell French Canon SC" (multiselection-typography.json).
};
const FILE_B = {
id: "434b0541-fa2f-802f-8006-59827d964a9b",
pageId: "434b0541-fa2f-802f-8006-59827d964a9c",
// render-wasm/get-file-text-custom-fonts.json - a mostly empty file whose
// only text uses the default font (no typography asset).
};
async function serveTwoFiles(page) {
const fileABody = await readFile(
"playwright/data/workspace/multiselection-typography.json",
"utf-8",
);
const fileBBody = await readFile(
"playwright/data/render-wasm/get-file-text-custom-fonts.json",
"utf-8",
);
// Dispatch on the `id` query param of the `get-file` RPC so each file gets
// its own fixture while keeping a single SPA session alive.
await page.route(/get\-file\?/, (route) => {
const url = new URL(route.request().url());
const fileId = url.searchParams.get("id");
const body = fileId === FILE_A.id ? fileABody : fileBBody;
return route.fulfill({
status: 200,
contentType: "application/transit+json",
body,
});
});
}
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
// WASM_FLAGS already enables the v2 text editor / render-wasm. Add the WASM
// text editor on top so typography styles are read through the current text
// values path.
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]);
});
test("BUG 10925 - typography font does not leak into new text in a different file", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.mockRPC(
"get-font-variants?team-id=*",
"render-wasm/get-font-variants-custom-fonts.json",
);
await serveTwoFiles(page);
// ---- File A: select the text linked to a typography and change a font ----
await workspace.goToWorkspace({ fileId: FILE_A.id, pageId: FILE_A.pageId });
await workspace.waitForFirstRender();
await workspace.doubleClickLeafLayer("Text with typography asset one");
await workspace.textEditor.startEditing();
// Changing a font attribute triggers save-font with the current text-node
// attrs (including the typography refs) storing them into default-font.
await workspace.textEditor.changeFontSize(24);
await workspace.textEditor.stopEditing();
// ---- File B: same SPA session, switch to a file with no typography ----
await workspace.goToWorkspace({ fileId: FILE_B.id, pageId: FILE_B.pageId });
await workspace.waitForFirstRender();
// Create a brand-new text layer in file B and query its font-family.
await workspace.createTextShape(100, 100, 300, 200, "hello");
await workspace.textEditor.stopEditing();
await workspace.clickLeafLayer("hello");
await workspace.textEditor.startEditing();
await workspace.page.keyboard.press("ControlOrMeta+a");
const fontFamily = workspace.rightSidebar.getByTitle("Font Family");
await expect(fontFamily).toContainText("Source Sans Pro");
// The custom typography family from file A (IM Fell French Canon SC) must NOT
// be carried over.
await expect(fontFamily).not.toContainText("IM Fell");
});

View File

@ -1,5 +1,6 @@
(ns app.main.data.nitrate
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.organization :as cto]
[app.common.uri :as u]
@ -351,6 +352,24 @@
(rx/empty)))))))))))
(defn retry-organization-sso
"Retries the organization SSO login flow after a failed attempt, reusing
the same check-nitrate-sso RPC used elsewhere to move the user through
the organization's identity provider. Passing `team-id` enables the
backend's non-member short-circuit. Falls back to navigating straight
to `dest-url` when no fresh SSO redirect is needed or available."
[{:keys [team-id organization-id dest-url]}]
(ptk/reify ::retry-organization-sso
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id
:organization-id organization-id
:url dest-url}))
(rx/map (fn [{:keys [redirect-uri]}]
(rt/nav-raw :uri (or redirect-uri dest-url))))
(rx/catch (fn [_]
(rx/of (rt/nav-raw :uri dest-url))))))))
(defn- fetch-organizations-allowed
"Returns an rx observable of an `organizations-allowed` map (organization-id -> boolean).
Organizations where :add-anybody-to-team is permitted are pre-approved;

View File

@ -39,6 +39,7 @@
:uri plugin-url
:omit-default-headers true
:response-type :json})
(rx/timeout 15000)
(rx/map :body)
(rx/map #(preg/parse-manifest plugin-url %))))

View File

@ -346,7 +346,8 @@
(assoc :recent-colors (:recent-colors storage/user))
(assoc :recent-fonts (:recent-fonts storage/user))
(assoc :current-file-id file-id)
(assoc :workspace-presence {})))
(assoc :workspace-presence {})
(update :workspace-global dissoc :default-font)))
ptk/WatchEvent
(watch [_ state stream]
@ -544,7 +545,7 @@
:workspace-tokens
:workspace-undo
:workspace-versions)
(update :workspace-global dissoc :read-only?)
(update :workspace-global dissoc :read-only? :default-font)
(assoc-in [:workspace-global :options-mode] :design)
(update :files d/update-vals #(dissoc % :data))))

View File

@ -831,7 +831,8 @@
(let [multiple? (->> data vals (d/seek #(= % :multiple)))]
(cond-> state
(not multiple?)
(assoc-in [:workspace-global :default-font] data))))))
(assoc-in [:workspace-global :default-font]
(dissoc data :typography-ref-id :typography-ref-file)))))))
(defn apply-text-modifier
[shape text-modifier]

View File

@ -1063,6 +1063,14 @@
(fn [content]
(st/emit! (dcm/add-comment thread content))))
on-key-down
(mf/use-fn
(fn [event]
(when (kbd/esc? event)
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dcm/close-thread)))))
on-cancel
(mf/use-fn #(st/emit! (dcm/close-thread)))]
@ -1086,6 +1094,7 @@
:style {:left (str pos-x "px")
:top (str pos-y "px")
"--comment-height" (str max-height "px")}
:on-key-down on-key-down
:on-click dom/stop-propagation}
[:div {:class (stl/css :floating-thread-header)}

View File

@ -211,7 +211,7 @@
(mf/with-layout-effect
[plugin-url team-id project-id]
(when plugin-url
(when (and plugin-url project-id)
(->> (dp/fetch-manifest plugin-url)
(rx/subs!
(fn [plugin]

View File

@ -1392,13 +1392,7 @@
on-sub-menu-pointer-enter
(mf/use-fn
(fn [_]
(mf/set-ref-val! hovering?* true)))
on-power-up-click
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"}))
(dom/open-new-window "https://penpot.app/pricing")))]
(mf/set-ref-val! hovering?* true)))]
(mf/with-effect [teams]
(when (and (contains? cf/flags :admin-console)
@ -1420,17 +1414,6 @@
[:> subscription-sidebar* {:profile profile}])))
;; TODO remove this block when subscriptions is full implemented
(when (contains? cf/flags :subscriptions-old)
[:button {:class (stl/css :upgrade-plan-section)
:on-click on-power-up-click}
[:div {:class (stl/css :penpot-free)}
[:span (tr "dashboard.upgrade-plan.penpot-free")]
[:span {:class (stl/css :no-limits)}
(tr "dashboard.upgrade-plan.no-limits")]]
[:div {:class (stl/css :power-up)}
(tr "subscription.dashboard.upgrade-plan.power-up")]])
(when (and team profile)
[:& comments-section
{:profile profile

View File

@ -123,6 +123,29 @@
(errors/on-error cause))))
(st/emit! (rt/navigated match send-event-info?)))))
(defn- handle-sso-error-and-navigate
"Check if the current route has an SSO error marker. If so, assign an
exception with type :sso-error and organization-id from query params,
and deliberately do NOT proceed with normal navigation: emitting
`rt/navigated` would clear the exception that was just assigned.
Otherwise, delegate to `check-sso-and-navigate`."
[match send-event-info? url]
(let [route-name (name (get-in match [:data :name]))
sso-error? (some? (get-in match [:query-params :sso-error]))
organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*)
team-id-str (or (get-in match [:query-params :team-id])
(get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes
team-id (some-> team-id-str uuid/parse*)
is-workspace? (str/starts-with? route-name "workspace")
is-dashboard? (str/starts-with? route-name "dashboard")]
(if sso-error?
(st/emit! (rt/assign-exception {:type :sso-error
:organization-id organization-id
:team-id team-id
:is-workspace is-workspace?
:is-dashboard is-dashboard?}))
(check-sso-and-navigate match send-event-info? url))))
(defn on-navigate
[router path send-event-info?]
(let [location (.-location js/document)
@ -138,7 +161,7 @@
(st/emit! (rt/assign-exception {:type :not-found}))
(some? match)
(check-sso-and-navigate match send-event-info? (rt/get-current-href))
(handle-sso-error-and-navigate match send-event-info? (rt/get-current-href))
:else
;; We just recheck with an additional profile request; this

View File

@ -117,7 +117,8 @@
:data-testid "settings-profile"}
[:span {:class (stl/css :element-title)} (tr "labels.settings")]]
(when (contains? cf/flags :subscriptions)
(when (or (contains? cf/flags :subscriptions)
(contains? cf/flags :admin-console))
[:li {:class (stl/css-case :current subscription?
:settings-item true)
:on-click go-settings-subscription

View File

@ -13,6 +13,7 @@
[app.common.uuid :as uuid]
[app.main.data.auth :refer [is-authenticated?]]
[app.main.data.common :as dcm]
[app.main.data.nitrate :as dnt]
[app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp]
@ -433,43 +434,6 @@
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data] :as props}]
(let [type (get data :type)
cause (get data ::errors/instance)
report (mf/with-memo [cause]
(when (ex/exception? cause)
(errors/generate-report cause)))
props (mf/spread-props props {:report report})]
(mf/with-effect [report type cause]
(when (and (ex/exception? cause)
(not (contains? #{:not-found :authentication} type)))
(errors/submit-report :event-name "exception-page"
:report report
:hint (ex/get-hint cause))))
(case type
:not-found
[:> not-found* {}]
:authentication
[:> not-found* {}]
:bad-gateway
[:> bad-gateway* props]
:service-unavailable
[:> service-unavailable*]
:nitrate-unavailable
[:> nitrate-unavailable*]
[:> internal-error* props])))
(mf/defc context-wrapper*
[{:keys [is-workspace is-dashboard is-viewer profile children]}]
[:*
@ -515,6 +479,99 @@
children])
(mf/defc sso-error-section*
"Shown in place of the dashboard/workspace (same static skeleton and
`request-dialog*` used by the no-permission dialogs) when the organization
SSO exchange with the identity provider fails."
{::mf/private true}
[{:keys [organization-id team-id profile is-workspace is-dashboard]}]
(let [clean-url
(mf/with-memo []
(-> (rt/get-current-href)
(dom/remove-query-param :sso-error)
(dom/remove-query-param :organization-id)))
_ (mf/with-effect []
;; Consume the marker once: scrub it from the URL bar so a
;; browser refresh doesn't keep re-showing this dialog.
(dom/replace-history-state! clean-url))
on-close
(mf/use-fn
(mf/deps profile)
(fn []
;; Land on the user's own default team
(st/emit! (rt/assign-exception nil)
(dcm/go-to-dashboard-recent :team-id (:default-team-id profile)))))
on-retry
(mf/use-fn
(mf/deps organization-id team-id clean-url)
(fn []
(st/emit! (rt/assign-exception nil))
(if (or team-id organization-id)
;; Retry with team-id and/or organization-id to trigger SSO check
(st/emit! (dnt/retry-organization-sso {:team-id team-id
:organization-id organization-id
:dest-url clean-url}))
;; Fallback: just navigate to clean URL
(st/emit! (rt/nav-raw :uri clean-url)))))]
[:> context-wrapper* {:is-dashboard (or is-dashboard (not is-workspace))
:is-workspace is-workspace
:profile profile}
[:> request-dialog* {:title (tr "labels.sso-error.title")
:content [(tr "labels.sso-error.desc-message")]
:button-text (tr "labels.sso-error.retry")
:on-button-click on-retry
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]]))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data] :as props}]
(let [type (get data :type)
cause (get data ::errors/instance)
organization-id (get data :organization-id)
report (mf/with-memo [cause]
(when (ex/exception? cause)
(errors/generate-report cause)))
props (mf/spread-props props {:report report})]
(mf/with-effect [report type cause]
(when (and (ex/exception? cause)
(not (contains? #{:not-found :authentication} type)))
(errors/submit-report :event-name "exception-page"
:report report
:hint (ex/get-hint cause))))
(case type
:not-found
[:> not-found* {}]
:authentication
[:> not-found* {}]
:bad-gateway
[:> bad-gateway* props]
:service-unavailable
[:> service-unavailable*]
:nitrate-unavailable
[:> nitrate-unavailable*]
:sso-error
[:> sso-error-section* {:organization-id organization-id
:team-id (get data :team-id)
:profile (mf/deref refs/profile)
:is-workspace (get data :is-workspace false)
:is-dashboard (get data :is-dashboard true)}]
[:> internal-error* props])))
(mf/defc exception-page*
[{:keys [data route] :as props}]

View File

@ -69,6 +69,7 @@
// SVG inside deco-before no class available on the raw element
.deco-before svg {
position: absolute;
fill: var(--color-foreground-secondary);
block-size: 1537px;
inline-size: px2rem(80);
inset-block-end: 0;
@ -76,6 +77,7 @@
// SVG inside deco-after2 no class available on the raw element
.deco-after2 svg {
fill: var(--color-foreground-secondary);
block-size: 1537px;
inline-size: px2rem(80);
}

View File

@ -927,13 +927,6 @@
(keyword))]
(reset! selected-sub-menu* menu))))
on-power-up-click
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-pricing-click"
::ev/origin "workspace:menu"}))
(dom/open-new-window "https://penpot.app/pricing")))
toggle-flag
(mf/use-fn
(fn [event]
@ -1130,21 +1123,10 @@
[:> icon* {:icon-id i/arrow-right
:class (stl/css :item-arrow)}]]
(when (and (contains? cf/flags :subscriptions)
(not= "enterprise" subscription-type))
[:> main-menu-power-up* {:close-sub-menu close-sub-menu}])
;; TODO remove this block when subscriptions is full implemented
(when (contains? cf/flags :subscriptions-old)
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item)
:on-click on-power-up-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-power-up-click)))
:on-pointer-enter close-sub-menu
:id "file-menu-power-up"}
[:span {:class (stl/css :item-name)}
(tr "subscription.workspace.header.menu.option.power-up")]])]
(when (or (and (contains? cf/flags :subscriptions)
(not= "enterprise" subscription-type))
(contains? cf/flags :admin-console))
[:> main-menu-power-up* {:close-sub-menu close-sub-menu}])]
(case selected-sub-menu
:file

View File

@ -363,7 +363,8 @@
:page-id page-id
:file-id file-id
:vport vport
:zoom zoom}])
:zoom zoom
:show-rulers show-rulers?}])
(when picking-color?
[:> pixel-overlay/pixel-overlay* {:vport vport

View File

@ -13,6 +13,7 @@
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.comments :as cmt]
[app.main.ui.workspace.viewport.rulers :as rulers]
[rumext.v2 :as mf]))
;; Pin transform for the bubble's frame so it follows the frame during a drag,
@ -70,7 +71,7 @@
(mf/defc comments-layer*
{::mf/wrap [mf/memo]}
[{:keys [vbox vport zoom file-id page-id]}]
[{:keys [vbox vport zoom file-id page-id show-rulers]}]
(let [vbox-x (dm/get-prop vbox :x)
vbox-y (dm/get-prop vbox :y)
vport-w (dm/get-prop vport :width)
@ -114,7 +115,15 @@
{:id "comments"
:class (stl/css :workspace-comments-container)
:style {:width (dm/str vport-w "px")
:height (dm/str vport-h "px")}}
:height (dm/str vport-h "px")
;; This layer sits above the canvas, so without clipping the
;; bubbles paint over the rulers as they pan past them. Keep
;; them out of the ruler bars, like `clip-handlers` does for
;; the selection handlers.
:clip-path (when show-rulers
(dm/fmt "inset(%px 0 0 %px)"
rulers/ruler-area-size
rulers/ruler-area-size))}}
[:div {:class (stl/css :threads)
:style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}}

View File

@ -666,7 +666,8 @@
:page-id page-id
:file-id file-id
:vport vport
:zoom zoom}])
:zoom zoom
:show-rulers show-rulers?}])
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref

View File

@ -875,35 +875,17 @@
[url]
(.replaceState (.-history globals/window) nil "" url))
(defn- update-query-params
"Apply `f` to the query-params map of `url`, returning the updated URL string.
Handles both plain query strings and fragment-based (hash) URLs."
[url f]
(let [transform (fn [parsed]
(update parsed :query
(fn [q]
(-> (u/query-string->map (or q ""))
f
u/map->query-string))))
parsed (u/uri url)
fragment (:fragment parsed)]
(if (str/blank? fragment)
(str (transform parsed))
(-> parsed
(assoc :fragment (str (transform (u/parse fragment))))
str))))
(defn append-query-param
"Return a new URL string with the given query parameter added or replaced.
Handles both plain query strings and fragment-based (hash) URLs."
[url key value]
(update-query-params url #(assoc % key value)))
(u/append-query-param url key value))
(defn remove-query-param
"Return a new URL string with the given query parameter removed.
Handles both plain query strings and fragment-based (hash) URLs."
[url key]
(update-query-params url #(dissoc % key)))
(u/remove-query-param url key))
(defn reload-current-window
([]

View File

@ -12,6 +12,7 @@
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.workspace.texts :as dwt]
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
[cljs.test :as t :include-macros true]
@ -377,6 +378,62 @@
(t/is (= "0.1" (:letter-spacing (first typographies)))
"float letter-spacing is normalised to 2-decimal string")))))))
;; ---------------------------------------------------------------------------
;; Tests: save-font must not persist typography refs into the global default font
;;
;; Root cause of #10925: typography assets are file-specific references, but
;; save-font used to write :typography-ref-id / :typography-ref-file into the
;; session-global [:workspace-global :default-font]. That state survives a file
;; switch, and v2-default-text-content bakes it into brand-new text shapes in
;; the other file, so they got a non-existent typography asset instead of the
;; default Penpot font. save-font now strips those two keys.
;; ---------------------------------------------------------------------------
(t/deftest save-font-strips-typography-refs-from-default-font
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(cths/add-sample-shape :text1
:type :text
:x 0 :y 0
:content (txt/change-text nil "hello")))
store (ths/setup-store file)
attrs {:font-id "roboto"
:font-family "Roboto"
:font-variant-id "regular"
:font-size "14"
:typography-ref-id (uuid/next)
:typography-ref-file (:id file)}]
(ths/run-store
store done [(dwt/save-font attrs)]
(fn [new-state]
(let [default-font (get-in new-state [:workspace-global :default-font])]
(t/is (some? default-font))
(t/is (= "roboto" (:font-id default-font)))
(t/is (nil? (:typography-ref-id default-font)))
(t/is (nil? (:typography-ref-file default-font)))))))))
(t/deftest save-font-preserves-other-font-attrs
(t/async
done
(let [store (ths/setup-store (cthf/sample-file :file1))
attrs {:font-family "Open Sans"
:font-id "opensans"
:font-variant-id "regular"
:font-size "18"
:line-height "1.5"
:letter-spacing "0"
:typography-ref-id (uuid/next)
:typography-ref-file (uuid/next)}]
(ths/run-store store done [(dwt/save-font attrs)]
(fn [new-state]
(let [default-font (get-in new-state [:workspace-global :default-font])]
(t/is (= "Open Sans" (:font-family default-font)))
(t/is (= "18" (:font-size default-font)))
(t/is (= "1.5" (:line-height default-font)))
(t/is (nil? (:typography-ref-id default-font)))
(t/is (nil? (:typography-ref-file default-font)))))))))
;; ---------------------------------------------------------------------------
;; Tests: fix-position with degenerate selrect
;; ---------------------------------------------------------------------------

View File

@ -10339,3 +10339,12 @@ msgstr "Click to close the path"
msgid "notifications.invitation-canceled"
msgstr "This invitation is no longer available."
msgid "labels.sso-error.title"
msgstr "We couldn't sign you in to your organization"
msgid "labels.sso-error.desc-message"
msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet. Your Penpot account isn't affected."
msgid "labels.sso-error.retry"
msgstr "Try again"

View File

@ -9989,3 +9989,12 @@ msgstr "Pulsar para cerrar la ruta"
msgid "notifications.invitation-canceled"
msgstr "Esta invitación ya no está disponible."
msgid "labels.sso-error.title"
msgstr "No pudimos iniciar sesión en tu organización"
msgid "labels.sso-error.desc-message"
msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio. Tu cuenta de Penpot no se ha visto afectada."
msgid "labels.sso-error.retry"
msgstr "Intentar de nuevo"