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/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/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)) 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/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/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 7f4b470ab3..2d7c4e22f7 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 @@ -259,6 +262,14 @@ (generate-nitrate-uri "api/teams/" team-id) cto/schema:team-with-organization params)) +(defn- get-teams-organizations-api + [cfg {:keys [team-ids] :as params}] + (let [params (assoc params :request-params {:team-ids team-ids})] + (request-to-nitrate cfg :post + (generate-nitrate-uri "api/teams/organizations") + [:vector cto/schema:team-with-organization] + params))) + (defn- get-organization-membership-api [cfg {:keys [profile-id organization-id] :as params}] (request-to-nitrate cfg :get @@ -489,6 +500,7 @@ [_ cfg] (when (contains? cf/flags :admin-console) {:get-team-organization (partial get-team-organization-api cfg) + :get-teams-organizations (partial get-teams-organizations-api cfg) :set-team-organization (partial set-team-organization-api cfg) :get-organization-membership (partial get-organization-membership-api cfg) :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) @@ -596,22 +608,25 @@ :cause cause) profile))))) +(defn- apply-organization-info-to-team + [team team-with-organization] + (let [organization (:organization team-with-organization)] + (if (some? organization) + (-> (cto/apply-organization team (assoc organization :custom-photo + (when-let [logo-id (:logo-id organization)] + (generate-public-uri "assets/by-id/" logo-id)))) + (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) + team))) + (defn add-organization-info-to-team "Enriches a team map with organization information from Nitrate. - Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. Returns the original team unchanged if the request fails or organization data is nil. Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." [cfg team params] (try - (let [params (assoc (or params {}) :team-id (:id team)) - team-with-organization (call cfg :get-team-organization params) - organization (:organization team-with-organization)] - (if (some? organization) - (-> (cto/apply-organization team (assoc organization :custom-photo - (when-let [logo-id (:logo-id organization)] - (generate-public-uri "assets/by-id/" logo-id)))) - (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) - team)) + (let [params (assoc (or params {}) :team-id (:id team)) + team-with-organization (call cfg :get-team-organization params)] + (apply-organization-info-to-team team team-with-organization)) (catch Throwable cause (if (= :nitrate-unavailable (-> cause ex-data :type)) (throw cause) @@ -621,6 +636,23 @@ :cause cause) team))))) +(defn add-organization-info-to-teams + "Enriches teams with organization information using one batched Nitrate request. + Teams absent from the Nitrate response are returned unchanged. + Rejects the request when Nitrate does not return a valid batch response." + [cfg teams params] + (let [request-params (assoc (or params {}) :team-ids (mapv :id teams)) + teams-with-organization (call cfg :get-teams-organizations request-params)] + (when (nil? teams-with-organization) + (ex/raise :type :nitrate-unavailable + :hint "nitrate did not return a valid teams organization response")) + (let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)] + (mapv (fn [{:keys [id] :as team}] + (if-let [team-with-organization (get organizations-by-team id)] + (apply-organization-info-to-team team team-with-organization) + team)) + teams)))) + (defn set-team-organization "Associates a team with an organization in Nitrate. Requires organization-id and is-default in params. @@ -637,3 +669,17 @@ :context {:team-id (:id team) :organization-id (:organization-id params)})) team)) + +(defn assert-membership + "Verifies that the user is a member of the organization. + Raises an exception if the organization doesn't exist or the user is not a member." + [cfg profile-id organization-id] + (let [membership (call cfg :get-organization-membership {:profile-id profile-id + :organization-id organization-id})] + (when-not (:organization-id membership) + (ex/raise :type :validation + :code :organization-does-not-exist)) + + (when-not (:is-member membership) + (ex/raise :type :validation + :code :user-doesnt-belong-organization)))) 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 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/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/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 196b35c051..ab2a0628a5 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, @@ -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/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/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) 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_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}) 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_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*))) 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)))) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 5c0477ed17..7f8fb136e1 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)}) @@ -1096,3 +1157,53 @@ :name "My Valid Team"} out (th/command! data)] (t/is (th/success? out))))) + +(t/deftest create-team-in-organization-regression + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [owner (th/create-profile* 401 {:is-active true}) + non-member (th/create-profile* 402 {:is-active true}) + organization-id (uuid/random) + params {::th/type :create-team + ::rpc/profile-id (:id owner) + :name "Test Team" + :organization-id organization-id} + + nitrate-call-fn + (fn [_cfg method p] + (case method + :get-organization-membership + (if (= (:profile-id p) (:id non-member)) + {:organization-id organization-id :is-member false} + {:organization-id organization-id :is-member true}) + + :get-organization-permissions + {:owner-id (:id owner) + :permissions {:create-teams "any"}} + + :set-team-organization + (let [team-id (:team-id p)] + {:id team-id + :name "Test Team" + :organization-id organization-id + :default-project-id (uuid/random)}) + + nil))] + + ;; Non-member should be denied with :user-doesnt-belong-organization + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call-fn] + (let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))] + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata))) + (t/is (= :user-doesnt-belong-organization (:code edata)))))) + + ;; Authorized member should succeed + (th/reset-mock! audit-mock) + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call-fn] + (let [out (th/command! params)] + (t/is (th/success? out)) + (let [team (:result out)] + (t/is (uuid? (:id team))) + (t/is (= "Test Team" (:name team))))))))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index bfccc0afdf..3f2150a651 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/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/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/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)))))) 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; } 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/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/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 %)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8917f2e97f..678abc049d 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 113561a506..862a40ceb6 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -831,7 +831,8 @@ (let [multiple? (->> data vals (d/seek #(= % :multiple)))] (cond-> state (not multiple?) - (assoc-in [:workspace-global :default-font] data)))))) + (assoc-in [:workspace-global :default-font] + (dissoc data :typography-ref-id :typography-ref-file))))))) (defn apply-text-modifier [shape text-modifier] 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)} 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] 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/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/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/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/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); } 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 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 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/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 ;; --------------------------------------------------------------------------- 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"