Add nitrate audit events

This commit is contained in:
Marina López 2026-07-30 15:32:45 +02:00 committed by GitHub
parent 91d4b16171
commit 31ccfa546f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1000 additions and 180 deletions

View File

@ -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/"

View File

@ -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"))

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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))))))))

View File

@ -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"})

View File

@ -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)))))

View File

@ -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

View 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}))

View File

@ -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

View File

@ -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

View File

@ -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*

View File

@ -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)}

View File

@ -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

View File

@ -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)}

View File

@ -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)}

View File

@ -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/")]