Add nitrate add team members permission

This commit is contained in:
Pablo Alba
2026-05-19 17:18:09 +02:00
committed by Pablo Alba
parent 3e733bb762
commit dac98c0625
12 changed files with 619 additions and 92 deletions

View File

@@ -410,6 +410,17 @@
[:permissions [:map-of :keyword :string]]]
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)))
(defn- redeem-activation-code-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
@@ -432,6 +443,7 @@
:get-org-summary (partial get-org-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-api cfg)
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
:get-org-members (partial get-org-members-api cfg)
:delete-owned-orgs (partial delete-owned-orgs-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
@@ -511,4 +523,3 @@

View File

@@ -372,3 +372,75 @@
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
nil)
(def ^:private sql:get-profiles-by-emails
"SELECT id, email
FROM profile
WHERE email = ANY(?)
AND deleted_at IS NULL")
(def ^:private sql:get-org-direct-invitation-emails
"SELECT DISTINCT email_to
FROM team_invitation
WHERE org_id = ?
AND team_id IS NULL
AND valid_until >= now()")
(defn get-org-direct-invitation-emails
"Returns the set of emails that have a pending direct org-level invitation
(i.e. invited to the org itself, not to a specific team)."
[conn org-id]
(->> (db/exec! conn [sql:get-org-direct-invitation-emails org-id])
(map :email-to)
(into #{})))
(def ^:private schema:check-org-members-params
[:map {:title "CheckOrgMembersParams"}
[:organization-id ::sm/uuid]
[:emails [:vector ::sm/email]]])
(sv/defmethod ::check-org-members
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:check-org-members-params
::sm/result [:map-of :string :boolean]
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
(or (when (contains? cf/flags :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)
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
invited-emails (get-org-direct-invitation-emails conn organization-id)]
(into {}
(map (fn [email]
(let [pid (get email->id email)]
[email (boolean (or (and pid (contains? org-member-ids pid))
(contains? invited-emails email)))])))
emails)))
{}))
(def ^:private schema:all-org-members-in-team-params
[:map {:title "CheckOrgMembersInTeamParams"}
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]])
(sv/defmethod ::all-org-members-in-team
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-org-members-in-team-params
::sm/result ::sm/boolean}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
org-member-ids (into #{} org-members)
team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))]
(every? #(contains? team-member-ids %) org-member-ids)))
false))

View File

