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] [:id ::sm/uuid]
[:is-your-penpot :boolean]]]]]) [:is-your-penpot :boolean]]]]])
(def ^:private schema:profile-org
[:map
[:is-member :boolean]
[:organization-id {:optional true} [:maybe ::sm/uuid]]
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
;; TODO Unify with schemas on backend/src/app/http/management.clj ;; TODO Unify with schemas on backend/src/app/http/management.clj
(def ^:private schema:timestamp (def ^:private schema:timestamp
(sm/type-schema (sm/type-schema
@ -196,6 +189,13 @@
:decode/json ct/inst :decode/json ct/inst
:encode/json inst-ms}})) :encode/json inst-ms}}))
(def ^:private schema:profile-org
[:map
[:is-member :boolean]
[:organization-id {:optional true} [:maybe ::sm/uuid]]
[:default-team-id {:optional true} [:maybe ::sm/uuid]]
[:created-at {:optional true} [:maybe schema:timestamp]]])
(def ^:private schema:subscription (def ^:private schema:subscription
[:map {:title "Subscription"} [:map {:title "Subscription"}
[:id ::sm/text] [:id ::sm/text]
@ -361,8 +361,13 @@
schema:profile-org params))) schema:profile-org params)))
(defn- remove-profile-from-org-api (defn- remove-profile-from-org-api
[cfg {:keys [profile-id organization-id] :as params}] [cfg {:keys [profile-id organization-id user-who-delete-member deleted-by-role] :as params}]
(let [params (assoc params :request-params {:user-id profile-id})] (let [request-params (cond-> {:user-id profile-id}
(some? user-who-delete-member)
(assoc :user-who-delete-member user-who-delete-member)
(some? deleted-by-role)
(assoc :deleted-by-role deleted-by-role))
params (assoc params :request-params request-params)]
(request-to-nitrate cfg :post (request-to-nitrate cfg :post
(generate-nitrate-uri (generate-nitrate-uri
"api/organizations/" "api/organizations/"

View File

@ -223,7 +223,9 @@
[:teams-to-delete ::sm/int] [:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int] [:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int] [:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]]) [:teams-to-detach ::sm/int]
[:member-added-at [:maybe ct/schema:inst]]
[:organization-member-count-before ::sm/int]])
(def ^:private schema:get-leave-org-summary (def ^:private schema:get-leave-org-summary
[:map [:map
@ -322,7 +324,8 @@
(defn leave-org (defn leave-org
[{:keys [::db/conn] :as cfg} [{:keys [::db/conn] :as cfg}
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?]}] {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?
user-who-delete-member deleted-by-role]}]
(let [org-prefix (str "[" (d/sanitize-string name) "] ") (let [org-prefix (str "[" (d/sanitize-string name) "] ")
{:keys [deletable-team-ids {:keys [deletable-team-ids
keep-default-team? keep-default-team?
@ -357,7 +360,11 @@
:organization-id id})) :organization-id id}))
;; Api call to nitrate ;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id}) (nitrate/call cfg :remove-profile-from-org
{:profile-id profile-id
:organization-id id
:user-who-delete-member user-who-delete-member
:deleted-by-role deleted-by-role})
nil)) nil))
@ -368,7 +375,10 @@
::sm/params schema:leave-org ::sm/params schema:leave-org
::db/transaction true} ::db/transaction true}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(leave-org cfg (assoc params :profile-id profile-id))) (leave-org cfg (assoc params
:profile-id profile-id
:user-who-delete-member profile-id
:deleted-by-role "organization-member")))
(sv/defmethod ::get-leave-org-summary (sv/defmethod ::get-leave-org-summary
@ -382,12 +392,20 @@
valid-teams-to-transfer valid-teams-to-transfer
valid-teams-to-exit valid-teams-to-exit
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id) valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
membership (nitrate/call cfg :get-org-membership
{:profile-id profile-id
:organization-id id})
organization-members (nitrate/call cfg :get-org-members
{:organization-id id})
teams-to-transfer-count (count valid-teams-to-transfer) teams-to-transfer-count (count valid-teams-to-transfer)
teams-to-exit-count (count valid-teams-to-exit)] teams-to-exit-count (count valid-teams-to-exit)]
(when-not valid-default-team (when-not valid-default-team
(ex/raise :type :validation (ex/raise :type :validation
:code :not-valid-teams)) :code :not-valid-teams))
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count))) (assoc
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)
:member-added-at (:created-at membership)
:organization-member-count-before (count organization-members))))
(def ^:private schema:remove-team-from-org (def ^:private schema:remove-team-from-org
@ -525,7 +543,9 @@
(teams/initialize-user-in-nitrate-org cfg member-id organization-id))) (teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
;; Api call to nitrate ;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] (let [team (nitrate/call cfg :set-team-org {:team-id team-id
:organization-id organization-id
:is-default false})]
;; Notify connected users ;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org")) (notifications/notify-team-change cfg team "dashboard.team-belong-org"))

View File

@ -127,7 +127,8 @@
:hint "The invited email is not a member of the organization"))))) :hint "The invited email is not a member of the organization")))))
(defn- create-invitation (defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}] [{:keys [::db/conn] :as cfg}
{:keys [team organization profile role email org-member-ids organization-member-ids] :as params}]
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
@ -203,6 +204,7 @@
(name role) expire])) (name role) expire]))
updated? (not= id (:id invitation)) updated? (not= id (:id invitation))
profile-id (:id profile) profile-id (:id profile)
team-organization-id (get-in team [:organization :id])
tprops {:profile-id profile-id tprops {:profile-id profile-id
:invitation-id (:id invitation) :invitation-id (:id invitation)
:valid-until expire :valid-until expire
@ -212,18 +214,37 @@
:member-email (:email-to invitation) :member-email (:email-to invitation)
:member-id (:id member) :member-id (:id member)
:role role} :role role}
audit-props
(cond-> {:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
:organization-id (:id organization)
:organization-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
organization
(assoc :user-who-send-invitation (str profile-id))
(not organization)
(assoc :team-belongs-to-organization (boolean team-organization-id)
:adds-invitee-to-organization (boolean team-organization-id)
:invitee-already-organization-member
(boolean
(and team-organization-id
member
(contains? organization-member-ids (:id member))))))
itoken (create-invitation-token cfg tprops) itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile-id)] ptoken (create-profile-identity-token cfg profile-id)]
(when (contains? cf/flags :log-invitation-tokens) (when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken)) (l/info :hint "invitation token" :token itoken))
(let [props (-> (dissoc tprops :profile-id) (let [props (audit/clean-props audit-props)
(audit/clean-props))
evname (cond evname (cond
(and updated? organization) "update-org-invitation" (and updated? organization) "update-organization-invitation"
updated? "update-team-invitation" updated? "update-team-invitation"
organization "create-org-invitation" organization "create-organization-invitation"
:else "create-team-invitation") :else "create-team-invitation")
event (-> (audit/event-from-rpc-params params) event (-> (audit/event-from-rpc-params params)
(assoc :name evname) (assoc :name evname)
@ -331,9 +352,14 @@
org (:organization team) org (:organization team)
org-id (:id org) org-id (:id org)
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org}))) restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
org-member-ids (when restricted? organization-member-ids
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id}))) (when org-id
params (assoc params :team team :org-member-ids org-member-ids) (into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
org-member-ids (when restricted? organization-member-ids)
params (assoc params
:team team
:org-member-ids org-member-ids
:organization-member-ids organization-member-ids)
;; Normalize input to a consistent format: [{:email :role}] ;; Normalize input to a consistent format: [{:email :role}]
invitation-data (cond invitation-data (cond

View File

@ -215,52 +215,106 @@
;; existing invitation; querying it when the invitation is absent ;; existing invitation; querying it when the invitation is absent
;; would call nitrate needlessly and could mask the clean ;; would call nitrate needlessly and could mask the clean
;; :canceled-invitation/:invalid-token response with a generic error. ;; :canceled-invitation/:invalid-token response with a generic error.
(let [membership (when org-invitation? (let [membership
(nitrate/call cfg :get-org-membership {:profile-id profile-id (when (contains? cf/flags :nitrate)
:organization-id organization-id}))] (cond
organization-id
(nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id})
team-id
(nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id
:team-id team-id})))
organization-id-on-add
(when (and (:organization-id membership)
(not (:is-member membership)))
(:organization-id membership))
organization-add-source
(when organization-id-on-add
(if organization-id
"direct-organization-invitation"
"team-invitation"))
organization-event-origin
(when organization-id-on-add
(if organization-id
"organization-invitation-acceptance"
"team-invitation-acceptance"))
organization-member-count-before
(when organization-id-on-add
(count
(nitrate/call cfg :get-org-members
{:organization-id organization-id-on-add})))]
(when (:is-member membership) (when (:is-member membership)
(ex/raise :type :validation (when org-invitation?
:code :already-an-org-member (ex/raise :type :validation
:team-id (:default-team-id membership) :code :already-an-org-member
:hint "the user is already a member of the organization")) :team-id (:default-team-id membership)
:hint "the user is already a member of the organization")))
(when (and org-invitation? (not (:organization-id membership))) (when (and org-invitation? (not (:organization-id membership)))
(ex/raise :type :validation (ex/raise :type :validation
:code :org-not-found :code :org-not-found
:team-id (:default-team-id profile) :team-id (:default-team-id profile)
:hint "the organization doesn't exist"))) :hint "the organization doesn't exist"))
;; if we have logged-in user and it matches the invitation we proceed ;; if we have logged-in user and it matches the invitation we proceed
;; with accepting the invitation and joining the current profile to the ;; with accepting the invitation and joining the current profile to the
;; invited team. ;; invited team.
(let [props {:team-id (:team-id claims) (let [props {:team-id (:team-id claims)
:role (:role claims) :role (:role claims)
:invitation-id (:id invitation)}] :invitation-id (:id invitation)}]
(audit/submit cfg (when team-id
(-> (audit/event-from-rpc-params params) (audit/submit cfg
(assoc :name "accept-team-invitation") (-> (audit/event-from-rpc-params params)
(assoc :props props))) (assoc :name "accept-team-invitation")
(assoc :props props)))
;; NOTE: Backward compatibility; old invitations can ;; NOTE: Backward compatibility; old invitations can
;; have the `created-by` to be nil; so in this case we ;; have the `created-by` to be nil; so in this case we
;; don't submit this event to the audit-log ;; don't submit this event to the audit-log
(when-let [created-by (:created-by invitation)] (when-let [created-by (:created-by invitation)]
(audit/submit cfg (audit/submit cfg
(-> (audit/event-from-rpc-params params) (-> (audit/event-from-rpc-params params)
(assoc :profile-id created-by) (assoc :profile-id created-by)
(assoc :name "accept-team-invitation-from") (assoc :name "accept-team-invitation-from")
(assoc :props (assoc props (assoc :props (assoc props
:profile-id (:id profile) :profile-id (:id profile)
:email (:email profile)))))) :email (:email profile)))))))
(let [accepted-team-id (accept-invitation cfg claims invitation profile)] (let [accepted-team-id (accept-invitation cfg claims invitation profile)]
(cond-> (assoc claims :state :created) (when organization-id-on-add
;; when the invitation is to an org, instead of a team, add the (audit/submit
;; accepted-team-id as :org-team-id cfg
(:organization-id claims) (-> (audit/event-from-rpc-params params)
(assoc :org-team-id accepted-team-id))))) (assoc :name "accept-organization-invitation")
(assoc :props
(-> props
(assoc :organization-id organization-id-on-add)
(audit/clean-props))))))
(cond-> (assoc claims :state :created)
;; when the invitation is to an org, instead of a team, add the
;; accepted-team-id as :org-team-id
(:organization-id claims)
(assoc :org-team-id accepted-team-id)
organization-id-on-add
(assoc :organization-invitation-audit
{:origin organization-event-origin
:props
(-> props
(assoc :organization-id organization-id-on-add
:organization-member-add-source organization-add-source
:belongs-to-team-on-add (boolean team-id)
:organization-member-count-before
organization-member-count-before)
(audit/clean-props))}))))))
(do (do
;; If the user is not logged-in and the invitation has been canceled ;; If the user is not logged-in and the invitation has been canceled
@ -289,4 +343,3 @@
[_ _ _] [_ _ _]
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-token)) :code :invalid-token))

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.organization :refer [schema:team-with-organization schema:organization-with-avatar schema:nitrate-sso]]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]] [app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.email :as eml] [app.email :as eml]
@ -619,8 +620,12 @@ RETURNING id, deleted_at;")
[:default-team-id ::sm/uuid]] [:default-team-id ::sm/uuid]]
::db/transaction true ::db/transaction true
::nitrate/sso false} ::nitrate/sso false}
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}] [cfg {actor-profile-id ::rpc/profile-id
(let [{:keys [valid-teams-to-delete-ids :keys [profile-id organization-id organization-name default-team-id]
:as params}]
(let [actor-profile-id (when-not (= actor-profile-id uuid/zero)
actor-profile-id)
{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer valid-teams-to-transfer
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id) valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
add-reassign-to (partial add-reassign-to cfg profile-id) add-reassign-to (partial add-reassign-to cfg profile-id)
@ -633,7 +638,10 @@ RETURNING id, deleted_at;")
:name organization-name :name organization-name
:teams-to-delete valid-teams-to-delete-ids :teams-to-delete valid-teams-to-delete-ids
:teams-to-leave valid-teams-to-leave :teams-to-leave valid-teams-to-leave
:skip-validation true)) :skip-validation true
:user-who-delete-member actor-profile-id
:deleted-by-role (when actor-profile-id
"organization-owner")))
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org") (notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
nil)) nil))
@ -809,7 +817,7 @@ RETURNING id, deleted_at;")
ip-addr (::rpc/ip-addr params)] ip-addr (::rpc/ip-addr params)]
(run! (fn [{:keys [type name profile-id props context] :as event}] (run! (fn [{:keys [type name profile-id props context] :as event}]
(let [context (-> (merge context context') (let [context (-> (merge context (d/without-nils context'))
(d/without-nils))] (d/without-nils))]
(audit/submit cfg {:type (d/nilv type "action") (audit/submit cfg {:type (d/nilv type "action")
:name name :name name

View File

@ -49,6 +49,47 @@
(t/is (= :authentication (th/ex-type (:error out)))) (t/is (= :authentication (th/ex-type (:error out))))
(t/is (= :authentication-required (th/ex-code (:error out)))))) (t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest create-and-update-organization-invitations-audit-props
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(binding [cf/flags (conj cf/flags :email-verification)]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
organization {:id (uuid/random)
:name "Acme"
:initials "AC"
:logo nil
:avatar-bg-url nil}
params {::th/type :invite-to-organization
::rpc/profile-id (:id owner)
:email (:email invitee)
:organization organization}
create-out (th/management-command! params)
update-out (th/management-command! params)
external-out (th/management-command! (assoc params :email "external@example.com"))
events (mapv second (:call-args-list @audit-mock))
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
external-event
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
(t/is (th/success? create-out))
(t/is (th/success? update-out))
(t/is (th/success? external-out))
(doseq [event [create-event update-event]]
(t/is (not (contains? (:props event) :event-origin)))
(t/is (= (str (:id owner))
(get-in event [:props :user-who-send-invitation])))
(t/is (= (:id organization)
(get-in event [:props :organization-id])))
(t/is (= (:email invitee)
(get-in event [:props :member-email])))
(t/is (= (:id invitee)
(get-in event [:props :member-id]))))
(t/is (not (contains? (:props external-event) :member-id)))))))
(t/deftest get-penpot-version (t/deftest get-penpot-version
(let [out (th/management-command! {::th/type :get-penpot-version}) (let [out (th/management-command! {::th/type :get-penpot-version})
version (-> out :result :version)] version (-> out :result :version)]
@ -1034,25 +1075,29 @@
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))}) (mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
(defn- nitrate-call-mock (defn- nitrate-call-mock
[org-summary] ([org-summary]
(fn [_cfg method _params] (nitrate-call-mock org-summary nil))
(case method ([org-summary remove-profile-params]
:get-org-summary org-summary (fn [_cfg method params]
:get-org-membership {:organization-id (:id org-summary) (case method
:is-member true} :get-org-summary org-summary
:remove-profile-from-org nil :get-org-membership {:organization-id (:id org-summary)
nil))) :is-member true}
:remove-profile-from-org (when remove-profile-params
(reset! remove-profile-params params))
nil))))
(t/deftest remove-from-organization-happy-path-no-extra-teams (t/deftest remove-from-organization-happy-path-no-extra-teams
;; User is only in its default team (which has files); it should be ;; User is only in its default team (which has files); it should be
;; kept, renamed and unset as default. A notification must be sent. ;; kept, renamed and unset as default. A notification must be sent.
;; --- Deferred org-summary: depends on team IDs from setup --- ;; --- Deferred org-summary: depends on team IDs from setup ---
(let [org-summary-ref (atom nil)] (let [org-summary-ref (atom nil)
remove-profile-params (atom nil)]
(with-mocks (with-mocks
[;; --- Nitrate mock: nil during setup, serve org-summary once available --- [;; --- Nitrate mock: nil during setup, serve org-summary once available ---
nitrate-mock {:target 'app.nitrate/call nitrate-mock {:target 'app.nitrate/call
:return (fn [& args] :return (fn [& args]
(apply (nitrate-call-mock @org-summary-ref) args))} (apply (nitrate-call-mock @org-summary-ref remove-profile-params) args))}
;; --- Message bus mock: capture published events --- ;; --- Message bus mock: capture published events ---
mbus-mock {:target 'app.msgbus/pub! :return nil}] mbus-mock {:target 'app.msgbus/pub! :return nil}]
@ -1089,6 +1134,10 @@
;; --- Verify: RPC returns success with no result payload --- ;; --- Verify: RPC returns success with no result payload ---
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (nil? (:result out))) (t/is (nil? (:result out)))
(t/is (= (:id org-owner)
(:user-who-delete-member @remove-profile-params)))
(t/is (= "organization-owner"
(:deleted-by-role @remove-profile-params)))
;; --- Verify: default team preserved, renamed and unset as default --- ;; --- Verify: default team preserved, renamed and unset as default ---
(let [team (th/db-get :team {:id (:id org-team)})] (let [team (th/db-get :team {:id (:id org-team)})]
@ -1107,12 +1156,13 @@
(t/deftest remove-from-organization-deletes-empty-default-team (t/deftest remove-from-organization-deletes-empty-default-team
;; When the default team has no files it should be soft-deleted. ;; When the default team has no files it should be soft-deleted.
;; --- Deferred org-summary: depends on team IDs from setup --- ;; --- Deferred org-summary: depends on team IDs from setup ---
(let [org-summary-ref (atom nil)] (let [org-summary-ref (atom nil)
remove-profile-params (atom nil)]
(with-mocks (with-mocks
[;; --- Nitrate mock: nil during setup, serve org-summary once available --- [;; --- Nitrate mock: nil during setup, serve org-summary once available ---
nitrate-mock {:target 'app.nitrate/call nitrate-mock {:target 'app.nitrate/call
:return (fn [& args] :return (fn [& args]
(apply (nitrate-call-mock @org-summary-ref) args))} (apply (nitrate-call-mock @org-summary-ref remove-profile-params) args))}
;; --- Message bus mock: swallow notifications --- ;; --- Message bus mock: swallow notifications ---
mbus-mock {:target 'app.msgbus/pub! :return nil}] mbus-mock {:target 'app.msgbus/pub! :return nil}]
@ -1133,7 +1183,7 @@
;; --- Exercise: remove the user from the org --- ;; --- Exercise: remove the user from the org ---
out (th/management-command! out (th/management-command!
{::th/type :remove-from-organization {::th/type :remove-from-organization
::rpc/profile-id (:id org-owner) ::rpc/profile-id uuid/zero
:profile-id (:id user) :profile-id (:id user)
:organization-id organization-id :organization-id organization-id
:organization-name "Acme Org" :organization-name "Acme Org"
@ -1141,6 +1191,8 @@
;; --- Verify: RPC returns success --- ;; --- Verify: RPC returns success ---
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (nil? (:user-who-delete-member @remove-profile-params)))
(t/is (nil? (:deleted-by-role @remove-profile-params)))
;; --- Verify: empty default team is soft-deleted --- ;; --- Verify: empty default team is soft-deleted ---
(let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})] (let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team)))))))) (t/is (some? (:deleted-at team))))))))

View File

@ -45,19 +45,28 @@
"Creates a mock for nitrate/call that returns the given org-summary for "Creates a mock for nitrate/call that returns the given org-summary for
:get-org-summary, a valid membership for :get-org-membership, and nil for :get-org-summary, a valid membership for :get-org-membership, and nil for
any other method." any other method."
[org-summary] ([org-summary]
(fn [_cfg method _params] (nitrate-call-mock org-summary nil))
(case method ([org-summary remove-profile-params]
:get-org-summary org-summary (fn [_cfg method params]
:get-org-membership {:is-member true (case method
:organization-id (:id org-summary)} :get-org-summary org-summary
nil))) :get-org-membership {:is-member true
:organization-id (:id org-summary)}
:remove-profile-from-org (when remove-profile-params
(reset! remove-profile-params params))
nil))))
(defn- nitrate-org-summary-only-mock (defn- nitrate-org-summary-only-mock
[org-summary] [org-summary]
(fn [_cfg method _params] (fn [_cfg method _params]
(case method (case method
:get-org-summary org-summary :get-org-summary org-summary
:get-org-membership {:is-member true
:organization-id (:id org-summary)
:created-at (ct/inst "2026-07-17T12:00:00Z")}
:get-org-members [(:owner-id org-summary)
(uuid/random)]
nil))) nil)))
(defn- active-sso-call-mock (defn- active-sso-call-mock
@ -173,14 +182,15 @@
;; The user's personal penpot team in the org context ;; The user's personal penpot team in the org context
your-penpot-id (:id org-default-team) your-penpot-id (:id org-default-team)
org-summary (make-org-summary org-summary (make-org-summary
:organization-id organization-id :organization-id organization-id
:organization-name "Test Org" :organization-name "Test Org"
:owner-id (:id profile-owner) :owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id] :your-penpot-teams [your-penpot-id]
:org-teams [])] :org-teams [])
remove-profile-params (atom nil)]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)] (with-redefs [nitrate/call (nitrate-call-mock org-summary remove-profile-params)]
(let [data {::th/type :leave-org (let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user) ::rpc/profile-id (:id profile-user)
:id organization-id :id organization-id
@ -198,7 +208,12 @@
;; unset as a default team. ;; unset as a default team.
(let [team (th/db-get :team {:id your-penpot-id})] (let [team (th/db-get :team {:id your-penpot-id})]
(t/is (str/starts-with? (:name team) "[Test Org] ")) (t/is (str/starts-with? (:name team) "[Test Org] "))
(t/is (false? (:is-default team)))))))) (t/is (false? (:is-default team))))
(t/is (= (:id profile-user)
(:user-who-delete-member @remove-profile-params)))
(t/is (= "organization-member"
(:deleted-by-role @remove-profile-params)))))))
(t/deftest leave-org-deletes-org-default-team-when-empty (t/deftest leave-org-deletes-org-default-team-when-empty
(let [profile-owner (th/create-profile* 1 {:is-active true}) (let [profile-owner (th/create-profile* 1 {:is-active true})
@ -413,7 +428,9 @@
(t/is (= {:teams-to-delete 0 (t/is (= {:teams-to-delete 0
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 0 :teams-to-exit 0
:teams-to-detach 0} :teams-to-detach 0
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
:organization-member-count-before 2}
(:result out))))))) (:result out)))))))
(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files (t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files
@ -445,7 +462,9 @@
(t/is (= {:teams-to-delete 1 (t/is (= {:teams-to-delete 1
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 0 :teams-to-exit 0
:teams-to-detach 1} :teams-to-detach 1
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
:organization-member-count-before 2}
(:result out))))))) (:result out)))))))
(t/deftest leave-org-error-org-owner-cannot-leave (t/deftest leave-org-error-org-owner-cannot-leave
@ -920,7 +939,7 @@
(t/is (= :not-valid-teams (th/ex-code (:error out)))))))) (t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(defn- add-team-to-org-nitrate-mock (defn- add-team-to-org-nitrate-mock
[{:keys [org-id org-summary org-perms owner-id team-id sso-active?]}] [{:keys [org-id org-summary org-perms owner-id team-id sso-active? set-team-params]}]
(fn [_cfg method params] (fn [_cfg method params]
(case method (case method
:get-org-membership (if (= (:profile-id params) owner-id) :get-org-membership (if (= (:profile-id params) owner-id)
@ -929,7 +948,10 @@
:get-org-members [owner-id] :get-org-members [owner-id]
:get-team-org {:organization nil} :get-team-org {:organization nil}
:get-org-permissions org-perms :get-org-permissions org-perms
:set-team-org {:id team-id} :set-team-org (do
(when set-team-params
(reset! set-team-params params))
{:id team-id})
:get-org-sso {:active sso-active?} :get-org-sso {:active sso-active?}
:get-org-summary (assoc org-summary :teams [{:id team-id}]) :get-org-summary (assoc org-summary :teams [{:id team-id}])
:add-profile-to-org {:is-member true} :add-profile-to-org {:is-member true}
@ -956,7 +978,8 @@
:permissions {:create-teams "any" :permissions {:create-teams "any"
:move-teams "always" :move-teams "always"
:new-team-members "members"}} :new-team-members "members"}}
sent (atom [])] sent (atom [])
set-team-params (atom nil)]
(th/db-insert! :team-invitation (th/db-insert! :team-invitation
{:id (uuid/random) {:id (uuid/random)
@ -974,7 +997,8 @@
:org-perms org-perms :org-perms org-perms
:owner-id (:id owner) :owner-id (:id owner)
:team-id (:id team) :team-id (:id team)
:sso-active? true}) :sso-active? true
:set-team-params set-team-params})
teams/initialize-user-in-nitrate-org (fn [& _] nil) teams/initialize-user-in-nitrate-org (fn [& _] nil)
eml/send! (fn [params] (swap! sent conj params))] eml/send! (fn [params] (swap! sent conj params))]
(let [out (th/command! {::th/type :add-team-to-organization (let [out (th/command! {::th/type :add-team-to-organization
@ -983,6 +1007,11 @@
:organization-id org-id})] :organization-id org-id})]
(t/is (th/success? out)))) (t/is (th/success? out))))
(t/is (= {:team-id (:id team)
:organization-id org-id
:is-default false}
@set-team-params))
(let [emails (->> @sent (map :to) set)] (let [emails (->> @sent (map :to) set)]
(t/is (= 2 (count @sent))) (t/is (= 2 (count @sent)))
(t/is (= #{"member302@example.com" "external301@example.com"} emails)) (t/is (= #{"member302@example.com" "external301@example.com"} emails))
@ -990,6 +1019,26 @@
(t/is (= org-name (:organization-name email-params))) (t/is (= org-name (:organization-name email-params)))
(t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) (t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
(t/deftest create-team-in-organization-passes-association-to-nitrate
(let [organization-id (uuid/random)
team {:id (uuid/random)
:created-at (ct/now)}
params* (atom nil)]
(with-redefs [nitrate/call (fn [_cfg method params]
(when (= method :set-team-org)
(reset! params* params))
{:id (:id team)})]
(nitrate/set-team-organization
{}
team
{:organization-id organization-id
:is-default false}))
(t/is (= {:team-id (:id team)
:organization-id organization-id
:is-default false}
@params*))))
(t/deftest add-team-to-organization-skips-sso-emails-when-sso-inactive (t/deftest add-team-to-organization-skips-sso-emails-when-sso-inactive
(let [owner (th/create-profile* 303 {:is-active true :email "owner303@example.com"}) (let [owner (th/create-profile* 303 {:is-active true :email "owner303@example.com"})
member (th/create-profile* 304 {:is-active true :email "member304@example.com"}) member (th/create-profile* 304 {:is-active true :email "member304@example.com"})

View File

@ -13,7 +13,9 @@
[app.db :as db] [app.db :as db]
[app.email.blacklist :as email.blacklist] [app.email.blacklist :as email.blacklist]
[app.http :as http] [app.http :as http]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.storage :as sto] [app.storage :as sto]
[app.tokens :as tokens] [app.tokens :as tokens]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
@ -103,6 +105,63 @@
(t/is (= :validation (:type edata))) (t/is (= :validation (:type edata)))
(t/is (= :member-is-muted (:code edata)))))))) (t/is (= :member-is-muted (:code edata))))))))
(t/deftest create-and-update-team-invitations-include-organization-props
(with-mocks [email-mock {:target 'app.email/send! :return nil}
audit-mock {:target 'app.loggers.audit/submit :return nil}]
(let [owner (th/create-profile* 101 {:is-active true})
invitee (th/create-profile* 102 {:is-active true})
org-team (th/create-team* 101 {:profile-id (:id owner)})
plain-team (th/create-team* 102 {:profile-id (:id owner)})
org-id (uuid/random)
org {:id org-id
:name "Acme"
:slug "acme"
:owner-id (:id owner)
:avatar-bg-url "https://example.com/avatar.svg"
:permissions {:new-team-members "anyone"}}
nitrate-call
(fn [_cfg method params]
(case method
:get-team-org
(if (= (:team-id params) (:id org-team))
{:organization org :is-your-penpot false}
{:organization nil :is-your-penpot false})
:get-org-members
[(:id invitee)]
nil))
invite! (fn [team email]
(th/command! {::th/type :create-team-invitations
::rpc/profile-id (:id owner)
:team-id (:id team)
:role :editor
:emails [email]}))]
(with-redefs [cf/flags (conj cf/flags :nitrate :email-verification)
nitrate/call nitrate-call]
(t/is (th/success? (invite! org-team (:email invitee))))
(t/is (th/success? (invite! org-team (:email invitee))))
(t/is (th/success? (invite! plain-team "external@example.com"))))
(let [events (mapv second (:call-args-list @audit-mock))
create-org (first (filter #(and (= "create-team-invitation" (:name %))
(= (:email invitee)
(get-in % [:props :member-email])))
events))
update-org (first (filter #(= "update-team-invitation" (:name %)) events))
create-plain (first (filter #(and (= "create-team-invitation" (:name %))
(= "external@example.com"
(get-in % [:props :member-email])))
events))]
(doseq [event [create-org update-org]]
(t/is (true? (get-in event [:props :team-belongs-to-organization])))
(t/is (true? (get-in event [:props :adds-invitee-to-organization])))
(t/is (true? (get-in event [:props :invitee-already-organization-member]))))
(t/is (false? (get-in create-plain [:props :team-belongs-to-organization])))
(t/is (false? (get-in create-plain [:props :adds-invitee-to-organization])))
(t/is (false? (get-in create-plain [:props :invitee-already-organization-member])))))))
(t/deftest create-team-invitations-blacklisted-domain (t/deftest create-team-invitations-blacklisted-domain
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true}) (let [profile1 (th/create-profile* 1 {:is-active true})
@ -378,6 +437,158 @@
(t/is (= :validation (:type edata))) (t/is (= :validation (:type edata)))
(t/is (= :invalid-token (:code edata))))))))) (t/is (= :invalid-token (:code edata)))))))))
(t/deftest accept-organization-invitation-audit-event
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
(let [inviter (th/create-profile* 201 {:is-active true})
invitee (th/create-profile* 202 {:is-active true})
team (th/create-team* 201 {:profile-id (:id inviter)})
organization-id (uuid/random)
default-team-id (uuid/random)
direct-token (tokens/generate
th/*system*
{:iss :team-invitation
:exp (ct/in-future "1h")
:profile-id (:id inviter)
:role :editor
:organization-id organization-id
:member-email (:email invitee)
:member-id (:id invitee)})
team-token (tokens/generate
th/*system*
{:iss :team-invitation
:exp (ct/in-future "1h")
:profile-id (:id inviter)
:role :editor
:team-id (:id team)
:member-email (:email invitee)
:member-id (:id invitee)})
verify! (fn [token]
(th/command! {::th/type :verify-token
::rpc/profile-id (:id invitee)
:token token}))
organization-event
(fn []
(->> (:call-args-list @audit-mock)
(map second)
(filter #(= "accept-organization-invitation" (:name %)))
first))
frontend-event (atom nil)]
(db/insert! (:app.db/pool th/*system*)
:team-invitation
{:org-id organization-id
:email-to (:email invitee)
:created-by (:id inviter)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call
(fn [_cfg method _params]
(case method
:get-org-membership {:organization-id organization-id
:is-member false}
:get-org-members [(:id inviter) (uuid/random) (uuid/random)]
nil))
teams/initialize-user-in-nitrate-org
(fn [& _] default-team-id)]
(let [out (verify! direct-token)]
(t/is (th/success? out))
(reset! frontend-event
(get-in out [:result :organization-invitation-audit]))))
(let [event (organization-event)]
(t/is (= organization-id (get-in event [:props :organization-id])))
(t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before)))
(t/is (= :editor (get-in event [:props :role])))
(t/is (uuid? (get-in event [:props :invitation-id])))
(t/is (= "organization-invitation-acceptance"
(:origin @frontend-event)))
(t/is (= organization-id
(get-in @frontend-event [:props :organization-id])))
(t/is (= "direct-organization-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add])))
(t/is (= 3
(get-in @frontend-event [:props :organization-member-count-before])))
(t/is (not-any? #(contains? #{"accept-team-invitation"
"accept-team-invitation-from"}
(:name (second %)))
(:call-args-list @audit-mock))))
(th/reset-mock! audit-mock)
(db/insert! (:app.db/pool th/*system*)
:team-invitation
{:team-id (:id team)
:email-to (:email invitee)
:created-by (:id inviter)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call
(fn [_cfg method _params]
(case method
:get-org-membership-by-team {:organization-id organization-id
:is-member false}
:get-org-members (into [(:id inviter)]
(repeatedly 4 uuid/random))
nil))
teams/add-profile-to-team! (fn [& _] nil)]
(let [out (verify! team-token)]
(t/is (th/success? out))
(reset! frontend-event
(get-in out [:result :organization-invitation-audit]))))
(let [events (mapv second (:call-args-list @audit-mock))
event (organization-event)]
(t/is (some #(= "accept-team-invitation" (:name %)) events))
(t/is (some #(= "accept-team-invitation-from" (:name %)) events))
(t/is (= (:id team) (get-in event [:props :team-id])))
(t/is (= organization-id (get-in event [:props :organization-id])))
(t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before)))
(t/is (= "team-invitation-acceptance"
(:origin @frontend-event)))
(t/is (= (:id team) (get-in @frontend-event [:props :team-id])))
(t/is (= organization-id
(get-in @frontend-event [:props :organization-id])))
(t/is (= "team-invitation"
(get-in @frontend-event [:props :organization-member-add-source])))
(t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add])))
(t/is (= 5
(get-in @frontend-event [:props :organization-member-count-before]))))
(th/reset-mock! audit-mock)
(db/insert! (:app.db/pool th/*system*)
:team-invitation
{:team-id (:id team)
:email-to (:email invitee)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call
(fn [_cfg method _params]
(case method
:get-org-membership-by-team {:organization-id organization-id
:is-member true}
:get-org-members (throw (ex-info "unexpected member count" {}))
nil))
teams/add-profile-to-team! (fn [& _] nil)]
(let [out (verify! team-token)]
(t/is (th/success? out))
(reset! frontend-event
(get-in out [:result :organization-invitation-audit]))))
(let [events (mapv second (:call-args-list @audit-mock))]
(t/is (some #(= "accept-team-invitation" (:name %)) events))
(t/is (not-any? #(= "accept-organization-invitation" (:name %)) events))
(t/is (nil? @frontend-event))))))
(t/deftest create-team-invitations-with-email-verification-disabled (t/deftest create-team-invitations-with-email-verification-disabled
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true}) (let [profile1 (th/create-profile* 1 {:is-active true})
@ -845,4 +1056,3 @@
:name "My Valid Team"} :name "My Valid Team"}
out (th/command! data)] out (th/command! data)]
(t/is (th/success? out))))) (t/is (th/success? out)))))

View File

@ -8,6 +8,7 @@
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.nitrate-audit :as nitrate-audit]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.data.team :as dt] [app.main.data.team :as dt]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -22,6 +23,9 @@
(def ^:private nitrate-entry-active-key ::nitrate-entry-active) (def ^:private nitrate-entry-active-key ::nitrate-entry-active)
(def ^:private nitrate-entry-pending-popup-key ::nitrate-entry-pending-popup) (def ^:private nitrate-entry-pending-popup-key ::nitrate-entry-pending-popup)
(defn account-age-days
[profile]
(nitrate-audit/age-days (:created-at profile)))
(defn activate-nitrate-entry-popup! (defn activate-nitrate-entry-popup!
[] []
@ -119,7 +123,7 @@
:cancel-callback (build nitrate-checkout-cancelled-token)})) :cancel-callback (build nitrate-checkout-cancelled-token)}))
(defn go-to-buy-nitrate-license (defn go-to-buy-nitrate-license
[subscription base-url event-origin subscription-mode] [subscription base-url event-origin subscription-mode subscription-start-origin]
(let [{:keys [success-callback error-callback finish-error-callback cancel-callback]} (let [{:keys [success-callback error-callback finish-error-callback cancel-callback]}
(build-nitrate-callback-urls base-url) (build-nitrate-callback-urls base-url)
params {:subscription subscription params {:subscription subscription
@ -132,7 +136,8 @@
::ev/origin event-origin ::ev/origin event-origin
:product "nitrate:enterprise" :product "nitrate:enterprise"
:billing-period subscription :billing-period subscription
:subscription-mode subscription-mode})] :subscription-mode subscription-mode
:subscription-start-origin subscription-start-origin})]
(->> st/stream (->> st/stream
(rx/filter (ptk/type? ::ev/chunk-persisted)) (rx/filter (ptk/type? ::ev/chunk-persisted))
(rx/take 1) (rx/take 1)
@ -157,27 +162,50 @@
(dm/get-in profile [:subscription :status])))) (dm/get-in profile [:subscription :status]))))
(defn leave-org (defn leave-org
[{:keys [id name default-team-id teams-to-delete teams-to-leave on-error] :as params}] [{:keys [id
name
default-team-id
teams-to-delete
teams-to-leave
member-added-at
organization-member-count-before
on-error]}]
(ptk/reify ::leave-org (ptk/reify ::leave-org
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [profile-team-id (dm/get-in state [:profile :default-team-id])] (let [profile-id (dm/get-in state [:profile :id])
(->> (rp/cmd! ::leave-org {:id id profile-team-id (dm/get-in state [:profile :default-team-id])
:name name subscription-status
:default-team-id default-team-id (if (= "trialing" (dm/get-in state [:profile :subscription :status]))
:teams-to-delete teams-to-delete "trial"
:teams-to-leave teams-to-leave}) "active")
(rx/mapcat audit-event
(fn [_] (nitrate-audit/delete-organization-member-event
(rx/of {:organization-id id
(dt/fetch-teams) :user-id profile-id
(dcm/go-to-dashboard-recent :team-id profile-team-id) :user-who-delete-member profile-id
(modal/hide) :deleted-by-role "organization-member"
(ntf/show {:content (tr "dasboard.leave-org.toast" name) :member-added-at member-added-at
:type :toast :organization-member-count-before organization-member-count-before
:level :success})))) :subscription-status subscription-status})]
(rx/catch on-error)))))) (rx/concat
(rx/of audit-event)
(->> (rp/cmd! ::leave-org {:id id
:name name
:default-team-id default-team-id
:teams-to-delete teams-to-delete
:teams-to-leave teams-to-leave})
(rx/mapcat
(fn [_]
(rx/of
(dt/fetch-teams)
(dcm/go-to-dashboard-recent :team-id profile-team-id)
(modal/hide)
(ntf/show {:content (tr "dasboard.leave-org.toast" name)
:type :toast
:level :success}))))
(rx/catch on-error)))))))
(defn show-leave-org-modal (defn show-leave-org-modal
[{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}] [{:keys [organization profile default-team-id leave-fn teams-to-transfer on-error]}]
@ -191,7 +219,14 @@
(let [num-teams-to-delete (:teams-to-delete summary) (let [num-teams-to-delete (:teams-to-delete summary)
num-teams-to-transfer (:teams-to-transfer summary) num-teams-to-transfer (:teams-to-transfer summary)
num-teams-to-exit (:teams-to-exit summary) num-teams-to-exit (:teams-to-exit summary)
num-teams-to-detach (:teams-to-detach summary)] num-teams-to-detach (:teams-to-detach summary)
leave-fn
(fn [params]
(leave-fn
(assoc params
:member-added-at (:member-added-at summary)
:organization-member-count-before
(:organization-member-count-before summary))))]
(cond (cond
(pos? num-teams-to-transfer) (pos? num-teams-to-transfer)
(rx/of (rx/of
@ -278,26 +313,50 @@
organization requires a fresh Nitrate SSO session. When SSO is required, organization requires a fresh Nitrate SSO session. When SSO is required,
stores the pending action and redirects to the provider so the dashboard stores the pending action and redirects to the provider so the dashboard
can resume the operation after the callback." can resume the operation after the callback."
[{:keys [team-id organization-id]}] [{:keys [team-id organization-id skip-audit?]}]
(ptk/reify ::add-team-to-organization (ptk/reify ::add-team-to-organization
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ state _]
(let [pending-id (str (uuid/next)) (let [team (dm/get-in state [:teams team-id])
organization-team-count-before
(nitrate-audit/organization-team-count
(vals (:teams state))
organization-id)
team-previous-organization-status
(if (or (:organization-id team)
(get-in team [:organization :id]))
"other-organization"
"no-organization")
subscription-status
(or (dm/get-in state [:profile :subscription :status])
"active")
audit-event
(nitrate-audit/add-team-to-organization-event
{:team team
:organization-id organization-id
:organization-team-count-before organization-team-count-before
:team-previous-organization-status team-previous-organization-status
:add-method "move-existing-team-to-organization"
:subscription-status subscription-status})
pending-id (str (uuid/next))
callback-url (dom/append-query-param (rt/get-current-href) callback-url (dom/append-query-param (rt/get-current-href)
:pending-action-id pending-id)] :pending-action-id pending-id)]
(->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url callback-url}) (rx/concat
(rx/mapcat (when-not skip-audit?
(fn [{:keys [authorized redirect-uri]}] (rx/of audit-event))
(if authorized (->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url callback-url})
(->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id}) (rx/mapcat
(rx/map (fn [_] (modal/hide)))) (fn [{:keys [authorized redirect-uri]}]
(if redirect-uri (if authorized
(do (->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id})
(ss/save-pending-action! pending-id {:type :add-team-to-organization (rx/map (fn [_] (modal/hide))))
:team-id team-id (if redirect-uri
:organization-id organization-id}) (do
(rx/of (rt/nav-raw :uri (str redirect-uri)))) (ss/save-pending-action! pending-id {:type :add-team-to-organization
(rx/empty)))))))))) :team-id team-id
:organization-id organization-id})
(rx/of (rt/nav-raw :uri (str redirect-uri))))
(rx/empty)))))))))))
(defn- fetch-orgs-allowed (defn- fetch-orgs-allowed

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.helpers :as dsh]
[app.main.data.media :as di] [app.main.data.media :as di]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.nitrate-audit :as nitrate-audit]
[app.main.data.profile :as dp] [app.main.data.profile :as dp]
[app.main.features :as features] [app.main.features :as features]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -438,17 +439,36 @@
(dm/assert! (string? name)) (dm/assert! (string? name))
(ptk/reify ::create-team (ptk/reify ::create-team
ptk/WatchEvent ptk/WatchEvent
(watch [it _ _] (watch [it state _]
(let [{:keys [on-success on-error] (let [{:keys [on-success on-error]
:or {on-success identity :or {on-success identity
on-error rx/throw}} (meta params) on-error rx/throw}} (meta params)
features features/global-enabled-features features features/global-enabled-features
organization-team-count-before
(when organization-id
(nitrate-audit/organization-team-count
(vals (:teams state))
organization-id))
subscription-status
(or (dm/get-in state [:profile :subscription :status])
"active")
audit-event
(when organization-id
(nitrate-audit/add-team-to-organization-event
{:organization-id organization-id
:organization-team-count-before organization-team-count-before
:team-previous-organization-status "no-organization"
:add-method "create-team-in-organization"
:subscription-status subscription-status}))
params (cond-> {:name name :features features} params (cond-> {:name name :features features}
organization-id (assoc :organization-id organization-id))] organization-id (assoc :organization-id organization-id))]
(->> (rp/cmd! :create-team (with-meta params (meta it))) (rx/concat
(rx/tap on-success) (when audit-event
(rx/map team-created) (rx/of audit-event))
(rx/catch on-error)))))) (->> (rp/cmd! :create-team (with-meta params (meta it)))
(rx/tap on-success)
(rx/map team-created)
(rx/catch on-error)))))))
;; --- EVENT: create-team-with-invitations ;; --- EVENT: create-team-with-invitations

View File

@ -9,6 +9,7 @@
[app.config :as cf] [app.config :as cf]
[app.main.data.auth :as da] [app.main.data.auth :as da]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.data.profile :as du] [app.main.data.profile :as du]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -44,6 +45,13 @@
(defmethod handle-token :team-invitation (defmethod handle-token :team-invitation
[{:keys [state team-id org-team-id organization-name invitation-token] :as tdata}] [{:keys [state team-id org-team-id organization-name invitation-token] :as tdata}]
(when-let [{:keys [origin props]} (:organization-invitation-audit tdata)]
(st/emit!
(ev/event
(assoc props
::ev/name "accept-organization-invitation"
::ev/origin origin))))
(case state (case state
:created :created
(if org-team-id (if org-team-id

View File

@ -282,7 +282,8 @@
(case (:type action) (case (:type action)
:add-team-to-organization :add-team-to-organization
(st/emit! (dnt/add-team-to-organization {:team-id (:team-id action) (st/emit! (dnt/add-team-to-organization {:team-id (:team-id action)
:organization-id (:organization-id action)})) :organization-id (:organization-id action)
:skip-audit? true}))
nil))))) nil)))))
(mf/defc dashboard* (mf/defc dashboard*

View File

@ -304,7 +304,15 @@
(mf/defc organizations-selector-dropdown* (mf/defc organizations-selector-dropdown*
{::mf/private true} {::mf/private true}
[{:keys [organization organizations profile] :rest props}] [{:keys [organization organizations profile] :rest props}]
(let [on-org-click (let [teams (mf/deref refs/teams)
subscription-type (get-subscription-type (-> profile :props :subscription))
team-count (count teams)
account-age-days (dnt/account-age-days profile)
on-org-click
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
(let [team-id (-> (dom/get-current-target event) (let [team-id (-> (dom/get-current-target event)
@ -314,18 +322,27 @@
on-create-org-click on-create-org-click
(mf/use-fn (mf/use-fn
(mf/deps profile) (mf/deps account-age-days profile subscription-type team-count)
(fn [] (fn []
(cond (if (and (not= subscription-type "unlimited")
(= (get-subscription-type (-> profile :props :subscription)) "unlimited") (dnt/is-valid-license? profile))
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
(dnt/is-valid-license? profile)
(dnt/go-to-nitrate-ac-create-organization (dnt/go-to-nitrate-ac-create-organization
"dashboard:organization-switcher") "dashboard:organization-switcher")
(st/emit!
:else (ev/event
(st/emit! (dnt/show-nitrate-popup :nitrate-form))))) (cond-> {::ev/name "open-subscription-modal"
::ev/origin "dashboard:organization-switcher"
:product "nitrate:enterprise"
:source "frontend"
:has-teams (pos? team-count)
:team-count team-count}
(some? account-age-days)
(assoc :account-age-days account-age-days)))
(dnt/show-nitrate-popup
:nitrate-form
(cond-> {:subscription-start-origin "dashboard:organization-switcher"}
(= subscription-type "unlimited")
(assoc :show-contact-sales-option true)))))))
on-go-to-cc-click on-go-to-cc-click
(mf/use-fn (mf/use-fn
@ -648,7 +665,9 @@
leave-fn leave-fn
(mf/use-fn (mf/use-fn
(mf/deps on-error organization default-team-id not-owned-teams owned-teams) (mf/deps on-error organization default-team-id not-owned-teams owned-teams)
(fn [{:keys [teams-to-transfer]}] (fn [{:keys [teams-to-transfer
member-added-at
organization-member-count-before]}]
(let [teams-to-leave (cond->> not-owned-teams (let [teams-to-leave (cond->> not-owned-teams
:always :always
(map #(select-keys % [:id])) (map #(select-keys % [:id]))
@ -664,6 +683,9 @@
:default-team-id default-team-id :default-team-id default-team-id
:teams-to-delete teams-to-delete :teams-to-delete teams-to-delete
:teams-to-leave teams-to-leave :teams-to-leave teams-to-leave
:member-added-at member-added-at
:organization-member-count-before
organization-member-count-before
:on-error on-error}))))) :on-error on-error})))))
on-leave-clicked on-leave-clicked
@ -695,6 +717,12 @@
[{:keys [team profile]}] [{:keys [team profile]}]
(let [teams (mf/deref refs/teams) (let [teams (mf/deref refs/teams)
subscription-type (get-subscription-type (-> profile :props :subscription))
team-count (count teams)
account-age-days (dnt/account-age-days profile)
current-org (dtm/team->organization team) current-org (dtm/team->organization team)
;; Find the "your-penpot" teams, and transform them in orgs. When ;; Find the "your-penpot" teams, and transform them in orgs. When
@ -772,18 +800,27 @@
on-create-org-click on-create-org-click
(mf/use-fn (mf/use-fn
(mf/deps profile) (mf/deps account-age-days profile subscription-type team-count)
(fn [] (fn []
(cond (if (and (not= subscription-type "unlimited")
(= (get-subscription-type (-> profile :props :subscription)) "unlimited") (dnt/is-valid-license? profile))
(st/emit! (dnt/show-nitrate-popup :nitrate-form {:show-contact-sales-option true}))
(dnt/is-valid-license? profile)
(dnt/go-to-nitrate-ac-create-organization (dnt/go-to-nitrate-ac-create-organization
"dashboard:organization-switcher") "dashboard:organization-switcher")
(st/emit!
:else (ev/event
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] (cond-> {::ev/name "open-subscription-modal"
::ev/origin "dashboard:create-organization-button"
:product "nitrate:enterprise"
:source "frontend"
:has-teams (pos? team-count)
:team-count team-count}
(some? account-age-days)
(assoc :account-age-days account-age-days)))
(dnt/show-nitrate-popup
:nitrate-form
(cond-> {:subscription-start-origin "dashboard:create-organization-button"}
(= subscription-type "unlimited")
(assoc :show-contact-sales-option true)))))))]
(if show-dropdown? (if show-dropdown?
[:div {:class (stl/css :sidebar-org-switch)} [:div {:class (stl/css :sidebar-org-switch)}
[:div {:class (stl/css :org-switch-content)} [:div {:class (stl/css :org-switch-content)}

View File

@ -152,6 +152,10 @@
subscription-type subscription-type
(if nitrate? (:type nitrate-license) (get-subscription-type (-> profile :props :subscription))) (if nitrate? (:type nitrate-license) (get-subscription-type (-> profile :props :subscription)))
team-count (count teams)
account-age-days (dnt/account-age-days profile)
teams-loaded? (seq teams) teams-loaded? (seq teams)
no-orgs-created? (mf/with-memo [teams] no-orgs-created? (mf/with-memo [teams]
@ -162,11 +166,22 @@
handle-click handle-click
(mf/use-fn (mf/use-fn
(mf/deps subscription-type) (mf/deps account-age-days subscription-type team-count)
(fn [] (fn []
(st/emit! (dnt/show-nitrate-popup :nitrate-form (st/emit!
(when (= subscription-type "unlimited") (ev/event
{:show-contact-sales-option true}))))) (cond-> {::ev/name "open-subscription-modal"
::ev/origin "dashboard:promotional-banner"
:product "nitrate:enterprise"
:source "frontend"
:has-teams (pos? team-count)
:team-count team-count}
(some? account-age-days)
(assoc :account-age-days account-age-days)))
(dnt/show-nitrate-popup :nitrate-form
(cond-> {:subscription-start-origin "dashboard:promotional-banner"}
(= subscription-type "unlimited")
(assoc :show-contact-sales-option true))))))
handle-go-to-cc handle-go-to-cc
(mf/use-fn (mf/use-fn

View File

@ -7,6 +7,7 @@
(ns app.main.ui.nitrate.nitrate-form (ns app.main.ui.nitrate.nitrate-form
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.main.data.event :as ev]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt] [app.main.data.nitrate :as dnt]
[app.main.refs :as refs] [app.main.refs :as refs]
@ -25,6 +26,7 @@
[connectivity] [connectivity]
(let [show-contact-sales-option (:show-contact-sales-option connectivity) (let [show-contact-sales-option (:show-contact-sales-option connectivity)
subscription-start-origin (:subscription-start-origin connectivity)
online? (and (:licenses connectivity) (not show-contact-sales-option)) online? (and (:licenses connectivity) (not show-contact-sales-option))
profile (mf/deref refs/profile) profile (mf/deref refs/profile)
on-click on-click
@ -34,16 +36,31 @@
"monthly" "monthly"
dnt/go-to-ac-url dnt/go-to-ac-url
"dashboard:plan-confirmation-modal" "dashboard:plan-confirmation-modal"
(if (:subscription profile) "paid" "trial")))) (if (:subscription profile) "paid" "trial")
subscription-start-origin)))
on-activate-click on-activate-click
(mf/use-fn (mf/use-fn
(fn [] (fn []
(st/emit! (modal/show {:type :nitrate-code-activation}))))] (st/emit! (modal/show {:type :nitrate-code-activation}))))
on-see-plan-click
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "open-current-subscription"
::ev/origin "dashboard:plan-confirmation-modal"}))))
on-close
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "close-subscription-modal"
::ev/origin "nitrate:plan-confirmation-modal"
:product "nitrate:enterprise"}))
(modal/hide!)))]
[:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :subscription-success)} [:div {:class (stl/css :modal-dialog :subscription-success)}
[:button {:class (stl/css :close-btn) :on-click modal/hide!} [:button {:class (stl/css :close-btn) :on-click on-close}
[:> icon* {:icon-id "close" [:> icon* {:icon-id "close"
:size "m"}]] :size "m"}]]
[:div {:class (stl/css :modal-success-content)} [:div {:class (stl/css :modal-success-content)}
@ -89,7 +106,9 @@
(tr "nitrate.form.enter-code")]] (tr "nitrate.form.enter-code")]]
[:p {:class (stl/css :modal-text-medium)} [:p {:class (stl/css :modal-text-medium)}
[:a {:class (stl/css :link) :href dnt/go-to-subscription-url} [:a {:class (stl/css :link)
:href dnt/go-to-subscription-url
:on-click on-see-plan-click}
(tr "nitrate.form.see-plan")]]] (tr "nitrate.form.see-plan")]]]
[:div {:class (stl/css :contact)} [:div {:class (stl/css :contact)}

View File

@ -193,21 +193,31 @@
(rt/encode-url)) (rt/encode-url))
href (dm/str "payments/subscriptions/create?type=unlimited&show=" href (dm/str "payments/subscriptions/create?type=unlimited&show="
add-payment-details? "&quantity=" add-payment-details? "&quantity="
min-members "&returnUrl=" return-url)] min-members "&returnUrl=" return-url)
event-name (if subscribe-to-trial
"create-trial-subscription"
"create-subscription")
subscription-mode (if subscribe-to-trial "trial" "paid")]
(reset! form nil) (reset! form nil)
(st/emit! (ev/event {::ev/name "create-trial-subscription" (st/emit! (ev/event {::ev/name event-name
:type "unlimited" :type "unlimited"
:quantity min-members}) :quantity min-members
:subscription-mode subscription-mode})
(rt/nav-raw :href href)))))) (rt/nav-raw :href href))))))
subscribe-to-enterprise subscribe-to-enterprise
(mf/use-fn (mf/use-fn
(fn [] (fn []
(st/emit! (ev/event {::ev/name "create-trial-subscription" (let [event-name (if subscribe-to-trial
:type "enterprise"})) "create-trial-subscription"
(let [return-url (-> (rt/get-current-href) (rt/encode-url)) "create-subscription")
subscription-mode (if subscribe-to-trial "trial" "paid")
return-url (-> (rt/get-current-href) (rt/encode-url))
href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)] href (dm/str "payments/subscriptions/create?type=enterprise&returnUrl=" return-url)]
(st/emit! (rt/nav-raw :href href))))) (st/emit! (ev/event {::ev/name event-name
:type "enterprise"
:subscription-mode subscription-mode})
(rt/nav-raw :href href)))))
handle-accept-dialog handle-accept-dialog
(mf/use-fn (mf/use-fn
@ -224,7 +234,10 @@
handle-close-dialog handle-close-dialog
(mf/use-fn (mf/use-fn
(fn [] (fn []
(st/emit! (ev/event {::ev/name "close-subscription-modal"})) (when (= subscription-type "unlimited")
(st/emit! (ev/event {::ev/name "close-subscription-modal"
::ev/origin "subscriptions:unlimited"
:product "unlimited"})))
(modal/hide!))) (modal/hide!)))
on-submit on-submit
@ -482,6 +495,14 @@
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)] href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
(st/emit! (rt/nav-raw :href href))))) (st/emit! (rt/nav-raw :href href)))))
go-to-nitrate-payments
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "open-subscription-management"
::ev/origin "settings"
:section "nitrate:enterprise"}))
(dnt/go-to-nitrate-billing)))
open-subscription-modal open-subscription-modal
(mf/use-fn (mf/use-fn
(mf/deps subscription-editors nitrate-license) (mf/deps subscription-editors nitrate-license)
@ -492,7 +513,8 @@
(st/emit! (dnt/show-nitrate-popup (st/emit! (dnt/show-nitrate-popup
:nitrate-dialog :nitrate-dialog
{:nitrate-license nitrate-license {:nitrate-license nitrate-license
:event-origin "settings:plan-confirmation-modal"})) :event-origin "settings:plan-confirmation-modal"
:subscription-start-origin "settings"}))
(st/emit! (st/emit!
(modal/show :management-dialog (modal/show :management-dialog
{:subscription-type subscription-type {:subscription-type subscription-type
@ -602,7 +624,7 @@
(tr "subscription.settings.manage-your-subscription") (tr "subscription.settings.manage-your-subscription")
(tr "nitrate.subscription.settings.manual-cancel")) (tr "nitrate.subscription.settings.manual-cancel"))
:cta-link (if (and (:licenses connectivity) (not (:manual nitrate-license))) :cta-link (if (and (:licenses connectivity) (not (:manual nitrate-license)))
dnt/go-to-nitrate-billing go-to-nitrate-payments
open-cancel-contact-sales-modal) open-cancel-contact-sales-modal)
:code-action (when (:manual nitrate-license) :renovate) :code-action (when (:manual nitrate-license) :renovate)
:current-plan true}] :current-plan true}]
@ -768,12 +790,15 @@
(mf/defc subscribe-nitrate-dialog (mf/defc subscribe-nitrate-dialog
{::mf/register modal/components {::mf/register modal/components
::mf/register-as :nitrate-dialog} ::mf/register-as :nitrate-dialog}
[{:keys [nitrate-license show-contact-sales-option event-origin] :as connectivity}] [{:keys [nitrate-license show-contact-sales-option event-origin subscription-start-origin] :as connectivity}]
;; TODO add translations for this texts when we have the definitive ones ;; TODO add translations for this texts when we have the definitive ones
(let [online? (:licenses connectivity) (let [online? (:licenses connectivity)
handle-close-dialog handle-close-dialog
(mf/use-fn (mf/use-fn
(fn [] (fn []
(st/emit! (ev/event {::ev/name "close-subscription-modal"
::ev/origin "nitrate:plan-confirmation-modal"
:product "nitrate:enterprise"}))
(modal/hide!))) (modal/hide!)))
on-subscribe-click on-subscribe-click
@ -783,7 +808,8 @@
"monthly" "monthly"
(rt/get-current-href) (rt/get-current-href)
event-origin event-origin
(if nitrate-license "paid" "trial"))))] (if nitrate-license "paid" "trial")
subscription-start-origin)))]
[:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog)} [:div {:class (stl/css :modal-dialog)}

View File

@ -6,10 +6,142 @@
(ns frontend-tests.data.nitrate-test (ns frontend-tests.data.nitrate-test
(:require (:require
[app.common.time :as ct]
[app.common.uri :as u] [app.common.uri :as u]
[app.main.data.event :as ev]
[app.main.data.nitrate :as dnt] [app.main.data.nitrate :as dnt]
[app.main.data.nitrate-audit :as nitrate-audit]
[app.main.store :as st]
[app.main.ui.auth.verify-token :as verify-token]
[cljs.test :as t :include-macros true])) [cljs.test :as t :include-macros true]))
(t/deftest account-age-days-test
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
(t/testing "returns the number of complete days since account creation"
(t/is (= 10
(dnt/account-age-days
{:created-at (ct/inst "2026-07-17T00:00:00Z")}))))
(t/testing "does not return negative ages"
(t/is (= 0
(dnt/account-age-days
{:created-at (ct/inst "2026-07-28T00:00:00Z")}))))
(t/testing "returns nil when the creation date is not an instant"
(t/is (nil? (dnt/account-age-days {})))
(t/is (nil? (dnt/account-age-days {:created-at "invalid"}))))))
(t/deftest add-team-to-organization-audit-event-test
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
(t/testing "reports creating the first team in an organization"
(let [event @(nitrate-audit/add-team-to-organization-event
{:organization-id "organization-1"
:organization-team-count-before 0
:team-previous-organization-status "no-organization"
:add-method "create-team-in-organization"
:subscription-status "trialing"})]
(t/is (= "add-team-to-organization" (::ev/name event)))
(t/is (= "dashboard:create-team-in-organization" (::ev/origin event)))
(t/is (= {:is-your-penpot false
:add-method "create-team-in-organization"
:organization-id "organization-1"
:organization-team-count-before 0
:team-previous-organization-status "no-organization"
:is-first-team-in-organization true
:subscription-status "trialing"}
(dissoc event ::ev/name ::ev/origin)))))
(t/testing "reports moving an older team from another organization"
(let [event @(nitrate-audit/add-team-to-organization-event
{:team {:id "team-2"
:created-at (ct/inst "2026-07-17T12:00:00Z")}
:organization-id "organization-2"
:organization-team-count-before 3
:team-previous-organization-status "other-organization"
:add-method "move-existing-team-to-organization"
:subscription-status "active"})]
(t/is (= "dashboard:move-team-to-organization" (::ev/origin event)))
(t/is (= 10 (:team-age-days event)))
(t/is (false? (:is-first-team-in-organization event)))))))
(t/deftest organization-team-count-test
(let [organization-id "organization-1"
teams [{:id "default"
:is-default true
:organization-id organization-id}
{:id "team-1"
:is-default false
:organization-id organization-id}
{:id "team-2"
:is-default false
:organization {:id organization-id}}
{:id "other-team"
:is-default false
:organization-id "organization-2"}]]
(t/is (= 2
(nitrate-audit/organization-team-count
teams
organization-id)))))
(t/deftest delete-organization-member-audit-event-test
(with-redefs [ct/now (constantly (ct/inst "2026-07-27T12:00:00Z"))]
(let [event @(nitrate-audit/delete-organization-member-event
{:organization-id "organization-1"
:user-id "profile-1"
:user-who-delete-member "profile-1"
:deleted-by-role "organization-member"
:member-added-at (ct/inst "2026-07-17T12:00:00Z")
:organization-member-count-before 4
:subscription-status "trial"})]
(t/is (= "delete-organization-member" (::ev/name event)))
(t/is (nil? (::ev/origin event)))
(t/is (= {:organization-id "organization-1"
:user-id "profile-1"
:user-who-delete-member "profile-1"
:deleted-by-role "organization-member"
:days-since-member-added 10
:organization-member-count-before 4
:subscription-status "trial"}
(dissoc event ::ev/name))))
(t/testing "keeps a null age when the membership date is unavailable"
(let [event @(nitrate-audit/delete-organization-member-event
{:organization-id "organization-1"
:user-id "profile-1"
:user-who-delete-member "profile-1"
:deleted-by-role "organization-member"
:organization-member-count-before 4
:subscription-status "active"})]
(t/is (contains? event :days-since-member-added))
(t/is (nil? (:days-since-member-added event)))))))
(t/deftest accept-organization-invitation-audit-event-test
(let [emitted (atom [])
props {:team-id "team-1"
:organization-id "organization-1"
:role :editor
:invitation-id "invitation-1"
:organization-member-add-source "team-invitation"
:belongs-to-team-on-add true
:organization-member-count-before 4}]
(with-redefs [st/emit! (fn
([event]
(swap! emitted conj event))
([event & events]
(swap! emitted into (cons event events))))]
(verify-token/handle-token
{:iss :team-invitation
:state :created
:team-id "team-1"
:organization-invitation-audit
{:origin "team-invitation-acceptance"
:props props}}))
(let [event @(first @emitted)]
(t/is (= "accept-organization-invitation" (::ev/name event)))
(t/is (= "team-invitation-acceptance" (::ev/origin event)))
(t/is (= props (dissoc event ::ev/name ::ev/origin))))))
(t/deftest build-admin-console-url-preserves-public-uri-subpath (t/deftest build-admin-console-url-preserves-public-uri-subpath
(t/testing "builds admin console routes below the configured Penpot subpath" (t/testing "builds admin console routes below the configured Penpot subpath"
(let [public-uri (u/uri "https://example.com/penpot/")] (let [public-uri (u/uri "https://example.com/penpot/")]