From dac98c0625ef47e7eec199e0b447a0a88b8732ea Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 19 May 2026 17:18:09 +0200 Subject: [PATCH] :sparkles: Add nitrate add team members permission --- backend/src/app/nitrate.clj | 13 +- backend/src/app/rpc/commands/nitrate.clj | 72 +++++++ .../app/rpc/commands/teams_invitations.clj | 61 ++++-- backend/src/app/rpc/management/nitrate.clj | 47 ++++ .../rpc_management_nitrate_test.clj | 96 +++++++++ .../app/common/types/nitrate_permissions.cljc | 25 ++- common/src/app/common/types/organization.cljc | 4 +- frontend/src/app/main/data/team.cljs | 203 ++++++++++++------ frontend/src/app/main/ui/dashboard/team.cljs | 72 ++++++- frontend/src/app/main/ui/dashboard/team.scss | 48 +++++ frontend/translations/en.po | 35 +++ frontend/translations/es.po | 35 +++ 12 files changed, 619 insertions(+), 92 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 1a77e6af2b..20943ad8a4 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -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 @@ - diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 55535f54a6..c0cd31c35b 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -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)) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index a7c551e941..9657372438 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -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) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 1bccedef3f..ffc62b1a3f 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -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)))) + diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 246b85302a..3069613bec 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/src/app/common/types/nitrate_permissions.cljc b/common/src/app/common/types/nitrate_permissions.cljc index fa7f72e741..f21f8bff70 100644 --- a/common/src/app/common/types/nitrate_permissions.cljc +++ b/common/src/app/common/types/nitrate_permissions.cljc @@ -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] diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 7a817d9e8b..32fcfef6e4 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -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 diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index c4dfcc50b2..417c07b972 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index a6908edf82..9bbc5795e8 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 5fe7d4ba21..1eb50e7a2a 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -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 { diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 201f641b3b..bb9865fd68 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6d6189b790..490cdf02f3 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"