Add nitrate permission team members design review changes

This commit is contained in:
Pablo Alba 2026-05-27 13:47:49 +02:00 committed by Pablo Alba
parent ac3950e36c
commit c51a137ca9
14 changed files with 300 additions and 159 deletions

View File

@ -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}))

View File

@ -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

View File

@ -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

View File

@ -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"})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -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)))))))))))))))))

View File

@ -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")}))

View File

@ -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}]]])]]))

View File

@ -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)}

View File

@ -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 {

View File

@ -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

View File

@ -69,6 +69,7 @@
.header-avatar {
grid-template-columns: auto 1fr;
gap: var(--sp-s);
width: 100%;
}
.input {

View File

@ -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);
}

View File

@ -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"

View File

@ -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"