From 8fa15f240f541fe878971f75645a598706118196 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 24 Jun 2026 10:02:13 +0200 Subject: [PATCH] :sparkles: Check for nitrate sso on move team to another organization (#10379) --- backend/src/app/auth/oidc.clj | 5 +- backend/src/app/nitrate.clj | 275 ++++++++++--------- backend/src/app/rpc.clj | 51 ++-- backend/src/app/rpc/commands/nitrate.clj | 37 +-- frontend/src/app/main/data/nitrate.cljs | 65 ++--- frontend/src/app/main/ui.cljs | 14 +- frontend/src/app/main/ui/dashboard.cljs | 18 +- frontend/src/app/main/ui/dashboard/team.cljs | 6 +- frontend/src/app/util/dom.cljs | 38 ++- frontend/src/app/util/session_state.cljs | 29 ++ 10 files changed, 316 insertions(+), 222 deletions(-) create mode 100644 frontend/src/app/util/session_state.cljs diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index d72efa811d..286ff3be45 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -805,9 +805,8 @@ ;; Org 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 [team-id (:team-id state) - organization-id (:organization-id state) - sso (nitrate/call cfg :get-org-sso-by-team {:team-id team-id}) + (let [organization-id (:organization-id state) + sso (nitrate/call cfg :get-org-sso {:organization-id organization-id}) provider (prepare-org-sso-provider cfg sso) ;; verify token or throw error _info (get-info cfg provider state code) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index b5f63add0e..3da69d29a1 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -15,6 +15,7 @@ [app.common.schema.generators :as sg] [app.common.time :as ct] [app.common.types.organization :as cto] + [app.common.uri :as u] [app.config :as cf] [app.http.client :as http] [app.http.session :as session] @@ -27,6 +28,22 @@ ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- join-base-uri + [base-uri & segments] + (apply u/join (u/ensure-path-slash base-uri) segments)) + +(defn- generate-nitrate-uri + "Joins relative path segments to the Nitrate backend URI. + Segments must not start with `/`" + [& segments] + (apply join-base-uri (cf/get :nitrate-backend-uri) segments)) + +(defn- generate-public-uri + "Joins relative path segments to the public backend URI. + Segments must not start with `/`" + [& segments] + (apply join-base-uri (cf/get :public-uri) segments)) + (defn- request-builder [cfg method uri shared-key profile-id request-params] (fn [] @@ -225,56 +242,49 @@ (defn- get-team-org-api [cfg {:keys [team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/teams/" - team-id) - cto/schema:team-with-organization params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/teams/" team-id) + cto/schema:team-with-organization params)) (defn- get-org-membership-api [cfg {:keys [profile-id organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/members/" - profile-id) - schema:profile-org params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "members/" + profile-id) + schema:profile-org params)) (defn- get-org-membership-by-team-api [cfg {:keys [profile-id team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/teams/" - team-id - "/users/" - profile-id) - schema:profile-org params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/teams/" + team-id + "users/" + profile-id) + schema:profile-org params)) (defn- get-org-summary-api [cfg {:keys [organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/summary") - schema:org-summary params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "summary") + schema:org-summary params)) (defn- get-owned-orgs-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/users/" - profile-id - "/owned-organizations") - [:vector schema:org-summary] - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/users/" + profile-id + "owned-organizations") + [:vector schema:org-summary] + params)) (def ^:private schema:org-summary-counts [:map @@ -288,99 +298,87 @@ (defn- get-owned-orgs-summary-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - orgs (request-to-nitrate cfg :get - (str baseuri - "/api/users/" - profile-id - "/owned-organizations-summary") - [:vector schema:org-summary-counts] - params)] + (let [orgs (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/users/" + profile-id + "owned-organizations-summary") + [:vector schema:org-summary-counts] + params)] (mapv (fn [org] (if-let [logo-id (:logo-id org)] - (assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id)) + (assoc org :custom-photo (generate-public-uri "assets/by-id/" logo-id)) org)) orgs))) (defn- cleanup-deleted-penpot-user-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :post - (str baseuri - "/api/users/" - profile-id - "/cleanup-after-deletion") - nil params))) + (request-to-nitrate cfg :post + (generate-nitrate-uri + "api/users/" + profile-id + "cleanup-after-deletion") + nil params)) (defn- set-team-org-api [cfg {:keys [organization-id team-id is-default] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - params (assoc params :request-params {:team-id team-id + (let [params (assoc params :request-params {:team-id team-id :is-your-penpot (true? is-default)}) team (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/add-team") + (generate-nitrate-uri + "api/organizations/" + organization-id + "add-team") cto/schema:team-with-organization params) custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])] - (str (cf/get :public-uri) "/assets/by-id/" logo-id))] + (generate-public-uri "assets/by-id/" logo-id))] (cond-> team custom-photo (assoc-in [:organization :custom-photo] custom-photo)))) (defn- add-profile-to-org-api [cfg {:keys [profile-id organization-id team-id email] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - request-params (cond-> {:user-id profile-id :team-id team-id} + (let [request-params (cond-> {:user-id profile-id :team-id team-id} (some? email) (assoc :email email)) params (assoc params :request-params request-params)] (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/add-user") + (generate-nitrate-uri + "api/organizations/" + organization-id + "add-user") schema:profile-org params))) (defn- remove-profile-from-org-api [cfg {:keys [profile-id organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - params (assoc params :request-params {:user-id profile-id})] + (let [params (assoc params :request-params {:user-id profile-id})] (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/remove-user") + (generate-nitrate-uri + "api/organizations/" + organization-id + "remove-user") nil params))) (defn- remove-team-from-org-api [cfg {:keys [team-id organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - params (assoc params :request-params {:team-id team-id})] + (let [params (assoc params :request-params {:team-id team-id})] (request-to-nitrate cfg :post - (str baseuri - "/api/organizations/" - organization-id - "/remove-team") + (generate-nitrate-uri + "api/organizations/" + organization-id + "remove-team") nil params))) (defn- delete-team-api [cfg {:keys [team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :delete - (str baseuri - "/api/teams/" - team-id) - nil params))) + (request-to-nitrate cfg :delete + (generate-nitrate-uri "api/teams/" team-id) + nil params)) (defn- get-subscription-api [cfg {:keys [profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/subscriptions/" - profile-id) - schema:subscription params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/subscriptions/" profile-id) + schema:subscription params)) (def ^:private schema:subscription-warning [:maybe @@ -392,21 +390,16 @@ (defn- get-subscription-warning-api [cfg {:keys [penpot-id profile-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri) - penpot-id (or penpot-id profile-id)] + (let [penpot-id (or penpot-id profile-id)] (request-to-nitrate cfg :get - (str baseuri - "/api/subscription-warning/" - penpot-id) + (generate-nitrate-uri "api/subscription-warning/" penpot-id) schema:subscription-warning params))) (defn- get-connectivity-api [cfg params] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/connectivity") - schema:connectivity params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/connectivity") + schema:connectivity params)) (def ^:private schema:redeem-result [:map @@ -414,17 +407,16 @@ (defn- get-org-permissions-api [cfg {:keys [organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/permissions") - [:map - [:organization-id ::sm/uuid] - [:owner-id ::sm/uuid] - [:permissions [:map-of :keyword :string]]] - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "permissions") + [:map + [:organization-id ::sm/uuid] + [:owner-id ::sm/uuid] + [:permissions [:map-of :keyword :string]]] + params)) (def ^:private schema:nitrate-sso [:map @@ -437,35 +429,40 @@ [:issuer [:maybe :string]] [:scopes [:maybe [::sm/set ::sm/text]]]]) +(defn- get-org-sso-api + "Fetches the SSO configuration for an organization from Nitrate." + [cfg {:keys [organization-id] :as params}] + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "sso") + schema:nitrate-sso + params)) + (defn- get-org-sso-by-team-api [cfg {:keys [team-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/teams/" - team-id - "/sso") - schema:nitrate-sso - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri "api/teams/" team-id "sso") + schema:nitrate-sso + params)) (defn- get-org-members-api [cfg {:keys [organization-id] :as params}] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get - (str baseuri - "/api/organizations/" - organization-id - "/members-list") - [:vector ::sm/uuid] - params))) + (request-to-nitrate cfg :get + (generate-nitrate-uri + "api/organizations/" + organization-id + "members-list") + [:vector ::sm/uuid] + params)) (defn- redeem-activation-code-api [cfg params] - (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :post - (str baseuri "/api/activation-codes/redeem") - schema:redeem-result - (assoc params :throw-on-error? true)))) + (request-to-nitrate cfg :post + (generate-nitrate-uri "api/activation-codes/redeem") + schema:redeem-result + (assoc params :throw-on-error? true))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION @@ -487,6 +484,7 @@ :remove-profile-from-org (partial remove-profile-from-org-api cfg) :get-org-permissions (partial get-org-permissions-api cfg) :get-org-sso-by-team (partial get-org-sso-by-team-api cfg) + :get-org-sso (partial get-org-sso-api cfg) :delete-team (partial delete-team-api cfg) :remove-team-from-org (partial remove-team-from-org-api cfg) :get-subscription (partial get-subscription-api cfg) @@ -499,11 +497,14 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn sso-session-authorized? - "Fetches the org-SSO config for the given team and checks whether - the HTTP request has a valid session entry for it. Returns a map + "Fetches the org-SSO config for the given organization or team and checks + whether the HTTP request has a valid session entry for it. Returns a map with :authorized and :sso keys." - [cfg team-id request] - (let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})] + [cfg organization-id team-id request] + (let [session (session/get-session request) + sso (if organization-id + (call cfg :get-org-sso {:organization-id organization-id}) + (call cfg :get-org-sso-by-team {:team-id team-id}))] (if-not (:active sso) {:authorized true :sso sso} (if (or (:issuer sso) (:base-url sso)) @@ -549,7 +550,7 @@ (if (some? org) (-> (cto/apply-organization team (assoc org :custom-photo (when-let [logo-id (:logo-id org)] - (str (cf/get :public-uri) "/assets/by-id/" logo-id)))) + (generate-public-uri "assets/by-id/" logo-id)))) (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org))))) team)) (catch Throwable cause diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index d950d34e62..c99a9210a1 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -223,15 +223,16 @@ (defn- wrap-nitrate-sso "Enforce Nitrate organization SSO authentication for RPC handlers. - Resolves the team context from request params using priority order: - 1. Explicit :team-id param - 2. Explicit :project-id param → lookup project.team_id - 3. Explicit :file-id param → lookup file's team via join - 4. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file) + Resolves the organization/team context from request params using priority order: + 1. Explicit :organization-id param + 2. Explicit :team-id param + 3. Explicit :project-id param -> lookup project.team_id + 4. Explicit :file-id param -> lookup file's team via join + 5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file) - Once team-id is resolved, checks if the user is authorized within that org's SSO - session using nitrate/sso-session-authorized?. Results are cached by [profile-id cache-ref] - for 15 minutes to avoid repeated lookups. + Once the context is resolved, checks if the user is authorized within that org's + SSO session using nitrate/sso-session-authorized?. Authorized results are cached + by [profile-id cache-ref] for 15 minutes to avoid repeated lookups. Only activates when: - Nitrate flag is enabled @@ -245,27 +246,31 @@ (::nitrate/sso mdata true)) (fn [cfg params] ;; Resolve team/project/file from explicit keys or from :id via metadata - (let [id-type (::id-type mdata) - id (uuid/coerce (:id params)) - team-id (or (uuid/coerce (:team-id params)) - (when (= id-type :team) id)) - project-id (or (uuid/coerce (:project-id params)) - (when (= id-type :project) id)) - file-id (or (uuid/coerce (:file-id params)) - (when (= id-type :file) id))] - (if (or team-id project-id file-id) - (let [cache-ref (or team-id project-id file-id) + (let [organization-id (uuid/coerce (:organization-id params)) + id-type (::id-type mdata) + id (uuid/coerce (:id params)) + team-id (or (uuid/coerce (:team-id params)) + (when (= id-type :team) id)) + project-id (or (uuid/coerce (:project-id params)) + (when (= id-type :project) id)) + file-id (or (uuid/coerce (:file-id params)) + (when (= id-type :file) id))] + (if (or organization-id team-id project-id file-id) + (let [cache-ref (or organization-id team-id project-id file-id) profile-id (::profile-id params) cache-key [profile-id cache-ref] cached (cache/get org-sso-auth-cache cache-key) result (if (some? cached) cached - (let [team-id (or team-id - (when project-id - (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) - (:id (teams/get-team-for-file cfg file-id))) + (let [team-id (when-not organization-id + (or team-id + (when project-id + (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) + (:id (teams/get-team-for-file cfg file-id)))) request (-> (meta params) (get ::http/request)) - {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request) + {:keys [authorized sso]} (if organization-id + (nitrate/sso-session-authorized? cfg organization-id nil request) + (nitrate/sso-session-authorized? cfg nil team-id request)) entry {:authorized authorized :organization-id (:organization-id sso)}] (when authorized diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index f7cbe5cb4f..cbd64ad43f 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -444,7 +444,7 @@ ::doc/added "2.17" ::sm/params schema:add-team-to-organization ::db/transaction true} - [cfg {:keys [::rpc/profile-id team-id organization-id]}] + [cfg {:keys [::rpc/profile-id team-id organization-id]}] (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) @@ -621,13 +621,17 @@ (def ^:private schema:check-nitrate-sso - [:map {:title "AuthSsoParams"} - [:team-id ::sm/uuid] - [:url ::sm/uri]]) + [:and + [:map {:title "CheckNitrateSsoParams"} + [:team-id {:optional true} ::sm/uuid] + [:organization-id {:optional true} ::sm/uuid] + [:url ::sm/uri]] + [::sm/contains-any #{:team-id :organization-id}]]) (sv/defmethod ::check-nitrate-sso "Check if a user needs to login into the organization SSO. - Returns {:authorized true} when SSO is not active for the team. + Accepts either team-id (to look up the org via the team) or organization-id directly. + Returns {:authorized true} when SSO is not active. Returns {:authorized false :redirect-uri } when SSO is active; the client must redirect there. The OIDC provider itself handles re-authentication transparently if the user already has an active SSO session." @@ -635,22 +639,21 @@ ::doc/added "2.19" ::sm/params schema:check-nitrate-sso ::nitrate/sso false} - [cfg {:keys [team-id url] :as params}] + [cfg {:keys [team-id organization-id url] :as params}] (if (contains? cf/flags :nitrate) - (let [request (rph/get-request params) - {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)] + (let [request (rph/get-request params) + {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)] (if authorized {:authorized true} (if-let [issuer (or (:issuer sso) (:base-url sso))] - (let [oidc-provider (oidc/prepare-org-sso-provider cfg sso) - organization-id (:organization-id sso) - state-token (tokens/generate cfg {:iss "oidc" - :dest-url url - :team-id team-id - :organization-id organization-id - :issuer issuer - :exp (ct/in-future "4h")}) - redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)] + (let [oidc-provider (oidc/prepare-org-sso-provider cfg sso) + org-id (or organization-id (:organization-id sso)) + state-token (tokens/generate cfg {:iss "oidc" + :dest-url url + :organization-id org-id + :issuer issuer + :exp (ct/in-future "4h")}) + redirect-uri (oidc/build-auth-redirect-uri oidc-provider state-token)] {:authorized false :redirect-uri redirect-uri}) {:authorized false diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 033d3525d0..5abc969155 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -3,6 +3,7 @@ [app.common.data.macros :as dm] [app.common.types.nitrate-permissions :as nitrate-perms] [app.common.uri :as u] + [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.modal :as modal] @@ -11,10 +12,11 @@ [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] + [app.util.dom :as dom] [app.util.i18n :refer [tr]] + [app.util.session-state :as ss] [app.util.storage :as storage] [beicon.v2.core :as rx] - [cuerdas.core :as str] [potok.v2.core :as ptk])) (def ^:private nitrate-entry-active-key ::nitrate-entry-active) @@ -90,28 +92,12 @@ (def nitrate-checkout-finish-error-token "nitrate-checkout-finish-error") (def nitrate-checkout-cancelled-token "nitrate-checkout-cancelled") -(defn- append-query-param - [url key value] - (let [assoc-q (fn [u] - (update u :query - (fn [q] - (-> (u/query-string->map (or q "")) - (assoc (name key) value) - u/map->query-string)))) - parsed (u/uri url) - fragment (:fragment parsed)] - (if (str/blank? fragment) - (str (assoc-q parsed)) - (-> parsed - (assoc :fragment (str (assoc-q (u/parse fragment)))) - str)))) - (defn build-nitrate-callback-urls "Build the success/error/cancel callback URLs from a base URL by appending a `subscription` query param identifying the outcome." [base-url] (let [build (fn [token] - (append-query-param base-url :subscription token))] + (dom/append-query-param base-url :subscription token))] {:success-callback (build "subscribed-to-penpot-nitrate") :error-callback (build nitrate-checkout-error-token) :finish-error-callback (build nitrate-checkout-finish-error-token) @@ -261,15 +247,32 @@ (modal/show :no-permission-modal {:type :no-orgs-change})))))))))) -(defn add-team-to-org - [{:keys [team-id organization-id] :as params}] - (ptk/reify ::add-team-to-org +(defn add-team-to-organization + "Adds a team to an organization after checking whether the target + organization requires a fresh Nitrate SSO session. When SSO is required, + stores the pending action and redirects to the provider so the dashboard + can resume the operation after the callback." + [{:keys [team-id organization-id]}] + (ptk/reify ::add-team-to-organization ptk/WatchEvent (watch [_ _ _] - (->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id}) - (rx/mapcat - (fn [_] - (rx/of (modal/hide)))))))) + (let [pending-id (str (uuid/next)) + callback-url (dom/append-query-param (rt/get-current-href) + :pending-action-id pending-id)] + (->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url callback-url}) + (rx/mapcat + (fn [{:keys [authorized redirect-uri]}] + (if authorized + (->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id}) + (rx/map (fn [_] (modal/hide)))) + (if redirect-uri + (do + (ss/save-pending-action! pending-id {:type :add-team-to-organization + :team-id team-id + :organization-id organization-id}) + (rx/of (rt/nav-raw :uri (str redirect-uri)))) + (rx/empty)))))))))) + (defn- fetch-orgs-allowed "Returns an rx observable of an `orgs-allowed` map (org-id -> boolean). @@ -286,12 +289,12 @@ (merge (into {} (map (fn [org] [(:id org) true])) add-anybody-orgs) checked-orgs))))))) -(defn show-add-team-to-org-modal +(defn show-add-team-to-organization-modal "Fetches fresh team/org data, then shows the add-to-org modal restricted to orgs where the user has permission, or the no-permission modal if none qualify." [{:keys [team-id]}] - (ptk/reify ::show-add-team-to-org-modal + (ptk/reify ::show-add-team-to-organization-modal ptk/WatchEvent (watch [_ state _] (let [profile-id (dm/get-in state [:profile :id])] @@ -306,8 +309,8 @@ (or (= perm "any") is-own?))) all-orgs) team (first (filter #(= (:id %) team-id) teams)) on-confirm (fn [organization-id] - (st/emit! (add-team-to-org {:team-id team-id - :organization-id organization-id}))) + (st/emit! (add-team-to-organization {:team-id team-id + :organization-id organization-id}))) show-select-modal (fn [orgs-allowed] (let [has-filtered? (< (count orgs) (count all-orgs)) @@ -377,8 +380,8 @@ orgs (filter can-create? orgs-by-move) selectable-orgs (remove #(= current-org-id (:id %)) orgs) on-confirm (fn [organization-id] - (st/emit! (add-team-to-org {:team-id team-id - :organization-id organization-id})))] + (st/emit! (add-team-to-organization {:team-id team-id + :organization-id organization-id})))] (if (empty? selectable-orgs) (rx/of (dt/teams-fetched teams) (modal/show :no-permission-modal {:type :no-orgs-change})) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 157ae24d9b..d3216efd70 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -229,11 +229,12 @@ :dashboard-settings :dashboard-deleted) (let [params (get params :query) - team-id (some-> params :team-id uuid/parse*) - project-id (some-> params :project-id uuid/parse*) - search-term (some-> params :search-term) - plugin-url (some-> params :plugin) - template (some-> params :template)] + team-id (some-> params :team-id uuid/parse*) + project-id (some-> params :project-id uuid/parse*) + search-term (some-> params :search-term) + plugin-url (some-> params :plugin) + template (some-> params :template) + pending-action-id (some-> params :pending-action-id uuid/parse*)] [:? #_[:& app.main.ui.releases/release-notes-modal {:version "2.5"}] #_[:& app.main.ui.onboarding/onboarding-templates-modal] @@ -257,7 +258,8 @@ :search-term search-term :plugin-url plugin-url :project-id project-id - :template template}]]]) + :template template + :pending-action-id pending-action-id}]]]) :workspace (let [params (get params :query) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index bda371bd46..fb96681b6c 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -41,6 +41,7 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.object :as obj] + [app.util.session-state :as ss] [app.util.storage :as storage] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -270,8 +271,22 @@ (st/emit! (dprof/update-profile-props {:onboarding-viewed true}) (dnt/show-nitrate-popup :nitrate-form))))) +(defn- use-pending-action + "Consumes a pending dashboard action from session storage and resumes it" + [pending-action-id] + (mf/with-effect [pending-action-id] + (when (some? pending-action-id) + (dom/replace-history-state! + (dom/remove-query-param (rt/get-current-href) :pending-action-id)) + (when-let [action (ss/consume-pending-action! (str pending-action-id))] + (case (:type action) + :add-team-to-organization + (st/emit! (dnt/add-team-to-organization {:team-id (:team-id action) + :organization-id (:organization-id action)})) + nil))))) + (mf/defc dashboard* - [{:keys [profile project-id team-id search-term plugin-url template section]}] + [{:keys [profile project-id team-id search-term plugin-url template section pending-action-id]}] (let [team (mf/deref refs/team) projects (mf/deref refs/projects) @@ -309,6 +324,7 @@ (use-plugin-register plugin-url team-id (:id default-project)) (use-templates-import can-edit? template default-project) (use-nitrate-entry-popup) + (use-pending-action pending-action-id) [:& (mf/provider ctx/current-project-id) {:value project-id} [:> modal-container*] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 8deb4e8b23..56df6cc5f1 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -1559,11 +1559,11 @@ (fn [] (st/emit! (dnt/show-remove-team-from-org-modal {:team-id (:id team)})))) - on-add-team-to-org + on-add-team-to-organization (mf/use-fn (mf/deps team) (fn [] - (st/emit! (dnt/show-add-team-to-org-modal {:team-id (:id team)})))) + (st/emit! (dnt/show-add-team-to-organization-modal {:team-id (:id team)})))) on-change-team-org (mf/use-fn @@ -1640,7 +1640,7 @@ (when can-add-to-organization? [:div {:class (stl/css :block-content)} [:span {:class (stl/css :block-text)} - [:a {:on-click on-add-team-to-org} (tr "dashboard.team-organization.add")]]])]))]) + [:a {:on-click on-add-team-to-organization} (tr "dashboard.team-organization.add")]]])]))]) [:div {:class (stl/css :block)} [:div {:class (stl/css :block-label)} diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index f4be22b6c9..faf5501f37 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -12,6 +12,7 @@ [app.common.geom.rect :as grc] [app.common.logging :as log] [app.common.media :as cm] + [app.common.uri :as u] [app.util.globals :as globals] [app.util.object :as obj] [app.util.webapi :as wapi] @@ -855,7 +856,42 @@ (defn browser-back [] - (.back (.-history js/window))) + (.back (.-history globals/window))) + +(defn replace-history-state! + "Replace the current browser history entry URL without triggering navigation." + [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))) + +(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))) (defn reload-current-window ([] diff --git a/frontend/src/app/util/session_state.cljs b/frontend/src/app/util/session_state.cljs new file mode 100644 index 0000000000..d16f3b6779 --- /dev/null +++ b/frontend/src/app/util/session_state.cljs @@ -0,0 +1,29 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.util.session-state + "Helpers for persisting transient state in browser session storage + so it survives cross-origin redirects." + (:require + [app.util.storage :as storage])) + +(def ^:private pending-actions-key ::pending-actions) + +(defn save-pending-action! + "Persist an action map under `id` in the browser session." + [id data] + (binding [storage/*sync* true] + (swap! storage/session update pending-actions-key assoc id data))) + +(defn consume-pending-action! + "Read and remove the pending action stored under `id`. + Returns nil if not found." + [id] + (let [action (get-in @storage/session [pending-actions-key id])] + (when (some? action) + (binding [storage/*sync* true] + (swap! storage/session update pending-actions-key dissoc id))) + action))