Add Nitrate's advanced permissions

*  Add Nitrate's advanced permissions

* 📎 Code review
This commit is contained in:
María Valderrama 2026-05-06 10:13:17 +02:00 committed by GitHub
parent 94f8370d98
commit 4ddabaebff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 285 additions and 83 deletions

View File

@ -348,6 +348,20 @@
[:map
[:cancel-at [:maybe schema:timestamp]]])
(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]
[:create-teams [:enum "any" "onlyMe"]]]
params)))
(defn- redeem-activation-code-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
@ -372,6 +386,7 @@
:add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
:get-org-permissions (partial get-org-permissions-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)

View File

@ -12,6 +12,7 @@
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
@ -305,6 +306,20 @@
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :nitrate)
(let [org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})]
(if (nil? org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(let [create-perm (:create-teams org-perms)
is-owner? (= profile-id (:owner-id org-perms))]
(when (and (= create-perm "onlyMe") (not is-owner?))
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to add teams in this organization"))))))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
;; Add teammates to the org if needed
(doseq [{member-id :profile-id} team-members

View File

@ -506,11 +506,27 @@
(sv/defmethod ::create-team
{::doc/added "1.17"
::sm/params schema:create-team}
[cfg {:keys [::rpc/profile-id] :as params}]
[cfg {:keys [::rpc/profile-id organization-id] :as params}]
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
;; When creating inside an org, verify the user has permission to do so.
;; Fail closed: if org permissions cannot be fetched, deny the operation.
(when (and organization-id (contains? cf/flags :nitrate))
(let [org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})]
(if (nil? org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(let [create-perm (:create-teams org-perms)
is-owner? (= profile-id (:owner-id org-perms))]
(when (and (= create-perm "onlyMe") (not is-owner?))
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to create teams in this organization"))))))
(let [features (-> (cfeat/get-enabled-features cf/flags)
(set/difference cfeat/frontend-only-features)
(set/difference cfeat/no-team-inheritable-features))

View File

@ -15,7 +15,8 @@
[:slug ::sm/text]
[:owner-id ::sm/uuid]
[:avatar-bg-url ::sm/uri]
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]])
(def schema:team-with-organization
@ -31,7 +32,8 @@
[:custom-photo :organization-custom-photo]
[:slug :organization-slug]
[:avatar-bg-url :organization-avatar-bg-url]
[:owner-id :organization-owner-id]])
[:owner-id :organization-owner-id]
[:create-teams :organization-create-teams]])
(defn apply-organization
"Updates a team map with organization fields sourced from org.
@ -47,4 +49,4 @@
(assoc acc team-k v)
(dissoc acc team-k))))
team
organization->team-keys)))
organization->team-keys)))

View File

