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"} ^{:refresh "30s"}
{:default {:default
[[:default :window "200000/h"]] [[: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} #{:main/login-with-ldap}
;; [[:burst :bucket "60/60/1m"]] [[: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)))] (assoc :query (u/map->query-string params)))]
(redirect-response uri)))) (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 (defn- redirect-to-register
[cfg info provider] [cfg info provider]
(let [info (assoc info (let [info (assoc info
@ -887,6 +894,39 @@
{::yres/status 200 {::yres/status 200
::yres/body {:redirect-uri uri}})) ::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 (defn- callback-handler
[cfg {:keys [params] :as request}] [cfg {:keys [params] :as request}]
(if-let [error (get params :error)] (if-let [error (get params :error)]
@ -898,18 +938,8 @@
;; Organization SSO flow: state carries :dest-url — exchange the authorization ;; Organization SSO flow: state carries :dest-url — exchange the authorization
;; code with the OIDC provider to verify authentication actually occurred. ;; code with the OIDC provider to verify authentication actually occurred.
(if-let [dest-url (:dest-url state)] (if (:dest-url state)
(let [organization-id (:organization-id state) (organization-sso-callback-handler cfg request state code)
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))
(let [provider (resolve-provider cfg state) (let [provider (resolve-provider cfg state)
info (get-info cfg provider state code) info (get-info cfg provider state code)

View File

@ -24,7 +24,8 @@
(:import (:import
io.undertow.server.RequestTooBigException io.undertow.server.RequestTooBigException
java.io.InputStream java.io.InputStream
java.io.OutputStream)) java.io.OutputStream
java.security.MessageDigest))
(set! *warn-on-reflection* true) (set! *warn-on-reflection* true)
@ -329,6 +330,11 @@
{:name ::auth {:name ::auth
:compile (constantly wrap-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 (defn- wrap-shared-key-auth
[handler keys] [handler keys]
(if (seq keys) (if (seq keys)
@ -338,7 +344,7 @@
(let [key-id (-> key-id str/lower keyword)] (let [key-id (-> key-id str/lower keyword)]
(if (and (string? key) (if (and (string? key)
(contains? keys key-id) (contains? keys key-id)
(= key (get keys key-id))) (constant-time-eq? key (get keys key-id)))
(-> request (-> request
(assoc ::http/auth-key-id key-id) (assoc ::http/auth-key-id key-id)
(handler)) (handler))

View File

@ -226,6 +226,14 @@
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)]) (-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count)))) (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 (def ^:private sql:clear-organization-sso-sessions
(str "UPDATE http_session_v2 " (str "UPDATE http_session_v2 "
"SET props = props #- ARRAY['~:sso', ?]::text[] " "SET props = props #- ARRAY['~:sso', ?]::text[] "

View File

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

View File

@ -167,6 +167,9 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:name ::sm/text] [:name ::sm/text]
[:owner-id ::sm/uuid] [: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 [:teams
[:vector [:vector
[:map [:map
@ -259,6 +262,14 @@
(generate-nitrate-uri "api/teams/" team-id) (generate-nitrate-uri "api/teams/" team-id)
cto/schema:team-with-organization params)) 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 (defn- get-organization-membership-api
[cfg {:keys [profile-id organization-id] :as params}] [cfg {:keys [profile-id organization-id] :as params}]
(request-to-nitrate cfg :get (request-to-nitrate cfg :get
@ -489,6 +500,7 @@
[_ cfg] [_ cfg]
(when (contains? cf/flags :admin-console) (when (contains? cf/flags :admin-console)
{:get-team-organization (partial get-team-organization-api cfg) {: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) :set-team-organization (partial set-team-organization-api cfg)
:get-organization-membership (partial get-organization-membership-api cfg) :get-organization-membership (partial get-organization-membership-api cfg)
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
@ -596,22 +608,25 @@
:cause cause) :cause cause)
profile))))) 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 (defn add-organization-info-to-team
"Enriches a team map with organization information from Nitrate. "Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or organization data is nil. Returns the original team unchanged if the request fails or organization data is nil.
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
[cfg team params] [cfg team params]
(try (try
(let [params (assoc (or params {}) :team-id (:id team)) (let [params (assoc (or params {}) :team-id (:id team))
team-with-organization (call cfg :get-team-organization params) team-with-organization (call cfg :get-team-organization params)]
organization (:organization team-with-organization)] (apply-organization-info-to-team team team-with-organization))
(if (some? organization)
(-> (cto/apply-organization team (assoc organization :custom-photo
(when-let [logo-id (:logo-id organization)]
(generate-public-uri "assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
team))
(catch Throwable cause (catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type)) (if (= :nitrate-unavailable (-> cause ex-data :type))
(throw cause) (throw cause)
@ -621,6 +636,23 @@
:cause cause) :cause cause)
team))))) 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 (defn set-team-organization
"Associates a team with an organization in Nitrate. "Associates a team with an organization in Nitrate.
Requires organization-id and is-default in params. Requires organization-id and is-default in params.
@ -637,3 +669,17 @@
:context {:team-id (:id team) :context {:team-id (:id team)
:organization-id (:organization-id params)})) :organization-id (:organization-id params)}))
team)) 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]}] [conn {:keys [profile-id file-id pages who-comment who-inspect]}]
(let [pages (db/create-array conn "uuid" pages) (let [pages (db/create-array conn "uuid" pages)
slink (db/insert! conn :share-link slink (db/insert! conn :share-link
{:id (uuid/next) {:id (uuid/random)
:file-id file-id :file-id file-id
:who-comment who-comment :who-comment who-comment
:who-inspect who-inspect :who-inspect who-inspect

View File

@ -41,17 +41,6 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :cant-move-default-team)))) :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 (def schema:connectivity
[:map {:title "nitrate-connectivity"} [:map {:title "nitrate-connectivity"}
@ -335,7 +324,7 @@
(when-not skip-validation (when-not skip-validation
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave)) (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) ;; delete only eligible teams (non-protected and without files)
(doseq [id deletable-team-ids] (doseq [id deletable-team-ids]
@ -421,7 +410,7 @@
(assert-is-owner cfg profile-id team-id) (assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id) (assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id) (nitrate/assert-membership cfg profile-id organization-id)
;; Check moveTeams permission on the source organization ;; Check moveTeams permission on the source organization
(when (contains? cf/flags :admin-console) (when (contains? cf/flags :admin-console)
(let [organization-perms (nitrate/call cfg :get-organization-permissions (let [organization-perms (nitrate/call cfg :get-organization-permissions
@ -491,7 +480,7 @@
(assert-is-owner cfg profile-id team-id) (assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id) (assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id) (nitrate/assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :admin-console) (when (contains? cf/flags :admin-console)
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) (let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
@ -575,7 +564,7 @@
::db/transaction true} ::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}] [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
(or (when (contains? cf/flags :admin-console) (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) (let [emails-array (db/create-array conn "text" emails)
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles) email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
@ -603,7 +592,7 @@
(when-not (or (:is-admin perms) (:is-owner perms)) (when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation (ex/raise :type :validation
:code :insufficient-permissions)) :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}) (let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
organization-member-ids (into #{} organization-members) organization-member-ids (into #{} organization-members)
team-members (db/query cfg :team-profile-rel {:team-id team-id}) 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}) (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))] team-member-ids (into #{} (map :profile-id team-members))]
;; Validate requester membership in all organizations before fetching 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 {} (into {}
(map (fn [organization-id] (map (fn [organization-id]
@ -664,7 +653,7 @@
(when-not (or (:is-admin perms) (:is-owner perms)) (when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation (ex/raise :type :validation
:code :insufficient-permissions)) :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)] (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
{:has-external-invitations (boolean (seq external-emails)) {:has-external-invitations (boolean (seq external-emails))
:allows-anybody allows-anybody})) :allows-anybody allows-anybody}))

View File

@ -534,6 +534,10 @@
:deleted-at deleted-at :deleted-at deleted-at
:id profile-id}}) :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/wrap nil)
(rph/with-transform (session/delete-fn cfg))))) (rph/with-transform (session/delete-fn cfg)))))

View File

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

View File

@ -488,6 +488,21 @@ RETURNING id, deleted_at;")
;; API: invite-to-organization ;; 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 (sv/defmethod ::invite-to-organization
"Invite to organization" "Invite to organization"
{::doc/added "2.15" {::doc/added "2.15"
@ -495,8 +510,11 @@ RETURNING id, deleted_at;")
[:email ::sm/email] [:email ::sm/email]
[:organization cto/schema:organization-with-avatar]] [:organization cto/schema:organization-with-avatar]]
::nitrate/sso false} ::nitrate/sso false}
[cfg params] [cfg {profile-id ::rpc/profile-id
(db/tx-run! cfg ti/create-organization-invitation params) :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) nil)

View File

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

View File

@ -733,7 +733,7 @@
(t/is (= 2 (count rows))) (t/is (= 2 (count rows)))
(t/is (= 1 (count (remove (comp some? :deleted-at) 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") (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 ;; Now that file-gc have marked for deletion the object
;; thumbnail lets execute the objects-gc task which remove ;; thumbnail lets execute the objects-gc task which remove
@ -2377,8 +2377,6 @@
(let [edata (-> out :error ex-data)] (let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata)))))) (t/is (= :not-found (:type edata))))))
;; --- Security Fix Tests ---
(t/deftest link-file-to-library-circular-reference (t/deftest link-file-to-library-circular-reference
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:profile-id (:id profile) file1 (th/create-file* 1 {:profile-id (:id profile)
@ -2448,3 +2446,24 @@
(t/is (th/ex-info? (:error out))) (t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)] (let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata)))))) (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 (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type))) (t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))) (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/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest create-and-update-organization-invitations-audit-props (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} (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}] nitrate-mock {:target 'app.nitrate/call :return nil}]
(binding [cf/flags (conj cf/flags :email-verification)] (let [profile (th/create-profile* 105 {:is-active true})
(let [owner (th/create-profile* 101 {:is-active true}) organization-id (uuid/random)
invitee (th/create-profile* 102 {:is-active true}) out (th/management-command! {::th/type :invite-to-organization
organization {:id (uuid/random) ::rpc/profile-id (:id profile)
:name "Acme" :email "victim@example.com"
:initials "AC" :organization {:id organization-id
:logo nil :name "Fabricated Organization"
:avatar-bg-url nil} :initials "FO"
params {::th/type :invite-to-organization :logo "https://evil.example/logo.png"
::rpc/profile-id (:id owner) :avatar-bg-url nil}})]
:email (:email invitee) (t/is (not (th/success? out)))
:organization organization} (t/is (= :not-found (th/ex-type (:error out))))
create-out (th/management-command! params) (t/is (= :object-not-found (th/ex-code (:error out))))
update-out (th/management-command! params) (t/is (not (:called? @email-mock))))))
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/deftest invite-to-organization-uses-authoritative-branding
(t/is (not (contains? (:props event) :event-origin))) (let [organization-summary-ref (atom nil)]
(t/is (= (str (:id owner)) (with-mocks [email-mock {:target 'app.email/send! :return nil}
(get-in event [:props :user-who-send-invitation]))) nitrate-mock {:target 'app.nitrate/call
(t/is (= (:id organization) :return (fn [_cfg method _params]
(get-in event [:props :organization-id]))) (when (= method :get-organization-summary)
(t/is (= (:email invitee) @organization-summary-ref))}]
(get-in event [:props :member-email]))) (binding [cf/flags (conj cf/flags :email-verification)]
(t/is (= (:id invitee) (let [owner (th/create-profile* 106 {:is-active true})
(get-in event [:props :member-id])))) organization-id (uuid/random)
logo-id (uuid/random)
(t/is (not (contains? (:props external-event) :member-id))))))) _ (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 (t/deftest get-penpot-version
(let [out (th/management-command! {::th/type :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)} :organization (organization-data organization-id organization-owner-id)}
{:id (:team-id params) {:id (:team-id params)
:is-your-penpot false :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))) (f)))
(defn- with-captured-messages (defn- with-captured-messages

View File

@ -388,6 +388,31 @@
(let [result (th/run-task! :objects-gc {:min-age 0})] (let [result (th/run-task! :objects-gc {:min-age 0})]
(t/is (= 10 (:processed result)))))) (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/deftest email-blacklist-1
(t/is (false? (email.blacklist/enabled? th/*system*))) (t/is (false? (email.blacklist/enabled? th/*system*)))

View File

@ -241,3 +241,24 @@
error-data (ex-data error)] error-data (ex-data error)]
(t/is (th/ex-info? error)) (t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found)))))) (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/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 (t/deftest team-deletion-1
(let [profile1 (th/create-profile* 1 {:is-active true}) (let [profile1 (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)}) team (th/create-team* 1 {:profile-id (:id profile1)})
@ -1096,3 +1157,53 @@
:name "My Valid Team"} :name "My Valid Team"}
out (th/command! data)] out (th/command! data)]
(t/is (th/success? out))))) (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 :render-switch
:hide-release-modal :hide-release-modal
:subscriptions :subscriptions
:subscriptions-old
:inspect-styles :inspect-styles
;; Enable performance logs in devconsole (disabled by default) ;; Enable performance logs in devconsole (disabled by default)
:perf-logs :perf-logs

View File

@ -2345,7 +2345,12 @@
updated-sync-groups (into #{} updated-sync-groups (into #{}
(keep #(ctk/resolve-sync-group (:type previous-shape) %)) (keep #(ctk/resolve-sync-group (:type previous-shape) %))
updated-attrs) 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) roperations (into [{:type :set-touched :touched new-touched}] roperations)
uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)] uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)]
(cond-> changes (cond-> changes

View File

@ -67,6 +67,36 @@
path path
(str 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 #?(:clj
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer] (defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
(.write writer "#") (.write writer "#")

View File

@ -10,6 +10,7 @@
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls] [app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc] [app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho] [app.common.test-helpers.compositions :as tho]
@ -3101,3 +3102,82 @@
(t/is (= 150 (:width rect02'))) (t/is (= 150 (:width rect02')))
(t/is (= (+ (:y copy02') 70) (:y rect02'))) (t/is (= (+ (:y copy02') 70) (:y rect02')))
(t/is (= (:y rect02') (get-in rect02' [:selrect :y]))))) (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 { location /mcp/ws {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection '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; proxy_http_version 1.1;
} }
location /mcp/stream { 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; proxy_http_version 1.1;
} }
location /mcp/sse { 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; 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 (ns app.main.data.nitrate
(:require (:require
[app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.types.organization :as cto] [app.common.types.organization :as cto]
[app.common.uri :as u] [app.common.uri :as u]
@ -351,6 +352,24 @@
(rx/empty))))))))))) (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 (defn- fetch-organizations-allowed
"Returns an rx observable of an `organizations-allowed` map (organization-id -> boolean). "Returns an rx observable of an `organizations-allowed` map (organization-id -> boolean).
Organizations where :add-anybody-to-team is permitted are pre-approved; Organizations where :add-anybody-to-team is permitted are pre-approved;

View File

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

View File

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

View File

@ -831,7 +831,8 @@
(let [multiple? (->> data vals (d/seek #(= % :multiple)))] (let [multiple? (->> data vals (d/seek #(= % :multiple)))]
(cond-> state (cond-> state
(not multiple?) (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 (defn apply-text-modifier
[shape text-modifier] [shape text-modifier]

View File

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

View File

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

View File

@ -1392,13 +1392,7 @@
on-sub-menu-pointer-enter on-sub-menu-pointer-enter
(mf/use-fn (mf/use-fn
(fn [_] (fn [_]
(mf/set-ref-val! hovering?* true))) (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/with-effect [teams] (mf/with-effect [teams]
(when (and (contains? cf/flags :admin-console) (when (and (contains? cf/flags :admin-console)
@ -1420,17 +1414,6 @@
[:> subscription-sidebar* {:profile profile}]))) [:> 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) (when (and team profile)
[:& comments-section [:& comments-section
{:profile profile {:profile profile

View File

@ -123,6 +123,29 @@
(errors/on-error cause)))) (errors/on-error cause))))
(st/emit! (rt/navigated match send-event-info?))))) (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 (defn on-navigate
[router path send-event-info?] [router path send-event-info?]
(let [location (.-location js/document) (let [location (.-location js/document)
@ -138,7 +161,7 @@
(st/emit! (rt/assign-exception {:type :not-found})) (st/emit! (rt/assign-exception {:type :not-found}))
(some? match) (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 :else
;; We just recheck with an additional profile request; this ;; We just recheck with an additional profile request; this

View File

@ -117,7 +117,8 @@
:data-testid "settings-profile"} :data-testid "settings-profile"}
[:span {:class (stl/css :element-title)} (tr "labels.settings")]] [: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? [:li {:class (stl/css-case :current subscription?
:settings-item true) :settings-item true)
:on-click go-settings-subscription :on-click go-settings-subscription

View File

@ -13,6 +13,7 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.auth :refer [is-authenticated?]] [app.main.data.auth :refer [is-authenticated?]]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.nitrate :as dnt]
[app.main.errors :as errors] [app.main.errors :as errors]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -433,43 +434,6 @@
(rx/of default) (rx/of default)
(rx/throw cause))))))) (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* (mf/defc context-wrapper*
[{:keys [is-workspace is-dashboard is-viewer profile children]}] [{:keys [is-workspace is-dashboard is-viewer profile children]}]
[:* [:*
@ -515,6 +479,99 @@
children]) 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* (mf/defc exception-page*
[{:keys [data route] :as props}] [{:keys [data route] :as props}]

View File

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

View File

@ -927,13 +927,6 @@
(keyword))] (keyword))]
(reset! selected-sub-menu* menu)))) (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 toggle-flag
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
@ -1130,21 +1123,10 @@
[:> icon* {:icon-id i/arrow-right [:> icon* {:icon-id i/arrow-right
:class (stl/css :item-arrow)}]] :class (stl/css :item-arrow)}]]
(when (and (contains? cf/flags :subscriptions) (when (or (and (contains? cf/flags :subscriptions)
(not= "enterprise" subscription-type)) (not= "enterprise" subscription-type))
[:> main-menu-power-up* {:close-sub-menu close-sub-menu}]) (contains? cf/flags :admin-console))
[:> 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")]])]
(case selected-sub-menu (case selected-sub-menu
:file :file

View File

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

View File

@ -13,6 +13,7 @@
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.comments :as cmt] [app.main.ui.comments :as cmt]
[app.main.ui.workspace.viewport.rulers :as rulers]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
;; Pin transform for the bubble's frame so it follows the frame during a drag, ;; Pin transform for the bubble's frame so it follows the frame during a drag,
@ -70,7 +71,7 @@
(mf/defc comments-layer* (mf/defc comments-layer*
{::mf/wrap [mf/memo]} {::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) (let [vbox-x (dm/get-prop vbox :x)
vbox-y (dm/get-prop vbox :y) vbox-y (dm/get-prop vbox :y)
vport-w (dm/get-prop vport :width) vport-w (dm/get-prop vport :width)
@ -114,7 +115,15 @@
{:id "comments" {:id "comments"
:class (stl/css :workspace-comments-container) :class (stl/css :workspace-comments-container)
:style {:width (dm/str vport-w "px") :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) [:div {:class (stl/css :threads)
:style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}} :style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}}

View File

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

View File

@ -875,35 +875,17 @@
[url] [url]
(.replaceState (.-history globals/window) nil "" 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 (defn append-query-param
"Return a new URL string with the given query parameter added or replaced. "Return a new URL string with the given query parameter added or replaced.
Handles both plain query strings and fragment-based (hash) URLs." Handles both plain query strings and fragment-based (hash) URLs."
[url key value] [url key value]
(update-query-params url #(assoc % key value))) (u/append-query-param url key value))
(defn remove-query-param (defn remove-query-param
"Return a new URL string with the given query parameter removed. "Return a new URL string with the given query parameter removed.
Handles both plain query strings and fragment-based (hash) URLs." Handles both plain query strings and fragment-based (hash) URLs."
[url key] [url key]
(update-query-params url #(dissoc % key))) (u/remove-query-param url key))
(defn reload-current-window (defn reload-current-window
([] ([]

View File

@ -12,6 +12,7 @@
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.workspace.texts :as dwt] [app.main.data.workspace.texts :as dwt]
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth] [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
[cljs.test :as t :include-macros true] [cljs.test :as t :include-macros true]
@ -377,6 +378,62 @@
(t/is (= "0.1" (:letter-spacing (first typographies))) (t/is (= "0.1" (:letter-spacing (first typographies)))
"float letter-spacing is normalised to 2-decimal string"))))))) "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 ;; Tests: fix-position with degenerate selrect
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------

View File

@ -10339,3 +10339,12 @@ msgstr "Click to close the path"
msgid "notifications.invitation-canceled" msgid "notifications.invitation-canceled"
msgstr "This invitation is no longer available." 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" msgid "notifications.invitation-canceled"
msgstr "Esta invitación ya no está disponible." 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"