From 399b00b86d06013250a2280f2d532da3371d93b6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 11:24:24 +0200 Subject: [PATCH 01/18] :bug: Add permission checks to WebSocket subscription handlers (#11054) * :bug: Add permission checks to WebSocket subscription handlers Check file and team read permissions before allowing WebSocket subscriptions to prevent resource enumeration via presence notifications. AI-assisted-by: mimo-v2.5-pro * :bug: Fix random backend test failure --- backend/src/app/http/websocket.clj | 10 ++++++-- backend/test/backend_tests/rpc_file_test.clj | 25 ++++++++++++++++--- backend/test/backend_tests/rpc_font_test.clj | 21 ++++++++++++++++ .../test/backend_tests/rpc_project_test.clj | 21 ++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/backend/src/app/http/websocket.clj b/backend/src/app/http/websocket.clj index 4dd74dfc11..2517b42725 100644 --- a/backend/src/app/http/websocket.clj +++ b/backend/src/app/http/websocket.clj @@ -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)))] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index cb0997576d..d1ec0eb233 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -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)))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index d7b326e5ed..0f86a64cb2 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -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)))) diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj index 96376a42b1..3f80f06f6b 100644 --- a/backend/test/backend_tests/rpc_project_test.clj +++ b/backend/test/backend_tests/rpc_project_test.clj @@ -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)))) From 6951876c131a3cf72e75cb3de66c1706c32cce4d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 11:25:15 +0200 Subject: [PATCH 02/18] :bug: Use constant-time comparison for shared key authentication (#11122) Replace standard '=' operator with MessageDigest/isEqual to prevent timing attacks on shared key authentication middleware. Closes #11121 AI-assisted-by: qwen3.7-plus --- backend/src/app/http/middleware.clj | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index fa2faa8a55..31b96927a6 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -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)) From 5571c53502bc1a4768eb72e279b6b229e6e021ca Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 11:27:12 +0200 Subject: [PATCH 03/18] :bug: Use random UUIDs for share link IDs (#11117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share link IDs function as capability secrets — anyone possessing the ID can read a file without authentication. The previous UUIDv8 scheme is predictable (56 bits fixed per process + 48-bit timestamp). Changed to uuid/random (UUIDv4) for genuine unpredictability. Closes #11116 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/files_share.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/files_share.clj b/backend/src/app/rpc/commands/files_share.clj index bb925f243e..9a8326d06d 100644 --- a/backend/src/app/rpc/commands/files_share.clj +++ b/backend/src/app/rpc/commands/files_share.clj @@ -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 From a131e40a6dc33da724bd3409d847e9682243e539 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 09:08:32 +0000 Subject: [PATCH 04/18] :sparkles: Add proper rlimit config and propagate limit timestamp Replace the placeholder rlimit.edn with a real per-endpoint configuration covering auth, SSRF, search, email, media and project operations. The previous file only had a commented-out example, so all limits fell back to the 200k/h default window. Also propagate the evaluated `now` timestamp into both bucket and window result maps, so consumers (e.g. soft-mode reports) can know exactly when the limit was checked. AI-assisted-by: minimax-m3 --- backend/resources/rlimit.edn | 309 ++++++++++++++++++++++++++++++++- backend/src/app/rpc/rlimit.clj | 2 + 2 files changed, 305 insertions(+), 6 deletions(-) diff --git a/backend/resources/rlimit.edn b/backend/resources/rlimit.edn index 118f30f70a..68b3153848 100644 --- a/backend/resources/rlimit.edn +++ b/backend/resources/rlimit.edn @@ -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"]]} diff --git a/backend/src/app/rpc/rlimit.clj b/backend/src/app/rpc/rlimit.clj index 8c28f6a3c6..abc77d81ea 100644 --- a/backend/src/app/rpc/rlimit.clj +++ b/backend/src/app/rpc/rlimit.clj @@ -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) From bf9825fcfe579c5966f1f6e61766928faf7d278a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 7 Aug 2026 12:33:11 +0200 Subject: [PATCH 05/18] :bug: Fix close modal with esc (#11131) --- frontend/src/app/main/ui/comments.cljs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 4a7032b62c..550b4b8e37 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -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)} From e2d429d283184311edc9118296c12fcee3dae526 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 13:37:00 +0200 Subject: [PATCH 06/18] :bug: Add timeout to plugin manifest fetch (#11120) The fetch-manifest function previously had no timeout, causing the plugin installation flow to hang indefinitely if the server accepted the connection but never completed the response. Added a 15-second timeout using rx/timeout to abort the request automatically. Closes #11119 AI-assisted-by: qwen3.7-plus --- frontend/src/app/main/data/plugins.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 6635fc070b..4f7e8b7ceb 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -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 %)))) From 2f04fcddbf15a56f5a4205a47e1d7d31ba7111b5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 13:44:13 +0200 Subject: [PATCH 07/18] :bug: Invalidate all sessions on profile deletion (#11115) When a profile is deleted, only the current session was being invalidated. Other active sessions on different devices remained functional until the background cleanup task completed. Add session/invalidate-all helper that deletes all sessions for a profile by profile_id, and call it from delete-profile before the response transform. This ensures immediate access revocation across all devices when an account is deleted. Closes #11114 AI-assisted-by: qwen3.7-plus --- backend/src/app/http/session.clj | 8 ++++++ backend/src/app/rpc/commands/profile.clj | 4 +++ .../test/backend_tests/rpc_profile_test.clj | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 614942c072..61140a780c 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -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[] " diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 26716cc411..77307a1482 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -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))))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index f846cfb343..f9900d44ab 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -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*))) From e01b36b84180c6351410561bed00b178a46ec660 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 19:21:32 +0200 Subject: [PATCH 08/18] :bug: Add project-id guard to use-plugin-register layout effect (#10859) Add `project-id` to the guard condition in `use-plugin-register`'s layout effect so the plugin "Try out" flow waits until projects have loaded. Previously, only `plugin-url` was checked, which allowed the fetch to fire before projects were available, sending a nil `project-id` and causing a 400 validation error from the backend. AI-assisted-by: mimo-v2.5-pro --- frontend/src/app/main/ui/dashboard.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 20daf964a7..622873ac4c 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -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] From b9c92496f1fdb25f8dd42ff010bfcb1a471f6178 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 10 Aug 2026 10:56:04 +0200 Subject: [PATCH 09/18] :bug: Fix overrides lost after switch (#10619) --- common/src/app/common/logic/libraries.cljc | 7 +- .../logic/variants_switch_test.cljc | 80 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index a89aa633ab..b234263468 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -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 diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index ed9eeae783..375b6e6c6e 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -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)))))) From d63d6370c01393203ba7618bc7438a2c0c8eddb5 Mon Sep 17 00:00:00 2001 From: Jules Date: Mon, 10 Aug 2026 03:34:52 -0600 Subject: [PATCH 10/18] :bug: Fix stale DNS caching in frontend nginx MCP proxy (#10947) The generated /etc/nginx/overrides/server.d/mcp-locations.conf used a plain proxy_pass target (e.g. `proxy_pass http://penpot-mcp:4402;`) where $PENPOT_MCP_URI/$PENPOT_MCP_URI_WS are shell variables substituted once by envsubst in nginx-entrypoint.sh at container startup, not nginx variables. nginx resolves a literal proxy_pass hostname once when the config loads and never re-checks it, so the existing `resolver 127.0.0.11 valid=10s;` directive in overrides/http.d/resolvers.conf has no effect on these three locations - it only applies to nginx variables evaluated per-request. In multi-container deployments where the penpot-mcp container restarts or is recreated independently of penpot-frontend (image update, OOM, orchestrator reschedule), it gets a new IP from Docker's/the orchestrator's DNS, and the frontend's nginx keeps forwarding to the old, now-dead address until penpot-frontend itself is restarted. This surfaces to users as `wss:///mcp/ws` failing to connect from the browser after enabling the MCP plugin, with `connect() failed (111: Connection refused)` in the frontend's nginx logs. Route each location through a `set $var ...; proxy_pass $var;` pair so proxy_pass evaluates a real nginx variable, letting the pre-existing resolver directive re-resolve penpot-mcp within its 10s TTL instead of caching the address for the container's lifetime. For /mcp/stream and /mcp/sse, the set value also appends $is_args$args explicitly: when proxy_pass targets a variable AND that variable's value includes a URI/path component, nginx does not automatically forward the original request's query string the way it does for a static proxy_pass target - it must be appended by hand, or the userToken query parameter used for multi-user authentication is silently dropped before reaching the MCP server. /mcp/ws has no path component in its target so it isn't affected by this and needed no such change. Verified locally: force-recreated the penpot-mcp container onto a different IP while leaving penpot-frontend untouched; the /mcp/ws WebSocket upgrade kept returning 101 Switching Protocols throughout, both immediately and after the resolver's TTL window. Separately verified /mcp/stream: a POST with ?userToken=... now shows up server-side as userTokenFp= instead of , and an actual MCP client (Claude Code) using this proxy can now call authenticated tools like execute_code successfully. Signed-off-by: Jules LaPrairie --- docker/images/files/nginx-mcp-locations.conf.template | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docker/images/files/nginx-mcp-locations.conf.template b/docker/images/files/nginx-mcp-locations.conf.template index ab4df0acbb..6ff6fda592 100644 --- a/docker/images/files/nginx-mcp-locations.conf.template +++ b/docker/images/files/nginx-mcp-locations.conf.template @@ -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; } From 86c563f11f40ac01260a8977a0f9069068303aca Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 10 Aug 2026 11:35:51 +0200 Subject: [PATCH 11/18] :bug: Fix font family typography asset persist across files in new created text layers (#11134) --- ...workspace-texts-typography-persist.spec.js | 110 ++++++++++++++++++ frontend/src/app/main/data/workspace.cljs | 5 +- .../src/app/main/data/workspace/texts.cljs | 3 +- .../data/workspace_texts_test.cljs | 57 +++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js diff --git a/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js b/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js new file mode 100644 index 0000000000..d9b6c98c02 --- /dev/null +++ b/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js @@ -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"); +}); \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index cedfad1d96..9bf11fb701 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -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)))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 2a905523b6..fb33068adc 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -824,7 +824,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] diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index a52202bc48..5822b571cf 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -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 ;; --------------------------------------------------------------------------- From 900a7ef498253f2472ce4dff823f242d5251131a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 10 Aug 2026 13:42:28 +0200 Subject: [PATCH 12/18] :recycle: Show subscription section to everybody (#11007) --- common/src/app/common/flags.cljc | 1 - .../src/app/main/ui/dashboard/sidebar.cljs | 19 +------------- .../src/app/main/ui/settings/sidebar.cljs | 3 ++- .../src/app/main/ui/workspace/main_menu.cljs | 26 +++---------------- 4 files changed, 7 insertions(+), 42 deletions(-) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 9988c1a9f8..c5129dd5c3 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -147,7 +147,6 @@ :render-switch :hide-release-modal :subscriptions - :subscriptions-old :inspect-styles ;; Enable performance logs in devconsole (disabled by default) :perf-logs diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index b2dfb2f119..ed39edaaf1 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -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 diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 3ddff8f862..99313c13b4 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index bb2c6a2d88..e08de97b03 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -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 From 5d2cb22966f1ac84774af75fb8742c0e7cda0aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 10 Aug 2026 13:43:27 +0200 Subject: [PATCH 13/18] :zap: Fetch team organization in a single batch (#11140) --- backend/src/app/nitrate.clj | 49 ++++++++++++--- backend/src/app/rpc/commands/teams.clj | 10 +-- ...pc_organization_owner_permissions_test.clj | 11 +++- backend/test/backend_tests/rpc_team_test.clj | 61 +++++++++++++++++++ 4 files changed, 115 insertions(+), 16 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 7f4b470ab3..3fc4fefe68 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -259,6 +259,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 +497,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 +605,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 +633,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. diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 196b35c051..0467082b1a 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -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, diff --git a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj index 8168242f16..a971c57539 100644 --- a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj +++ b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj @@ -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 diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 5c0477ed17..920defb2ba 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -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)}) From 0fd2a9d26ff8961dd7155f3707fc29c1438be845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 10 Aug 2026 13:45:15 +0200 Subject: [PATCH 14/18] :bug: Secure organization invitation creation (#11164) --- backend/src/app/nitrate.clj | 3 + backend/src/app/rpc/management/nitrate.clj | 22 ++- .../rpc_management_nitrate_test.clj | 165 ++++++++++++++---- 3 files changed, 152 insertions(+), 38 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 3fc4fefe68..fab5899b74 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -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 diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index a87dd74ccb..ca334ffb79 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -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) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 769b5ec535..4cb32401cf 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -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}) From 16e52b0494423c42775386d28467bd5b4ab0f58b Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 10 Aug 2026 15:51:31 +0200 Subject: [PATCH 15/18] :bug: Fix error page logo not visible in dark mode (#11167) --- frontend/src/app/main/ui/static.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index 9f87bd9d3d..1708e23cd7 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -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); } From 83efa28b121faa5f0cc541afc35e6cfb1c9f806c Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Mon, 10 Aug 2026 16:18:58 +0200 Subject: [PATCH 16/18] :bug: Keep comment bubbles from painting over the rulers (#11168) The comments layer lives in the viewport overlays, which are absolutely positioned above the canvas, and the container itself carries a high z-index. A comment bubble panned into the ruler bars therefore painted on top of them, covering the ticks and numbers. Clip the comments container to the area outside the ruler bars while the rulers are visible, the same thing the `clip-handlers` clip path already does so the selection handlers stay off the rulers. Clipping only the comments container leaves the text editing overlay, which shares the viewport overlays, untouched. Fixes #11163. Signed-off-by: Filip Sajdak Co-authored-by: Claude Opus 5 --- frontend/src/app/main/ui/workspace/viewport.cljs | 3 ++- .../app/main/ui/workspace/viewport/comments.cljs | 13 +++++++++++-- .../src/app/main/ui/workspace/viewport_wasm.cljs | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 5f71417631..4814be5042 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.cljs b/frontend/src/app/main/ui/workspace/viewport/comments.cljs index 288b537f1b..ca0117bae5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/comments.cljs @@ -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)}} diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b55d8c554c..5a9601502d 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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 From d4294bbf1eeec4174c677bb4ba5846271151ee89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Tue, 11 Aug 2026 09:13:13 +0200 Subject: [PATCH 17/18] :bug: Fix missing membership check in create-team (#11166) --- backend/src/app/nitrate.clj | 14 ++++++ backend/src/app/rpc/commands/nitrate.clj | 25 +++------- backend/src/app/rpc/commands/teams.clj | 3 ++ backend/test/backend_tests/rpc_team_test.clj | 50 ++++++++++++++++++++ 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index fab5899b74..2d7c4e22f7 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -669,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)))) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index b7143ebe85..af993ea179 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -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})) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 0467082b1a..ab2a0628a5 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -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) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 920defb2ba..7f8fb136e1 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1157,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))))))))) From 1e6d438257c04f094386f1a9f440fce10134491d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Tue, 11 Aug 2026 09:14:29 +0200 Subject: [PATCH 18/18] :bug: Fix SSO failure logging user out instead of showing error page (#11129) * :bug: Fix SSO failure logging user out instead of showing error page * :paperclip: Code review --- backend/src/app/auth/oidc.clj | 54 +++++++--- common/src/app/common/uri.cljc | 30 ++++++ frontend/src/app/main/data/nitrate.cljs | 19 ++++ frontend/src/app/main/ui/routes.cljs | 25 ++++- frontend/src/app/main/ui/static.cljs | 131 +++++++++++++++++------- frontend/src/app/util/dom.cljs | 22 +--- frontend/translations/en.po | 9 ++ frontend/translations/es.po | 9 ++ 8 files changed, 229 insertions(+), 70 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index ecf8b658a5..c32f3bbf75 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -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) diff --git a/common/src/app/common/uri.cljc b/common/src/app/common/uri.cljc index b82b5c0e74..c61cc69737 100644 --- a/common/src/app/common/uri.cljc +++ b/common/src/app/common/uri.cljc @@ -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 "#") diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index ff0f48c3e2..3470dcc81d 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -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; diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 52d7554e1e..5b5c985043 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -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 diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 9e6dfa68f0..9232c6598c 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -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}] diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 6bdc663f42..6a0f514018 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -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 ([] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b9621af9e4..c9dcc0f1dd 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 51165035e2..1fb282b4e1 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"