mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +00:00
✨ Add nitrate audit events
This commit is contained in:
parent
91d4b16171
commit
31ccfa546f
@ -173,13 +173,6 @@
|
|||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:is-your-penpot :boolean]]]]])
|
[:is-your-penpot :boolean]]]]])
|
||||||
|
|
||||||
(def ^:private schema:profile-org
|
|
||||||
[:map
|
|
||||||
[:is-member :boolean]
|
|
||||||
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
|
||||||
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
|
|
||||||
|
|
||||||
|
|
||||||
;; TODO Unify with schemas on backend/src/app/http/management.clj
|
;; TODO Unify with schemas on backend/src/app/http/management.clj
|
||||||
(def ^:private schema:timestamp
|
(def ^:private schema:timestamp
|
||||||
(sm/type-schema
|
(sm/type-schema
|
||||||
@ -196,6 +189,13 @@
|
|||||||
:decode/json ct/inst
|
:decode/json ct/inst
|
||||||
:encode/json inst-ms}}))
|
:encode/json inst-ms}}))
|
||||||
|
|
||||||
|
(def ^:private schema:profile-org
|
||||||
|
[:map
|
||||||
|
[:is-member :boolean]
|
||||||
|
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:default-team-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:created-at {:optional true} [:maybe schema:timestamp]]])
|
||||||
|
|
||||||
(def ^:private schema:subscription
|
(def ^:private schema:subscription
|
||||||
[:map {:title "Subscription"}
|
[:map {:title "Subscription"}
|
||||||
[:id ::sm/text]
|
[:id ::sm/text]
|
||||||
@ -361,8 +361,13 @@
|
|||||||
schema:profile-org params)))
|
schema:profile-org params)))
|
||||||
|
|
||||||
(defn- remove-profile-from-org-api
|
(defn- remove-profile-from-org-api
|
||||||
[cfg {:keys [profile-id organization-id] :as params}]
|
[cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}]
|
||||||
(let [params (assoc params :request-params {:user-id profile-id})]
|
(let [request-params (cond-> {:user-id profile-id}
|
||||||
|
(some? user-who-delete-member)
|
||||||
|
(assoc :user-who-delete-member user-who-delete-member)
|
||||||
|
(some? deleted-by-role)
|
||||||
|
(assoc :deleted-by-role deleted-by-role))
|
||||||
|
params (assoc params :request-params request-params)]
|
||||||
(request-to-nitrate cfg :post
|
(request-to-nitrate cfg :post
|
||||||
(generate-nitrate-uri
|
(generate-nitrate-uri
|
||||||
"api/organizations/"
|
"api/organizations/"
|
||||||
|
|||||||
@ -223,7 +223,9 @@
|
|||||||
[:teams-to-delete ::sm/int]
|
[:teams-to-delete ::sm/int]
|
||||||
[:teams-to-transfer ::sm/int]
|
[:teams-to-transfer ::sm/int]
|
||||||
[:teams-to-exit ::sm/int]
|
[:teams-to-exit ::sm/int]
|
||||||
[:teams-to-detach ::sm/int]])
|
[:teams-to-detach ::sm/int]
|
||||||
|
[:member-added-at [:maybe ct/schema:inst]]
|
||||||
|
[:organization-member-count-before ::sm/int]])
|
||||||
|
|
||||||
(def ^:private schema:get-leave-org-summary
|
(def ^:private schema:get-leave-org-summary
|
||||||
[:map
|
[:map
|
||||||
@ -322,7 +324,8 @@
|
|||||||
|
|
||||||
(defn leave-org
|
(defn leave-org
|
||||||
[{:keys [::db/conn] :as cfg}
|
[{:keys [::db/conn] :as cfg}
|
||||||
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?]}]
|
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?
|
||||||
|
user-who-delete-member deleted-by-role]}]
|
||||||
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
||||||
{:keys [deletable-team-ids
|
{:keys [deletable-team-ids
|
||||||
keep-default-team?
|
keep-default-team?
|
||||||
@ -357,7 +360,11 @@
|
|||||||
:organization-id id}))
|
:organization-id id}))
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
|
(nitrate/call cfg :remove-profile-from-org
|
||||||
|
{:profile-id profile-id
|
||||||
|
:organization-id id
|
||||||
|
:user-who-delete-member user-who-delete-member
|
||||||
|
:deleted-by-role deleted-by-role})
|
||||||
|
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
@ -368,7 +375,10 @@
|
|||||||
::sm/params schema:leave-org
|
::sm/params schema:leave-org
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(leave-org cfg (assoc params :profile-id profile-id)))
|
(leave-org cfg (assoc params
|
||||||
|
:profile-id profile-id
|
||||||
|
:user-who-delete-member profile-id
|
||||||
|
:deleted-by-role "organization-member")))
|
||||||
|
|
||||||
|
|
||||||
(sv/defmethod ::get-leave-org-summary
|
(sv/defmethod ::get-leave-org-summary
|
||||||
@ -382,12 +392,20 @@
|
|||||||
valid-teams-to-transfer
|
valid-teams-to-transfer
|
||||||
valid-teams-to-exit
|
valid-teams-to-exit
|
||||||
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
|
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
|
||||||
|
membership (nitrate/call cfg :get-org-membership
|
||||||
|
{:profile-id profile-id
|
||||||
|
:organization-id id})
|
||||||
|
organization-members (nitrate/call cfg :get-org-members
|
||||||
|
{:organization-id id})
|
||||||
teams-to-transfer-count (count valid-teams-to-transfer)
|
teams-to-transfer-count (count valid-teams-to-transfer)
|
||||||
teams-to-exit-count (count valid-teams-to-exit)]
|
teams-to-exit-count (count valid-teams-to-exit)]
|
||||||
(when-not valid-default-team
|
(when-not valid-default-team
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-valid-teams))
|
:code :not-valid-teams))
|
||||||
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)))
|
(assoc
|
||||||
|
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
|
||||||
|
:member-added-at (:created-at membership)
|
||||||
|
:organization-member-count-before (count organization-members))))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private schema:remove-team-from-org
|
(def ^:private schema:remove-team-from-org
|
||||||
@ -525,7 +543,9 @@
|
|||||||
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
|
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
(let [team (nitrate/call cfg :set-team-org {:team-id team-id
|
||||||
|
:organization-id organization-id
|
||||||
|
:is-default false})]
|
||||||
;; 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"))
|
||||||
|
|
||||||
|
|||||||
@ -127,7 +127,8 @@
|
|||||||
:hint "The invited email is not a member of the organization")))))
|
: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 org-member-ids] :as params}]
|
[{:keys [::db/conn] :as cfg}
|
||||||
|
{:keys [team organization profile role email org-member-ids organization-member-ids] :as params}]
|
||||||
|
|
||||||
(assert (db/connection-map? cfg)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
@ -203,6 +204,7 @@
|
|||||||
(name role) expire]))
|
(name role) expire]))
|
||||||
updated? (not= id (:id invitation))
|
updated? (not= id (:id invitation))
|
||||||
profile-id (:id profile)
|
profile-id (:id profile)
|
||||||
|
team-organization-id (get-in team [:organization :id])
|
||||||
tprops {:profile-id profile-id
|
tprops {:profile-id profile-id
|
||||||
:invitation-id (:id invitation)
|
:invitation-id (:id invitation)
|
||||||
:valid-until expire
|
:valid-until expire
|
||||||
@ -212,18 +214,37 @@
|
|||||||
:member-email (:email-to invitation)
|
:member-email (:email-to invitation)
|
||||||
:member-id (:id member)
|
:member-id (:id member)
|
||||||
:role role}
|
:role role}
|
||||||
|
audit-props
|
||||||
|
(cond-> {:invitation-id (:id invitation)
|
||||||
|
:valid-until expire
|
||||||
|
:team-id (:id team)
|
||||||
|
:organization-id (:id organization)
|
||||||
|
:organization-name (:name organization)
|
||||||
|
:member-email (:email-to invitation)
|
||||||
|
:member-id (:id member)
|
||||||
|
:role role}
|
||||||
|
organization
|
||||||
|
(assoc :user-who-send-invitation (str profile-id))
|
||||||
|
|
||||||
|
(not organization)
|
||||||
|
(assoc :team-belongs-to-organization (boolean team-organization-id)
|
||||||
|
:adds-invitee-to-organization (boolean team-organization-id)
|
||||||
|
:invitee-already-organization-member
|
||||||
|
(boolean
|
||||||
|
(and team-organization-id
|
||||||
|
member
|
||||||
|
(contains? organization-member-ids (:id member))))))
|
||||||
itoken (create-invitation-token cfg tprops)
|
itoken (create-invitation-token cfg tprops)
|
||||||
ptoken (create-profile-identity-token cfg profile-id)]
|
ptoken (create-profile-identity-token cfg profile-id)]
|
||||||
|
|
||||||
(when (contains? cf/flags :log-invitation-tokens)
|
(when (contains? cf/flags :log-invitation-tokens)
|
||||||
(l/info :hint "invitation token" :token itoken))
|
(l/info :hint "invitation token" :token itoken))
|
||||||
|
|
||||||
(let [props (-> (dissoc tprops :profile-id)
|
(let [props (audit/clean-props audit-props)
|
||||||
(audit/clean-props))
|
|
||||||
evname (cond
|
evname (cond
|
||||||
(and updated? organization) "update-org-invitation"
|
(and updated? organization) "update-organization-invitation"
|
||||||
updated? "update-team-invitation"
|
updated? "update-team-invitation"
|
||||||
organization "create-org-invitation"
|
organization "create-organization-invitation"
|
||||||
:else "create-team-invitation")
|
:else "create-team-invitation")
|
||||||
event (-> (audit/event-from-rpc-params params)
|
event (-> (audit/event-from-rpc-params params)
|
||||||
(assoc :name evname)
|
(assoc :name evname)
|
||||||
@ -331,9 +352,14 @@
|
|||||||
org (:organization team)
|
org (:organization team)
|
||||||
org-id (:id org)
|
org-id (:id org)
|
||||||
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
|
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
|
||||||
org-member-ids (when restricted?
|
organization-member-ids
|
||||||
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
|
(when org-id
|
||||||
params (assoc params :team team :org-member-ids org-member-ids)
|
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
|
||||||
|
org-member-ids (when restricted? organization-member-ids)
|
||||||
|
params (assoc params
|
||||||
|
:team team
|
||||||
|
:org-member-ids org-member-ids
|
||||||
|
:organization-member-ids organization-member-ids)
|
||||||
|
|
||||||
;; Normalize input to a consistent format: [{:email :role}]
|
;; Normalize input to a consistent format: [{:email :role}]
|
||||||
invitation-data (cond
|
invitation-data (cond
|
||||||
|
|||||||
@ -215,52 +215,106 @@
|
|||||||
;; existing invitation; querying it when the invitation is absent
|
;; existing invitation; querying it when the invitation is absent
|
||||||
;; would call nitrate needlessly and could mask the clean
|
;; would call nitrate needlessly and could mask the clean
|
||||||
;; :canceled-invitation/:invalid-token response with a generic error.
|
;; :canceled-invitation/:invalid-token response with a generic error.
|
||||||
(let [membership (when org-invitation?
|
(let [membership
|
||||||
(nitrate/call cfg :get-org-membership {:profile-id profile-id
|
(when (contains? cf/flags :nitrate)
|
||||||
:organization-id organization-id}))]
|
(cond
|
||||||
|
organization-id
|
||||||
|
(nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||||
|
:organization-id organization-id})
|
||||||
|
|
||||||
|
team-id
|
||||||
|
(nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id
|
||||||
|
:team-id team-id})))
|
||||||
|
|
||||||
|
organization-id-on-add
|
||||||
|
(when (and (:organization-id membership)
|
||||||
|
(not (:is-member membership)))
|
||||||
|
(:organization-id membership))
|
||||||
|
|
||||||
|
organization-add-source
|
||||||
|
(when organization-id-on-add
|
||||||
|
(if organization-id
|
||||||
|
"direct-organization-invitation"
|
||||||
|
"team-invitation"))
|
||||||
|
|
||||||
|
organization-event-origin
|
||||||
|
(when organization-id-on-add
|
||||||
|
(if organization-id
|
||||||
|
"organization-invitation-acceptance"
|
||||||
|
"team-invitation-acceptance"))
|
||||||
|
|
||||||
|
organization-member-count-before
|
||||||
|
(when organization-id-on-add
|
||||||
|
(count
|
||||||
|
(nitrate/call cfg :get-org-members
|
||||||
|
{:organization-id organization-id-on-add})))]
|
||||||
|
|
||||||
(when (:is-member membership)
|
(when (:is-member membership)
|
||||||
(ex/raise :type :validation
|
(when org-invitation?
|
||||||
:code :already-an-org-member
|
(ex/raise :type :validation
|
||||||
:team-id (:default-team-id membership)
|
:code :already-an-org-member
|
||||||
:hint "the user is already a member of the organization"))
|
:team-id (:default-team-id membership)
|
||||||
|
:hint "the user is already a member of the organization")))
|
||||||
|
|
||||||
(when (and org-invitation? (not (:organization-id membership)))
|
(when (and org-invitation? (not (:organization-id membership)))
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :org-not-found
|
:code :org-not-found
|
||||||
:team-id (:default-team-id profile)
|
:team-id (:default-team-id profile)
|
||||||
:hint "the organization doesn't exist")))
|
:hint "the organization doesn't exist"))
|
||||||
|
|
||||||
;; if we have logged-in user and it matches the invitation we proceed
|
;; if we have logged-in user and it matches the invitation we proceed
|
||||||
;; with accepting the invitation and joining the current profile to the
|
;; with accepting the invitation and joining the current profile to the
|
||||||
;; invited team.
|
;; invited team.
|
||||||
(let [props {:team-id (:team-id claims)
|
(let [props {:team-id (:team-id claims)
|
||||||
:role (:role claims)
|
:role (:role claims)
|
||||||
:invitation-id (:id invitation)}]
|
:invitation-id (:id invitation)}]
|
||||||
|
|
||||||
(audit/submit cfg
|
(when team-id
|
||||||
(-> (audit/event-from-rpc-params params)
|
(audit/submit cfg
|
||||||
(assoc :name "accept-team-invitation")
|
(-> (audit/event-from-rpc-params params)
|
||||||
(assoc :props props)))
|
(assoc :name "accept-team-invitation")
|
||||||
|
(assoc :props props)))
|
||||||
|
|
||||||
;; NOTE: Backward compatibility; old invitations can
|
;; NOTE: Backward compatibility; old invitations can
|
||||||
;; have the `created-by` to be nil; so in this case we
|
;; have the `created-by` to be nil; so in this case we
|
||||||
;; don't submit this event to the audit-log
|
;; don't submit this event to the audit-log
|
||||||
(when-let [created-by (:created-by invitation)]
|
(when-let [created-by (:created-by invitation)]
|
||||||
(audit/submit cfg
|
(audit/submit cfg
|
||||||
(-> (audit/event-from-rpc-params params)
|
(-> (audit/event-from-rpc-params params)
|
||||||
(assoc :profile-id created-by)
|
(assoc :profile-id created-by)
|
||||||
(assoc :name "accept-team-invitation-from")
|
(assoc :name "accept-team-invitation-from")
|
||||||
(assoc :props (assoc props
|
(assoc :props (assoc props
|
||||||
:profile-id (:id profile)
|
:profile-id (:id profile)
|
||||||
:email (:email profile))))))
|
:email (:email profile)))))))
|
||||||
|
|
||||||
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||||
(cond-> (assoc claims :state :created)
|
(when organization-id-on-add
|
||||||
;; when the invitation is to an org, instead of a team, add the
|
(audit/submit
|
||||||
;; accepted-team-id as :org-team-id
|
cfg
|
||||||
(:organization-id claims)
|
(-> (audit/event-from-rpc-params params)
|
||||||
(assoc :org-team-id accepted-team-id)))))
|
(assoc :name "accept-organization-invitation")
|
||||||
|
(assoc :props
|
||||||
|
(-> props
|
||||||
|
(assoc :organization-id organization-id-on-add)
|
||||||
|
(audit/clean-props))))))
|
||||||
|
|
||||||
|
(cond-> (assoc claims :state :created)
|
||||||
|
;; when the invitation is to an org, instead of a team, add the
|
||||||
|
;; accepted-team-id as :org-team-id
|
||||||
|
(:organization-id claims)
|
||||||
|
(assoc :org-team-id accepted-team-id)
|
||||||
|
|
||||||
|
organization-id-on-add
|
||||||
|
(assoc :organization-invitation-audit
|
||||||
|
{:origin organization-event-origin
|
||||||
|
:props
|
||||||
|
(-> props
|
||||||
|
(assoc :organization-id organization-id-on-add
|
||||||
|
:organization-member-add-source organization-add-source
|
||||||
|
:belongs-to-team-on-add (boolean team-id)
|
||||||
|
:organization-member-count-before
|
||||||
|
organization-member-count-before)
|
||||||
|
(audit/clean-props))}))))))
|
||||||
|
|
||||||
(do
|
(do
|
||||||
;; If the user is not logged-in and the invitation has been canceled
|
;; If the user is not logged-in and the invitation has been canceled
|
||||||
@ -289,4 +343,3 @@
|
|||||||
[_ _ _]
|
[_ _ _]
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :invalid-token))
|
:code :invalid-token))
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]]
|
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]]
|
||||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||||
[app.common.types.team :refer [schema:team]]
|
[app.common.types.team :refer [schema:team]]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email :as eml]
|
[app.email :as eml]
|
||||||
@ -619,8 +620,12 @@ RETURNING id, deleted_at;")
|
|||||||
[:default-team-id ::sm/uuid]]
|
[:default-team-id ::sm/uuid]]
|
||||||
::db/transaction true
|
::db/transaction true
|
||||||
::nitrate/sso false}
|
::nitrate/sso false}
|
||||||
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
|
[cfg {actor-profile-id ::rpc/profile-id
|
||||||
(let [{:keys [valid-teams-to-delete-ids
|
:keys [profile-id organization-id organization-name default-team-id]
|
||||||
|
:as params}]
|
||||||
|
(let [actor-profile-id (when-not (= actor-profile-id uuid/zero)
|
||||||
|
actor-profile-id)
|
||||||
|
{:keys [valid-teams-to-delete-ids
|
||||||
valid-teams-to-transfer
|
valid-teams-to-transfer
|
||||||
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
||||||
add-reassign-to (partial add-reassign-to cfg profile-id)
|
add-reassign-to (partial add-reassign-to cfg profile-id)
|
||||||
@ -633,7 +638,10 @@ RETURNING id, deleted_at;")
|
|||||||
:name organization-name
|
:name organization-name
|
||||||
:teams-to-delete valid-teams-to-delete-ids
|
:teams-to-delete valid-teams-to-delete-ids
|
||||||
:teams-to-leave valid-teams-to-leave
|
:teams-to-leave valid-teams-to-leave
|
||||||
:skip-validation true))
|
:skip-validation true
|
||||||
|
:user-who-delete-member actor-profile-id
|
||||||
|
:deleted-by-role (when actor-profile-id
|
||||||
|
"organization-owner")))
|
||||||
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
|
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
@ -809,7 +817,7 @@ RETURNING id, deleted_at;")
|
|||||||
ip-addr (::rpc/ip-addr params)]
|
ip-addr (::rpc/ip-addr params)]
|
||||||
|
|
||||||
(run! (fn [{:keys [type name profile-id props context] :as event}]
|
(run! (fn [{:keys [type name profile-id props context] :as event}]
|
||||||
(let [context (-> (merge context context')
|
(let [context (-> (merge context (d/without-nils context'))
|
||||||
(d/without-nils))]
|
(d/without-nils))]
|
||||||
(audit/submit cfg {:type (d/nilv type "action")
|
(audit/submit cfg {:type (d/nilv type "action")
|
||||||
:name name
|
:name name
|
||||||
|
|||||||
@ -49,6 +49,47 @@
|
|||||||
(t/is (= :authentication (th/ex-type (:error out))))
|
(t/is (= :authentication (th/ex-type (:error out))))
|
||||||
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
||||||
|
|
||||||
|
(t/deftest create-and-update-organization-invitations-audit-props
|
||||||
|
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||||
|
audit-mock {:target 'app.loggers.audit/submit :return nil}
|
||||||
|
nitrate-mock {:target 'app.nitrate/call :return nil}]
|
||||||
|
(binding [cf/flags (conj cf/flags :email-verification)]
|
||||||
|
(let [owner (th/create-profile* 101 {:is-active true})
|
||||||
|
invitee (th/create-profile* 102 {:is-active true})
|
||||||
|
organization {:id (uuid/random)
|
||||||
|
:name "Acme"
|
||||||
|
:initials "AC"
|
||||||
|
:logo nil
|
||||||
|
:avatar-bg-url nil}
|
||||||
|
params {::th/type :invite-to-organization
|
||||||
|
::rpc/profile-id (:id owner)
|
||||||
|
:email (:email invitee)
|
||||||
|
:organization organization}
|
||||||
|
create-out (th/management-command! params)
|
||||||
|
update-out (th/management-command! params)
|
||||||
|
external-out (th/management-command! (assoc params :email "external@example.com"))
|
||||||
|
events (mapv second (:call-args-list @audit-mock))
|
||||||
|
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
|
||||||
|
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
|
||||||
|
external-event
|
||||||
|
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
|
||||||
|
(t/is (th/success? create-out))
|
||||||
|
(t/is (th/success? update-out))
|
||||||
|
(t/is (th/success? external-out))
|
||||||
|
|
||||||
|
(doseq [event [create-event update-event]]
|
||||||
|
(t/is (not (contains? (:props event) :event-origin)))
|
||||||
|
(t/is (= (str (:id owner))
|
||||||
|
(get-in event [:props :user-who-send-invitation])))
|
||||||
|
(t/is (= (:id organization)
|
||||||
|
(get-in event [:props :organization-id])))
|
||||||
|
(t/is (= (:email invitee)
|
||||||
|
(get-in event [:props :member-email])))
|
||||||
|
(t/is (= (:id invitee)
|
||||||
|
(get-in event [:props :member-id]))))
|
||||||
|
|
||||||
|
(t/is (not (contains? (:props external-event) :member-id)))))))
|
||||||
|
|
||||||
(t/deftest get-penpot-version
|
(t/deftest get-penpot-version
|
||||||
(let [out (th/management-command! {::th/type :get-penpot-version})
|
(let [out (th/management-command! {::th/type :get-penpot-version})
|
||||||
version (-> out :result :version)]
|
version (-> out :result :version)]
|
||||||
@ -1034,25 +1075,29 @@
|
|||||||
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
|
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
|
||||||
|
|
||||||
(defn- nitrate-call-mock
|
(defn- nitrate-call-mock
|
||||||
[org-summary]
|
([org-summary]
|
||||||
(fn [_cfg method _params]
|
(nitrate-call-mock org-summary nil))
|
||||||
(case method
|
([org-summary remove-profile-params]
|
||||||
:get-org-summary org-summary
|
(fn [_cfg method params]
|
||||||
:get-org-membership {:organization-id (:id org-summary)
|
(case method
|
||||||
:is-member true}
|
:get-org-summary org-summary
|
||||||
:remove-profile-from-org nil
|
:get-org-membership {:organization-id (:id org-summary)
|
||||||
nil)))
|
:is-member true}
|
||||||
|
:remove-profile-from-org (when remove-profile-params
|
||||||
|
(reset! remove-profile-params params))
|
||||||
|
nil))))
|
||||||
|
|
||||||
(t/deftest remove-from-organization-happy-path-no-extra-teams
|
(t/deftest remove-from-organization-happy-path-no-extra-teams
|
||||||
;; User is only in its default team (which has files); it should be
|
;; User is only in its default team (which has files); it should be
|
||||||
;; kept, renamed and unset as default. A notification must be sent.
|
;; kept, renamed and unset as default. A notification must be sent.
|
||||||
;; --- Deferred org-summary: depends on team IDs from setup ---
|
;; --- Deferred org-summary: depends on team IDs from setup ---
|
||||||
(let [org-summary-ref (atom nil)]
|
(let [org-summary-ref (atom nil)
|
||||||
|
remove-profile-params (atom nil)]
|
||||||
(with-mocks
|
(with-mocks
|
||||||
[;; --- Nitrate mock: nil during setup, serve org-summary once available ---
|
[;; --- Nitrate mock: nil during setup, serve org-summary once available ---
|
||||||
nitrate-mock {:target 'app.nitrate/call
|
nitrate-mock {:target 'app.nitrate/call
|
||||||
:return (fn [& args]
|
:return (fn [& args]
|
||||||
(apply (nitrate-call-mock @org-summary-ref) args))}
|
(apply (nitrate-call-mock @org-summary-ref remove-profile-params) args))}
|
||||||
;; --- Message bus mock: capture published events ---
|
;; --- Message bus mock: capture published events ---
|
||||||
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
||||||
|
|
||||||
@ -1089,6 +1134,10 @@
|
|||||||
;; --- Verify: RPC returns success with no result payload ---
|
;; --- Verify: RPC returns success with no result payload ---
|
||||||
(t/is (th/success? out))
|
(t/is (th/success? out))
|
||||||
(t/is (nil? (:result out)))
|
(t/is (nil? (:result out)))
|
||||||
|
(t/is (= (:id org-owner)
|
||||||
|
(:user-who-delete-member @remove-profile-params)))
|
||||||
|
(t/is (= "organization-owner"
|
||||||
|
(:deleted-by-role @remove-profile-params)))
|
||||||
|
|
||||||
;; --- Verify: default team preserved, renamed and unset as default ---
|
;; --- Verify: default team preserved, renamed and unset as default ---
|
||||||
(let [team (th/db-get :team {:id (:id org-team)})]
|
(let [team (th/db-get :team {:id (:id org-team)})]
|
||||||
@ -1107,12 +1156,13 @@
|
|||||||
(t/deftest remove-from-organization-deletes-empty-default-team
|
(t/deftest remove-from-organization-deletes-empty-default-team
|
||||||
;; When the default team has no files it should be soft-deleted.
|
;; When the default team has no files it should be soft-deleted.
|
||||||
;; --- Deferred org-summary: depends on team IDs from setup ---
|
;; --- Deferred org-summary: depends on team IDs from setup ---
|
||||||
(let [org-summary-ref (atom nil)]
|
(let [org-summary-ref (atom nil)
|
||||||
|
remove-profile-params (atom nil)]
|
||||||
(with-mocks
|
(with-mocks
|
||||||
[;; --- Nitrate mock: nil during setup, serve org-summary once available ---
|
[;; --- Nitrate mock: nil during setup, serve org-summary once available ---
|
||||||
nitrate-mock {:target 'app.nitrate/call
|
nitrate-mock {:target 'app.nitrate/call
|
||||||
:return (fn [& args]
|
:return (fn [& args]
|
||||||
(apply (nitrate-call-mock @org-summary-ref) args))}
|
(apply (nitrate-call-mock @org-summary-ref remove-profile-params) args))}
|
||||||
;; --- Message bus mock: swallow notifications ---
|
;; --- Message bus mock: swallow notifications ---
|
||||||
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
||||||
|
|
||||||
@ -1133,7 +1183,7 @@
|
|||||||
;; --- Exercise: remove the user from the org ---
|
;; --- Exercise: remove the user from the org ---
|
||||||
out (th/management-command!
|
out (th/management-command!
|
||||||
{::th/type :remove-from-organization
|
{::th/type :remove-from-organization
|
||||||
::rpc/profile-id (:id org-owner)
|
::rpc/profile-id uuid/zero
|
||||||
:profile-id (:id user)
|
:profile-id (:id user)
|
||||||
:organization-id organization-id
|
:organization-id organization-id
|
||||||
:organization-name "Acme Org"
|
:organization-name "Acme Org"
|
||||||
@ -1141,6 +1191,8 @@
|
|||||||
|
|
||||||
;; --- Verify: RPC returns success ---
|
;; --- Verify: RPC returns success ---
|
||||||
(t/is (th/success? out))
|
(t/is (th/success? out))
|
||||||
|
(t/is (nil? (:user-who-delete-member @remove-profile-params)))
|
||||||
|
(t/is (nil? (:deleted-by-role @remove-profile-params)))
|
||||||
;; --- Verify: empty default team is soft-deleted ---
|
;; --- Verify: empty default team is soft-deleted ---
|
||||||
(let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})]
|
(let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})]
|
||||||
(t/is (some? (:deleted-at team))))))))
|
(t/is (some? (:deleted-at team))))))))
|
||||||
|
|||||||
@ -45,19 +45,28 @@
|
|||||||
"Creates a mock for nitrate/call that returns the given org-summary for
|
"Creates a mock for nitrate/call that returns the given org-summary for
|
||||||
:get-org-summary, a valid membership for :get-org-membership, and nil for
|
:get-org-summary, a valid membership for :get-org-membership, and nil for
|
||||||
any other method."
|
any other method."
|
||||||
[org-summary]
|
([org-summary]
|
||||||
(fn [_cfg method _params]
|
(nitrate-call-mock org-summary nil))
|
||||||
(case method
|
([org-summary remove-profile-params]
|
||||||
:get-org-summary org-summary
|
(fn [_cfg method params]
|
||||||
:get-org-membership {:is-member true
|
(case method
|
||||||
:organization-id (:id org-summary)}
|
:get-org-summary org-summary
|
||||||
nil)))
|
:get-org-membership {:is-member true
|
||||||
|
:organization-id (:id org-summary)}
|
||||||
|
:remove-profile-from-org (when remove-profile-params
|
||||||
|
(reset! remove-profile-params params))
|
||||||
|
nil))))
|
||||||
|
|
||||||
(defn- nitrate-org-summary-only-mock
|
(defn- nitrate-org-summary-only-mock
|
||||||
[org-summary]
|
[org-summary]
|
||||||
(fn [_cfg method _params]
|
(fn [_cfg method _params]
|
||||||
(case method
|
(case method
|
||||||
:get-org-summary org-summary
|
:get-org-summary org-summary
|
||||||
|
:get-org-membership {:is-member true
|
||||||
|
:organization-id (:id org-summary)
|
||||||
|
:created-at (ct/inst "2026-07-17T12:00:00Z")}
|
||||||
|
:get-org-members [(:owner-id org-summary)
|
||||||
|
(uuid/random)]
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
(defn- active-sso-call-mock
|
(defn- active-sso-call-mock
|
||||||
@ -173,14 +182,15 @@
|
|||||||
;; The user's personal penpot team in the org context
|
;; The user's personal penpot team in the org context
|
||||||
your-penpot-id (:id org-default-team)
|
your-penpot-id (:id org-default-team)
|
||||||
|
|
||||||
org-summary (make-org-summary
|
org-summary (make-org-summary
|
||||||
:organization-id organization-id
|
:organization-id organization-id
|
||||||
:organization-name "Test Org"
|
:organization-name "Test Org"
|
||||||
:owner-id (:id profile-owner)
|
:owner-id (:id profile-owner)
|
||||||
:your-penpot-teams [your-penpot-id]
|
:your-penpot-teams [your-penpot-id]
|
||||||
:org-teams [])]
|
:org-teams [])
|
||||||
|
remove-profile-params (atom nil)]
|
||||||
|
|
||||||
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
|
(with-redefs [nitrate/call (nitrate-call-mock org-summary remove-profile-params)]
|
||||||
(let [data {::th/type :leave-org
|
(let [data {::th/type :leave-org
|
||||||
::rpc/profile-id (:id profile-user)
|
::rpc/profile-id (:id profile-user)
|
||||||
:id organization-id
|
:id organization-id
|
||||||
@ -198,7 +208,12 @@
|
|||||||
;; unset as a default team.
|
;; unset as a default team.
|
||||||
(let [team (th/db-get :team {:id your-penpot-id})]
|
(let [team (th/db-get :team {:id your-penpot-id})]
|
||||||
(t/is (str/starts-with? (:name team) "[Test Org] "))
|
(t/is (str/starts-with? (:name team) "[Test Org] "))
|
||||||
(t/is (false? (:is-default team))))))))
|
(t/is (false? (:is-default team))))
|
||||||
|
|
||||||
|
(t/is (= (:id profile-user)
|
||||||
|
(:user-who-delete-member @remove-profile-params)))
|
||||||
|
(t/is (= "organization-member"
|
||||||
|
(:deleted-by-role @remove-profile-params)))))))
|
||||||
|
|
||||||
(t/deftest leave-org-deletes-org-default-team-when-empty
|
(t/deftest leave-org-deletes-org-default-team-when-empty
|
||||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
@ -413,7 +428,9 @@
|
|||||||
(t/is (= {:teams-to-delete 0
|
(t/is (= {:teams-to-delete 0
|
||||||
:teams-to-transfer 0
|
:teams-to-transfer 0
|
||||||
:teams-to-exit 0
|
:teams-to-exit 0
|
||||||
:teams-to-detach 0}
|
:teams-to-detach 0
|
||||||
|
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
||||||
|
:organization-member-count-before 2}
|
||||||
(:result out)))))))
|
(:result out)))))))
|
||||||
|
|
||||||
(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files
|
(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files
|
||||||
@ -445,7 +462,9 @@
|
|||||||
(t/is (= {:teams-to-delete 1
|
(t/is (= {:teams-to-delete 1
|
||||||
:teams-to-transfer 0
|
:teams-to-transfer 0
|
||||||
:teams-to-exit 0
|
:teams-to-exit 0
|
||||||
:teams-to-detach 1}
|
:teams-to-detach 1
|
||||||
|
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
||||||
|
:organization-member-count-before 2}
|
||||||
(:result out)))))))
|
(:result out)))))))
|
||||||
|
|
||||||
(t/deftest leave-org-error-org-owner-cannot-leave
|
(t/deftest leave-org-error-org-owner-cannot-leave
|
||||||
@ -920,7 +939,7 @@
|
|||||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
(defn- add-team-to-org-nitrate-mock
|
(defn- add-team-to-org-nitrate-mock
|
||||||
[{:keys [org-id org-summary org-perms owner-id team-id sso-active?]}]
|
[{:keys [org-id org-summary org-perms owner-id team-id sso-active? set-team-params]}]
|
||||||
(fn [_cfg method params]
|
(fn [_cfg method params]
|
||||||
(case method
|
(case method
|
||||||
:get-org-membership (if (= (:profile-id params) owner-id)
|
:get-org-membership (if (= (:profile-id params) owner-id)
|
||||||
@ -929,7 +948,10 @@
|
|||||||
:get-org-members [owner-id]
|
:get-org-members [owner-id]
|
||||||
:get-team-org {:organization nil}
|
:get-team-org {:organization nil}
|
||||||
:get-org-permissions org-perms
|
:get-org-permissions org-perms
|
||||||
:set-team-org {:id team-id}
|
:set-team-org (do
|
||||||
|
(when set-team-params
|
||||||
|
(reset! set-team-params params))
|
||||||
|
{:id team-id})
|
||||||
:get-org-sso {:active sso-active?}
|
:get-org-sso {:active sso-active?}
|
||||||
:get-org-summary (assoc org-summary :teams [{:id team-id}])
|
:get-org-summary (assoc org-summary :teams [{:id team-id}])
|
||||||
:add-profile-to-org {:is-member true}
|
:add-profile-to-org {:is-member true}
|
||||||
@ -956,7 +978,8 @@
|
|||||||
:permissions {:create-teams "any"
|
:permissions {:create-teams "any"
|
||||||
:move-teams "always"
|
:move-teams "always"
|
||||||
:new-team-members "members"}}
|
:new-team-members "members"}}
|
||||||
sent (atom [])]
|
sent (atom [])
|
||||||
|
set-team-params (atom nil)]
|
||||||
|
|
||||||
(th/db-insert! :team-invitation
|
(th/db-insert! :team-invitation
|
||||||
{:id (uuid/random)
|
{:id (uuid/random)
|
||||||
@ -974,7 +997,8 @@
|
|||||||
:org-perms org-perms
|
:org-perms org-perms
|
||||||
:owner-id (:id owner)
|
:owner-id (:id owner)
|
||||||
:team-id (:id team)
|
:team-id (:id team)
|
||||||
:sso-active? true})
|
:sso-active? true
|
||||||
|
:set-team-params set-team-params})
|
||||||
teams/initialize-user-in-nitrate-org (fn [& _] nil)
|
teams/initialize-user-in-nitrate-org (fn [& _] nil)
|
||||||
eml/send! (fn [params] (swap! sent conj params))]
|
eml/send! (fn [params] (swap! sent conj params))]
|
||||||
(let [out (th/command! {::th/type :add-team-to-organization
|
(let [out (th/command! {::th/type :add-team-to-organization
|
||||||
@ -983,6 +1007,11 @@
|
|||||||
:organization-id org-id})]
|
:organization-id org-id})]
|
||||||
(t/is (th/success? out))))
|
(t/is (th/success? out))))
|
||||||
|
|
||||||
|
(t/is (= {:team-id (:id team)
|
||||||
|
:organization-id org-id
|
||||||
|
:is-default false}
|
||||||
|
@set-team-params))
|
||||||
|
|
||||||
(let [emails (->> @sent (map :to) set)]
|
(let [emails (->> @sent (map :to) set)]
|
||||||
(t/is (= 2 (count @sent)))
|
(t/is (= 2 (count @sent)))
|
||||||
(t/is (= #{"member302@example.com" "external301@example.com"} emails))
|
(t/is (= #{"member302@example.com" "external301@example.com"} emails))
|
||||||
@ -990,6 +1019,26 @@
|
|||||||
(t/is (= org-name (:organization-name email-params)))
|
(t/is (= org-name (:organization-name email-params)))
|
||||||
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
|
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
|
||||||
|
|
||||||
|
(t/deftest create-team-in-organization-passes-association-to-nitrate
|
||||||
|
(let [organization-id (uuid/random)
|
||||||
|
team {:id (uuid/random)
|
||||||
|
:created-at (ct/now)}
|
||||||
|
params* (atom nil)]
|
||||||
|
(with-redefs [nitrate/call (fn [_cfg method params]
|
||||||
|
(when (= method :set-team-org)
|
||||||
|
(reset! params* params))
|
||||||
|
{:id (:id team)})]
|
||||||
|
(nitrate/set-team-organization
|
||||||
|
{}
|
||||||
|
team
|
||||||
|
{:organization-id organization-id
|
||||||
|
:is-default false}))
|
||||||
|
|
||||||
|
(t/is (= {:team-id (:id team)
|
||||||
|
:organization-id organization-id
|
||||||
|
:is-default false}
|
||||||
|
@params*))))
|
||||||
|
|
||||||
(t/deftest add-team-to-organization-skips-sso-emails-when-sso-inactive
|
(t/deftest add-team-to-organization-skips-sso-emails-when-sso-inactive
|
||||||
(let [owner (th/create-profile* 303 {:is-active true :email "owner303@example.com"})
|
(let [owner (th/create-profile* 303 {:is-active true :email "owner303@example.com"})
|
||||||
member (th/create-profile* 304 {:is-active true :email "member304@example.com"})
|
member (th/create-profile* 304 {:is-active true :email "member304@example.com"})
|
||||||
|
|||||||
@ -13,7 +13,9 @@
|
|||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email.blacklist :as email.blacklist]
|
[app.email.blacklist :as email.blacklist]
|
||||||
[app.http :as http]
|
[app.http :as http]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.tokens :as tokens]
|
[app.tokens :as tokens]
|
||||||
[backend-tests.helpers :as th]
|
[backend-tests.helpers :as th]
|
||||||
@ -103,6 +105,63 @@
|
|||||||
(t/is (= :validation (:type edata)))
|
(t/is (= :validation (:type edata)))
|
||||||
(t/is (= :member-is-muted (:code edata))))))))
|
(t/is (= :member-is-muted (:code edata))))))))
|
||||||
|
|
||||||
|
(t/deftest create-and-update-team-invitations-include-organization-props
|
||||||
|
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||||
|
audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
||||||
|
(let [owner (th/create-profile* 101 {:is-active true})
|
||||||
|
invitee (th/create-profile* 102 {:is-active true})
|
||||||
|
org-team (th/create-team* 101 {:profile-id (:id owner)})
|
||||||
|
plain-team (th/create-team* 102 {:profile-id (:id owner)})
|
||||||
|
org-id (uuid/random)
|
||||||
|
org {:id org-id
|
||||||
|
:name "Acme"
|
||||||
|
:slug "acme"
|
||||||
|
:owner-id (:id owner)
|
||||||
|
:avatar-bg-url "https://example.com/avatar.svg"
|
||||||
|
:permissions {:new-team-members "anyone"}}
|
||||||
|
nitrate-call
|
||||||
|
(fn [_cfg method params]
|
||||||
|
(case method
|
||||||
|
:get-team-org
|
||||||
|
(if (= (:team-id params) (:id org-team))
|
||||||
|
{:organization org :is-your-penpot false}
|
||||||
|
{:organization nil :is-your-penpot false})
|
||||||
|
|
||||||
|
:get-org-members
|
||||||
|
[(:id invitee)]
|
||||||
|
|
||||||
|
nil))
|
||||||
|
invite! (fn [team email]
|
||||||
|
(th/command! {::th/type :create-team-invitations
|
||||||
|
::rpc/profile-id (:id owner)
|
||||||
|
:team-id (:id team)
|
||||||
|
:role :editor
|
||||||
|
:emails [email]}))]
|
||||||
|
(with-redefs [cf/flags (conj cf/flags :nitrate :email-verification)
|
||||||
|
nitrate/call nitrate-call]
|
||||||
|
(t/is (th/success? (invite! org-team (:email invitee))))
|
||||||
|
(t/is (th/success? (invite! org-team (:email invitee))))
|
||||||
|
(t/is (th/success? (invite! plain-team "external@example.com"))))
|
||||||
|
|
||||||
|
(let [events (mapv second (:call-args-list @audit-mock))
|
||||||
|
create-org (first (filter #(and (= "create-team-invitation" (:name %))
|
||||||
|
(= (:email invitee)
|
||||||
|
(get-in % [:props :member-email])))
|
||||||
|
events))
|
||||||
|
update-org (first (filter #(= "update-team-invitation" (:name %)) events))
|
||||||
|
create-plain (first (filter #(and (= "create-team-invitation" (:name %))
|
||||||
|
(= "external@example.com"
|
||||||
|
(get-in % [:props :member-email])))
|
||||||
|
events))]
|
||||||
|
(doseq [event [create-org update-org]]
|
||||||
|
(t/is (true? (get-in event [:props :team-belongs-to-organization])))
|
||||||
|
(t/is (true? (get-in event [:props :adds-invitee-to-organization])))
|
||||||
|
(t/is (true? (get-in event [:props :invitee-already-organization-member]))))
|
||||||
|
|
||||||
|
(t/is (false? (get-in create-plain [:props :team-belongs-to-organization])))
|
||||||
|
(t/is (false? (get-in create-plain [:props :adds-invitee-to-organization])))
|
||||||
|
(t/is (false? (get-in create-plain [:props :invitee-already-organization-member])))))))
|
||||||
|
|
||||||
(t/deftest create-team-invitations-blacklisted-domain
|
(t/deftest create-team-invitations-blacklisted-domain
|
||||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||||
@ -378,6 +437,158 @@
|
|||||||
(t/is (= :validation (:type edata)))
|
(t/is (= :validation (:type edata)))
|
||||||
(t/is (= :invalid-token (:code edata)))))))))
|
(t/is (= :invalid-token (:code edata)))))))))
|
||||||
|
|
||||||
|
(t/deftest accept-organization-invitation-audit-event
|
||||||
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
||||||
|
(let [inviter (th/create-profile* 201 {:is-active true})
|
||||||
|
invitee (th/create-profile* 202 {:is-active true})
|
||||||
|
team (th/create-team* 201 {:profile-id (:id inviter)})
|
||||||
|
organization-id (uuid/random)
|
||||||
|
default-team-id (uuid/random)
|
||||||
|
direct-token (tokens/generate
|
||||||
|
th/*system*
|
||||||
|
{:iss :team-invitation
|
||||||
|
:exp (ct/in-future "1h")
|
||||||
|
:profile-id (:id inviter)
|
||||||
|
:role :editor
|
||||||
|
:organization-id organization-id
|
||||||
|
:member-email (:email invitee)
|
||||||
|
:member-id (:id invitee)})
|
||||||
|
team-token (tokens/generate
|
||||||
|
th/*system*
|
||||||
|
{:iss :team-invitation
|
||||||
|
:exp (ct/in-future "1h")
|
||||||
|
:profile-id (:id inviter)
|
||||||
|
:role :editor
|
||||||
|
:team-id (:id team)
|
||||||
|
:member-email (:email invitee)
|
||||||
|
:member-id (:id invitee)})
|
||||||
|
verify! (fn [token]
|
||||||
|
(th/command! {::th/type :verify-token
|
||||||
|
::rpc/profile-id (:id invitee)
|
||||||
|
:token token}))
|
||||||
|
organization-event
|
||||||
|
(fn []
|
||||||
|
(->> (:call-args-list @audit-mock)
|
||||||
|
(map second)
|
||||||
|
(filter #(= "accept-organization-invitation" (:name %)))
|
||||||
|
first))
|
||||||
|
frontend-event (atom nil)]
|
||||||
|
|
||||||
|
(db/insert! (:app.db/pool th/*system*)
|
||||||
|
:team-invitation
|
||||||
|
{:org-id organization-id
|
||||||
|
:email-to (:email invitee)
|
||||||
|
:created-by (:id inviter)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "48h")})
|
||||||
|
|
||||||
|
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||||
|
nitrate/call
|
||||||
|
(fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-membership {:organization-id organization-id
|
||||||
|
:is-member false}
|
||||||
|
:get-org-members [(:id inviter) (uuid/random) (uuid/random)]
|
||||||
|
nil))
|
||||||
|
teams/initialize-user-in-nitrate-org
|
||||||
|
(fn [& _] default-team-id)]
|
||||||
|
(let [out (verify! direct-token)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(reset! frontend-event
|
||||||
|
(get-in out [:result :organization-invitation-audit]))))
|
||||||
|
|
||||||
|
(let [event (organization-event)]
|
||||||
|
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||||
|
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||||
|
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||||
|
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||||
|
(t/is (= :editor (get-in event [:props :role])))
|
||||||
|
(t/is (uuid? (get-in event [:props :invitation-id])))
|
||||||
|
(t/is (= "organization-invitation-acceptance"
|
||||||
|
(:origin @frontend-event)))
|
||||||
|
(t/is (= organization-id
|
||||||
|
(get-in @frontend-event [:props :organization-id])))
|
||||||
|
(t/is (= "direct-organization-invitation"
|
||||||
|
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||||
|
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||||
|
(t/is (= 3
|
||||||
|
(get-in @frontend-event [:props :organization-member-count-before])))
|
||||||
|
(t/is (not-any? #(contains? #{"accept-team-invitation"
|
||||||
|
"accept-team-invitation-from"}
|
||||||
|
(:name (second %)))
|
||||||
|
(:call-args-list @audit-mock))))
|
||||||
|
|
||||||
|
(th/reset-mock! audit-mock)
|
||||||
|
(db/insert! (:app.db/pool th/*system*)
|
||||||
|
:team-invitation
|
||||||
|
{:team-id (:id team)
|
||||||
|
:email-to (:email invitee)
|
||||||
|
:created-by (:id inviter)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "48h")})
|
||||||
|
|
||||||
|
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||||
|
nitrate/call
|
||||||
|
(fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-membership-by-team {:organization-id organization-id
|
||||||
|
:is-member false}
|
||||||
|
:get-org-members (into [(:id inviter)]
|
||||||
|
(repeatedly 4 uuid/random))
|
||||||
|
nil))
|
||||||
|
teams/add-profile-to-team! (fn [& _] nil)]
|
||||||
|
(let [out (verify! team-token)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(reset! frontend-event
|
||||||
|
(get-in out [:result :organization-invitation-audit]))))
|
||||||
|
|
||||||
|
(let [events (mapv second (:call-args-list @audit-mock))
|
||||||
|
event (organization-event)]
|
||||||
|
(t/is (some #(= "accept-team-invitation" (:name %)) events))
|
||||||
|
(t/is (some #(= "accept-team-invitation-from" (:name %)) events))
|
||||||
|
(t/is (= (:id team) (get-in event [:props :team-id])))
|
||||||
|
(t/is (= organization-id (get-in event [:props :organization-id])))
|
||||||
|
(t/is (not (contains? (:props event) :organization-member-add-source)))
|
||||||
|
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
|
||||||
|
(t/is (not (contains? (:props event) :organization-member-count-before)))
|
||||||
|
(t/is (= "team-invitation-acceptance"
|
||||||
|
(:origin @frontend-event)))
|
||||||
|
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
|
||||||
|
(t/is (= organization-id
|
||||||
|
(get-in @frontend-event [:props :organization-id])))
|
||||||
|
(t/is (= "team-invitation"
|
||||||
|
(get-in @frontend-event [:props :organization-member-add-source])))
|
||||||
|
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
|
||||||
|
(t/is (= 5
|
||||||
|
(get-in @frontend-event [:props :organization-member-count-before]))))
|
||||||
|
|
||||||
|
(th/reset-mock! audit-mock)
|
||||||
|
(db/insert! (:app.db/pool th/*system*)
|
||||||
|
:team-invitation
|
||||||
|
{:team-id (:id team)
|
||||||
|
:email-to (:email invitee)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "48h")})
|
||||||
|
|
||||||
|
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||||
|
nitrate/call
|
||||||
|
(fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-membership-by-team {:organization-id organization-id
|
||||||
|
:is-member true}
|
||||||
|
:get-org-members (throw (ex-info "unexpected member count" {}))
|
||||||
|
nil))
|
||||||
|
teams/add-profile-to-team! (fn [& _] nil)]
|
||||||
|
(let [out (verify! team-token)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(reset! frontend-event
|
||||||
|
(get-in out [:result :organization-invitation-audit]))))
|
||||||
|
|
||||||
|
(let [events (mapv second (:call-args-list @audit-mock))]
|
||||||
|
(t/is (some #(= "accept-team-invitation" (:name %)) events))
|
||||||
|
(t/is (not-any? #(= "accept-organization-invitation" (:name %)) events))
|
||||||
|
(t/is (nil? @frontend-event))))))
|
||||||
|
|
||||||
(t/deftest create-team-invitations-with-email-verification-disabled
|
(t/deftest create-team-invitations-with-email-verification-disabled
|
||||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||||
@ -845,4 +1056,3 @@
|
|||||||
:name "My Valid Team"}
|
:name "My Valid Team"}
|
||||||
out (th/command! data)]
|
out (th/command! data)]
|
||||||
(t/is (th/success? out)))))
|
(t/is (th/success? out)))))
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
[app.main.data.common :as dcm]
|
[app.main.data.common :as dcm]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
|
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||||
[app.main.data.notifications :as ntf]
|
[app.main.data.notifications :as ntf]
|
||||||
[app.main.data.team :as dt]
|
[app.main.data.team :as dt]
|
||||||
[app.main.repo :as rp]
|
[app.main.repo :as rp]
|
||||||
@ -22,6 +23,9 @@
|
|||||||
|
|
||||||
(def ^:private nitrate-entry-active-key ::nitrate-entry-active)
|
(def ^:private nitrate-entry-active-key ::nitrate-entry-active)
|
||||||
(def ^:private nitrate-entry-pending-popup-key ::nitrate-entry-pending-popup)
|
(def ^:private nitrate-entry-pending-popup-key ::nitrate-entry-pending-popup)
|
||||||
|
(defn account-age-days
|
||||||
|
[profile]
|
||||||
|
(nitrate-audit/age-days (:created-at profile)))
|
||||||
|
|
||||||
(defn activate-nitrate-entry-popup!
|
(defn activate-nitrate-entry-popup!
|
||||||
[]
|
[]
|
||||||
@ -119,7 +123,7 @@
|
|||||||
:cancel-callback (build nitrate-checkout-cancelled-token)}))
|
:cancel-callback (build nitrate-checkout-cancelled-token)}))
|
||||||
|
|
||||||
(defn go-to-buy-nitrate-license
|
(defn go-to-buy-nitrate-license
|
||||||
[subscription base-url event-origin subscription-mode]
|
[subscription base-url event-origin subscription-mode subscription-start-origin]
|
||||||
(let [{:keys [success-callback error-callback finish-error-callback cancel-callback]}
|
(let [{:keys [success-callback error-callback finish-error-callback cancel-callback]}
|
||||||
(build-nitrate-callback-urls base-url)
|
(build-nitrate-callback-urls base-url)
|
||||||
params {:subscription subscription
|
params {:subscription subscription
|
||||||
@ -132,7 +136,8 @@
|
|||||||
::ev/origin event-origin
|
::ev/origin event-origin
|
||||||
:product "nitrate:enterprise"
|
:product "nitrate:enterprise"
|
||||||
:billing-period subscription
|
:billing-period subscription
|
||||||
:subscription-mode subscription-mode})]
|
:subscription-mode subscription-mode
|
||||||
|
:subscription-start-origin subscription-start-origin})]
|
||||||
(->> st/stream
|
(->> st/stream
|
||||||
(rx/filter (ptk/type? ::ev/chunk-persisted))
|
(rx/filter (ptk/type? ::ev/chunk-persisted))
|
||||||
(rx/take 1)
|
(rx/take 1)
|
||||||
@ -157,27 +162,50 @@
|
|||||||
(dm/get-in profile [:subscription :status]))))
|
(dm/get-in profile [:subscription :status]))))
|
||||||
|
|
||||||
(defn leave-org
|
(defn leave-org
|
||||||
[{:keys [id name default-team-id teams-to-delete teams-to-leave on-error] :as params}]
|
[{:keys [id
|
||||||
|
name
|
||||||
|
default-team-id
|
||||||
|
teams-to-delete
|
||||||
|
teams-to-leave
|
||||||
|
member-added-at
|
||||||
|
organization-member-count-before
|
||||||
|
on-error]}]
|
||||||
|
|
||||||
(ptk/reify ::leave-org
|
(ptk/reify ::leave-org
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [profile-team-id (dm/get-in state [:profile :default-team-id])]
|
(let [profile-id (dm/get-in state [:profile :id])
|
||||||
(->> (rp/cmd! ::leave-org {:id id
|
profile-team-id (dm/get-in state [:profile :default-team-id])
|
||||||
:name name
|
subscription-status
|
||||||
:default-team-id default-team-id
|
(if (= "trialing" (dm/get-in state [:profile :subscription :status]))
|
||||||
:teams-to-delete teams-to-delete
|
"trial"
|
||||||
:teams-to-leave teams-to-leave})
|
"active")
|
||||||
(rx/mapcat
|
audit-event
|
||||||
(fn [_]
|
(nitrate-audit/delete-organization-member-event
|
||||||
(rx/of
|
{:organization-id id
|
||||||
(dt/fetch-teams)
|
:user-id profile-id
|
||||||
(dcm/go-to-dashboard-recent :team-id profile-team-id)
|
:user-who-delete-member profile-id
|
||||||
(modal/hide)
|
:deleted-by-role "organization-member"
|
||||||
(ntf/show {:content (tr "dasboard.leave-org.toast" name)
|
:member-added-at member-added-at
|
||||||
:type :toast
|
:organization-member-count-before organization-member-count-before
|
||||||
:level :success}))))
|
:subscription-status subscription-status})]
|
||||||
(rx/catch on-error))))))
|
(rx/concat
|
||||||
|
(rx/of audit-event)
|
||||||
|
(->> (rp/cmd! ::leave-org {:id id
|
||||||
|
:name name
|
||||||
|
:default-team-id default-team-id
|
||||||
|
:teams-to-delete teams-to-delete
|
||||||
|
:teams-to-leave teams-to-leave})
|
||||||
|
(rx/mapcat
|
||||||
|
(fn [_]
|
||||||
|
(rx/of
|
||||||
|
(dt/fetch-teams)
|
||||||
|
(dcm/go-to-dashboard-recent :team-id profile-team-id)
|
||||||
|
(modal/hide)
|
||||||
|
(ntf/show {:content (tr "dasboard.leave-org.toast" name)
|
||||||
|
:type :toast
|
||||||
|
:level :success}))))
|
||||||
|
(rx/catch on-error)))))))
|
||||||
|
|
||||||
(defn show-leave-org-modal
|
(defn show-leave-org-modal
|
||||||
[{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}]
|
[{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}]
|
||||||
@ -191,7 +219,14 @@
|
|||||||
(let [num-teams-to-delete (:teams-to-delete summary)
|
(let [num-teams-to-delete (:teams-to-delete summary)
|
||||||
num-teams-to-transfer (:teams-to-transfer summary)
|
num-teams-to-transfer (:teams-to-transfer summary)
|
||||||
num-teams-to-exit (:teams-to-exit summary)
|
num-teams-to-exit (:teams-to-exit summary)
|
||||||
num-teams-to-detach (:teams-to-detach summary)]
|
num-teams-to-detach (:teams-to-detach summary)
|
||||||
|
leave-fn
|
||||||
|
(fn [params]
|
||||||
|
(leave-fn
|
||||||
|
(assoc params
|
||||||
|
:member-added-at (:member-added-at summary)
|
||||||
|
:organization-member-count-before
|
||||||
|
(:organization-member-count-before summary))))]
|
||||||
(cond
|
(cond
|
||||||
(pos? num-teams-to-transfer)
|
(pos? num-teams-to-transfer)
|
||||||
(rx/of
|
(rx/of
|
||||||
@ -278,26 +313,50 @@
|
|||||||
organization requires a fresh Nitrate SSO session. When SSO is required,
|
organization requires a fresh Nitrate SSO session. When SSO is required,
|
||||||
stores the pending action and redirects to the provider so the dashboard
|
stores the pending action and redirects to the provider so the dashboard
|
||||||
can resume the operation after the callback."
|
can resume the operation after the callback."
|
||||||
[{:keys [team-id organization-id]}]
|
[{:keys [team-id organization-id skip-audit?]}]
|
||||||
(ptk/reify ::add-team-to-organization
|
(ptk/reify ::add-team-to-organization
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ _]
|
(watch [_ state _]
|
||||||
(let [pending-id (str (uuid/next))
|
(let [team (dm/get-in state [:teams team-id])
|
||||||
|
organization-team-count-before
|
||||||
|
(nitrate-audit/organization-team-count
|
||||||
|
(vals (:teams state))
|
||||||
|
organization-id)
|
||||||
|
team-previous-organization-status
|
||||||
|
(if (or (:organization-id team)
|
||||||
|
(get-in team [:organization :id]))
|
||||||
|
"other-organization"
|
||||||
|
"no-organization")
|
||||||
|
subscription-status
|
||||||
|
(or (dm/get-in state [:profile :subscription :status])
|
||||||
|
"active")
|
||||||
|
audit-event
|
||||||
|
(nitrate-audit/add-team-to-organization-event
|
||||||
|
{:team team
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-team-count-before organization-team-count-before
|
||||||
|
:team-previous-organization-status team-previous-organization-status
|
||||||
|
:add-method "move-existing-team-to-organization"
|
||||||
|
:subscription-status subscription-status})
|
||||||
|
pending-id (str (uuid/next))
|
||||||
callback-url (dom/append-query-param (rt/get-current-href)
|
callback-url (dom/append-query-param (rt/get-current-href)
|
||||||
:pending-action-id pending-id)]
|
:pending-action-id pending-id)]
|
||||||
(->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url callback-url})
|
(rx/concat
|
||||||
(rx/mapcat
|
(when-not skip-audit?
|
||||||
(fn [{:keys [authorized redirect-uri]}]
|
(rx/of audit-event))
|
||||||
(if authorized
|
(->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url callback-url})
|
||||||
(->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id})
|
(rx/mapcat
|
||||||
(rx/map (fn [_] (modal/hide))))
|
(fn [{:keys [authorized redirect-uri]}]
|
||||||
(if redirect-uri
|
(if authorized
|
||||||
(do
|
(->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id})
|
||||||
(ss/save-pending-action! pending-id {:type :add-team-to-organization
|
(rx/map (fn [_] (modal/hide))))
|
||||||
:team-id team-id
|
(if redirect-uri
|
||||||
:organization-id organization-id})
|
(do
|
||||||
(rx/of (rt/nav-raw :uri (str redirect-uri))))
|
(ss/save-pending-action! pending-id {:type :add-team-to-organization
|
||||||
(rx/empty))))))))))
|
:team-id team-id
|
||||||
|
:organization-id organization-id})
|
||||||
|
(rx/of (rt/nav-raw :uri (str redirect-uri))))
|
||||||
|
(rx/empty)))))))))))
|
||||||
|
|
||||||
|
|
||||||
(defn- fetch-orgs-allowed
|
(defn- fetch-orgs-allowed
|
||||||
|
|||||||
80
frontend/src/app/main/data/nitrate_audit.cljs
Normal file
80
frontend/src/app/main/data/nitrate_audit.cljs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.main.data.nitrate-audit
|
||||||
|
(:require
|
||||||
|
[app.common.math :as mth]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.main.data.event :as ev]))
|
||||||
|
|
||||||
|
(def ^:private milliseconds-per-day (* 24 60 60 1000))
|
||||||
|
|
||||||
|
(defn age-days
|
||||||
|
[created-at]
|
||||||
|
(when (ct/inst? created-at)
|
||||||
|
(-> (ct/diff-ms created-at (ct/now))
|
||||||
|
(/ milliseconds-per-day)
|
||||||
|
(mth/floor)
|
||||||
|
(mth/max 0))))
|
||||||
|
|
||||||
|
(defn organization-team-count
|
||||||
|
[teams organization-id]
|
||||||
|
(->> teams
|
||||||
|
(filter #(and (not (:is-default %))
|
||||||
|
(= organization-id
|
||||||
|
(or (:organization-id %)
|
||||||
|
(get-in % [:organization :id])))))
|
||||||
|
(count)))
|
||||||
|
|
||||||
|
(defn add-team-to-organization-event
|
||||||
|
[{:keys [team
|
||||||
|
organization-id
|
||||||
|
organization-team-count-before
|
||||||
|
team-previous-organization-status
|
||||||
|
add-method
|
||||||
|
subscription-status]}]
|
||||||
|
(let [team-created-at (:created-at team)
|
||||||
|
event-origin (case add-method
|
||||||
|
"create-team-in-organization"
|
||||||
|
"dashboard:create-team-in-organization"
|
||||||
|
|
||||||
|
"move-existing-team-to-organization"
|
||||||
|
"dashboard:move-team-to-organization")
|
||||||
|
props (cond-> {:is-your-penpot false
|
||||||
|
:add-method add-method
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-team-count-before organization-team-count-before
|
||||||
|
:team-previous-organization-status team-previous-organization-status
|
||||||
|
:is-first-team-in-organization (zero? organization-team-count-before)
|
||||||
|
:subscription-status subscription-status}
|
||||||
|
(:id team)
|
||||||
|
(assoc :team-id (:id team))
|
||||||
|
|
||||||
|
(ct/inst? team-created-at)
|
||||||
|
(assoc :team-created-at (.toISOString team-created-at)
|
||||||
|
:team-age-days (age-days team-created-at)))]
|
||||||
|
(ev/event
|
||||||
|
(assoc props
|
||||||
|
::ev/name "add-team-to-organization"
|
||||||
|
::ev/origin event-origin))))
|
||||||
|
|
||||||
|
(defn delete-organization-member-event
|
||||||
|
[{:keys [organization-id
|
||||||
|
user-id
|
||||||
|
user-who-delete-member
|
||||||
|
deleted-by-role
|
||||||
|
member-added-at
|
||||||
|
organization-member-count-before
|
||||||
|
subscription-status]}]
|
||||||
|
(ev/event
|
||||||
|
{::ev/name "delete-organization-member"
|
||||||
|
:organization-id organization-id
|
||||||
|
:user-id user-id
|
||||||
|
:user-who-delete-member user-who-delete-member
|
||||||
|
:deleted-by-role deleted-by-role
|
||||||
|
:days-since-member-added (age-days member-added-at)
|
||||||
|
:organization-member-count-before organization-member-count-before
|
||||||
|
:subscription-status subscription-status}))
|
||||||
@ -18,6 +18,7 @@
|
|||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.media :as di]
|
[app.main.data.media :as di]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
|
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||||
[app.main.data.profile :as dp]
|
[app.main.data.profile :as dp]
|
||||||
[app.main.features :as features]
|
[app.main.features :as features]
|
||||||
[app.main.repo :as rp]
|
[app.main.repo :as rp]
|
||||||
@ -438,17 +439,36 @@
|
|||||||
(dm/assert! (string? name))
|
(dm/assert! (string? name))
|
||||||
(ptk/reify ::create-team
|
(ptk/reify ::create-team
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [it _ _]
|
(watch [it state _]
|
||||||
(let [{:keys [on-success on-error]
|
(let [{:keys [on-success on-error]
|
||||||
:or {on-success identity
|
:or {on-success identity
|
||||||
on-error rx/throw}} (meta params)
|
on-error rx/throw}} (meta params)
|
||||||
features features/global-enabled-features
|
features features/global-enabled-features
|
||||||
|
organization-team-count-before
|
||||||
|
(when organization-id
|
||||||
|
(nitrate-audit/organization-team-count
|
||||||
|
(vals (:teams state))
|
||||||
|
organization-id))
|
||||||
|
subscription-status
|
||||||
|
(or (dm/get-in state [:profile :subscription :status])
|
||||||
|
"active")
|
||||||
|
audit-event
|
||||||
|
(when organization-id
|
||||||
|
(nitrate-audit/add-team-to-organization-event
|
||||||
|
{:organization-id organization-id
|
||||||
|
:organization-team-count-before organization-team-count-before
|
||||||
|
:team-previous-organization-status "no-organization"
|
||||||
|
:add-method "create-team-in-organization"
|
||||||
|
:subscription-status subscription-status}))
|
||||||
params (cond-> {:name name :features features}
|
params (cond-> {:name name :features features}
|
||||||
organization-id (assoc :organization-id organization-id))]
|
organization-id (assoc :organization-id organization-id))]
|
||||||
(->> (rp/cmd! :create-team (with-meta params (meta it)))
|
(rx/concat
|
||||||
(rx/tap on-success)
|
(when audit-event
|
||||||
(rx/map team-created)
|
(rx/of audit-event))
|
||||||
(rx/catch on-error))))))
|
(->> (rp/cmd! :create-team (with-meta params (meta it)))
|
||||||
|
(rx/tap on-success)
|
||||||
|
(rx/map team-created)
|
||||||
|
(rx/catch on-error)))))))
|
||||||
|
|
||||||
;; --- EVENT: create-team-with-invitations
|
;; --- EVENT: create-team-with-invitations
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.data.auth :as da]
|
[app.main.data.auth :as da]
|
||||||
[app.main.data.common :as dcm]
|
[app.main.data.common :as dcm]
|
||||||
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.notifications :as ntf]
|
[app.main.data.notifications :as ntf]
|
||||||
[app.main.data.profile :as du]
|
[app.main.data.profile :as du]
|
||||||
[app.main.repo :as rp]
|
[app.main.repo :as rp]
|
||||||
@ -44,6 +45,13 @@
|
|||||||
|
|
||||||
(defmethod handle-token :team-invitation
|
(defmethod handle-token :team-invitation
|
||||||
[{:keys [state team-id org-team-id organization-name invitation-token] :as tdata}]
|
[{:keys [state team-id org-team-id organization-name invitation-token] :as tdata}]
|
||||||
|
(when-let [{:keys [origin props]} (:organization-invitation-audit tdata)]
|
||||||
|
(st/emit!
|
||||||
|
(ev/event
|
||||||
|
(assoc props
|
||||||
|
::ev/name "accept-organization-invitation"
|
||||||
|
::ev/origin origin))))
|
||||||
|
|
||||||
(case state
|
(case state
|
||||||
:created
|
:created
|
||||||
(if org-team-id
|
(if org-team-id
|
||||||
|
|||||||
@ -282,7 +282,8 @@
|
|||||||
(case (:type action)
|
(case (:type action)
|
||||||
:add-team-to-organization
|
:add-team-to-organization
|
||||||
(st/emit! (dnt/add-team-to-organization {:team-id (:team-id action)
|
(st/emit! (dnt/add-team-to-organization {:team-id (:team-id action)
|
||||||
:organization-id (:organization-id action)}))
|
:organization-id (:organization-id action)
|
||||||
|
:skip-audit? true}))
|
||||||
nil)))))
|
nil)))))
|
||||||
|
|
||||||
(mf/defc dashboard*
|
(mf/defc dashboard*
|
||||||
|
|||||||
@ -304,7 +304,15 @@
|
|||||||
(mf/defc organizations-selector-dropdown*
|
(mf/defc organizations-selector-dropdown*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [organization organizations profile] :rest props}]
|
[{:keys [organization organizations profile] :rest props}]
|
||||||
(let [on-org-click
|
(let [teams (mf/deref refs/teams)
|
||||||
|
|
||||||
|
subscription-type (get-subscription-type (-> profile :props :subscription))
|
||||||
|
|
||||||
|
team-count (count teams)
|
||||||
|
|
||||||
|
account-age-days (dnt/account-age-days profile)
|
||||||
|
|
||||||
|
on-org-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(let [team-id (-> (dom/get-current-target event)
|
(let [team-id (-> (dom/get-current-target event)
|
||||||
@ -314,18 +322,27 @@
|
|||||||
|
|
||||||
on-create-org-click
|
on-create-org-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps profile)
|
(mf/deps account-age-days profile subscription-type team-count)
|
||||||
(fn []
|
(fn []
|
||||||
(cond
|
(if (and (not= subscription-type "unlimited")
|
||||||
(= (get-subscription-type (-> profile :props :subscription)) "unlimited")
|
(dnt/is-valid-license? profile))
|
||||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
|
|
||||||
|
|
||||||
(dnt/is-valid-license? profile)
|
|
||||||
(dnt/go-to-nitrate-ac-create-organization
|
(dnt/go-to-nitrate-ac-create-organization
|
||||||
"dashboard:organization-switcher")
|
"dashboard:organization-switcher")
|
||||||
|
(st/emit!
|
||||||
:else
|
(ev/event
|
||||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))
|
(cond-> {::ev/name "open-subscription-modal"
|
||||||
|
::ev/origin "dashboard:organization-switcher"
|
||||||
|
:product "nitrate:enterprise"
|
||||||
|
:source "frontend"
|
||||||
|
:has-teams (pos? team-count)
|
||||||
|
:team-count team-count}
|
||||||
|
(some? account-age-days)
|
||||||
|
(assoc :account-age-days account-age-days)))
|
||||||
|
(dnt/show-nitrate-popup
|
||||||
|
:nitrate-form
|
||||||
|
(cond-> {:subscription-start-origin "dashboard:organization-switcher"}
|
||||||
|
(= subscription-type "unlimited")
|
||||||
|
(assoc :show-contact-sales-option true)))))))
|
||||||
|
|
||||||
on-go-to-cc-click
|
on-go-to-cc-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@ -648,7 +665,9 @@
|
|||||||
leave-fn
|
leave-fn
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps on-error organization default-team-id not-owned-teams owned-teams)
|
(mf/deps on-error organization default-team-id not-owned-teams owned-teams)
|
||||||
(fn [{:keys [teams-to-transfer]}]
|
(fn [{:keys [teams-to-transfer
|
||||||
|
member-added-at
|
||||||
|
organization-member-count-before]}]
|
||||||
(let [teams-to-leave (cond->> not-owned-teams
|
(let [teams-to-leave (cond->> not-owned-teams
|
||||||
:always
|
:always
|
||||||
(map #(select-keys % [:id]))
|
(map #(select-keys % [:id]))
|
||||||
@ -664,6 +683,9 @@
|
|||||||
:default-team-id default-team-id
|
:default-team-id default-team-id
|
||||||
:teams-to-delete teams-to-delete
|
:teams-to-delete teams-to-delete
|
||||||
:teams-to-leave teams-to-leave
|
:teams-to-leave teams-to-leave
|
||||||
|
:member-added-at member-added-at
|
||||||
|
:organization-member-count-before
|
||||||
|
organization-member-count-before
|
||||||
:on-error on-error})))))
|
:on-error on-error})))))
|
||||||
|
|
||||||
on-leave-clicked
|
on-leave-clicked
|
||||||
@ -695,6 +717,12 @@
|
|||||||
[{:keys [team profile]}]
|
[{:keys [team profile]}]
|
||||||
(let [teams (mf/deref refs/teams)
|
(let [teams (mf/deref refs/teams)
|
||||||
|
|
||||||
|
subscription-type (get-subscription-type (-> profile :props :subscription))
|
||||||
|
|
||||||
|
team-count (count teams)
|
||||||
|
|
||||||
|
account-age-days (dnt/account-age-days profile)
|
||||||
|
|
||||||
current-org (dtm/team->organization team)
|
current-org (dtm/team->organization team)
|
||||||
|
|
||||||
;; Find the "your-penpot" teams, and transform them in orgs. When
|
;; Find the "your-penpot" teams, and transform them in orgs. When
|
||||||
@ -772,18 +800,27 @@
|
|||||||
|
|
||||||
on-create-org-click
|
on-create-org-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps profile)
|
(mf/deps account-age-days profile subscription-type team-count)
|
||||||
(fn []
|
(fn []
|
||||||
(cond
|
(if (and (not= subscription-type "unlimited")
|
||||||
(= (get-subscription-type (-> profile :props :subscription)) "unlimited")
|
(dnt/is-valid-license? profile))
|
||||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
|
|
||||||
|
|
||||||
(dnt/is-valid-license? profile)
|
|
||||||
(dnt/go-to-nitrate-ac-create-organization
|
(dnt/go-to-nitrate-ac-create-organization
|
||||||
"dashboard:organization-switcher")
|
"dashboard:organization-switcher")
|
||||||
|
(st/emit!
|
||||||
:else
|
(ev/event
|
||||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))]
|
(cond-> {::ev/name "open-subscription-modal"
|
||||||
|
::ev/origin "dashboard:create-organization-button"
|
||||||
|
:product "nitrate:enterprise"
|
||||||
|
:source "frontend"
|
||||||
|
:has-teams (pos? team-count)
|
||||||
|
:team-count team-count}
|
||||||
|
(some? account-age-days)
|
||||||
|
(assoc :account-age-days account-age-days)))
|
||||||
|
(dnt/show-nitrate-popup
|
||||||
|
:nitrate-form
|
||||||
|
(cond-> {:subscription-start-origin "dashboard:create-organization-button"}
|
||||||
|
(= subscription-type "unlimited")
|
||||||
|
(assoc :show-contact-sales-option true)))))))]
|
||||||
(if show-dropdown?
|
(if show-dropdown?
|
||||||
[:div {:class (stl/css :sidebar-org-switch)}
|
[:div {:class (stl/css :sidebar-org-switch)}
|
||||||
[:div {:class (stl/css :org-switch-content)}
|
[:div {:class (stl/css :org-switch-content)}
|
||||||
|
|||||||
@ -152,6 +152,10 @@
|
|||||||
subscription-type
|
subscription-type
|
||||||
(if nitrate? (:type nitrate-license) (get-subscription-type (-> profile :props :subscription)))
|
(if nitrate? (:type nitrate-license) (get-subscription-type (-> profile :props :subscription)))
|
||||||
|
|
||||||
|
team-count (count teams)
|
||||||
|
|
||||||
|
account-age-days (dnt/account-age-days profile)
|
||||||
|
|
||||||
teams-loaded? (seq teams)
|
teams-loaded? (seq teams)
|
||||||
|
|
||||||
no-orgs-created? (mf/with-memo [teams]
|
no-orgs-created? (mf/with-memo [teams]
|
||||||
@ -162,11 +166,22 @@
|
|||||||
|
|
||||||
handle-click
|
handle-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps subscription-type)
|
(mf/deps account-age-days subscription-type team-count)
|
||||||
(fn []
|
(fn []
|
||||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form
|
(st/emit!
|
||||||
(when (= subscription-type "unlimited")
|
(ev/event
|
||||||
{:show-contact-sales-option true})))))
|
(cond-> {::ev/name "open-subscription-modal"
|
||||||
|
::ev/origin "dashboard:promotional-banner"
|
||||||
|
:product "nitrate:enterprise"
|
||||||
|
:source "frontend"
|
||||||
|
:has-teams (pos? team-count)
|
||||||
|
:team-count team-count}
|
||||||
|
(some? account-age-days)
|
||||||
|
(assoc :account-age-days account-age-days)))
|
||||||
|
(dnt/show-nitrate-popup :nitrate-form
|
||||||
|
(cond-> {:subscription-start-origin "dashboard:promotional-banner"}
|
||||||
|
(= subscription-type "unlimited")
|
||||||
|
(assoc :show-contact-sales-option true))))))
|
||||||
|
|
||||||
handle-go-to-cc
|
handle-go-to-cc
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.main.ui.nitrate.nitrate-form
|
(ns app.main.ui.nitrate.nitrate-form
|
||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
[app.main.data.nitrate :as dnt]
|
[app.main.data.nitrate :as dnt]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
@ -25,6 +26,7 @@
|
|||||||
[connectivity]
|
[connectivity]
|
||||||
|
|
||||||
(let [show-contact-sales-option (:show-contact-sales-option connectivity)
|
(let [show-contact-sales-option (:show-contact-sales-option connectivity)
|
||||||
|
subscription-start-origin (:subscription-start-origin connectivity)
|
||||||
online? (and (:licenses connectivity) (not show-contact-sales-option))
|
online? (and (:licenses connectivity) (not show-contact-sales-option))
|
||||||
profile (mf/deref refs/profile)
|
profile (mf/deref refs/profile)
|
||||||
on-click
|
on-click
|
||||||
@ -34,16 +36,31 @@
|
|||||||
"monthly"
|
"monthly"
|
||||||
dnt/go-to-ac-url
|
dnt/go-to-ac-url
|
||||||
"dashboard:plan-confirmation-modal"
|
"dashboard:plan-confirmation-modal"
|
||||||
(if (:subscription profile) "paid" "trial"))))
|
(if (:subscription profile) "paid" "trial")
|
||||||
|
subscription-start-origin)))
|
||||||
|
|
||||||
on-activate-click
|
on-activate-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn []
|
(fn []
|
||||||
(st/emit! (modal/show {:type :nitrate-code-activation}))))]
|
(st/emit! (modal/show {:type :nitrate-code-activation}))))
|
||||||
|
|
||||||
|
on-see-plan-click
|
||||||
|
(mf/use-fn
|
||||||
|
(fn []
|
||||||
|
(st/emit! (ev/event {::ev/name "open-current-subscription"
|
||||||
|
::ev/origin "dashboard:plan-confirmation-modal"}))))
|
||||||
|
|
||||||
|
on-close
|
||||||
|
(mf/use-fn
|
||||||
|
(fn []
|
||||||
|
(st/emit! (ev/event {::ev/name "close-subscription-modal"
|
||||||
|
::ev/origin "nitrate:plan-confirmation-modal"
|
||||||
|
:product "nitrate:enterprise"}))
|
||||||
|
(modal/hide!)))]
|
||||||
|
|
||||||
[:div {:class (stl/css :modal-overlay)}
|
[:div {:class (stl/css :modal-overlay)}
|
||||||
[:div {:class (stl/css :modal-dialog :subscription-success)}
|
[:div {:class (stl/css :modal-dialog :subscription-success)}
|
||||||
[:button {:class (stl/css :close-btn) :on-click modal/hide!}
|
[:button {:class (stl/css :close-btn) :on-click on-close}
|
||||||
[:> icon* {:icon-id "close"
|
[:> icon* {:icon-id "close"
|
||||||
:size "m"}]]
|
:size "m"}]]
|
||||||
[:div {:class (stl/css :modal-success-content)}
|
[:div {:class (stl/css :modal-success-content)}
|
||||||
@ -89,7 +106,9 @@
|
|||||||
(tr "nitrate.form.enter-code")]]
|
(tr "nitrate.form.enter-code")]]
|
||||||
|
|
||||||
[:p {:class (stl/css :modal-text-medium)}
|
[:p {:class (stl/css :modal-text-medium)}
|
||||||
[:a {:class (stl/css :link) :href dnt/go-to-subscription-url}
|
[:a {:class (stl/css :link)
|
||||||
|
:href dnt/go-to-subscription-url
|
||||||
|
:on-click on-see-plan-click}
|
||||||
(tr "nitrate.form.see-plan")]]]
|
(tr "nitrate.form.see-plan")]]]
|
||||||
|
|
||||||
[:div {:class (stl/css :contact)}
|
[:div {:class (stl/css :contact)}
|
||||||
|
|||||||
@ -193,21 +193,31 @@
|
|||||||
(rt/encode-url))
|
(rt/encode-url))
|
||||||
href (dm/str "payments/subscriptions/create?type=unlimited&show="
|
href (dm/str "payments/subscriptions/create?type=unlimited&show="
|
||||||
add-payment-details? "&quantity="
|
add-payment-details? "&quantity="
|
||||||
min-members "&returnUrl=" return-url)]
|
min-members "&returnUrl=" return-url)
|
||||||
|
event-name (if subscribe-to-trial
|
||||||
|
"create-trial-subscription"
|
||||||
|
"create-subscription")
|
||||||
|
subscription-mode (if subscribe-to-trial "trial" "paid")]
|
||||||
(reset! form nil)
|
(reset! form nil)
|
||||||
(st/emit! (ev/event {::ev/name "create-trial-subscription"
|
(st/emit! (ev/event {::ev/name event-name
|
||||||
:type "unlimited"
|
:type "unlimited"
|
||||||
:quantity min-members})
|
:quantity min-members
|
||||||
|
:subscription-mode subscription-mode})
|
||||||
(rt/nav-raw :href href))))))
|
(rt/nav-raw :href href))))))
|
||||||
|
|
||||||
subscribe-to-enterprise
|
subscribe-to-enterprise
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn []
|
(fn []
|
||||||
(st/emit! (ev/event {::ev/name "create-trial-subscription"
|
(let [event-name (if subscribe-to-trial
|
||||||
:type "enterprise"}))
|
"create-trial-subscription"
|
||||||
(let [return-url (-> (rt/get-current-href) (rt/encode-url))
|
"create-subscription")
|
||||||
|
subscription-mode (if subscribe-to-trial "trial" "paid")
|
||||||
|
return-url (-> (rt/get-current-href) (rt/encode-url))
|
||||||
href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)]
|
href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)]
|
||||||
(st/emit! (rt/nav-raw :href href)))))
|
(st/emit! (ev/event {::ev/name event-name
|
||||||
|
:type "enterprise"
|
||||||
|
:subscription-mode subscription-mode})
|
||||||
|
(rt/nav-raw :href href)))))
|
||||||
|
|
||||||
handle-accept-dialog
|
handle-accept-dialog
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@ -224,7 +234,10 @@
|
|||||||
handle-close-dialog
|
handle-close-dialog
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn []
|
(fn []
|
||||||
(st/emit! (ev/event {::ev/name "close-subscription-modal"}))
|
(when (= subscription-type "unlimited")
|
||||||
|
(st/emit! (ev/event {::ev/name "close-subscription-modal"
|
||||||
|
::ev/origin "subscriptions:unlimited"
|
||||||
|
:product "unlimited"})))
|
||||||
(modal/hide!)))
|
(modal/hide!)))
|
||||||
|
|
||||||
on-submit
|
on-submit
|
||||||
@ -482,6 +495,14 @@
|
|||||||
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
|
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
|
||||||
(st/emit! (rt/nav-raw :href href)))))
|
(st/emit! (rt/nav-raw :href href)))))
|
||||||
|
|
||||||
|
go-to-nitrate-payments
|
||||||
|
(mf/use-fn
|
||||||
|
(fn []
|
||||||
|
(st/emit! (ev/event {::ev/name "open-subscription-management"
|
||||||
|
::ev/origin "settings"
|
||||||
|
:section "nitrate:enterprise"}))
|
||||||
|
(dnt/go-to-nitrate-billing)))
|
||||||
|
|
||||||
open-subscription-modal
|
open-subscription-modal
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps subscription-editors nitrate-license)
|
(mf/deps subscription-editors nitrate-license)
|
||||||
@ -492,7 +513,8 @@
|
|||||||
(st/emit! (dnt/show-nitrate-popup
|
(st/emit! (dnt/show-nitrate-popup
|
||||||
:nitrate-dialog
|
:nitrate-dialog
|
||||||
{:nitrate-license nitrate-license
|
{:nitrate-license nitrate-license
|
||||||
:event-origin "settings:plan-confirmation-modal"}))
|
:event-origin "settings:plan-confirmation-modal"
|
||||||
|
:subscription-start-origin "settings"}))
|
||||||
(st/emit!
|
(st/emit!
|
||||||
(modal/show :management-dialog
|
(modal/show :management-dialog
|
||||||
{:subscription-type subscription-type
|
{:subscription-type subscription-type
|
||||||
@ -602,7 +624,7 @@
|
|||||||
(tr "subscription.settings.manage-your-subscription")
|
(tr "subscription.settings.manage-your-subscription")
|
||||||
(tr "nitrate.subscription.settings.manual-cancel"))
|
(tr "nitrate.subscription.settings.manual-cancel"))
|
||||||
:cta-link (if (and (:licenses connectivity) (not (:manual nitrate-license)))
|
:cta-link (if (and (:licenses connectivity) (not (:manual nitrate-license)))
|
||||||
dnt/go-to-nitrate-billing
|
go-to-nitrate-payments
|
||||||
open-cancel-contact-sales-modal)
|
open-cancel-contact-sales-modal)
|
||||||
:code-action (when (:manual nitrate-license) :renovate)
|
:code-action (when (:manual nitrate-license) :renovate)
|
||||||
:current-plan true}]
|
:current-plan true}]
|
||||||
@ -768,12 +790,15 @@
|
|||||||
(mf/defc subscribe-nitrate-dialog
|
(mf/defc subscribe-nitrate-dialog
|
||||||
{::mf/register modal/components
|
{::mf/register modal/components
|
||||||
::mf/register-as :nitrate-dialog}
|
::mf/register-as :nitrate-dialog}
|
||||||
[{:keys [nitrate-license show-contact-sales-option event-origin] :as connectivity}]
|
[{:keys [nitrate-license show-contact-sales-option event-origin subscription-start-origin] :as connectivity}]
|
||||||
;; TODO add translations for this texts when we have the definitive ones
|
;; TODO add translations for this texts when we have the definitive ones
|
||||||
(let [online? (:licenses connectivity)
|
(let [online? (:licenses connectivity)
|
||||||
handle-close-dialog
|
handle-close-dialog
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn []
|
(fn []
|
||||||
|
(st/emit! (ev/event {::ev/name "close-subscription-modal"
|
||||||
|
::ev/origin "nitrate:plan-confirmation-modal"
|
||||||
|
:product "nitrate:enterprise"}))
|
||||||
(modal/hide!)))
|
(modal/hide!)))
|
||||||
|
|
||||||
on-subscribe-click
|
on-subscribe-click
|
||||||
@ -783,7 +808,8 @@
|
|||||||
"monthly"
|
"monthly"
|
||||||
(rt/get-current-href)
|
(rt/get-current-href)
|
||||||
event-origin
|
event-origin
|
||||||
(if nitrate-license "paid" "trial"))))]
|
(if nitrate-license "paid" "trial")
|
||||||
|
subscription-start-origin)))]
|
||||||
|
|
||||||
[:div {:class (stl/css :modal-overlay)}
|
[:div {:class (stl/css :modal-overlay)}
|
||||||
[:div {:class (stl/css :modal-dialog)}
|
[:div {:class (stl/css :modal-dialog)}
|
||||||
|
|||||||
@ -6,10 +6,142 @@
|
|||||||
|
|
||||||
(ns frontend-tests.data.nitrate-test
|
(ns frontend-tests.data.nitrate-test
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.time :as ct]
|
||||||
[app.common.uri :as u]
|
[app.common.uri :as u]
|
||||||
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.nitrate :as dnt]
|
[app.main.data.nitrate :as dnt]
|
||||||
|
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||||
|
[app.main.store :as st]
|
||||||
|
[app.main.ui.auth.verify-token :as verify-token]
|
||||||
[cljs.test :as t :include-macros true]))
|
[cljs.test :as t :include-macros true]))
|
||||||
|
|
||||||
|
(t/deftest account-age-days-test
|
||||||
|
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
|
||||||
|
(t/testing "returns the number of complete days since account creation"
|
||||||
|
(t/is (= 10
|
||||||
|
(dnt/account-age-days
|
||||||
|
{:created-at (ct/inst "2026-07-17T00:00:00Z")}))))
|
||||||
|
|
||||||
|
(t/testing "does not return negative ages"
|
||||||
|
(t/is (= 0
|
||||||
|
(dnt/account-age-days
|
||||||
|
{:created-at (ct/inst "2026-07-28T00:00:00Z")}))))
|
||||||
|
|
||||||
|
(t/testing "returns nil when the creation date is not an instant"
|
||||||
|
(t/is (nil? (dnt/account-age-days {})))
|
||||||
|
(t/is (nil? (dnt/account-age-days {:created-at "invalid"}))))))
|
||||||
|
|
||||||
|
(t/deftest add-team-to-organization-audit-event-test
|
||||||
|
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
|
||||||
|
(t/testing "reports creating the first team in an organization"
|
||||||
|
(let [event @(nitrate-audit/add-team-to-organization-event
|
||||||
|
{:organization-id "organization-1"
|
||||||
|
:organization-team-count-before 0
|
||||||
|
:team-previous-organization-status "no-organization"
|
||||||
|
:add-method "create-team-in-organization"
|
||||||
|
:subscription-status "trialing"})]
|
||||||
|
(t/is (= "add-team-to-organization" (::ev/name event)))
|
||||||
|
(t/is (= "dashboard:create-team-in-organization" (::ev/origin event)))
|
||||||
|
(t/is (= {:is-your-penpot false
|
||||||
|
:add-method "create-team-in-organization"
|
||||||
|
:organization-id "organization-1"
|
||||||
|
:organization-team-count-before 0
|
||||||
|
:team-previous-organization-status "no-organization"
|
||||||
|
:is-first-team-in-organization true
|
||||||
|
:subscription-status "trialing"}
|
||||||
|
(dissoc event ::ev/name ::ev/origin)))))
|
||||||
|
|
||||||
|
(t/testing "reports moving an older team from another organization"
|
||||||
|
(let [event @(nitrate-audit/add-team-to-organization-event
|
||||||
|
{:team {:id "team-2"
|
||||||
|
:created-at (ct/inst "2026-07-17T12:00:00Z")}
|
||||||
|
:organization-id "organization-2"
|
||||||
|
:organization-team-count-before 3
|
||||||
|
:team-previous-organization-status "other-organization"
|
||||||
|
:add-method "move-existing-team-to-organization"
|
||||||
|
:subscription-status "active"})]
|
||||||
|
(t/is (= "dashboard:move-team-to-organization" (::ev/origin event)))
|
||||||
|
(t/is (= 10 (:team-age-days event)))
|
||||||
|
(t/is (false? (:is-first-team-in-organization event)))))))
|
||||||
|
|
||||||
|
(t/deftest organization-team-count-test
|
||||||
|
(let [organization-id "organization-1"
|
||||||
|
teams [{:id "default"
|
||||||
|
:is-default true
|
||||||
|
:organization-id organization-id}
|
||||||
|
{:id "team-1"
|
||||||
|
:is-default false
|
||||||
|
:organization-id organization-id}
|
||||||
|
{:id "team-2"
|
||||||
|
:is-default false
|
||||||
|
:organization {:id organization-id}}
|
||||||
|
{:id "other-team"
|
||||||
|
:is-default false
|
||||||
|
:organization-id "organization-2"}]]
|
||||||
|
(t/is (= 2
|
||||||
|
(nitrate-audit/organization-team-count
|
||||||
|
teams
|
||||||
|
organization-id)))))
|
||||||
|
|
||||||
|
(t/deftest delete-organization-member-audit-event-test
|
||||||
|
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
|
||||||
|
(let [event @(nitrate-audit/delete-organization-member-event
|
||||||
|
{:organization-id "organization-1"
|
||||||
|
:user-id "profile-1"
|
||||||
|
:user-who-delete-member "profile-1"
|
||||||
|
:deleted-by-role "organization-member"
|
||||||
|
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
|
||||||
|
:organization-member-count-before 4
|
||||||
|
:subscription-status "trial"})]
|
||||||
|
(t/is (= "delete-organization-member" (::ev/name event)))
|
||||||
|
(t/is (nil? (::ev/origin event)))
|
||||||
|
(t/is (= {:organization-id "organization-1"
|
||||||
|
:user-id "profile-1"
|
||||||
|
:user-who-delete-member "profile-1"
|
||||||
|
:deleted-by-role "organization-member"
|
||||||
|
:days-since-member-added 10
|
||||||
|
:organization-member-count-before 4
|
||||||
|
:subscription-status "trial"}
|
||||||
|
(dissoc event ::ev/name))))
|
||||||
|
|
||||||
|
(t/testing "keeps a null age when the membership date is unavailable"
|
||||||
|
(let [event @(nitrate-audit/delete-organization-member-event
|
||||||
|
{:organization-id "organization-1"
|
||||||
|
:user-id "profile-1"
|
||||||
|
:user-who-delete-member "profile-1"
|
||||||
|
:deleted-by-role "organization-member"
|
||||||
|
:organization-member-count-before 4
|
||||||
|
:subscription-status "active"})]
|
||||||
|
(t/is (contains? event :days-since-member-added))
|
||||||
|
(t/is (nil? (:days-since-member-added event)))))))
|
||||||
|
|
||||||
|
(t/deftest accept-organization-invitation-audit-event-test
|
||||||
|
(let [emitted (atom [])
|
||||||
|
props {:team-id "team-1"
|
||||||
|
:organization-id "organization-1"
|
||||||
|
:role :editor
|
||||||
|
:invitation-id "invitation-1"
|
||||||
|
:organization-member-add-source "team-invitation"
|
||||||
|
:belongs-to-team-on-add true
|
||||||
|
:organization-member-count-before 4}]
|
||||||
|
(with-redefs [st/emit! (fn
|
||||||
|
([event]
|
||||||
|
(swap! emitted conj event))
|
||||||
|
([event & events]
|
||||||
|
(swap! emitted into (cons event events))))]
|
||||||
|
(verify-token/handle-token
|
||||||
|
{:iss :team-invitation
|
||||||
|
:state :created
|
||||||
|
:team-id "team-1"
|
||||||
|
:organization-invitation-audit
|
||||||
|
{:origin "team-invitation-acceptance"
|
||||||
|
:props props}}))
|
||||||
|
|
||||||
|
(let [event @(first @emitted)]
|
||||||
|
(t/is (= "accept-organization-invitation" (::ev/name event)))
|
||||||
|
(t/is (= "team-invitation-acceptance" (::ev/origin event)))
|
||||||
|
(t/is (= props (dissoc event ::ev/name ::ev/origin))))))
|
||||||
|
|
||||||
(t/deftest build-admin-console-url-preserves-public-uri-subpath
|
(t/deftest build-admin-console-url-preserves-public-uri-subpath
|
||||||
(t/testing "builds admin console routes below the configured Penpot subpath"
|
(t/testing "builds admin console routes below the configured Penpot subpath"
|
||||||
(let [public-uri (u/uri "https://example.com/penpot/")]
|
(let [public-uri (u/uri "https://example.com/penpot/")]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user