From c51a137ca93541fb1526ae9ae2bb8ed4c5238c21 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 27 May 2026 13:47:49 +0200 Subject: [PATCH] :sparkles: Add nitrate permission team members design review changes --- backend/src/app/rpc/commands/nitrate.clj | 123 +++++++++++++----- .../app/rpc/commands/teams_invitations.clj | 20 +-- backend/src/app/rpc/management/nitrate.clj | 16 +-- .../rpc_management_nitrate_test.clj | 25 +--- frontend/src/app/main/data/nitrate.cljs | 100 +++++++------- frontend/src/app/main/data/team.cljs | 2 +- frontend/src/app/main/ui/alert.cljs | 18 +-- frontend/src/app/main/ui/dashboard/team.cljs | 54 ++++++-- frontend/src/app/main/ui/dashboard/team.scss | 63 +++++++-- .../src/app/main/ui/ds/controls/combobox.cljs | 17 ++- .../src/app/main/ui/ds/controls/combobox.scss | 1 + .../main/ui/ds/controls/shared/option.scss | 2 +- frontend/translations/en.po | 10 +- frontend/translations/es.po | 8 +- 14 files changed, 300 insertions(+), 159 deletions(-) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index eb1fcdc07f..6161a4eac7 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -375,6 +375,45 @@ (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org") nil) +(def ^:private sql:get-team-invitation-emails + "SELECT email_to + FROM team_invitation + WHERE team_id = ? + AND valid_until > now()") + +(def ^:private sql:delete-team-external-invitations + "DELETE FROM team_invitation + WHERE team_id = ? + AND email_to = ANY(?) + AND valid_until > now()") + +(def ^:private sql:get-profiles-by-emails + "SELECT id, email + FROM profile + WHERE email = ANY(?) + AND deleted_at IS NULL") + +(defn- get-external-invitation-info + "Returns info about external (non-org-member) invitations pending for a team. + External invitations are those sent to users who are not members of the given org. + Returns {:allows-anybody bool :external-emails [...]}" + [{:keys [::db/conn] :as cfg} team-id organization-id] + (let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id}) + allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})] + (if allows-anybody + {:allows-anybody true :external-emails []} + (let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id]) + emails (map :email-to invitation-emails)] + (if (empty? emails) + {:allows-anybody false :external-emails []} + (let [emails-array (db/create-array conn "text" (vec emails)) + profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) + org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id})) + external-emails (->> profiles + (remove #(contains? org-member-ids (:id %))) + (map :email) + (vec))] + {:allows-anybody false :external-emails external-emails})))))) (def ^:private schema:add-team-to-organization [:map @@ -429,42 +468,29 @@ :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed - :hint "You are not allowed to add teams in this organization")))) + :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 - :when (not= member-id profile-id)] - (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) + (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 + :when (not= member-id profile-id)] + (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) - ;; Api call to nitrate - (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] + ;; Api call to nitrate + (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] + + ;; Notify connected users + (notifications/notify-team-change cfg team "dashboard.team-belong-org")) + + ;; Delete pending invitations for users who are not members of the target organization + (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] + (when (and (not allows-anybody) (seq external-emails)) + (let [conn (::db/conn cfg) + emails-array (db/create-array conn "text" external-emails)] + (db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))) - ;; 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] @@ -482,13 +508,11 @@ (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)] + org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id 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)))]))) + [email (boolean (and pid (contains? org-member-ids pid)))]))) emails))) {})) @@ -547,4 +571,33 @@ organization-ids))) {})) +(def ^:private schema:check-team-external-invitations-params + [:map {:title "CheckTeamExternalInvitationsParams"} + [:team-id ::sm/uuid] + [:organization-id ::sm/uuid]]) + +(def ^:private schema:check-team-external-invitations-result + [:map {:title "CheckTeamExternalInvitationsResult"} + [:has-external-invitations ::sm/boolean] + [:allows-anybody ::sm/boolean]]) + +(sv/defmethod ::check-team-external-invitations + {::rpc/auth true + ::doc/added "2.17" + ::sm/params schema:check-team-external-invitations-params + ::sm/result schema:check-team-external-invitations-result + ::db/transaction true} + [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 [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] + {:has-external-invitations (boolean (seq external-emails)) + :allows-anybody allows-anybody})) + {:has-external-invitations false + :allows-anybody false})) + diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 9657372438..7d61b3ea0a 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -25,7 +25,6 @@ [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] @@ -115,20 +114,18 @@ (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 + "Asserts that member is an org member when the org restricts who can be added to teams." - [member email org-member-ids invited-emails] + [member org-member-ids] (when (some? org-member-ids) - (let [is-member? (or (and (some? member) (contains? org-member-ids (:id member))) - (contains? invited-emails email))] + (let [is-member? (and (some? member) (contains? org-member-ids (:id member)))] (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 org-member-ids invited-emails] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}] (assert (db/connection-map? cfg) "expected cfg with valid connection") @@ -146,11 +143,10 @@ :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. + ;; the email is already an org member 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)) + (assert-email-can-be-invited member org-member-ids)) ;; When we have email verification disabled and invitation user is @@ -338,9 +334,7 @@ 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) + params (assoc params :team team :org-member-ids org-member-ids) ;; Normalize input to a consistent format: [{:email :role}] invitation-data (cond diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index c241644662..2cf2134ff1 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -490,6 +490,7 @@ RETURNING id, deleted_at;") ti.email_to AS email, ti.created_at AS sent_at, p.fullname AS name, + p.id AS profile_id, p.photo_id FROM team_invitation AS ti LEFT JOIN profile AS p @@ -511,6 +512,7 @@ LEFT JOIN profile AS p [:email ::sm/email] [:sent-at ::sm/inst] [:name {:optional true} [:maybe ::sm/text]] + [:profile-id {:optional true} [:maybe ::sm/uuid]] [:photo-url {:optional true} ::sm/uri]]]) (sv/defmethod ::get-org-invitations @@ -687,22 +689,19 @@ LEFT JOIN profile AS p "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" + "Delete 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 [organization-id team-ids member-ids]}] + [cfg {:keys [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) @@ -710,12 +709,7 @@ LEFT JOIN profile AS p (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)) + 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 diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 242a73e2df..2032e4cc6d 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -686,7 +686,6 @@ (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)}) @@ -696,7 +695,7 @@ ::rpc/profile-id (:id profile) :organization-id org-id :team-ids [(:id team-1) (:id team-2)] - :member-ids [(:id member1) (:id member2)]}] + :member-ids [(:id member1)]}] ;; Should remain: member1 is an org member. (th/db-insert! :team-invitation @@ -708,7 +707,7 @@ :role "editor" :valid-until (ct/in-future "24h")}) - ;; Should remain: has org-level invitation (not an org member yet). + ;; Org-level invitation remains (out of team cleanup scope). (th/db-insert! :team-invitation {:id (uuid/random) :org-id org-id @@ -718,6 +717,7 @@ :role "editor" :valid-until (ct/in-future "24h")}) + ;; Should be deleted: team invitation for non-member (th/db-insert! :team-invitation {:id (uuid/random) :team-id (:id team-2) @@ -727,17 +727,7 @@ :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). + ;; Should be deleted: orphaned invitation (th/db-insert! :team-invitation {:id (uuid/random) :team-id (:id team-2) @@ -747,7 +737,7 @@ :role "editor" :valid-until (ct/in-future "24h")}) - ;; Should remain: expired invitation (should not be cleaned up). + ;; Should be deleted: expired invitation. (th/db-insert! :team-invitation {:id (uuid/random) :team-id (:id team-1) @@ -774,10 +764,9 @@ ;; 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 (= 1 (count (th/db-query :team-invitation {:email-to "pending@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 (= 0 (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"}))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 439c2ded07..45bd1da882 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -267,6 +267,21 @@ (fn [_] (rx/of (modal/hide)))))))) +(defn- fetch-orgs-allowed + "Returns an rx observable of an `orgs-allowed` map (org-id -> boolean). + Orgs where :add-anybody-to-team is permitted are pre-approved; + the rest are verified via :all-team-members-in-orgs." + [team-id orgs] + (let [add-anybody-orgs (filterv #(nitrate-perms/allowed? :add-anybody-to-team {:org-perms %}) orgs) + orgs-to-check (filterv #(not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms %})) orgs) + org-ids-to-check (mapv :id orgs-to-check)] + (if (empty? org-ids-to-check) + (rx/of (into {} (map (fn [org] [(:id org) true])) orgs)) + (->> (rp/cmd! :all-team-members-in-orgs {:team-id team-id :organization-ids org-ids-to-check}) + (rx/map (fn [checked-orgs] + (merge (into {} (map (fn [org] [(:id org) true])) add-anybody-orgs) + checked-orgs))))))) + (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 @@ -286,15 +301,6 @@ is-own? (= profile-id (:owner-id org))] (or (= perm "any") is-own?))) all-orgs) team (first (filter #(= (:id %) team-id) teams)) - add-anybody-to-team-orgs - (filterv #(nitrate-perms/allowed? :add-anybody-to-team - {:org-perms %}) - orgs) - orgs-to-check - (filterv #(not (nitrate-perms/allowed? :add-anybody-to-team - {:org-perms %})) - orgs) - org-ids-to-check (mapv :id orgs-to-check) on-confirm (fn [organization-id] (st/emit! (add-team-to-org {:team-id team-id :organization-id organization-id}))) @@ -308,45 +314,27 @@ :orgs-allowed orgs-allowed :current-organization-id (dm/get-in team [:organization :id]) :on-confirm on-confirm + :team-id team-id :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))))] - (cond - (empty? orgs) + (if (empty? orgs) (rx/of (dt/teams-fetched teams) (modal/show :no-permission-modal {:type :no-orgs-create})) - - (empty? org-ids-to-check) - (let [orgs-allowed (into {} - (map (fn [org] [(:id org) true])) - orgs)] - (rx/of (dt/teams-fetched teams) - (show-select-modal orgs-allowed))) - - :else - (->> (rp/cmd! :all-team-members-in-orgs - {:team-id team-id - :organization-ids org-ids-to-check}) + (->> (fetch-orgs-allowed team-id orgs) (rx/mapcat - (fn [checked-orgs] - (let [orgs-allowed - (merge (into {} - (map (fn [org] [(:id org) true])) - add-anybody-to-team-orgs) - checked-orgs) - valid-orgs - (filterv #(true? (get orgs-allowed (:id %))) orgs)] + (fn [orgs-allowed] + (let [valid-orgs (filterv #(true? (get orgs-allowed (:id %))) orgs)] (rx/of (dt/teams-fetched teams) (if (empty? valid-orgs) (modal/show {:type :alert + :hide-actions? true :message (tr "dashboard.team-organization.add.no-valid-orgs") - :accept-label (tr "labels.accept") - :accept-style :primary :title (tr "dashboard.select-org-modal.title")}) (show-select-modal orgs-allowed)))))))))))))))) @@ -387,19 +375,33 @@ 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? selectable-orgs) - (modal/show :no-permission-modal {:type :no-orgs-change}) - (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 selectable-orgs - :current-organization-id current-org-id - :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))))))))))))) + (if (empty? selectable-orgs) + (rx/of (dt/teams-fetched teams) + (modal/show :no-permission-modal {:type :no-orgs-change})) + (->> (fetch-orgs-allowed team-id selectable-orgs) + (rx/mapcat + (fn [orgs-allowed] + (let [valid-orgs (filterv #(true? (get orgs-allowed (:id %))) selectable-orgs) + has-filtered? (< (count orgs) (count all-orgs)) + extra-props (when has-filtered? + {:info-message-key "dashboard.select-org-modal.permission-info"})] + (rx/of + (dt/teams-fetched teams) + (if (empty? valid-orgs) + (modal/show + {:type :alert + :hide-actions? true + :message (tr "dashboard.team-organization.add.no-valid-orgs") + :title (tr "dashboard.change-org-modal.title")}) + (modal/show :select-organization-modal + (merge {:organizations selectable-orgs + :orgs-allowed orgs-allowed + :current-organization-id current-org-id + :on-confirm on-confirm + :team-id team-id + :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))))))))))))))))) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index b54faa8583..63d7a2e75c 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -159,7 +159,7 @@ (if all-org-members-in-team? (rx/of (modal/show {:type :alert - :message (tr "modals.invite-restricted-members.all-org-members-in-team") + :message (tr "modals.invite-restricted-members.all-org-members-in-team" (get-in team [:organization :name])) :accept-label (tr "labels.accept") :accept-style :primary :title (tr "modals.invite-team-member.title")})) diff --git a/frontend/src/app/main/ui/alert.cljs b/frontend/src/app/main/ui/alert.cljs index 0a19710be2..8a33996700 100644 --- a/frontend/src/app/main/ui/alert.cljs +++ b/frontend/src/app/main/ui/alert.cljs @@ -27,6 +27,7 @@ title on-accept hint + hide-actions? accept-label accept-style] :as props}] @@ -77,11 +78,12 @@ :appearance :ghost} hint])] - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css-case :accept-btn true - :danger (= accept-style :danger) - :primary (= accept-style :primary)) - :type "button" - :value accept-label - :on-click accept-fn}]]]]])) + (when-not hide-actions? + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + [:input {:class (stl/css-case :accept-btn true + :danger (= accept-style :danger) + :primary (= accept-style :primary)) + :type "button" + :value accept-label + :on-click accept-fn}]]])]])) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 3f0848380f..bb933982dd 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -18,6 +18,7 @@ [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] [app.main.refs :as refs] + [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.alert] [app.main.ui.components.dropdown :refer [dropdown]] @@ -287,15 +288,16 @@ 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)} + [:div {:class (stl/css :modal-restricted-header)} + [:h2 {:class (stl/css :modal-restricted-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)} + [:div {:class (stl/css :modal-restricted-content)} [:p (tr "modals.invite-restricted-members.description")] [:& context-notification {:content (tr "modals.invite-restricted-members.warning") + :class (stl/css :restricted-warning) :level :warning}] [:div {:class (stl/css :restricted-emails-section)} [:button {:class (stl/css :restricted-emails-toggle) @@ -834,8 +836,8 @@ [{:keys [selected delete on-confirm]}] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-invitation-container :modal-container)} - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} + [:div {:class (stl/css :modal-invitation-header)} + [:h2 {:class (stl/css :modal-invitation-title)} (if delete (tr "dashboard.invitation-modal.title.delete-invitations") (tr "dashboard.invitation-modal.title.resend-invitations"))] @@ -883,7 +885,7 @@ (mf/defc select-organization-modal {::mf/register modal/components ::mf/register-as :select-organization-modal} - [{:keys [organizations orgs-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}] + [{:keys [organizations orgs-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key team-id]}] (let [valid-organizations (mf/with-memo [organizations] (remove #(= (:id %) current-organization-id) organizations)) options (mf/with-memo [valid-organizations orgs-allowed] @@ -905,11 +907,28 @@ form (fm/use-form :schema schema:organization-form :initial {}) + warning-info* (mf/use-state nil) + warning-info (deref warning-info*) + selected-org (mf/with-memo [warning-info valid-organizations] + (when warning-info + (d/seek #(= (:id %) (:organization-id warning-info)) valid-organizations))) + on-change (mf/use-fn - (mf/deps form) + (mf/deps form team-id) (fn [id] - (uforms/on-input-change form :selected-id id))) + (uforms/on-input-change form :selected-id id) + ;; Check for external invitations when selection changes + (when (and team-id id) + (let [org-id (d/parse-uuid id)] + (->> (rp/cmd! :check-team-external-invitations + {:team-id team-id + :organization-id org-id}) + (rx/subs! + (fn [result] + (reset! warning-info* (assoc result :organization-id org-id))) + (fn [_] + (reset! warning-info* nil)))))))) on-confirm' (mf/use-fn @@ -918,7 +937,7 @@ (on-confirm (dm/get-in @form [:clean-data :selected-id]))))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-select-org-container :modal-container)} - [:div {:class (stl/css :modal-header)} + [:div {:class (stl/css :modal-select-org-header)} [:h2 {:class (stl/css :modal-select-org-title)} (tr title-key)] @@ -928,7 +947,7 @@ (when text-key [:div {:class (stl/css :modal-content :modal-select-org-text)} (tr text-key)]) - [:div + [:div {:class (stl/css :modal-select-org-body)} (when info-message-key [:div {:class (stl/css :modal-select-org-info)} (tr info-message-key)]) @@ -940,7 +959,20 @@ :select-only true :default-selected (or (some-> (get-in @form [:data :selected-id]) str) "") :placeholder (tr placeholder-key) - :on-change on-change}]] + :on-change on-change}] + + ;; Warning for external invitations + (when (and warning-info + (:has-external-invitations warning-info) + (not (:allows-anybody warning-info)) + selected-org) + [:div {:class (stl/css :modal-select-org-warning)} + [:& context-notification + {:content (tr "dashboard.select-org-modal.external-invitations-will-be-canceled") + :class (stl/css :external-invitations-warning) + :level :warning}] + [:div {:class (stl/css :modal-select-org-content)} + (tr "dashboard.select-org-modal.external-invitations-warning" (:name selected-org))]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)} diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 96f86af78d..e7e062fe7f 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -865,7 +865,19 @@ flex-direction: column; } +.modal-invitation-header { + margin-block-end: var(--sp-xxxl); +} + +.modal-invitation-title { + @include t.use-typography("headline-large"); + + color: var(--color-foreground-primary); +} + .modal-invitation-content { + @include t.use-typography("body-medium"); + overflow: auto; color: var(--color-foreground-secondary); } @@ -888,6 +900,26 @@ color: var(--color-foreground-secondary); } +.modal-restricted-header { + margin-block-end: var(--sp-xxxl); +} + +.modal-restricted-title { + @include t.use-typography("headline-large"); + + color: var(--color-foreground-primary); +} + +.modal-restricted-content { + @include t.use-typography("body-medium"); + + margin-block-end: 0; +} + +.restricted-warning { + margin-block: var(--sp-xxl); +} + .restricted-email-summary { color: var(--color-accent-primary); } @@ -895,7 +927,6 @@ .restricted-emails-section { display: flex; flex-direction: column; - gap: var(--sp-l); } .restricted-emails-toggle { @@ -935,35 +966,51 @@ width: $sz-512; } +.modal-select-org-header { + margin-block-end: var(--sp-xxxl); +} + +.modal-select-org-body { + display: flex; + flex-direction: column; + gap: var(--sp-xxl); +} + +.modal-select-org-warning { + display: flex; + flex-direction: column; + gap: var(--sp-xxl); +} + .modal-select-org-content { - @include t.use-typography("body-large"); + @include t.use-typography("body-medium"); color: var(--color-foreground-secondary); - overflow: auto; - 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"); + @include t.use-typography("headline-large"); color: var(--color-foreground-primary); - text-transform: uppercase; height: $sz-40; } .modal-select-org-text { - @include t.use-typography("body-large"); + @include t.use-typography("body-medium"); color: var(--color-foreground-secondary); } +.external-invitations-warning { + margin: 0; +} + // ORGANIZATIONS SETTINGS .org-block-content { diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index 127f61ce67..421d4fc6c1 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -14,11 +14,14 @@ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] + [app.util.globals :as globals] [app.util.keyboard :as kbd] [app.util.object :as obj] [cuerdas.core :as str] + [goog.events :as gevents] [rumext.v2 :as mf] - [rumext.v2.util :as mfu])) + [rumext.v2.util :as mfu]) + (:import goog.events.EventType)) (def ^:private schema:combobox [:map @@ -262,6 +265,18 @@ (mf/set-ref-val! value-ref nil) (on-change value)))) + (mf/with-effect [is-open] + (when is-open + (let [handler (fn [event] + (let [wrapper-node (mf/ref-val combobox-ref) + target (dom/get-target event)] + (when (and (some? wrapper-node) + (not (dom/child? target wrapper-node))) + (reset! is-open* false)))) + key (gevents/listen globals/document EventType.MOUSEDOWN handler)] + (fn [] + (gevents/unlistenByKey key))))) + [:div {:ref combobox-ref :class (stl/css-case :wrapper true diff --git a/frontend/src/app/main/ui/ds/controls/combobox.scss b/frontend/src/app/main/ui/ds/controls/combobox.scss index c048720476..d00fdf9504 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.scss +++ b/frontend/src/app/main/ui/ds/controls/combobox.scss @@ -69,6 +69,7 @@ .header-avatar { grid-template-columns: auto 1fr; gap: var(--sp-s); + width: 100%; } .input { diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss index c4b72e8ec7..cab29f221a 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss @@ -28,7 +28,7 @@ color: var(--options-fg-color); cursor: default; - &:hover, + &:hover:not(.option-disabled), &[aria-selected="true"] { --options-bg-color: var(--color-background-quaternary); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a08607c6ba..1aac489c25 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1151,7 +1151,7 @@ msgid "dashboard.team-organization.add" msgstr "Add to an organization" msgid "dashboard.team-organization.add.no-valid-orgs" -msgstr "You are not allowed to add this team to any of your organizations." +msgstr "You don't have permission to add this team to any of your organizations." msgid "dashboard.select-org-modal.title" msgstr "Add team to an organization" @@ -1165,6 +1165,12 @@ msgstr "Select an organization" msgid "dashboard.select-org-modal.accept" msgstr "ADD TO ORGANIZATION" +msgid "dashboard.select-org-modal.external-invitations-will-be-canceled" +msgstr "Pending invitations to external users will be canceled." + +msgid "dashboard.select-org-modal.external-invitations-warning" +msgstr "\"%s\" only allows organization members to join its teams. Any pending invitations to people outside the organization will be revoked." + msgid "dashboard.change-org-modal.title" msgstr "Change team's organization" @@ -3769,7 +3775,7 @@ msgstr "Only existing members of the organization can be invited. Because none o #: 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." +msgstr "Only \"%s\" organization's members 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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index a23d11ef3d..e59600b82e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1169,6 +1169,12 @@ msgstr "Elige una organización" msgid "dashboard.select-org-modal.accept" msgstr "AÑADIR A UNA ORGANIZACIÓN" +msgid "dashboard.select-org-modal.external-invitations-will-be-canceled" +msgstr "Las invitaciones pendientes a usuarios externos serán canceladas." + +msgid "dashboard.select-org-modal.external-invitations-warning" +msgstr "\"%s\" solo permite que los miembros de la organización se unan a sus equipos. Cualquier invitación pendiente a personas fuera de la organización será revocada." + msgid "dashboard.change-org-modal.title" msgstr "Cambiar el equipo de organización" @@ -3658,7 +3664,7 @@ msgstr "Solo se puede invitar a miembros existentes de la organización. Como ni #: 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." +msgstr "Solo se puede invitar a este equipo a miembros de la organización \"%s\", 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"