From 2a48747cf68627c7556bc205f8684dc4b82b9f58 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 3 Jun 2026 19:09:49 +0200 Subject: [PATCH] :sparkles: Review nitrate add team members permission --- backend/src/app/rpc/management/nitrate.clj | 70 ++++++++++++++----- .../rpc_management_nitrate_test.clj | 62 +++++++++++++++- frontend/src/app/main/ui/dashboard/team.scss | 3 +- 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 81073e625d..f448ae630e 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -710,7 +710,8 @@ LEFT JOIN profile AS p :organizations organizations})))) nil) -;; API: cleanup-org-team-invitations +;; API: exists-org-team-invitations-for-non-members / +;; delete-org-team-invitations-for-non-members (def ^:private sql:get-profile-emails-by-ids "SELECT email @@ -718,35 +719,68 @@ LEFT JOIN profile AS p WHERE id = ANY(?) AND deleted_at IS NULL") -(def ^:private sql:delete-orphaned-team-invitations +(def ^:private sql:exists-non-member-org-team-invitations + "SELECT EXISTS ( + SELECT 1 + FROM team_invitation + WHERE team_id = ANY(?) + AND email_to <> ALL(?) + ) AS non_member") + +(def ^:private sql:delete-non-member-org-team-invitations "DELETE FROM team_invitation WHERE team_id = ANY(?) AND email_to <> ALL(?) RETURNING email_to") -(def ^:private schema:cleanup-org-team-invitations-params +(def ^:private schema:org-team-invitations-for-non-members-params [:map [: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" +(def ^:private schema:exists-org-team-invitations-for-non-members-result + [:map [:exists ::sm/boolean]]) + +(defn- org-team-invitations-for-non-members-arrays + "Member emails and PG arrays used by exists/delete org team invitation endpoints." + [conn {:keys [team-ids member-ids]}] + (let [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 #{}))] + {:emails-array (db/create-array conn "text" (vec member-emails)) + :teams-array (db/create-array conn "uuid" team-ids)})) + +(defn- non-member-org-team-invitations-exist? + [conn params] + (let [{:keys [emails-array teams-array]} + (org-team-invitations-for-non-members-arrays conn params)] + (-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations + teams-array + emails-array]) + :non-member))) + +(sv/defmethod ::exists-org-team-invitations-for-non-members + "Return if there are any team invitations for emails that are not organization members." {::doc/added "2.18" - ::sm/params schema:cleanup-org-team-invitations-params - ::db/transaction true} - [cfg {:keys [team-ids member-ids]}] + ::sm/params schema:org-team-invitations-for-non-members-params + ::sm/result schema:exists-org-team-invitations-for-non-members-result} + [cfg params] (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 #{})) + {:exists (boolean (non-member-org-team-invitations-exist? conn params))}))) - emails-array (db/create-array conn "text" (vec member-emails)) - 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]) +(sv/defmethod ::delete-org-team-invitations-for-non-members + "Delete team invitations for emails that are not organization members." + {::doc/added "2.18" + ::sm/params schema:org-team-invitations-for-non-members-params + ::db/transaction true} + [cfg params] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [{:keys [emails-array teams-array]} + (org-team-invitations-for-non-members-arrays conn params)] + (db/exec! conn [sql:delete-non-member-org-team-invitations + teams-array + emails-array]) nil)))) ;; ---- API: push-audit-events diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 2032e4cc6d..abba0fa7a1 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -684,14 +684,72 @@ (t/is (nil? (:result out))) (t/is (empty? remaining))))) -(t/deftest cleanup-org-team-invitations-removes-orphaned-invitations +(t/deftest exists-org-team-invitations-for-non-members-reports-invitations-to-delete + (let [member1 (th/create-profile* 1 {:is-active true :email "member1@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) + base-params {::th/type :exists-org-team-invitations-for-non-members + ::rpc/profile-id (:id profile) + :organization-id org-id + :team-ids [(:id team-1) (:id team-2)] + :member-ids [(:id member1)]} + exist! (fn [] (-> (management-command-with-nitrate! base-params) + :result + :exists))] + + (t/is (false? (exist!))) + + (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")}) + (t/is (false? (exist!))) + + (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")}) + (t/is (false? (exist!))) + + (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")}) + (t/is (false? (exist!))) + + (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")}) + (t/is (true? (exist!))))) + +(t/deftest delete-org-team-invitations-for-non-members-removes-non-member-invitations (let [member1 (th/create-profile* 1 {:is-active true :email "member1@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 + params {::th/type :delete-org-team-invitations-for-non-members ::rpc/profile-id (:id profile) :organization-id org-id :team-ids [(:id team-1) (:id team-2)] diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index f4081adfb7..7509b52371 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -973,7 +973,7 @@ .modal-select-org-body { display: flex; flex-direction: column; - gap: var(--sp-xxl); + gap: var(--sp-s); } .modal-select-org-warning { @@ -998,7 +998,6 @@ @include t.use-typography("headline-large"); color: var(--color-foreground-primary); - height: $sz-40; } .modal-select-org-text {