mirror of
https://github.com/penpot/penpot.git
synced 2026-07-31 02:16:13 +00:00
✨ Add nitrate audit events
This commit is contained in:
parent
91d4b16171
commit
31ccfa546f
@ -173,13 +173,6 @@
|
||||
[:id ::sm/uuid]
|
||||
[: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
|
||||
(def ^:private schema:timestamp
|
||||
(sm/type-schema
|
||||
@ -196,6 +189,13 @@
|
||||
:decode/json ct/inst
|
||||
: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
|
||||
[:map {:title "Subscription"}
|
||||
[:id ::sm/text]
|
||||
@ -361,8 +361,13 @@
|
||||
schema:profile-org params)))
|
||||
|
||||
(defn- remove-profile-from-org-api
|
||||
[cfg {:keys [profile-id organization-id] :as params}]
|
||||
(let [params (assoc params :request-params {:user-id profile-id})]
|
||||
[cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}]
|
||||
(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
|
||||
(generate-nitrate-uri
|
||||
"api/organizations/"
|
||||
|
||||
@ -223,7 +223,9 @@
|
||||
[:teams-to-delete ::sm/int]
|
||||
[:teams-to-transfer ::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
|
||||
[:map
|
||||
@ -322,7 +324,8 @@
|
||||
|
||||
(defn leave-org
|
||||
[{: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) "] ")
|
||||
{:keys [deletable-team-ids
|
||||
keep-default-team?
|
||||
@ -357,7 +360,11 @@
|
||||
:organization-id id}))
|
||||
|
||||
;; 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))
|
||||
|
||||
@ -368,7 +375,10 @@
|
||||
::sm/params schema:leave-org
|
||||
::db/transaction true}
|
||||
[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
|
||||
@ -382,12 +392,20 @@
|
||||
valid-teams-to-transfer
|
||||
valid-teams-to-exit
|
||||
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-exit-count (count valid-teams-to-exit)]
|
||||
(when-not valid-default-team
|
||||
(ex/raise :type :validation
|
||||
: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
|
||||
@ -525,7 +543,9 @@
|
||||
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
|
||||
|
||||
;; Api call to nitrate
|
||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id
|
||||
:organization-id organization-id
|
||||
:is-default false})]
|
||||
;; Notify connected users
|
||||
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
|
||||
|
||||
|
||||
@ -127,7 +127,8 @@
|
||||
:hint "The invited email is not a member of the organization")))))
|
||||
|
||||
(defn- create-invitation
|
||||
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :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)
|
||||
"expected cfg with valid connection")
|
||||
@ -203,6 +204,7 @@
|
||||
(name role) expire]))
|
||||
updated? (not= id (:id invitation))
|
||||
profile-id (:id profile)
|
||||
team-organization-id (get-in team [:organization :id])
|
||||
tprops {:profile-id profile-id
|
||||
:invitation-id (:id invitation)
|
||||
:valid-until expire
|
||||
@ -212,18 +214,37 @@
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
: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)
|
||||
ptoken (create-profile-identity-token cfg profile-id)]
|
||||
|
||||
(when (contains? cf/flags :log-invitation-tokens)
|
||||
(l/info :hint "invitation token" :token itoken))
|
||||
|
||||
(let [props (-> (dissoc tprops :profile-id)
|
||||
(audit/clean-props))
|
||||
(let [props (audit/clean-props audit-props)
|
||||
evname (cond
|
||||
(and updated? organization) "update-org-invitation"
|
||||
(and updated? organization) "update-organization-invitation"
|
||||
updated? "update-team-invitation"
|
||||
organization "create-org-invitation"
|
||||
organization "create-organization-invitation"
|
||||
:else "create-team-invitation")
|
||||
event (-> (audit/event-from-rpc-params params)
|
||||
(assoc :name evname)
|
||||
@ -331,9 +352,14 @@
|
||||
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})))
|
||||
params (assoc params :team team :org-member-ids org-member-ids)
|
||||
organization-member-ids
|
||||
(when org-id
|
||||
(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}]
|
||||
invitation-data (cond
|
||||
|
||||
@ -215,52 +215,106 @@
|
||||
;; existing invitation; querying it when the invitation is absent
|
||||
;; would call nitrate needlessly and could mask the clean
|
||||
;; :canceled-invitation/:invalid-token response with a generic error.
|
||||
(let [membership (when org-invitation?
|
||||
(nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||
:organization-id organization-id}))]
|
||||
(let [membership
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(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)
|
||||
(ex/raise :type :validation
|
||||
:code :already-an-org-member
|
||||
:team-id (:default-team-id membership)
|
||||
:hint "the user is already a member of the organization"))
|
||||
(when org-invitation?
|
||||
(ex/raise :type :validation
|
||||
:code :already-an-org-member
|
||||
:team-id (:default-team-id membership)
|
||||
:hint "the user is already a member of the organization")))
|
||||
|
||||
(when (and org-invitation? (not (:organization-id membership)))
|
||||
(ex/raise :type :validation
|
||||
:code :org-not-found
|
||||
: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
|
||||
;; with accepting the invitation and joining the current profile to the
|
||||
;; invited team.
|
||||
(let [props {:team-id (:team-id claims)
|
||||
:role (:role claims)
|
||||
:invitation-id (:id invitation)}]
|
||||
;; if we have logged-in user and it matches the invitation we proceed
|
||||
;; with accepting the invitation and joining the current profile to the
|
||||
;; invited team.
|
||||
(let [props {:team-id (:team-id claims)
|
||||
:role (:role claims)
|
||||
:invitation-id (:id invitation)}]
|
||||
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :name "accept-team-invitation")
|
||||
(assoc :props props)))
|
||||
(when team-id
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :name "accept-team-invitation")
|
||||
(assoc :props props)))
|
||||
|
||||
;; NOTE: Backward compatibility; old invitations can
|
||||
;; have the `created-by` to be nil; so in this case we
|
||||
;; don't submit this event to the audit-log
|
||||
(when-let [created-by (:created-by invitation)]
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :profile-id created-by)
|
||||
(assoc :name "accept-team-invitation-from")
|
||||
(assoc :props (assoc props
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile))))))
|
||||
;; NOTE: Backward compatibility; old invitations can
|
||||
;; have the `created-by` to be nil; so in this case we
|
||||
;; don't submit this event to the audit-log
|
||||
(when-let [created-by (:created-by invitation)]
|
||||
(audit/submit cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(assoc :profile-id created-by)
|
||||
(assoc :name "accept-team-invitation-from")
|
||||
(assoc :props (assoc props
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)))))))
|
||||
|
||||
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||
(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)))))
|
||||
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
|
||||
(when organization-id-on-add
|
||||
(audit/submit
|
||||
cfg
|
||||
(-> (audit/event-from-rpc-params params)
|
||||
(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
|
||||
;; If the user is not logged-in and the invitation has been canceled
|
||||
@ -289,4 +343,3 @@
|
||||
[_ _ _]
|
||||
(ex/raise :type :validation
|
||||
: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.profile :refer [schema:profile, schema:basic-profile]]
|
||||
[app.common.types.team :refer [schema:team]]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
@ -619,8 +620,12 @@ RETURNING id, deleted_at;")
|
||||
[:default-team-id ::sm/uuid]]
|
||||
::db/transaction true
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
|
||||
(let [{:keys [valid-teams-to-delete-ids
|
||||
[cfg {actor-profile-id ::rpc/profile-id
|
||||
: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-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
||||
add-reassign-to (partial add-reassign-to cfg profile-id)
|
||||
@ -633,7 +638,10 @@ RETURNING id, deleted_at;")
|
||||
:name organization-name
|
||||
:teams-to-delete valid-teams-to-delete-ids
|
||||
: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")
|
||||
nil))
|
||||
|
||||
@ -809,7 +817,7 @@ RETURNING id, deleted_at;")
|
||||
ip-addr (::rpc/ip-addr params)]
|
||||
|
||||
(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))]
|
||||
(audit/submit cfg {:type (d/nilv type "action")
|
||||
:name name
|
||||
|
||||
@ -49,6 +49,47 @@
|
||||
(t/is (= :authentication (th/ex-type (: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
|
||||
(let [out (th/management-command! {::th/type :get-penpot-version})
|
||||
version (-> out :result :version)]
|
||||
@ -1034,25 +1075,29 @@
|
||||
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
|
||||
|
||||
(defn- nitrate-call-mock
|
||||
[org-summary]
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
:get-org-summary org-summary
|
||||
:get-org-membership {:organization-id (:id org-summary)
|
||||
:is-member true}
|
||||
:remove-profile-from-org nil
|
||||
nil)))
|
||||
([org-summary]
|
||||
(nitrate-call-mock org-summary nil))
|
||||
([org-summary remove-profile-params]
|
||||
(fn [_cfg method params]
|
||||
(case method
|
||||
:get-org-summary org-summary
|
||||
:get-org-membership {:organization-id (:id org-summary)
|
||||
: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
|
||||
;; User is only in its default team (which has files); it should be
|
||||
;; kept, renamed and unset as default. A notification must be sent.
|
||||
;; --- 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
|
||||
[;; --- Nitrate mock: nil during setup, serve org-summary once available ---
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
: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 ---
|
||||
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
||||
|
||||
@ -1089,6 +1134,10 @@
|
||||
;; --- Verify: RPC returns success with no result payload ---
|
||||
(t/is (th/success? 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 ---
|
||||
(let [team (th/db-get :team {:id (:id org-team)})]
|
||||
@ -1107,12 +1156,13 @@
|
||||
(t/deftest remove-from-organization-deletes-empty-default-team
|
||||
;; When the default team has no files it should be soft-deleted.
|
||||
;; --- 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
|
||||
[;; --- Nitrate mock: nil during setup, serve org-summary once available ---
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
: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 ---
|
||||
mbus-mock {:target 'app.msgbus/pub! :return nil}]
|
||||
|
||||
@ -1133,7 +1183,7 @@
|
||||
;; --- Exercise: remove the user from the org ---
|
||||
out (th/management-command!
|
||||
{::th/type :remove-from-organization
|
||||
::rpc/profile-id (:id org-owner)
|
||||
::rpc/profile-id uuid/zero
|
||||
:profile-id (:id user)
|
||||
:organization-id organization-id
|
||||
:organization-name "Acme Org"
|
||||
@ -1141,6 +1191,8 @@
|
||||
|
||||
;; --- Verify: RPC returns success ---
|
||||
(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 ---
|
||||
(let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})]
|
||||
(t/is (some? (:deleted-at team))))))))
|
||||
|
||||
@ -45,19 +45,28 @@
|
||||
"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
|
||||
any other method."
|
||||
[org-summary]
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
:get-org-summary org-summary
|
||||
:get-org-membership {:is-member true
|
||||
:organization-id (:id org-summary)}
|
||||
nil)))
|
||||
([org-summary]
|
||||
(nitrate-call-mock org-summary nil))
|
||||
([org-summary remove-profile-params]
|
||||
(fn [_cfg method params]
|
||||
(case method
|
||||
:get-org-summary org-summary
|
||||
: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
|
||||
[org-summary]
|
||||
(fn [_cfg method _params]
|
||||
(case method
|
||||
: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)))
|
||||
|
||||
(defn- active-sso-call-mock
|
||||
@ -173,14 +182,15 @@
|
||||
;; The user's personal penpot team in the org context
|
||||
your-penpot-id (:id org-default-team)
|
||||
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
:org-teams [])]
|
||||
org-summary (make-org-summary
|
||||
:organization-id organization-id
|
||||
:organization-name "Test Org"
|
||||
:owner-id (:id profile-owner)
|
||||
:your-penpot-teams [your-penpot-id]
|
||||
: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
|
||||
::rpc/profile-id (:id profile-user)
|
||||
:id organization-id
|
||||
@ -198,7 +208,12 @@
|
||||
;; unset as a default team.
|
||||
(let [team (th/db-get :team {:id your-penpot-id})]
|
||||
(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
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
@ -413,7 +428,9 @@
|
||||
(t/is (= {:teams-to-delete 0
|
||||
:teams-to-transfer 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)))))))
|
||||
|
||||
(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files
|
||||
@ -445,7 +462,9 @@
|
||||
(t/is (= {:teams-to-delete 1
|
||||
:teams-to-transfer 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)))))))
|
||||
|
||||
(t/deftest leave-org-error-org-owner-cannot-leave
|
||||
@ -920,7 +939,7 @@
|
||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||
|
||||
(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]
|
||||
(case method
|
||||
:get-org-membership (if (= (:profile-id params) owner-id)
|
||||
@ -929,7 +948,10 @@
|
||||
:get-org-members [owner-id]
|
||||
:get-team-org {:organization nil}
|
||||
: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-summary (assoc org-summary :teams [{:id team-id}])
|
||||
:add-profile-to-org {:is-member true}
|
||||
@ -956,7 +978,8 @@
|
||||
:permissions {:create-teams "any"
|
||||
:move-teams "always"
|
||||
:new-team-members "members"}}
|
||||
sent (atom [])]
|
||||
sent (atom [])
|
||||
set-team-params (atom nil)]
|
||||
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
@ -974,7 +997,8 @@
|
||||
:org-perms org-perms
|
||||
:owner-id (:id owner)
|
||||
:team-id (:id team)
|
||||
:sso-active? true})
|
||||
:sso-active? true
|
||||
:set-team-params set-team-params})
|
||||
teams/initialize-user-in-nitrate-org (fn [& _] nil)
|
||||
eml/send! (fn [params] (swap! sent conj params))]
|
||||
(let [out (th/command! {::th/type :add-team-to-organization
|
||||
@ -983,6 +1007,11 @@
|
||||
:organization-id org-id})]
|
||||
(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)]
|
||||
(t/is (= 2 (count @sent)))
|
||||
(t/is (= #{"member302@example.com" "external301@example.com"} emails))
|
||||
@ -990,6 +1019,26 @@
|
||||
(t/is (= org-name (:organization-name 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
|
||||
(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"})
|
||||
|
||||
@ -13,7 +13,9 @@
|
||||
[app.db :as db]
|
||||
[app.email.blacklist :as email.blacklist]
|
||||
[app.http :as http]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.storage :as sto]
|
||||
[app.tokens :as tokens]
|
||||
[backend-tests.helpers :as th]
|
||||
@ -103,6 +105,63 @@
|
||||
(t/is (= :validation (:type 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
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
@ -378,6 +437,158 @@
|
||||
(t/is (= :validation (:type 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
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
@ -845,4 +1056,3 @@
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.team :as dt]
|
||||
[app.main.repo :as rp]
|
||||
@ -22,6 +23,9 @@
|
||||
|
||||
(def ^:private nitrate-entry-active-key ::nitrate-entry-active)
|
||||
(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!
|
||||
[]
|
||||
@ -119,7 +123,7 @@
|
||||
:cancel-callback (build nitrate-checkout-cancelled-token)}))
|
||||
|
||||
(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]}
|
||||
(build-nitrate-callback-urls base-url)
|
||||
params {:subscription subscription
|
||||
@ -132,7 +136,8 @@
|
||||
::ev/origin event-origin
|
||||
:product "nitrate:enterprise"
|
||||
:billing-period subscription
|
||||
:subscription-mode subscription-mode})]
|
||||
:subscription-mode subscription-mode
|
||||
:subscription-start-origin subscription-start-origin})]
|
||||
(->> st/stream
|
||||
(rx/filter (ptk/type? ::ev/chunk-persisted))
|
||||
(rx/take 1)
|
||||
@ -157,27 +162,50 @@
|
||||
(dm/get-in profile [:subscription :status]))))
|
||||
|
||||
(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/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [profile-team-id (dm/get-in state [:profile :default-team-id])]
|
||||
(->> (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))))))
|
||||
(let [profile-id (dm/get-in state [:profile :id])
|
||||
profile-team-id (dm/get-in state [:profile :default-team-id])
|
||||
subscription-status
|
||||
(if (= "trialing" (dm/get-in state [:profile :subscription :status]))
|
||||
"trial"
|
||||
"active")
|
||||
audit-event
|
||||
(nitrate-audit/delete-organization-member-event
|
||||
{:organization-id id
|
||||
:user-id profile-id
|
||||
:user-who-delete-member profile-id
|
||||
:deleted-by-role "organization-member"
|
||||
:member-added-at member-added-at
|
||||
:organization-member-count-before organization-member-count-before
|
||||
:subscription-status subscription-status})]
|
||||
(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
|
||||
[{: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)
|
||||
num-teams-to-transfer (:teams-to-transfer 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
|
||||
(pos? num-teams-to-transfer)
|
||||
(rx/of
|
||||
@ -278,26 +313,50 @@
|
||||
organization requires a fresh Nitrate SSO session. When SSO is required,
|
||||
stores the pending action and redirects to the provider so the dashboard
|
||||
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/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [pending-id (str (uuid/next))
|
||||
(watch [_ state _]
|
||||
(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)
|
||||
:pending-action-id pending-id)]
|
||||
(->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url callback-url})
|
||||
(rx/mapcat
|
||||
(fn [{:keys [authorized redirect-uri]}]
|
||||
(if authorized
|
||||
(->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id})
|
||||
(rx/map (fn [_] (modal/hide))))
|
||||
(if redirect-uri
|
||||
(do
|
||||
(ss/save-pending-action! pending-id {:type :add-team-to-organization
|
||||
:team-id team-id
|
||||
:organization-id organization-id})
|
||||
(rx/of (rt/nav-raw :uri (str redirect-uri))))
|
||||
(rx/empty))))))))))
|
||||
(rx/concat
|
||||
(when-not skip-audit?
|
||||
(rx/of audit-event))
|
||||
(->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url callback-url})
|
||||
(rx/mapcat
|
||||
(fn [{:keys [authorized redirect-uri]}]
|
||||
(if authorized
|
||||
(->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id})
|
||||
(rx/map (fn [_] (modal/hide))))
|
||||
(if redirect-uri
|
||||
(do
|
||||
(ss/save-pending-action! pending-id {:type :add-team-to-organization
|
||||
:team-id team-id
|
||||
:organization-id organization-id})
|
||||
(rx/of (rt/nav-raw :uri (str redirect-uri))))
|
||||
(rx/empty)))))))))))
|
||||
|
||||
|
||||
(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.media :as di]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.nitrate-audit :as nitrate-audit]
|
||||
[app.main.data.profile :as dp]
|
||||
[app.main.features :as features]
|
||||
[app.main.repo :as rp]
|
||||
@ -438,17 +439,36 @@
|
||||
(dm/assert! (string? name))
|
||||
(ptk/reify ::create-team
|
||||
ptk/WatchEvent
|
||||
(watch [it _ _]
|
||||
(watch [it state _]
|
||||
(let [{:keys [on-success on-error]
|
||||
:or {on-success identity
|
||||
on-error rx/throw}} (meta params)
|
||||
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}
|
||||
organization-id (assoc :organization-id organization-id))]
|
||||
(->> (rp/cmd! :create-team (with-meta params (meta it)))
|
||||
(rx/tap on-success)
|
||||
(rx/map team-created)
|
||||
(rx/catch on-error))))))
|
||||
(rx/concat
|
||||
(when audit-event
|
||||
(rx/of audit-event))
|
||||
(->> (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
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
[app.config :as cf]
|
||||
[app.main.data.auth :as da]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.repo :as rp]
|
||||
@ -44,6 +45,13 @@
|
||||
|
||||
(defmethod handle-token :team-invitation
|
||||
[{: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
|
||||
:created
|
||||
(if org-team-id
|
||||
|
||||
@ -282,7 +282,8 @@
|
||||
(case (:type action)
|
||||
:add-team-to-organization
|
||||
(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)))))
|
||||
|
||||
(mf/defc dashboard*
|
||||
|
||||
@ -304,7 +304,15 @@
|
||||
(mf/defc organizations-selector-dropdown*
|
||||
{::mf/private true}
|
||||
[{: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
|
||||
(fn [event]
|
||||
(let [team-id (-> (dom/get-current-target event)
|
||||
@ -314,18 +322,27 @@
|
||||
|
||||
on-create-org-click
|
||||
(mf/use-fn
|
||||
(mf/deps profile)
|
||||
(mf/deps account-age-days profile subscription-type team-count)
|
||||
(fn []
|
||||
(cond
|
||||
(= (get-subscription-type (-> profile :props :subscription)) "unlimited")
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
|
||||
|
||||
(dnt/is-valid-license? profile)
|
||||
(if (and (not= subscription-type "unlimited")
|
||||
(dnt/is-valid-license? profile))
|
||||
(dnt/go-to-nitrate-ac-create-organization
|
||||
"dashboard:organization-switcher")
|
||||
|
||||
:else
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))
|
||||
(st/emit!
|
||||
(ev/event
|
||||
(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
|
||||
(mf/use-fn
|
||||
@ -648,7 +665,9 @@
|
||||
leave-fn
|
||||
(mf/use-fn
|
||||
(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
|
||||
:always
|
||||
(map #(select-keys % [:id]))
|
||||
@ -664,6 +683,9 @@
|
||||
:default-team-id default-team-id
|
||||
:teams-to-delete teams-to-delete
|
||||
: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-leave-clicked
|
||||
@ -695,6 +717,12 @@
|
||||
[{:keys [team profile]}]
|
||||
(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)
|
||||
|
||||
;; Find the "your-penpot" teams, and transform them in orgs. When
|
||||
@ -772,18 +800,27 @@
|
||||
|
||||
on-create-org-click
|
||||
(mf/use-fn
|
||||
(mf/deps profile)
|
||||
(mf/deps account-age-days profile subscription-type team-count)
|
||||
(fn []
|
||||
(cond
|
||||
(= (get-subscription-type (-> profile :props :subscription)) "unlimited")
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
|
||||
|
||||
(dnt/is-valid-license? profile)
|
||||
(if (and (not= subscription-type "unlimited")
|
||||
(dnt/is-valid-license? profile))
|
||||
(dnt/go-to-nitrate-ac-create-organization
|
||||
"dashboard:organization-switcher")
|
||||
|
||||
:else
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))]
|
||||
(st/emit!
|
||||
(ev/event
|
||||
(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?
|
||||
[:div {:class (stl/css :sidebar-org-switch)}
|
||||
[:div {:class (stl/css :org-switch-content)}
|
||||
|
||||
@ -152,6 +152,10 @@
|
||||
subscription-type
|
||||
(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)
|
||||
|
||||
no-orgs-created? (mf/with-memo [teams]
|
||||
@ -162,11 +166,22 @@
|
||||
|
||||
handle-click
|
||||
(mf/use-fn
|
||||
(mf/deps subscription-type)
|
||||
(mf/deps account-age-days subscription-type team-count)
|
||||
(fn []
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form
|
||||
(when (= subscription-type "unlimited")
|
||||
{:show-contact-sales-option true})))))
|
||||
(st/emit!
|
||||
(ev/event
|
||||
(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
|
||||
(mf/use-fn
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.main.ui.nitrate.nitrate-form
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.nitrate :as dnt]
|
||||
[app.main.refs :as refs]
|
||||
@ -25,6 +26,7 @@
|
||||
[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))
|
||||
profile (mf/deref refs/profile)
|
||||
on-click
|
||||
@ -34,16 +36,31 @@
|
||||
"monthly"
|
||||
dnt/go-to-ac-url
|
||||
"dashboard:plan-confirmation-modal"
|
||||
(if (:subscription profile) "paid" "trial"))))
|
||||
(if (:subscription profile) "paid" "trial")
|
||||
subscription-start-origin)))
|
||||
|
||||
on-activate-click
|
||||
(mf/use-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-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"
|
||||
:size "m"}]]
|
||||
[:div {:class (stl/css :modal-success-content)}
|
||||
@ -89,7 +106,9 @@
|
||||
(tr "nitrate.form.enter-code")]]
|
||||
|
||||
[: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")]]]
|
||||
|
||||
[:div {:class (stl/css :contact)}
|
||||
|
||||
@ -193,21 +193,31 @@
|
||||
(rt/encode-url))
|
||||
href (dm/str "payments/subscriptions/create?type=unlimited&show="
|
||||
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)
|
||||
(st/emit! (ev/event {::ev/name "create-trial-subscription"
|
||||
(st/emit! (ev/event {::ev/name event-name
|
||||
:type "unlimited"
|
||||
:quantity min-members})
|
||||
:quantity min-members
|
||||
:subscription-mode subscription-mode})
|
||||
(rt/nav-raw :href href))))))
|
||||
|
||||
subscribe-to-enterprise
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (ev/event {::ev/name "create-trial-subscription"
|
||||
:type "enterprise"}))
|
||||
(let [return-url (-> (rt/get-current-href) (rt/encode-url))
|
||||
(let [event-name (if subscribe-to-trial
|
||||
"create-trial-subscription"
|
||||
"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)]
|
||||
(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
|
||||
(mf/use-fn
|
||||
@ -224,7 +234,10 @@
|
||||
handle-close-dialog
|
||||
(mf/use-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!)))
|
||||
|
||||
on-submit
|
||||
@ -482,6 +495,14 @@
|
||||
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
|
||||
(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
|
||||
(mf/use-fn
|
||||
(mf/deps subscription-editors nitrate-license)
|
||||
@ -492,7 +513,8 @@
|
||||
(st/emit! (dnt/show-nitrate-popup
|
||||
:nitrate-dialog
|
||||
{:nitrate-license nitrate-license
|
||||
:event-origin "settings:plan-confirmation-modal"}))
|
||||
:event-origin "settings:plan-confirmation-modal"
|
||||
:subscription-start-origin "settings"}))
|
||||
(st/emit!
|
||||
(modal/show :management-dialog
|
||||
{:subscription-type subscription-type
|
||||
@ -602,7 +624,7 @@
|
||||
(tr "subscription.settings.manage-your-subscription")
|
||||
(tr "nitrate.subscription.settings.manual-cancel"))
|
||||
:cta-link (if (and (:licenses connectivity) (not (:manual nitrate-license)))
|
||||
dnt/go-to-nitrate-billing
|
||||
go-to-nitrate-payments
|
||||
open-cancel-contact-sales-modal)
|
||||
:code-action (when (:manual nitrate-license) :renovate)
|
||||
:current-plan true}]
|
||||
@ -768,12 +790,15 @@
|
||||
(mf/defc subscribe-nitrate-dialog
|
||||
{::mf/register modal/components
|
||||
::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
|
||||
(let [online? (:licenses connectivity)
|
||||
handle-close-dialog
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (ev/event {::ev/name "close-subscription-modal"
|
||||
::ev/origin "nitrate:plan-confirmation-modal"
|
||||
:product "nitrate:enterprise"}))
|
||||
(modal/hide!)))
|
||||
|
||||
on-subscribe-click
|
||||
@ -783,7 +808,8 @@
|
||||
"monthly"
|
||||
(rt/get-current-href)
|
||||
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-dialog)}
|
||||
|
||||
@ -6,10 +6,142 @@
|
||||
|
||||
(ns frontend-tests.data.nitrate-test
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.main.data.event :as ev]
|
||||
[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]))
|
||||
|
||||
(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/testing "builds admin console routes below the configured Penpot subpath"
|
||||
(let [public-uri (u/uri "https://example.com/penpot/")]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user