mirror of
https://github.com/penpot/penpot.git
synced 2026-05-24 16:38:40 -04:00
✨ Add nitrate add team members permission
This commit is contained in:
@@ -410,6 +410,17 @@
|
||||
[:permissions [:map-of :keyword :string]]]
|
||||
params)))
|
||||
|
||||
(defn- get-org-members-api
|
||||
[cfg {:keys [organization-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/organizations/"
|
||||
organization-id
|
||||
"/members-list")
|
||||
[:vector ::sm/uuid]
|
||||
params)))
|
||||
|
||||
(defn- redeem-activation-code-api
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
@@ -432,6 +443,7 @@
|
||||
:get-org-summary (partial get-org-summary-api cfg)
|
||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
||||
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
|
||||
:get-org-members (partial get-org-members-api cfg)
|
||||
:delete-owned-orgs (partial delete-owned-orgs-api cfg)
|
||||
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
||||
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
||||
@@ -511,4 +523,3 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -372,3 +372,75 @@
|
||||
;; Notify connected users
|
||||
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
||||
nil)
|
||||
|
||||
(def ^:private sql:get-profiles-by-emails
|
||||
"SELECT id, email
|
||||
FROM profile
|
||||
WHERE email = ANY(?)
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(def ^:private sql:get-org-direct-invitation-emails
|
||||
"SELECT DISTINCT email_to
|
||||
FROM team_invitation
|
||||
WHERE org_id = ?
|
||||
AND team_id IS NULL
|
||||
AND valid_until >= now()")
|
||||
|
||||
(defn get-org-direct-invitation-emails
|
||||
"Returns the set of emails that have a pending direct org-level invitation
|
||||
(i.e. invited to the org itself, not to a specific team)."
|
||||
[conn org-id]
|
||||
(->> (db/exec! conn [sql:get-org-direct-invitation-emails org-id])
|
||||
(map :email-to)
|
||||
(into #{})))
|
||||
|
||||
(def ^:private schema:check-org-members-params
|
||||
[:map {:title "CheckOrgMembersParams"}
|
||||
[:organization-id ::sm/uuid]
|
||||
[:emails [:vector ::sm/email]]])
|
||||
|
||||
(sv/defmethod ::check-org-members
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:check-org-members-params
|
||||
::sm/result [:map-of :string :boolean]
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
||||
(or (when (contains? cf/flags :nitrate)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(let [emails-array (db/create-array conn "text" emails)
|
||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
||||
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
|
||||
invited-emails (get-org-direct-invitation-emails conn organization-id)]
|
||||
(into {}
|
||||
(map (fn [email]
|
||||
(let [pid (get email->id email)]
|
||||
[email (boolean (or (and pid (contains? org-member-ids pid))
|
||||
(contains? invited-emails email)))])))
|
||||
emails)))
|
||||
{}))
|
||||
|
||||
(def ^:private schema:all-org-members-in-team-params
|
||||
[:map {:title "CheckOrgMembersInTeamParams"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::all-org-members-in-team
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:all-org-members-in-team-params
|
||||
::sm/result ::sm/boolean}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(let [perms (teams/get-permissions cfg profile-id team-id)]
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
|
||||
org-member-ids (into #{} org-members)
|
||||
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
team-member-ids (into #{} (map :profile-id team-members))]
|
||||
(every? #(contains? team-member-ids %) org-member-ids)))
|
||||
false))
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||
[app.common.types.team :as types.team]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
@@ -24,6 +25,7 @@
|
||||
[app.main :as-alias main]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.nitrate :as cnit]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
@@ -112,8 +114,21 @@
|
||||
(let [notifications (dm/get-in member [:props :notifications])]
|
||||
(not= :none (:email-invites notifications))))
|
||||
|
||||
(defn- assert-email-can-be-invited
|
||||
"Asserts that member/email is either an org member or has a pending
|
||||
direct org invitation. org-member-ids is non-nil only when the org
|
||||
restricts who can be added to teams."
|
||||
[member email org-member-ids invited-emails]
|
||||
(when (some? org-member-ids)
|
||||
(let [is-member? (or (and (some? member) (contains? org-member-ids (:id member)))
|
||||
(contains? invited-emails email))]
|
||||
(when-not is-member?
|
||||
(ex/raise :type :validation
|
||||
:code :email-not-org-member
|
||||
:hint "The invited email is not a member of the organization")))))
|
||||
|
||||
(defn- create-invitation
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids invited-emails] :as params}]
|
||||
|
||||
(assert (db/connection-map? cfg)
|
||||
"expected cfg with valid connection")
|
||||
@@ -130,6 +145,14 @@
|
||||
:code :email-domain-is-not-allowed
|
||||
:hint "email domain is in the blacklist"))
|
||||
|
||||
;; When nitrate is active and the team belongs to an org, check that
|
||||
;; the email is already an org member or has a pending org-level
|
||||
;; invitation, unless the org explicitly allows adding anybody.
|
||||
(when (and (contains? cf/flags :nitrate)
|
||||
(:organization team))
|
||||
(assert-email-can-be-invited member email org-member-ids invited-emails))
|
||||
|
||||
|
||||
;; When we have email verification disabled and invitation user is
|
||||
;; already present in the database, we proceed to add it to the
|
||||
;; team as-is, without email roundtrip.
|
||||
@@ -223,18 +246,15 @@
|
||||
:organization-initials (:initials organization)
|
||||
:token itoken
|
||||
:extra-data ptoken}))
|
||||
(let [team (if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-org-info-to-team cfg team {})
|
||||
team)]
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-team
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:organization (dm/get-in team [:organization :name])
|
||||
:token itoken
|
||||
:extra-data ptoken}))))
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/invite-to-team
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:organization (dm/get-in team [:organization :name])
|
||||
:token itoken
|
||||
:extra-data ptoken})))
|
||||
|
||||
itoken)))))
|
||||
|
||||
@@ -309,7 +329,20 @@
|
||||
- emails (set) + role (single role for all emails)
|
||||
- invitations (vector of {:email :role} maps)"
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
||||
(let [;; Normalize input to a consistent format: [{:email :role}]
|
||||
(let [;; Enrich team with org info once for all invitations when nitrate is active
|
||||
team (if (contains? cf/flags :nitrate)
|
||||
(nitrate/add-org-info-to-team cfg team {})
|
||||
team)
|
||||
org (:organization team)
|
||||
org-id (:id org)
|
||||
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
|
||||
org-member-ids (when restricted?
|
||||
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
|
||||
invited-emails (when restricted?
|
||||
(cnit/get-org-direct-invitation-emails conn org-id))
|
||||
params (assoc params :team team :org-member-ids org-member-ids :invited-emails invited-emails)
|
||||
|
||||
;; Normalize input to a consistent format: [{:email :role}]
|
||||
invitation-data (cond
|
||||
;; Case 1: emails + single role (create invitations style)
|
||||
(and emails role)
|
||||
|
||||
@@ -654,3 +654,50 @@ LEFT JOIN profile AS p
|
||||
:teams-to-transfer (count valid-teams-to-transfer)
|
||||
:teams-to-exit (count valid-teams-to-exit)}))
|
||||
|
||||
;; API: cleanup-org-team-invitations
|
||||
|
||||
(def ^:private sql:get-profile-emails-by-ids
|
||||
"SELECT email
|
||||
FROM profile
|
||||
WHERE id = ANY(?)
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(def ^:private sql:delete-orphaned-team-invitations
|
||||
"DELETE FROM team_invitation
|
||||
WHERE team_id = ANY(?)
|
||||
AND email_to <> ALL(?)
|
||||
AND valid_until >= now()
|
||||
RETURNING email_to")
|
||||
|
||||
(def ^:private schema:cleanup-org-team-invitations-params
|
||||
[:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:team-ids [:vector ::sm/uuid]]
|
||||
[:member-ids [:vector ::sm/uuid]]])
|
||||
|
||||
(sv/defmethod ::cleanup-org-team-invitations
|
||||
"Delete team invitations for emails that are not organization members
|
||||
and do not have pending org-level invitations"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:cleanup-org-team-invitations-params
|
||||
::db/transaction true}
|
||||
[cfg {:keys [organization-id team-ids member-ids]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||
(let [;; Get emails of organization members
|
||||
member-ids-array (db/create-array conn "uuid" member-ids)
|
||||
member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
|
||||
(map :email)
|
||||
(into #{}))
|
||||
|
||||
;; Get emails with org-level invitations
|
||||
org-invitation-emails (cnit/get-org-direct-invitation-emails conn organization-id)
|
||||
|
||||
;; Combine both sets: emails that should be kept
|
||||
emails-to-keep (into member-emails org-invitation-emails)
|
||||
emails-array (db/create-array conn "text" (vec emails-to-keep))
|
||||
teams-array (db/create-array conn "uuid" team-ids)]
|
||||
|
||||
;; Delete invitations that are not in the keep list
|
||||
(db/exec! conn [sql:delete-orphaned-team-invitations teams-array emails-array])
|
||||
nil))))
|
||||
|
||||
|
||||
@@ -675,6 +675,102 @@
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (empty? remaining)))))
|
||||
|
||||
(t/deftest cleanup-org-team-invitations-removes-orphaned-invitations
|
||||
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
|
||||
member2 (th/create-profile* 2 {:is-active true :email "member2@example.com"})
|
||||
profile (th/create-profile* 4 {:is-active true})
|
||||
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
||||
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
||||
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
||||
org-id (uuid/random)
|
||||
params {::th/type :cleanup-org-team-invitations
|
||||
::rpc/profile-id (:id profile)
|
||||
:organization-id org-id
|
||||
:team-ids [(:id team-1) (:id team-2)]
|
||||
:member-ids [(:id member1) (:id member2)]}]
|
||||
|
||||
;; Should remain: member1 is an org member.
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id team-1)
|
||||
:org-id nil
|
||||
:email-to "member1@example.com"
|
||||
:created-by (:id profile)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "24h")})
|
||||
|
||||
;; Should remain: has org-level invitation (not an org member yet).
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:org-id org-id
|
||||
:team-id nil
|
||||
:email-to "pending@example.com"
|
||||
:created-by (:id profile)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "24h")})
|
||||
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id team-2)
|
||||
:org-id nil
|
||||
:email-to "pending@example.com"
|
||||
:created-by (:id profile)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "24h")})
|
||||
|
||||
;; Should be deleted: not an org member and no org-level invitation.
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id team-1)
|
||||
:org-id nil
|
||||
:email-to "nonmember@example.com"
|
||||
:created-by (:id profile)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "24h")})
|
||||
|
||||
;; Should be deleted: orphaned invitation (no org member, no org invitation).
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id team-2)
|
||||
:org-id nil
|
||||
:email-to "orphan@example.com"
|
||||
:created-by (:id profile)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "24h")})
|
||||
|
||||
;; Should remain: expired invitation (should not be cleaned up).
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id team-1)
|
||||
:org-id nil
|
||||
:email-to "expired@example.com"
|
||||
:created-by (:id profile)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-past "1h")})
|
||||
|
||||
;; Should remain: outside org scope.
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id outside-team)
|
||||
:org-id nil
|
||||
:email-to "outsider@example.com"
|
||||
:created-by (:id profile)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "24h")})
|
||||
|
||||
(let [out (management-command-with-nitrate! params)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? (:result out)))
|
||||
|
||||
;; Verify remaining invitations.
|
||||
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"}))))
|
||||
(t/is (= 2 (count (th/db-query :team-invitation {:email-to "pending@example.com"}))))
|
||||
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "nonmember@example.com"}))))
|
||||
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"}))))
|
||||
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "expired@example.com"}))))
|
||||
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"})))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Tests: remove-from-org
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
{:create-teams "any"
|
||||
:delete-teams "onlyOwners"
|
||||
:move-teams "always"
|
||||
:send-invitations "ownersAndAdmins"})
|
||||
:send-invitations "ownersAndAdmins"
|
||||
:new-team-members "anyone"})
|
||||
|
||||
(defn- can-create-team?
|
||||
[{:keys [is-org-owner? permission-value]}]
|
||||
@@ -49,15 +50,21 @@
|
||||
|
||||
:else false))
|
||||
|
||||
(defn- can-add-anybody-to-team?
|
||||
[{:keys [permission-value]}]
|
||||
(= permission-value "anyone"))
|
||||
|
||||
(def ^:private action-rules
|
||||
{:create-team {:permission-key :create-teams
|
||||
:check-fn can-create-team?}
|
||||
:delete-team {:permission-key :delete-teams
|
||||
:check-fn can-delete-team?}
|
||||
:move-team {:permission-key :move-teams
|
||||
:check-fn can-move-team?}
|
||||
:send-invitations {:permission-key :send-invitations
|
||||
:check-fn can-invite-to-team?}})
|
||||
{:create-team {:permission-key :create-teams
|
||||
:check-fn can-create-team?}
|
||||
:delete-team {:permission-key :delete-teams
|
||||
:check-fn can-delete-team?}
|
||||
:move-team {:permission-key :move-teams
|
||||
:check-fn can-move-team?}
|
||||
:send-invitations {:permission-key :send-invitations
|
||||
:check-fn can-invite-to-team?}
|
||||
:add-anybody-to-team {:permission-key :new-team-members
|
||||
:check-fn can-add-anybody-to-team?}})
|
||||
|
||||
(defn- normalize-org-permissions
|
||||
[org-perms]
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
[:permissions {:optional true}
|
||||
[:maybe [:map
|
||||
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]
|
||||
[:delete-teams {:optional true} [:maybe [:enum "onlyMe" "onlyOwners"]]]]]]])
|
||||
[:delete-teams {:optional true} [:maybe [:enum "onlyMe" "onlyOwners"]]]
|
||||
[:move-teams {:optional true} [:maybe [:enum "always" "myOrganizations" "never"]]]
|
||||
[:new-team-members {:optional true} [:maybe [:enum "anyone" "members"]]]]]]])
|
||||
|
||||
|
||||
(def schema:team-with-organization
|
||||
|
||||
@@ -67,6 +67,19 @@
|
||||
(->> (rp/cmd! :get-teams)
|
||||
(rx/map teams-fetched)))))
|
||||
|
||||
(defn- with-refreshed-team
|
||||
"Fetches fresh team data from the server to ensure up-to-date org
|
||||
permissions, updates the app state, and calls f with the fresh team data.
|
||||
Returns an observable of events."
|
||||
[team-id f]
|
||||
(->> (rp/cmd! :get-teams)
|
||||
(rx/mapcat
|
||||
(fn [teams]
|
||||
(let [team (d/seek #(= (:id %) team-id) teams)]
|
||||
(rx/concat
|
||||
(rx/of (teams-fetched teams))
|
||||
(f team)))))))
|
||||
|
||||
(defn check-and-create-team
|
||||
"Fetches fresh team data from the server to ensure up-to-date org
|
||||
permissions, then shows the team-form modal or a no-permission modal."
|
||||
@@ -75,26 +88,23 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [profile-id (dm/get-in state [:profile :id])]
|
||||
(->> (rp/cmd! :get-teams)
|
||||
(rx/mapcat
|
||||
(fn [teams]
|
||||
(let [team (d/seek #(= (:id %) team-id) teams)
|
||||
organization (:organization team)
|
||||
in-org? (and (contains? cf/flags :nitrate) organization)
|
||||
can-create? (if in-org?
|
||||
(nitrate-perms/allowed? :create-team
|
||||
{:org-perms {:owner-id (:owner-id organization)
|
||||
:permissions (:permissions organization)}
|
||||
:profile-id profile-id
|
||||
:team-perms (:permissions team)})
|
||||
true)]
|
||||
(rx/of (teams-fetched teams)
|
||||
(if can-create?
|
||||
(modal/show :team-form (if in-org?
|
||||
{:organization-id (:id organization)
|
||||
:organization-name (:name organization)}
|
||||
{}))
|
||||
(modal/show :no-permission-modal {:type :create-team})))))))))))
|
||||
(with-refreshed-team team-id
|
||||
(fn [team]
|
||||
(let [organization (:organization team)
|
||||
in-org? (and (contains? cf/flags :nitrate) organization)
|
||||
can-create? (if in-org?
|
||||
(nitrate-perms/allowed? :create-team
|
||||
{:org-perms {:owner-id (:owner-id organization)
|
||||
:permissions (:permissions organization)}
|
||||
:profile-id profile-id
|
||||
:team-perms (:permissions team)})
|
||||
true)]
|
||||
(rx/of (if can-create?
|
||||
(modal/show :team-form (if in-org?
|
||||
{:organization-id (:id organization)
|
||||
:organization-name (:name organization)}
|
||||
{}))
|
||||
(modal/show :no-permission-modal {:type :create-team}))))))))))
|
||||
|
||||
(defn check-and-delete-team
|
||||
"Fetches fresh team data from the server to ensure up-to-date org
|
||||
@@ -104,31 +114,57 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [profile-id (dm/get-in state [:profile :id])]
|
||||
(->> (rp/cmd! :get-teams)
|
||||
(rx/mapcat
|
||||
(fn [teams]
|
||||
(let [team (d/seek #(= (:id %) team-id) teams)
|
||||
org (:organization team)
|
||||
in-org? (and (contains? cf/flags :nitrate) org)
|
||||
can-delete? (if in-org?
|
||||
(nitrate-perms/allowed? :delete-team
|
||||
{:org-perms {:owner-id (:owner-id org)
|
||||
:permissions (:permissions org)}
|
||||
:profile-id profile-id
|
||||
:team-perms (:permissions team)})
|
||||
(boolean (dm/get-in team [:permissions :is-owner])))
|
||||
message (if in-org?
|
||||
(tr "modals.delete-org-team-confirm.message" (:name org))
|
||||
(tr "modals.delete-team-confirm.message"))]
|
||||
(rx/of (teams-fetched teams)
|
||||
(if can-delete?
|
||||
(modal/show
|
||||
{:type :confirm
|
||||
:title (tr "modals.delete-team-confirm.title")
|
||||
:message message
|
||||
:accept-label (tr "modals.delete-team-confirm.accept")
|
||||
:on-accept delete-fn})
|
||||
(modal/show :no-permission-modal {:type :delete-team})))))))))))
|
||||
(with-refreshed-team team-id
|
||||
(fn [team]
|
||||
(let [org (:organization team)
|
||||
in-org? (and (contains? cf/flags :nitrate) org)
|
||||
can-delete? (if in-org?
|
||||
(nitrate-perms/allowed? :delete-team
|
||||
{:org-perms {:owner-id (:owner-id org)
|
||||
:permissions (:permissions org)}
|
||||
:profile-id profile-id
|
||||
:team-perms (:permissions team)})
|
||||
(boolean (dm/get-in team [:permissions :is-owner])))
|
||||
message (if in-org?
|
||||
(tr "modals.delete-org-team-confirm.message" (:name org))
|
||||
(tr "modals.delete-team-confirm.message"))]
|
||||
(rx/of (if can-delete?
|
||||
(modal/show
|
||||
{:type :confirm
|
||||
:title (tr "modals.delete-team-confirm.title")
|
||||
:message message
|
||||
:accept-label (tr "modals.delete-team-confirm.accept")
|
||||
:on-accept delete-fn})
|
||||
(modal/show :no-permission-modal {:type :delete-team}))))))))))
|
||||
|
||||
(defn- check-new-team-members-permission-and-show-invite-members
|
||||
"Receives refreshed team data with up-to-date org
|
||||
permissions, then shows the invite members modal or an appropriate alert."
|
||||
[{:keys [team invite-email origin]}]
|
||||
(ptk/reify ::check-new-team-members-permission-and-show-invite-members
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [show-invite (rx/of (modal/show {:type :invite-members
|
||||
:team team
|
||||
:origin (or origin :team)
|
||||
:invite-email invite-email}))]
|
||||
(if (and (contains? cf/flags :nitrate)
|
||||
(not (nitrate-perms/allowed? :add-anybody-to-team
|
||||
{:org-perms (:organization team)})))
|
||||
(->> (rp/cmd! :all-org-members-in-team
|
||||
{:team-id (:id team)
|
||||
:organization-id (get-in team [:organization :id])})
|
||||
(rx/mapcat
|
||||
(fn [all-org-members-in-team?]
|
||||
(if all-org-members-in-team?
|
||||
(rx/of (modal/show
|
||||
{:type :alert
|
||||
:message (tr "modals.invite-restricted-members.all-org-members-in-team")
|
||||
:accept-label (tr "labels.accept")
|
||||
:accept-style :primary
|
||||
:title (tr "modals.invite-team-member.title")}))
|
||||
show-invite))))
|
||||
show-invite)))))
|
||||
|
||||
(defn check-and-invite-members
|
||||
"Fetches fresh team data from the server to ensure up-to-date org
|
||||
@@ -139,23 +175,19 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [profile-id (dm/get-in state [:profile :id])]
|
||||
(->> (rp/cmd! :get-teams)
|
||||
(rx/mapcat
|
||||
(fn [teams]
|
||||
(let [team (d/seek #(= (:id %) team-id) teams)
|
||||
org (:organization team)
|
||||
can-invite? (nitrate-perms/can-send-invitations?
|
||||
{:nitrate-enabled? (contains? cf/flags :nitrate)
|
||||
:organization org
|
||||
:profile-id profile-id
|
||||
:team-permissions (:permissions team)})]
|
||||
(rx/of (teams-fetched teams)
|
||||
(if can-invite?
|
||||
(modal/show {:type :invite-members
|
||||
:team team
|
||||
:origin origin
|
||||
:invite-email invite-email})
|
||||
(modal/show :no-permission-modal {:type :invite-members})))))))))))
|
||||
(with-refreshed-team team-id
|
||||
(fn [team]
|
||||
(let [org (:organization team)
|
||||
can-invite? (nitrate-perms/can-send-invitations?
|
||||
{:nitrate-enabled? (contains? cf/flags :nitrate)
|
||||
:organization org
|
||||
:profile-id profile-id
|
||||
:team-permissions (:permissions team)})]
|
||||
(rx/of (if can-invite?
|
||||
(check-new-team-members-permission-and-show-invite-members {:team team
|
||||
:origin origin
|
||||
:invite-email invite-email})
|
||||
(modal/show :no-permission-modal {:type :invite-members}))))))))))
|
||||
|
||||
;; --- EVENT: fetch-members
|
||||
|
||||
@@ -520,6 +552,53 @@
|
||||
(rx/tap on-success)
|
||||
(rx/catch on-error))))))
|
||||
|
||||
(defn check-and-submit-invite-members
|
||||
"Fetches fresh team data from the server to ensure up-to-date org
|
||||
permissions, then submits member invitations or shows a restriction modal."
|
||||
[{:keys [team-id] :as params} origin do-invite-members]
|
||||
(ptk/reify ::check-and-submit-invite-members
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(with-refreshed-team team-id
|
||||
(fn [team]
|
||||
(if (not (nitrate-perms/allowed? :add-anybody-to-team
|
||||
{:org-perms (:organization team)}))
|
||||
(->> (rp/cmd! :check-org-members {:organization-id (get-in team [:organization :id])
|
||||
:emails (vec (:emails params))})
|
||||
(rx/mapcat
|
||||
(fn [result]
|
||||
(let [blocked (into [] (comp (filter (fn [[_ v]] (not v)))
|
||||
(map first))
|
||||
result)]
|
||||
(cond
|
||||
(empty? blocked)
|
||||
(do (do-invite-members params origin) (rx/empty))
|
||||
|
||||
(= (count blocked) (count result))
|
||||
(rx/of
|
||||
(modal/show
|
||||
{:type :alert
|
||||
:title (tr "modals.invite-restricted-members.all-blocked-title")
|
||||
:message (tr "modals.invite-restricted-members.all-blocked")
|
||||
:accept-label (tr "labels.accept")
|
||||
:accept-style :primary}))
|
||||
|
||||
:else
|
||||
(rx/of
|
||||
(modal/show
|
||||
{:type :invite-restricted-members
|
||||
:blocked-emails blocked
|
||||
:on-accept (fn []
|
||||
(let [valid-emails (into #{} (filter (fn [e] (get result e)))
|
||||
(:emails params))
|
||||
params' (assoc params :emails valid-emails)]
|
||||
(do-invite-members params' origin)))})))))))
|
||||
(do (do-invite-members params origin)
|
||||
(rx/empty)))))
|
||||
(do (do-invite-members params origin)
|
||||
(rx/empty))))))
|
||||
|
||||
(defn copy-invitation-link
|
||||
[{:keys [email team-id] :as params}]
|
||||
(assert (sm/check-email email))
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
[app.main.data.team :as dtm]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.alert]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.components.file-uploader :refer [file-uploader]]
|
||||
[app.main.ui.components.forms :as fm]
|
||||
@@ -153,6 +154,14 @@
|
||||
[:emails [::sm/set {:min 1} ::sm/email]]
|
||||
[:team-id ::sm/uuid]])
|
||||
|
||||
|
||||
(defn- do-invite-members!
|
||||
[params origin]
|
||||
(st/emit! (-> (dtm/create-invitations params)
|
||||
(with-meta {::ev/origin origin}))
|
||||
(dtm/fetch-invitations)
|
||||
(dtm/fetch-members)))
|
||||
|
||||
(mf/defc invite-members-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :invite-members
|
||||
@@ -222,11 +231,7 @@
|
||||
(let [params (:clean-data @form)
|
||||
mdata {:on-success (partial on-success form)
|
||||
:on-error (partial on-error form)}]
|
||||
(st/emit! (-> (dtm/create-invitations (with-meta params mdata))
|
||||
(with-meta {::ev/origin origin}))
|
||||
;; FIXME: looks duplicate
|
||||
(dtm/fetch-invitations)
|
||||
(dtm/fetch-members))))]
|
||||
(st/emit! (dtm/check-and-submit-invite-members (with-meta params mdata) origin do-invite-members!))))]
|
||||
|
||||
[:div {:class (stl/css-case :modal-team-container true
|
||||
:modal-team-container-workspace (= origin :workspace)
|
||||
@@ -269,6 +274,63 @@
|
||||
:disabled (and (boolean (some current-data-emails current-members-emails))
|
||||
(empty? (remove current-members-emails current-data-emails)))}]]]]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INVITE RESTRICTED MEMBERS MODAL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(mf/defc invite-restricted-members-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :invite-restricted-members}
|
||||
[{:keys [on-accept blocked-emails]}]
|
||||
(let [expanded* (mf/use-state false)
|
||||
expanded? (deref expanded*)
|
||||
on-toggle (mf/use-fn #(swap! expanded* not))]
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-restricted-container :modal-container)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h2 {:class (stl/css :modal-title)}
|
||||
(tr "modals.invite-restricted-members.title")]
|
||||
[:button {:class (stl/css :modal-close-btn)
|
||||
:on-click modal/hide!} deprecated-icon/close]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:p (tr "modals.invite-restricted-members.description")]
|
||||
[:& context-notification {:content (tr "modals.invite-restricted-members.warning")
|
||||
:level :warning}]
|
||||
[:div {:class (stl/css :restricted-emails-section)}
|
||||
[:button {:class (stl/css :restricted-emails-toggle)
|
||||
:type "button"
|
||||
:aria-expanded expanded?
|
||||
:on-click on-toggle}
|
||||
[:span {:class (stl/css :restricted-email-summary)}
|
||||
(tr "modals.invite-restricted-members.blocked-addresses")]
|
||||
[:> icon* {:icon-id i/arrow
|
||||
:size "s"
|
||||
:class (stl/css-case :restricted-emails-arrow true
|
||||
:expanded expanded?)}]]
|
||||
(when expanded?
|
||||
[:ul {:class (stl/css :restricted-email-list)}
|
||||
(for [email blocked-emails]
|
||||
[:li {:key email} email])])]]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)}
|
||||
[:> button*
|
||||
{:class (stl/css :cancel-button)
|
||||
:variant "secondary"
|
||||
:type "button"
|
||||
:on-click modal/hide!}
|
||||
(tr "modals.invite-restricted-members.cancel")]
|
||||
[:> button*
|
||||
{:class (stl/css :accept-btn)
|
||||
:variant "primary"
|
||||
:type "button"
|
||||
:on-click (fn []
|
||||
(modal/hide!)
|
||||
(on-accept))}
|
||||
(tr "modals.invite-restricted-members.send")]]]]]))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MEMBERS SECTION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -871,6 +871,54 @@
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
// INVITE RESTRICTED MEMBERS MODAL
|
||||
|
||||
.modal-restricted-container {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.restricted-email-summary {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.restricted-emails-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-l);
|
||||
}
|
||||
|
||||
.restricted-emails-toggle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.restricted-emails-arrow {
|
||||
color: var(--color-accent-primary);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.restricted-emails-arrow.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.restricted-email-list {
|
||||
list-style: none;
|
||||
margin: var(--sp-s) 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
// SELECT ORGANIZATION MODAL
|
||||
|
||||
.modal-select-org-container {
|
||||
|
||||
@@ -3731,6 +3731,41 @@ msgstr "Edit webhook"
|
||||
msgid "modals.edit-webhook.title"
|
||||
msgstr "Edit webhook"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.title"
|
||||
msgstr "Some invitations can't be sent"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.description"
|
||||
msgstr "Only existing members of the organization can be invited."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.warning"
|
||||
msgstr "Some people on your list won't receive the invitations."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.blocked-addresses"
|
||||
msgstr "Addresses that won't receive an invitation"
|
||||
|
||||
msgid "modals.invite-restricted-members.all-blocked-title"
|
||||
msgstr "No invitations were sent"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.all-blocked"
|
||||
msgstr "Only existing members of the organization can be invited. Because none of the people on your list are members, no invitations were sent."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.all-org-members-in-team"
|
||||
msgstr "Only existing members of the organization can be invited to this team, and they are all already team members. If you need more information, contact the organization's owner."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.send"
|
||||
msgstr "Send invites"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs:249
|
||||
msgid "modals.invite-member-confirm.accept"
|
||||
msgstr "Send invitation"
|
||||
|
||||
@@ -3620,6 +3620,41 @@ msgstr "Modificar webhook"
|
||||
msgid "modals.edit-webhook.title"
|
||||
msgstr "Modificar webhook"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.title"
|
||||
msgstr "Algunas invitaciones no se pueden enviar"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.description"
|
||||
msgstr "Solo se puede invitar a miembros existentes de la organización."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.warning"
|
||||
msgstr "Algunas personas de tu lista no recibirán las invitaciones."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.blocked-addresses"
|
||||
msgstr "Direcciones que no recibirán una invitación"
|
||||
|
||||
msgid "modals.invite-restricted-members.all-blocked-title"
|
||||
msgstr "No se envió ninguna invitación"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.all-blocked"
|
||||
msgstr "Solo se puede invitar a miembros existentes de la organización. Como ninguna de las personas de tu lista es miembro, no se envió ninguna invitación."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.all-org-members-in-team"
|
||||
msgstr "Solo se puede invitar a este equipo a miembros existentes de la organización, y todas esas personas ya son miembros del equipo. Si necesitas más información, contacta con la persona propietaria de la organización."
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.cancel"
|
||||
msgstr "Cancelar"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs
|
||||
msgid "modals.invite-restricted-members.send"
|
||||
msgstr "Enviar invitaciones"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs:249
|
||||
msgid "modals.invite-member-confirm.accept"
|
||||
msgstr "Enviar invitacion"
|
||||
|
||||
Reference in New Issue
Block a user