mirror of
https://github.com/penpot/penpot.git
synced 2026-06-09 17:02:05 +00:00
✨ Add nitrate permission team members design review changes
This commit is contained in:
parent
ac3950e36c
commit
c51a137ca9
@ -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}))
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"})))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -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)))))))))))))))))
|
||||
|
||||
@ -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")}))
|
||||
|
||||
@ -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}]]])]]))
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -69,6 +69,7 @@
|
||||
.header-avatar {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--sp-s);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user