@@ -14,6 +14,7 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team]
[app.common.uuid :as uuid]
[app.config :as cf]
@@ -24,6 +25,7 @@
[app.main :as-alias main]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.nitrate :as cnit]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
@@ -112,8 +114,21 @@
(let [notifications (dm/get-in member [:props :notifications])]
(not= :none (:email-invites notifications))))
(defn- assert-email-can-be-invited
"Asserts that member/email is either an org member or has a pending
direct org invitation. org-member-ids is non-nil only when the org
restricts who can be added to teams."
[member email org-member-ids invited-emails]
(when (some? org-member-ids)
(let [is-member? (or (and (some? member) (contains? org-member-ids (:id member)))
(contains? invited-emails email))]
(when-not is-member?
(ex/raise :type :validation
:code :email-not-org-member
:hint "The invited email is not a member of the organization")))))
(defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids invited-emails] :as params}]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
@@ -130,6 +145,14 @@
:code :email-domain-is-not-allowed
:hint "email domain is in the blacklist"))
;; When nitrate is active and the team belongs to an org, check that
;; the email is already an org member or has a pending org-level
;; invitation, unless the org explicitly allows adding anybody.
(when (and (contains? cf/flags :nitrate)
(:organization team))
(assert-email-can-be-invited member email org-member-ids invited-emails))
;; When we have email verification disabled and invitation user is
;; already present in the database, we proceed to add it to the
;; team as-is, without email roundtrip.
@@ -223,18 +246,15 @@
:organization-initials (:initials organization)
:token itoken
:extra-data ptoken}))
(let [team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {})
team)]
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (dm/get-in team [:organization :name])
:token itoken
:extra-data ptoken}))))
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (dm/get-in team [:organization :name])
:token itoken
:extra-data ptoken})))
itoken)))))
@@ -309,7 +329,20 @@
- emails (set) + role (single role for all emails)
- invitations (vector of {:email :role} maps)"
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
(let [;; Normalize input to a consistent format: [{:email :role}]
(let [;; Enrich team with org info once for all invitations when nitrate is active
team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {})
team)
org (:organization team)
org-id (:id org)
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
org-member-ids (when restricted?
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
invited-emails (when restricted?
(cnit/get-org-direct-invitation-emails conn org-id))
params (assoc params :team team :org-member-ids org-member-ids :invited-emails invited-emails)
;; Normalize input to a consistent format: [{:email :role}]
invitation-data (cond
;; Case 1: emails + single role (create invitations style)
(and emails role)

View File

@@ -654,3 +654,50 @@ LEFT JOIN profile AS p
:teams-to-transfer (count valid-teams-to-transfer)
:teams-to-exit (count valid-teams-to-exit)}))
;; API: cleanup-org-team-invitations
(def ^:private sql:get-profile-emails-by-ids
"SELECT email
FROM profile
WHERE id = ANY(?)
AND deleted_at IS NULL")
(def ^:private sql:delete-orphaned-team-invitations
"DELETE FROM team_invitation
WHERE team_id = ANY(?)
AND email_to <> ALL(?)
AND valid_until >= now()
RETURNING email_to")
(def ^:private schema:cleanup-org-team-invitations-params
[:map
[:organization-id ::sm/uuid]
[:team-ids [:vector ::sm/uuid]]
[:member-ids [:vector ::sm/uuid]]])
(sv/defmethod ::cleanup-org-team-invitations
"Delete team invitations for emails that are not organization members
and do not have pending org-level invitations"
{::doc/added "2.18"
::sm/params schema:cleanup-org-team-invitations-params
::db/transaction true}
[cfg {:keys [organization-id team-ids member-ids]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [;; Get emails of organization members
member-ids-array (db/create-array conn "uuid" member-ids)
member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
(map :email)
(into #{}))
;; Get emails with org-level invitations
org-invitation-emails (cnit/get-org-direct-invitation-emails conn organization-id)
;; Combine both sets: emails that should be kept
emails-to-keep (into member-emails org-invitation-emails)
emails-array (db/create-array conn "text" (vec emails-to-keep))
teams-array (db/create-array conn "uuid" team-ids)]
;; Delete invitations that are not in the keep list
(db/exec! conn [sql:delete-orphaned-team-invitations teams-array emails-array])
nil))))

View File

@@ -675,6 +675,102 @@
(t/is (nil? (:result out)))
(t/is (empty? remaining)))))
(t/deftest cleanup-org-team-invitations-removes-orphaned-invitations
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
member2 (th/create-profile* 2 {:is-active true :email "member2@example.com"})
profile (th/create-profile* 4 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
params {::th/type :cleanup-org-team-invitations
::rpc/profile-id (:id profile)
:organization-id org-id
:team-ids [(:id team-1) (:id team-2)]
:member-ids [(:id member1) (:id member2)]}]
;; Should remain: member1 is an org member.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "member1@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should remain: has org-level invitation (not an org member yet).
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: not an org member and no org-level invitation.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "nonmember@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: orphaned invitation (no org member, no org invitation).
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "orphan@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should remain: expired invitation (should not be cleaned up).
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "expired@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-past "1h")})
;; Should remain: outside org scope.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to "outsider@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (management-command-with-nitrate! params)]
(t/is (th/success? out))
(t/is (nil? (:result out)))
;; Verify remaining invitations.
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"}))))
(t/is (= 2 (count (th/db-query :team-invitation {:email-to "pending@example.com"}))))
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "nonmember@example.com"}))))
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"}))))
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "expired@example.com"}))))
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests: remove-from-org
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -10,7 +10,8 @@
{:create-teams "any"
:delete-teams "onlyOwners"
:move-teams "always"
:send-invitations "ownersAndAdmins"})
:send-invitations "ownersAndAdmins"
:new-team-members "anyone"})
(defn- can-create-team?
[{:keys [is-org-owner? permission-value]}]
@@ -49,15 +50,21 @@
:else false))
(defn- can-add-anybody-to-team?
[{:keys [permission-value]}]
(= permission-value "anyone"))
(def ^:private action-rules
{:create-team {:permission-key :create-teams
:check-fn can-create-team?}
:delete-team {:permission-key :delete-teams
:check-fn can-delete-team?}
:move-team {:permission-key :move-teams
:check-fn can-move-team?}
:send-invitations {:permission-key :send-invitations
:check-fn can-invite-to-team?}})
{:create-team {:permission-key :create-teams
:check-fn can-create-team?}
:delete-team {:permission-key :delete-teams
:check-fn can-delete-team?}
:move-team {:permission-key :move-teams
:check-fn can-move-team?}
:send-invitations {:permission-key :send-invitations
:check-fn can-invite-to-team?}
:add-anybody-to-team {:permission-key :new-team-members
:check-fn can-add-anybody-to-team?}})
(defn- normalize-org-permissions
[org-perms]

View File

@@ -20,7 +20,9 @@
[:permissions {:optional true}
[:maybe [:map
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]
[:delete-teams {:optional true} [:maybe [:enum "onlyMe" "onlyOwners"]]]]]]])
[:delete-teams {:optional true} [:maybe [:enum "onlyMe" "onlyOwners"]]]
[:move-teams {:optional true} [:maybe [:enum "always" "myOrganizations" "never"]]]
[:new-team-members {:optional true} [:maybe [:enum "anyone" "members"]]]]]]])
(def schema:team-with-organization

View File

@@ -67,6 +67,19 @@
(->> (rp/cmd! :get-teams)
(rx/map teams-fetched)))))
(defn- with-refreshed-team
"Fetches fresh team data from the server to ensure up-to-date org
permissions, updates the app state, and calls f with the fresh team data.
Returns an observable of events."
[team-id f]
(->> (rp/cmd! :get-teams)
(rx/mapcat
(fn [teams]
(let [team (d/seek #(= (:id %) team-id) teams)]
(rx/concat
(rx/of (teams-fetched teams))
(f team)))))))
(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."
@@ -75,26 +88,23 @@
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)
organization (:organization team)
in-org? (and (contains? cf/flags :nitrate) organization)
can-create? (if in-org?
(nitrate-perms/allowed? :create-team
{:org-perms {:owner-id (:owner-id organization)
:permissions (:permissions organization)}
:profile-id profile-id
:team-perms (:permissions team)})
true)]
(rx/of (teams-fetched teams)
(if can-create?
(modal/show :team-form (if in-org?
{:organization-id (:id organization)
:organization-name (:name organization)}
{}))
(modal/show :no-permission-modal {:type :create-team})))))))))))
(with-refreshed-team team-id
(fn [team]
(let [organization (:organization team)
in-org? (and (contains? cf/flags :nitrate) organization)
can-create? (if in-org?
(nitrate-perms/allowed? :create-team
{:org-perms {:owner-id (:owner-id organization)
:permissions (:permissions organization)}
:profile-id profile-id
:team-perms (:permissions team)})
true)]
(rx/of (if can-create?
(modal/show :team-form (if in-org?
{:organization-id (:id organization)
:organization-name (:name organization)}
{}))
(modal/show :no-permission-modal {:type :create-team}))))))))))
(defn check-and-delete-team
"Fetches fresh team data from the server to ensure up-to-date org
@@ -104,31 +114,57 @@
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)
org (:organization team)
in-org? (and (contains? cf/flags :nitrate) org)
can-delete? (if in-org?
(nitrate-perms/allowed? :delete-team
{:org-perms {:owner-id (:owner-id org)
:permissions (:permissions org)}
:profile-id profile-id
:team-perms (:permissions team)})
(boolean (dm/get-in team [:permissions :is-owner])))
message (if in-org?
(tr "modals.delete-org-team-confirm.message" (:name org))
(tr "modals.delete-team-confirm.message"))]
(rx/of (teams-fetched teams)
(if can-delete?
(modal/show
{:type :confirm
:title (tr "modals.delete-team-confirm.title")
:message message
:accept-label (tr "modals.delete-team-confirm.accept")
:on-accept delete-fn})
(modal/show :no-permission-modal {:type :delete-team})))))))))))
(with-refreshed-team team-id
(fn [team]
(let [org (:organization team)
in-org? (and (contains? cf/flags :nitrate) org)
can-delete? (if in-org?
(nitrate-perms/allowed? :delete-team
{:org-perms {:owner-id (:owner-id org)
:permissions (:permissions org)}
:profile-id profile-id
:team-perms (:permissions team)})
(boolean (dm/get-in team [:permissions :is-owner])))
message (if in-org?
(tr "modals.delete-org-team-confirm.message" (:name org))
(tr "modals.delete-team-confirm.message"))]
(rx/of (if can-delete?
(modal/show
{:type :confirm
:title (tr "modals.delete-team-confirm.title")
:message message
:accept-label (tr "modals.delete-team-confirm.accept")
:on-accept delete-fn})
(modal/show :no-permission-modal {:type :delete-team}))))))))))
(defn- check-new-team-members-permission-and-show-invite-members
"Receives refreshed team data with up-to-date org
permissions, then shows the invite members modal or an appropriate alert."
[{:keys [team invite-email origin]}]
(ptk/reify ::check-new-team-members-permission-and-show-invite-members
ptk/WatchEvent
(watch [_ _ _]
(let [show-invite (rx/of (modal/show {:type :invite-members
:team team
:origin (or origin :team)
:invite-email invite-email}))]
(if (and (contains? cf/flags :nitrate)
(not (nitrate-perms/allowed? :add-anybody-to-team
{:org-perms (:organization team)})))
(->> (rp/cmd! :all-org-members-in-team
{:team-id (:id team)
:organization-id (get-in team [:organization :id])})
(rx/mapcat
(fn [all-org-members-in-team?]
(if all-org-members-in-team?
(rx/of (modal/show
{:type :alert
:message (tr "modals.invite-restricted-members.all-org-members-in-team")
:accept-label (tr "labels.accept")
:accept-style :primary
:title (tr "modals.invite-team-member.title")}))
show-invite))))
show-invite)))))
(defn check-and-invite-members
"Fetches fresh team data from the server to ensure up-to-date org
@@ -139,23 +175,19 @@
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)
org (:organization team)
can-invite? (nitrate-perms/can-send-invitations?
{:nitrate-enabled? (contains? cf/flags :nitrate)
:organization org
:profile-id profile-id
:team-permissions (:permissions team)})]
(rx/of (teams-fetched teams)
(if can-invite?
(modal/show {:type :invite-members
:team team
:origin origin
:invite-email invite-email})
(modal/show :no-permission-modal {:type :invite-members})))))))))))
(with-refreshed-team team-id
(fn [team]
(let [org (:organization team)
can-invite? (nitrate-perms/can-send-invitations?
{:nitrate-enabled? (contains? cf/flags :nitrate)
:organization org
:profile-id profile-id
:team-permissions (:permissions team)})]
(rx/of (if can-invite?
(check-new-team-members-permission-and-show-invite-members {:team team
:origin origin
:invite-email invite-email})
(modal/show :no-permission-modal {:type :invite-members}))))))))))
;; --- EVENT: fetch-members
@@ -520,6 +552,53 @@
(rx/tap on-success)
(rx/catch on-error))))))
(defn check-and-submit-invite-members
"Fetches fresh team data from the server to ensure up-to-date org
permissions, then submits member invitations or shows a restriction modal."
[{:keys [team-id] :as params} origin do-invite-members]
(ptk/reify ::check-and-submit-invite-members
ptk/WatchEvent
(watch [_ _ _]
(if (contains? cf/flags :nitrate)
(with-refreshed-team team-id
(fn [team]
(if (not (nitrate-perms/allowed? :add-anybody-to-team
{:org-perms (:organization team)}))
(->> (rp/cmd! :check-org-members {:organization-id (get-in team [:organization :id])
:emails (vec (:emails params))})
(rx/mapcat
(fn [result]
(let [blocked (into [] (comp (filter (fn [[_ v]] (not v)))
(map first))
result)]
(cond
(empty? blocked)
(do (do-invite-members params origin) (rx/empty))
(= (count blocked) (count result))
(rx/of
(modal/show
{:type :alert
:title (tr "modals.invite-restricted-members.all-blocked-title")
:message (tr "modals.invite-restricted-members.all-blocked")
:accept-label (tr "labels.accept")
:accept-style :primary}))
:else
(rx/of
(modal/show
{:type :invite-restricted-members
:blocked-emails blocked
:on-accept (fn []
(let [valid-emails (into #{} (filter (fn [e] (get result e)))
(:emails params))
params' (assoc params :emails valid-emails)]
(do-invite-members params' origin)))})))))))
(do (do-invite-members params origin)
(rx/empty)))))
(do (do-invite-members params origin)
(rx/empty))))))
(defn copy-invitation-link
[{:keys [email team-id] :as params}]
(assert (sm/check-email email))

View File

@@ -19,6 +19,7 @@
[app.main.data.team :as dtm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.alert]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.components.forms :as fm]
@@ -153,6 +154,14 @@
[:emails [::sm/set {:min 1} ::sm/email]]
[:team-id ::sm/uuid]])
(defn- do-invite-members!
[params origin]
(st/emit! (-> (dtm/create-invitations params)
(with-meta {::ev/origin origin}))
(dtm/fetch-invitations)
(dtm/fetch-members)))
(mf/defc invite-members-modal
{::mf/register modal/components
::mf/register-as :invite-members
@@ -222,11 +231,7 @@
(let [params (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}]
(st/emit! (-> (dtm/create-invitations (with-meta params mdata))
(with-meta {::ev/origin origin}))
;; FIXME: looks duplicate
(dtm/fetch-invitations)
(dtm/fetch-members))))]
(st/emit! (dtm/check-and-submit-invite-members (with-meta params mdata) origin do-invite-members!))))]
[:div {:class (stl/css-case :modal-team-container true
:modal-team-container-workspace (= origin :workspace)
@@ -269,6 +274,63 @@
:disabled (and (boolean (some current-data-emails current-members-emails))
(empty? (remove current-members-emails current-data-emails)))}]]]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INVITE RESTRICTED MEMBERS MODAL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc invite-restricted-members-modal
{::mf/register modal/components
::mf/register-as :invite-restricted-members}
[{:keys [on-accept blocked-emails]}]
(let [expanded* (mf/use-state false)
expanded? (deref expanded*)
on-toggle (mf/use-fn #(swap! expanded* not))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-restricted-container :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)}
(tr "modals.invite-restricted-members.title")]
[:button {:class (stl/css :modal-close-btn)
:on-click modal/hide!} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
[:p (tr "modals.invite-restricted-members.description")]
[:& context-notification {:content (tr "modals.invite-restricted-members.warning")
:level :warning}]
[:div {:class (stl/css :restricted-emails-section)}
[:button {:class (stl/css :restricted-emails-toggle)
:type "button"
:aria-expanded expanded?
:on-click on-toggle}
[:span {:class (stl/css :restricted-email-summary)}
(tr "modals.invite-restricted-members.blocked-addresses")]
[:> icon* {:icon-id i/arrow
:size "s"
:class (stl/css-case :restricted-emails-arrow true
:expanded expanded?)}]]
(when expanded?
[:ul {:class (stl/css :restricted-email-list)}
(for [email blocked-emails]
[:li {:key email} email])])]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)}
[:> button*
{:class (stl/css :cancel-button)
:variant "secondary"
:type "button"
:on-click modal/hide!}
(tr "modals.invite-restricted-members.cancel")]
[:> button*
{:class (stl/css :accept-btn)
:variant "primary"
:type "button"
:on-click (fn []
(modal/hide!)
(on-accept))}
(tr "modals.invite-restricted-members.send")]]]]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MEMBERS SECTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -871,6 +871,54 @@
gap: var(--sp-s);
}
// INVITE RESTRICTED MEMBERS MODAL
.modal-restricted-container {
overflow: hidden;
display: flex;
flex-direction: column;
color: var(--color-foreground-secondary);
}
.restricted-email-summary {
color: var(--color-accent-primary);
}
.restricted-emails-section {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.restricted-emails-toggle {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--sp-xs);
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
.restricted-emails-arrow {
color: var(--color-accent-primary);
transition: transform 0.15s ease;
}
.restricted-emails-arrow.expanded {
transform: rotate(90deg);
}
.restricted-email-list {
list-style: none;
margin: var(--sp-s) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
// SELECT ORGANIZATION MODAL
.modal-select-org-container {

View File

@@ -3731,6 +3731,41 @@ msgstr "Edit webhook"
msgid "modals.edit-webhook.title"
msgstr "Edit webhook"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.title"
msgstr "Some invitations can't be sent"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.description"
msgstr "Only existing members of the organization can be invited."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.warning"
msgstr "Some people on your list won't receive the invitations."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.blocked-addresses"
msgstr "Addresses that won't receive an invitation"
msgid "modals.invite-restricted-members.all-blocked-title"
msgstr "No invitations were sent"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.all-blocked"
msgstr "Only existing members of the organization can be invited. Because none of the people on your list are members, no invitations were sent."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.all-org-members-in-team"
msgstr "Only existing members of the organization can be invited to this team, and they are all already team members. If you need more information, contact the organization's owner."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.cancel"
msgstr "Cancel"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.send"
msgstr "Send invites"
#: src/app/main/ui/dashboard/team.cljs:249
msgid "modals.invite-member-confirm.accept"
msgstr "Send invitation"

View File

@@ -3620,6 +3620,41 @@ msgstr "Modificar webhook"
msgid "modals.edit-webhook.title"
msgstr "Modificar webhook"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.title"
msgstr "Algunas invitaciones no se pueden enviar"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.description"
msgstr "Solo se puede invitar a miembros existentes de la organización."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.warning"
msgstr "Algunas personas de tu lista no recibirán las invitaciones."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.blocked-addresses"
msgstr "Direcciones que no recibirán una invitación"
msgid "modals.invite-restricted-members.all-blocked-title"
msgstr "No se envió ninguna invitación"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.all-blocked"
msgstr "Solo se puede invitar a miembros existentes de la organización. Como ninguna de las personas de tu lista es miembro, no se envió ninguna invitación."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.all-org-members-in-team"
msgstr "Solo se puede invitar a este equipo a miembros existentes de la organización, y todas esas personas ya son miembros del equipo. Si necesitas más información, contacta con la persona propietaria de la organización."
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.cancel"
msgstr "Cancelar"
#: src/app/main/ui/dashboard/team.cljs
msgid "modals.invite-restricted-members.send"
msgstr "Enviar invitaciones"
#: src/app/main/ui/dashboard/team.cljs:249
msgid "modals.invite-member-confirm.accept"
msgstr "Enviar invitacion"