mirror of
https://github.com/penpot/penpot.git
synced 2026-05-30 04:08:08 +00:00
✨ Add nitrate add team members permission
This commit is contained in:
parent
3e733bb762
commit
dac98c0625
@ -410,6 +410,17 @@
|
|||||||
[:permissions [:map-of :keyword :string]]]
|
[:permissions [:map-of :keyword :string]]]
|
||||||
params)))
|
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
|
(defn- redeem-activation-code-api
|
||||||
[cfg params]
|
[cfg params]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
@ -432,6 +443,7 @@
|
|||||||
:get-org-summary (partial get-org-summary-api cfg)
|
:get-org-summary (partial get-org-summary-api cfg)
|
||||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
||||||
:get-owned-orgs-summary (partial get-owned-orgs-summary-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)
|
:delete-owned-orgs (partial delete-owned-orgs-api cfg)
|
||||||
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
||||||
:remove-profile-from-org (partial remove-profile-from-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
|
;; Notify connected users
|
||||||
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
||||||
nil)
|
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.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
|
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||||
[app.common.types.team :as types.team]
|
[app.common.types.team :as types.team]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
@ -24,6 +25,7 @@
|
|||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.nitrate :as cnit]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
@ -112,8 +114,21 @@
|
|||||||
(let [notifications (dm/get-in member [:props :notifications])]
|
(let [notifications (dm/get-in member [:props :notifications])]
|
||||||
(not= :none (:email-invites 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
|
(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)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
@ -130,6 +145,14 @@
|
|||||||
:code :email-domain-is-not-allowed
|
:code :email-domain-is-not-allowed
|
||||||
:hint "email domain is in the blacklist"))
|
: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
|
;; When we have email verification disabled and invitation user is
|
||||||
;; already present in the database, we proceed to add it to the
|
;; already present in the database, we proceed to add it to the
|
||||||
;; team as-is, without email roundtrip.
|
;; team as-is, without email roundtrip.
|
||||||
@ -223,18 +246,15 @@
|
|||||||
:organization-initials (:initials organization)
|
:organization-initials (:initials organization)
|
||||||
:token itoken
|
:token itoken
|
||||||
:extra-data ptoken}))
|
:extra-data ptoken}))
|
||||||
(let [team (if (contains? cf/flags :nitrate)
|
(eml/send! {::eml/conn conn
|
||||||
(nitrate/add-org-info-to-team cfg team {})
|
::eml/factory eml/invite-to-team
|
||||||
team)]
|
:public-uri (cf/get :public-uri)
|
||||||
(eml/send! {::eml/conn conn
|
:to email
|
||||||
::eml/factory eml/invite-to-team
|
:invited-by (:fullname profile)
|
||||||
:public-uri (cf/get :public-uri)
|
:team (:name team)
|
||||||
:to email
|
:organization (dm/get-in team [:organization :name])
|
||||||
:invited-by (:fullname profile)
|
:token itoken
|
||||||
:team (:name team)
|
:extra-data ptoken})))
|
||||||
:organization (dm/get-in team [:organization :name])
|
|
||||||
:token itoken
|
|
||||||
:extra-data ptoken}))))
|
|
||||||
|
|
||||||
itoken)))))
|
itoken)))))
|
||||||
|
|
||||||
@ -309,7 +329,20 @@
|
|||||||
- emails (set) + role (single role for all emails)
|
- emails (set) + role (single role for all emails)
|
||||||
- invitations (vector of {:email :role} maps)"
|
- invitations (vector of {:email :role} maps)"
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
[{: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
|
invitation-data (cond
|
||||||
;; Case 1: emails + single role (create invitations style)
|
;; Case 1: emails + single role (create invitations style)
|
||||||
(and emails role)
|
(and emails role)
|
||||||
|
|||||||
@ -654,3 +654,50 @@ LEFT JOIN profile AS p
|
|||||||
:teams-to-transfer (count valid-teams-to-transfer)
|
:teams-to-transfer (count valid-teams-to-transfer)
|
||||||
:teams-to-exit (count valid-teams-to-exit)}))
|
: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 (nil? (:result out)))
|
||||||
(t/is (empty? remaining)))))
|
(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
|
;; Tests: remove-from-org
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
{:create-teams "any"
|
{:create-teams "any"
|
||||||
:delete-teams "onlyOwners"
|
:delete-teams "onlyOwners"
|
||||||
:move-teams "always"
|
:move-teams "always"
|
||||||
:send-invitations "ownersAndAdmins"})
|
:send-invitations "ownersAndAdmins"
|
||||||
|
:new-team-members "anyone"})
|
||||||
|
|
||||||
(defn- can-create-team?
|
(defn- can-create-team?
|
||||||
[{:keys [is-org-owner? permission-value]}]
|
[{:keys [is-org-owner? permission-value]}]
|
||||||
@ -49,15 +50,21 @@
|
|||||||
|
|
||||||
:else false))
|
:else false))
|
||||||
|
|
||||||
|
(defn- can-add-anybody-to-team?
|
||||||
|
[{:keys [permission-value]}]
|
||||||
|
(= permission-value "anyone"))
|
||||||
|
|
||||||
(def ^:private action-rules
|
(def ^:private action-rules
|
||||||
{:create-team {:permission-key :create-teams
|
{:create-team {:permission-key :create-teams
|
||||||
:check-fn can-create-team?}
|
:check-fn can-create-team?}
|
||||||
:delete-team {:permission-key :delete-teams
|
:delete-team {:permission-key :delete-teams
|
||||||
:check-fn can-delete-team?}
|
:check-fn can-delete-team?}
|
||||||
:move-team {:permission-key :move-teams
|
:move-team {:permission-key :move-teams
|
||||||
:check-fn can-move-team?}
|
:check-fn can-move-team?}
|
||||||
:send-invitations {:permission-key :send-invitations
|
:send-invitations {:permission-key :send-invitations
|
||||||
:check-fn can-invite-to-team?}})
|
: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
|
(defn- normalize-org-permissions
|
||||||
[org-perms]
|
[org-perms]
|
||||||
|
|||||||
@ -20,7 +20,9 @@
|
|||||||
[:permissions {:optional true}
|
[:permissions {:optional true}
|
||||||
[:maybe [:map
|
[:maybe [:map
|
||||||
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]
|
[: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
|
(def schema:team-with-organization
|
||||||
|
|||||||
@ -67,6 +67,19 @@
|
|||||||
(->> (rp/cmd! :get-teams)
|
(->> (rp/cmd! :get-teams)
|
||||||
(rx/map teams-fetched)))))
|
(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
|
(defn check-and-create-team
|
||||||
"Fetches fresh team data from the server to ensure up-to-date org
|
"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."
|
permissions, then shows the team-form modal or a no-permission modal."
|
||||||
@ -75,26 +88,23 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [profile-id (dm/get-in state [:profile :id])]
|
(let [profile-id (dm/get-in state [:profile :id])]
|
||||||
(->> (rp/cmd! :get-teams)
|
(with-refreshed-team team-id
|
||||||
(rx/mapcat
|
(fn [team]
|
||||||
(fn [teams]
|
(let [organization (:organization team)
|
||||||
(let [team (d/seek #(= (:id %) team-id) teams)
|
in-org? (and (contains? cf/flags :nitrate) organization)
|
||||||
organization (:organization team)
|
can-create? (if in-org?
|
||||||
in-org? (and (contains? cf/flags :nitrate) organization)
|
(nitrate-perms/allowed? :create-team
|
||||||
can-create? (if in-org?
|
{:org-perms {:owner-id (:owner-id organization)
|
||||||
(nitrate-perms/allowed? :create-team
|
:permissions (:permissions organization)}
|
||||||
{:org-perms {:owner-id (:owner-id organization)
|
:profile-id profile-id
|
||||||
:permissions (:permissions organization)}
|
:team-perms (:permissions team)})
|
||||||
:profile-id profile-id
|
true)]
|
||||||
:team-perms (:permissions team)})
|
(rx/of (if can-create?
|
||||||
true)]
|
(modal/show :team-form (if in-org?
|
||||||
(rx/of (teams-fetched teams)
|
{:organization-id (:id organization)
|
||||||
(if can-create?
|
:organization-name (:name organization)}
|
||||||
(modal/show :team-form (if in-org?
|
{}))
|
||||||
{:organization-id (:id organization)
|
(modal/show :no-permission-modal {:type :create-team}))))))))))
|
||||||
:organization-name (:name organization)}
|
|
||||||
{}))
|
|
||||||
(modal/show :no-permission-modal {:type :create-team})))))))))))
|
|
||||||
|
|
||||||
(defn check-and-delete-team
|
(defn check-and-delete-team
|
||||||
"Fetches fresh team data from the server to ensure up-to-date org
|
"Fetches fresh team data from the server to ensure up-to-date org
|
||||||
@ -104,31 +114,57 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [profile-id (dm/get-in state [:profile :id])]
|
(let [profile-id (dm/get-in state [:profile :id])]
|
||||||
(->> (rp/cmd! :get-teams)
|
(with-refreshed-team team-id
|
||||||
(rx/mapcat
|
(fn [team]
|
||||||
(fn [teams]
|
(let [org (:organization team)
|
||||||
(let [team (d/seek #(= (:id %) team-id) teams)
|
in-org? (and (contains? cf/flags :nitrate) org)
|
||||||
org (:organization team)
|
can-delete? (if in-org?
|
||||||
in-org? (and (contains? cf/flags :nitrate) org)
|
(nitrate-perms/allowed? :delete-team
|
||||||
can-delete? (if in-org?
|
{:org-perms {:owner-id (:owner-id org)
|
||||||
(nitrate-perms/allowed? :delete-team
|
:permissions (:permissions org)}
|
||||||
{:org-perms {:owner-id (:owner-id org)
|
:profile-id profile-id
|
||||||
:permissions (:permissions org)}
|
:team-perms (:permissions team)})
|
||||||
:profile-id profile-id
|
(boolean (dm/get-in team [:permissions :is-owner])))
|
||||||
:team-perms (:permissions team)})
|
message (if in-org?
|
||||||
(boolean (dm/get-in team [:permissions :is-owner])))
|
(tr "modals.delete-org-team-confirm.message" (:name org))
|
||||||
message (if in-org?
|
(tr "modals.delete-team-confirm.message"))]
|
||||||
(tr "modals.delete-org-team-confirm.message" (:name org))
|
(rx/of (if can-delete?
|
||||||
(tr "modals.delete-team-confirm.message"))]
|
(modal/show
|
||||||
(rx/of (teams-fetched teams)
|
{:type :confirm
|
||||||
(if can-delete?
|
:title (tr "modals.delete-team-confirm.title")
|
||||||
(modal/show
|
:message message
|
||||||
{:type :confirm
|
:accept-label (tr "modals.delete-team-confirm.accept")
|
||||||
:title (tr "modals.delete-team-confirm.title")
|
:on-accept delete-fn})
|
||||||
:message message
|
(modal/show :no-permission-modal {:type :delete-team}))))))))))
|
||||||
:accept-label (tr "modals.delete-team-confirm.accept")
|
|
||||||
:on-accept delete-fn})
|
(defn- check-new-team-members-permission-and-show-invite-members
|
||||||
(modal/show :no-permission-modal {:type :delete-team})))))))))))
|
"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
|
(defn check-and-invite-members
|
||||||
"Fetches fresh team data from the server to ensure up-to-date org
|
"Fetches fresh team data from the server to ensure up-to-date org
|
||||||
@ -139,23 +175,19 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [profile-id (dm/get-in state [:profile :id])]
|
(let [profile-id (dm/get-in state [:profile :id])]
|
||||||
(->> (rp/cmd! :get-teams)
|
(with-refreshed-team team-id
|
||||||
(rx/mapcat
|
(fn [team]
|
||||||
(fn [teams]
|
(let [org (:organization team)
|
||||||
(let [team (d/seek #(= (:id %) team-id) teams)
|
can-invite? (nitrate-perms/can-send-invitations?
|
||||||
org (:organization team)
|
{:nitrate-enabled? (contains? cf/flags :nitrate)
|
||||||
can-invite? (nitrate-perms/can-send-invitations?
|
:organization org
|
||||||
{:nitrate-enabled? (contains? cf/flags :nitrate)
|
:profile-id profile-id
|
||||||
:organization org
|
:team-permissions (:permissions team)})]
|
||||||
:profile-id profile-id
|
(rx/of (if can-invite?
|
||||||
:team-permissions (:permissions team)})]
|
(check-new-team-members-permission-and-show-invite-members {:team team
|
||||||
(rx/of (teams-fetched teams)
|
:origin origin
|
||||||
(if can-invite?
|
:invite-email invite-email})
|
||||||
(modal/show {:type :invite-members
|
(modal/show :no-permission-modal {:type :invite-members}))))))))))
|
||||||
:team team
|
|
||||||
:origin origin
|
|
||||||
:invite-email invite-email})
|
|
||||||
(modal/show :no-permission-modal {:type :invite-members})))))))))))
|
|
||||||
|
|
||||||
;; --- EVENT: fetch-members
|
;; --- EVENT: fetch-members
|
||||||
|
|
||||||
@ -520,6 +552,53 @@
|
|||||||
(rx/tap on-success)
|
(rx/tap on-success)
|
||||||
(rx/catch on-error))))))
|
(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
|
(defn copy-invitation-link
|
||||||
[{:keys [email team-id] :as params}]
|
[{:keys [email team-id] :as params}]
|
||||||
(assert (sm/check-email email))
|
(assert (sm/check-email email))
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
[app.main.data.team :as dtm]
|
[app.main.data.team :as dtm]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
|
[app.main.ui.alert]
|
||||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||||
[app.main.ui.components.file-uploader :refer [file-uploader]]
|
[app.main.ui.components.file-uploader :refer [file-uploader]]
|
||||||
[app.main.ui.components.forms :as fm]
|
[app.main.ui.components.forms :as fm]
|
||||||
@ -153,6 +154,14 @@
|
|||||||
[:emails [::sm/set {:min 1} ::sm/email]]
|
[:emails [::sm/set {:min 1} ::sm/email]]
|
||||||
[:team-id ::sm/uuid]])
|
[: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/defc invite-members-modal
|
||||||
{::mf/register modal/components
|
{::mf/register modal/components
|
||||||
::mf/register-as :invite-members
|
::mf/register-as :invite-members
|
||||||
@ -222,11 +231,7 @@
|
|||||||
(let [params (:clean-data @form)
|
(let [params (:clean-data @form)
|
||||||
mdata {:on-success (partial on-success form)
|
mdata {:on-success (partial on-success form)
|
||||||
:on-error (partial on-error form)}]
|
:on-error (partial on-error form)}]
|
||||||
(st/emit! (-> (dtm/create-invitations (with-meta params mdata))
|
(st/emit! (dtm/check-and-submit-invite-members (with-meta params mdata) origin do-invite-members!))))]
|
||||||
(with-meta {::ev/origin origin}))
|
|
||||||
;; FIXME: looks duplicate
|
|
||||||
(dtm/fetch-invitations)
|
|
||||||
(dtm/fetch-members))))]
|
|
||||||
|
|
||||||
[:div {:class (stl/css-case :modal-team-container true
|
[:div {:class (stl/css-case :modal-team-container true
|
||||||
:modal-team-container-workspace (= origin :workspace)
|
:modal-team-container-workspace (= origin :workspace)
|
||||||
@ -269,6 +274,63 @@
|
|||||||
:disabled (and (boolean (some current-data-emails current-members-emails))
|
:disabled (and (boolean (some current-data-emails current-members-emails))
|
||||||
(empty? (remove current-members-emails current-data-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
|
;; MEMBERS SECTION
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|||||||
@ -871,6 +871,54 @@
|
|||||||
gap: var(--sp-s);
|
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
|
// SELECT ORGANIZATION MODAL
|
||||||
|
|
||||||
.modal-select-org-container {
|
.modal-select-org-container {
|
||||||
|
|||||||
@ -3731,6 +3731,41 @@ msgstr "Edit webhook"
|
|||||||
msgid "modals.edit-webhook.title"
|
msgid "modals.edit-webhook.title"
|
||||||
msgstr "Edit webhook"
|
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
|
#: src/app/main/ui/dashboard/team.cljs:249
|
||||||
msgid "modals.invite-member-confirm.accept"
|
msgid "modals.invite-member-confirm.accept"
|
||||||
msgstr "Send invitation"
|
msgstr "Send invitation"
|
||||||
|
|||||||
@ -3620,6 +3620,41 @@ msgstr "Modificar webhook"
|
|||||||
msgid "modals.edit-webhook.title"
|
msgid "modals.edit-webhook.title"
|
||||||
msgstr "Modificar webhook"
|
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
|
#: src/app/main/ui/dashboard/team.cljs:249
|
||||||
msgid "modals.invite-member-confirm.accept"
|
msgid "modals.invite-member-confirm.accept"
|
||||||
msgstr "Enviar invitacion"
|
msgstr "Enviar invitacion"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user