@ -134,3 +134,81 @@
(rx/mapcat
(fn [_]
(rx/of (modal/hide))))))))
(defn show-add-team-to-org-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/WatchEvent
(watch [_ state _]
(let [profile-id (dm/get-in state [:profile :id])]
(->> (rp/cmd! :get-teams)
(rx/mapcat
(fn [teams]
(let [all-orgs (map dt/team->organization
(filter #(and (:is-default %) (:organization-id %)) teams))
orgs (filter (fn [org]
(let [perm (:create-teams org)
is-own? (= profile-id (:owner-id org))]
(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})))]
(rx/of (dt/teams-fetched teams)
(if (empty? orgs)
(modal/show :no-org-allows-create-team {})
(let [has-filtered? (< (count orgs) (count all-orgs))
extra-props (when has-filtered?
{:info-message-key "dashboard.select-org-modal.permission-info"})]
(modal/show :select-organization-modal
(merge {:organizations orgs
:current-organization-id (:organization-id team)
:on-confirm on-confirm
:title-key "dashboard.select-org-modal.title"
:choose-key "dashboard.select-org-modal.choose"
:placeholder-key "dashboard.select-org-modal.select"
:accept-key "dashboard.select-org-modal.accept"
:cancel-key "labels.cancel"}
extra-props)))))))))))))
(defn show-change-team-org-modal
"Fetches fresh team/org data, then shows the change-org modal
restricted to orgs where the user has permission, or the no-permission
modal if none qualify."
[{:keys [team-id]}]
(ptk/reify ::show-change-team-org-modal
ptk/WatchEvent
(watch [_ state _]
(let [profile-id (dm/get-in state [:profile :id])]
(->> (rp/cmd! :get-teams)
(rx/mapcat
(fn [teams]
(let [all-orgs (map dt/team->organization
(filter #(and (:is-default %) (:organization-id %)) teams))
orgs (filter (fn [org]
(let [perm (:create-teams org)
is-own? (= profile-id (:owner-id org))]
(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})))]
(rx/of (dt/teams-fetched teams)
(if (empty? orgs)
(modal/show :no-org-allows-create-team {})
(let [has-filtered? (< (count orgs) (count all-orgs))
extra-props (when has-filtered?
{:info-message-key "dashboard.select-org-modal.permission-info"})]
(modal/show :select-organization-modal
(merge {:organizations orgs
:current-organization-id (:organization-id team)
:on-confirm on-confirm
:title-key "dashboard.change-org-modal.title"
:choose-key "dashboard.change-org-modal.choose"
:placeholder-key "dashboard.change-org-modal.select"
:accept-key "dashboard.change-org-modal.accept"
:cancel-key "labels.cancel"}
extra-props)))))))))))))

View File

@ -16,6 +16,7 @@
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.media :as di]
[app.main.data.modal :as modal]
[app.main.data.profile :as dp]
[app.main.features :as features]
[app.main.repo :as rp]
@ -50,7 +51,8 @@
:organization-name
:organization-slug
:organization-owner-id
:organization-avatar-bg-url))]
:organization-avatar-bg-url
:organization-create-teams))]
(update state :teams assoc id team-updated)))
state
teams)))))
@ -63,6 +65,30 @@
(->> (rp/cmd! :get-teams)
(rx/map teams-fetched)))))
(defn check-and-create-team
"Fetches fresh team data from the server to ensure up-to-date org
permissions, then shows the team-form modal or a no-permission modal."
[team-id]
(ptk/reify ::check-and-create-team
ptk/WatchEvent
(watch [_ state _]
(let [profile-id (dm/get-in state [:profile :id])]
(->> (rp/cmd! :get-teams)
(rx/mapcat
(fn [teams]
(let [team (d/seek #(= (:id %) team-id) teams)
in-org? (and (contains? cf/flags :nitrate) (:organization-id team))
create-perm (:organization-create-teams team)
is-owner? (= profile-id (:organization-owner-id team))
can-create? (or (not in-org?) (= create-perm "any") is-owner?)]
(rx/of (teams-fetched teams)
(if can-create?
(modal/show :team-form (if in-org?
{:organization-id (:organization-id team)
:organization-name (:organization-name team)}
{}))
(modal/show :no-permission-create-team {:organization-name (:organization-name team)})))))))))))
;; --- EVENT: fetch-members
(defn- members-fetched
@ -598,5 +624,6 @@
:avatar-bg-url (:organization-avatar-bg-url team)
:custom-photo (:organization-custom-photo team)
:name (:organization-name team)
:default-team-id (:id team)})
:default-team-id (:id team)
:create-teams (:organization-create-teams team)})

View File

