diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 66a5b24724..78464da18f 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -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/" diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 0dee1d9c32..02e44a404f 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -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")) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index c17a4e5a45..a101a278b1 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -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 diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index ee15a63385..7d6125ccb4 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -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)) - diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 9c503b0005..ab286f7bba 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -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 diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 3bee962e85..b2029c1e60 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -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)))))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index a584cb6a66..4a5777bba9 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -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"}) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 3fff7c2953..3fc7747030 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -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))))) - diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index cf310693d5..f4d9945454 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -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 diff --git a/frontend/src/app/main/data/nitrate_audit.cljs b/frontend/src/app/main/data/nitrate_audit.cljs new file mode 100644 index 0000000000..619f56d3ec --- /dev/null +++ b/frontend/src/app/main/data/nitrate_audit.cljs @@ -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})) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index ede9ca26ce..a47ce5127f 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -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 diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index d67be7e5eb..3b1e38476f 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -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 diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 781bb1ec49..9a0d71cb84 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -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* diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 88a3b8275a..297cbd134f 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -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)} diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 95b83aa221..6537f738f6 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -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 diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index a3fa606b78..aa091d1fad 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -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)} diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 429301c6af..92fc7d9a59 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -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)} diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index ec0a2ba5fe..7b371ae247 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -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/")]