Check for nitrate sso on move team to another organization (#10379)

This commit is contained in:
Pablo Alba 2026-06-24 10:02:13 +02:00 committed by GitHub
parent fa9012e55f
commit 8fa15f240f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 316 additions and 222 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 <url>} 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

View File

@ -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}))

View File

@ -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)

View File

@ -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*]

View File

@ -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)}

View File

@ -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
([]

View File

@ -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))