@ -384,10 +384,9 @@
(mf/use-fn
(mf/deps team)
(fn []
(let [params (if (and (contains? cf/flags :nitrate) (:organization-id team))
{:organization-id (:organization-id team)}
{})]
(st/emit! (modal/show :team-form params)))))
(if (contains? cf/flags :nitrate)
(st/emit! (dtm/check-and-create-team (:id team)))
(st/emit! (modal/show :team-form {})))))
on-team-click
(mf/use-fn

View File

@ -810,7 +810,7 @@
(mf/defc select-organization-modal
{::mf/register modal/components
::mf/register-as :select-organization-modal}
[{:keys [organizations current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key]}]
[{:keys [organizations current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}]
(let [valid-organizations (mf/with-memo [organizations]
(remove #(= (:id %) current-organization-id) organizations))
options (mf/with-memo [valid-organizations]
@ -848,6 +848,9 @@
[:div {:class (stl/css :modal-content :modal-select-org-text)} (tr text-key)])
[:div
(when info-message-key
[:div {:class (stl/css :modal-select-org-info)}
(tr info-message-key)])
[:div {:class (stl/css :modal-select-org-content)}
(tr choose-key)]
[:> combobox* {:id "selected-id"
@ -1366,18 +1369,29 @@
can-edit (or (:is-owner permissions)
(:is-admin permissions))
organizations (mf/deref refs/teams)
organizations (mf/with-memo [organizations]
(->> (vals organizations)
(filter :is-default)
(filter :organization-id)
(map dtm/team->organization)))
profile (mf/deref refs/profile)
profile-id (:id profile)
all-organizations (mf/deref refs/teams)
all-organizations (mf/with-memo [all-organizations]
(->> (vals all-organizations)
(filter :is-default)
(filter :organization-id)
(map dtm/team->organization)))
;; Filter to orgs where user is allowed to create/add teams
organizations (mf/with-memo [all-organizations profile-id]
(->> all-organizations
(filter (fn [org]
(let [perm (:create-teams org)
is-owner? (= profile-id (:owner-id org))]
(or (= perm "any") is-owner?))))))
can-change-organization? (mf/with-memo [organizations]
(> (count organizations) 1))
can-add-to-organization? (mf/with-memo [organizations]
(and (pos? (count organizations))
can-add-to-organization? (mf/with-memo [organizations all-organizations]
(and (pos? (count all-organizations))
(not (:is-default team))))
show-org-options-menu*
@ -1424,41 +1438,17 @@
:accept-style :danger}]
(st/emit! (modal/show params)))))
on-add-team-to-org-confirm
(mf/use-fn
(mf/deps team)
(fn [organization-id]
(let [organization (d/seek #(= organization-id (:id %)) organizations)]
(when organization
(st/emit! (dnt/add-team-to-org {:team-id (:id team)
:organization-id organization-id}))))))
on-add-team-to-org
(mf/use-fn
(mf/deps organizations on-add-team-to-org-confirm)
(mf/deps team)
(fn []
(st/emit! (modal/show :select-organization-modal {:organizations organizations
:current-organization-id (:organization-id team)
:on-confirm on-add-team-to-org-confirm
:title-key "dashboard.select-org-modal.title"
:choose-key "dashboard.select-org-modal.choose"
:placeholder-key "dashboard.select-org-modal.select"
:accept-key "dashboard.select-org-modal.accept"
:cancel-key "labels.cancel"}))))
(st/emit! (dnt/show-add-team-to-org-modal {:team-id (:id team)}))))
on-change-team-org
(mf/use-fn
(mf/deps organizations on-add-team-to-org-confirm)
(mf/deps team)
(fn []
(st/emit! (modal/show :select-organization-modal {:organizations organizations
:current-organization-id (:organization-id team)
:on-confirm on-add-team-to-org-confirm
:title-key "dashboard.change-org-modal.title"
:text-key "dashboard.change-org-modal.text"
:choose-key "dashboard.change-org-modal.choose"
:placeholder-key "dashboard.change-org-modal.select"
:accept-key "dashboard.change-org-modal.accept"
:cancel-key "labels.cancel"}))))]
(st/emit! (dnt/show-change-team-org-modal {:team-id (:id team)}))))]
(mf/with-effect [team]
(dom/set-html-title (tr "title.team-settings"

View File

@ -887,6 +887,13 @@
margin-block-end: var(--sp-s);
}
.modal-select-org-info {
@include t.use-typography("body-medium");
color: var(--color-foreground-secondary);
margin-block-end: var(--sp-xxxl);
}
.modal-select-org-title {
@include t.use-typography("title-medium");

View File

@ -42,16 +42,19 @@
(modal/hide))))
(defn- on-error
[form _response]
(let [id (get-in @form [:clean-data :id])]
(if id
(rx/of (ntf/error "Error on updating team."))
(rx/of (ntf/error "Error on creating team.")))))
[form organization-name response]
(let [id (get-in @form [:clean-data :id])
code (-> response ex-data :code)]
(if (= code :not-allowed)
(rx/of (modal/show :no-permission-create-team {:organization-name organization-name}))
(if id
(rx/of (ntf/error "Error on updating team."))
(rx/of (ntf/error "Error on creating team."))))))
(defn- on-create-submit
[form]
[form organization-name]
(let [mdata {:on-success (partial on-create-success form)
:on-error (partial on-error form)}
:on-error (partial on-error form organization-name)}
data (:clean-data @form)
params (cond-> {:name (:name data)}
(:organization-id data) (assoc :organization-id (:organization-id data)))]
@ -61,23 +64,23 @@
(defn- on-update-submit
[form]
(let [mdata {:on-success (partial on-update-success form)
:on-error (partial on-error form)}
:on-error (partial on-error form nil)}
data (:clean-data @form)
team (select-keys data [:id :name])] ;; Only send name and id for updates
team (select-keys data [:id :name])]
(st/emit! (dtm/update-team (with-meta team mdata))
(modal/hide))))
(defn- on-submit
[form _]
[organization-name form _]
(let [data (:clean-data @form)]
(if (:id data)
(on-update-submit form)
(on-create-submit form))))
(on-create-submit form organization-name))))
(mf/defc team-form-modal
{::mf/register modal/components
::mf/register-as :team-form}
[{:keys [team organization-id] :as props}]
[{:keys [team organization-id organization-name] :as props}]
(let [initial (mf/use-memo
(mf/deps team organization-id)
(fn []
@ -89,18 +92,22 @@
organization-id (assoc :organization-id organization-id)))))
form (fm/use-form :schema schema:team-form
:initial initial)
on-submit* (mf/use-fn
(mf/deps organization-name)
(partial on-submit organization-name))
handle-keydown
(mf/use-fn
(mf/deps organization-name)
(fn [e]
(when (kbd/enter? e)
(dom/prevent-default e)
(dom/stop-propagation e)
(on-submit form e))))]
(on-submit organization-name form e))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:& fm/form {:form form
:on-submit on-submit
:on-submit on-submit*
:class (stl/css :team-form)}
[:div {:class (stl/css :modal-header)}
@ -132,3 +139,31 @@
:class (stl/css :accept-btn)}]]]]]]))
(mf/defc no-permission-create-team-modal*
{::mf/register modal/components
::mf/register-as :no-permission-create-team}
[{:keys [organization-name]}]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(tr "labels.create-team")]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:div (tr "dashboard.no-permission-create-team.message" organization-name)]]]])
(mf/defc no-org-allows-create-team-modal*
{::mf/register modal/components
::mf/register-as :no-org-allows-create-team}
[_props]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(tr "dashboard.select-org-modal.title")]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:div (tr "dashboard.no-org-allows-create-team.message")]]]])

View File

@ -5,6 +5,8 @@
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
@use "ds/typography.scss" as t;
.modal-overlay {
@extend %modal-overlay-base;
@ -15,11 +17,11 @@
}
.modal-header {
margin-bottom: deprecated.$s-24;
margin-block-end: var(--sp-xxl);
}
.modal-title {
@include deprecated.uppercase-title-typography;
@include t.use-typography("headline-large");
color: var(--modal-title-foreground-color);
}
@ -29,33 +31,31 @@
}
.modal-content {
margin-bottom: deprecated.$s-24;
margin-block-end: var(--sp-xxl);
color: var(--color-foreground-secondary);
@include t.use-typography("body-large");
}
.team-form {
min-width: deprecated.$s-400;
min-inline-size: px2rem(400);
}
.group-name-input {
@extend %input-element-label;
@include deprecated.body-small-typography;
margin-bottom: deprecated.$s-8;
margin-block-end: var(--sp-s);
}
label {
@include deprecated.flex-column;
@include deprecated.body-small-typography;
align-items: flex-start;
width: 100%;
border: none;
background-color: transparent;
height: 100%;
input {
@include deprecated.body-small-typography;
}
}
.group-name-input label {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
align-items: flex-start;
width: 100%;
border: none;
background-color: transparent;
height: 100%;
}
.action-buttons {

View File

@ -9395,4 +9395,13 @@ msgid "subscription.current-plan.nitrate-trial"
msgstr "Nitrate Trial"
msgid "subscription.current-plan.enterprise"
msgstr "Enterprise"
msgstr "Enterprise"
msgid "dashboard.no-permission-create-team.message"
msgstr "You are not allowed to create teams within %s organization. If you need more information, contact the owner."
msgid "dashboard.no-org-allows-create-team.message"
msgstr "You don't have permission to add teams to any of your organizations."
msgid "dashboard.select-org-modal.permission-info"
msgstr "Here you find all your organizations where you are allowed to create or add teams."

View File

@ -9103,4 +9103,13 @@ msgid "subscription.current-plan.nitrate-trial"
msgstr "Nitrate (Prueba)"
msgid "subscription.current-plan.enterprise"
msgstr "Enterprise"
msgstr "Enterprise"
msgid "dashboard.no-permission-create-team.message"
msgstr "No tienes permiso para crear equipos en la organización %s. Si necesitas más información, contacta con el propietario."
msgid "dashboard.no-org-allows-create-team.message"
msgstr "No tienes permiso para añadir equipos a ninguna de tus organizaciones."
msgid "dashboard.select-org-modal.permission-info"
msgstr "Aquí encontrarás todas las organizaciones en las que tienes permiso para crear o añadir equipos."