From 2a5b6a69ad189520ca00d7c3cbe0ddaaf0bb4d16 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 25 Jun 2026 09:53:07 +0200 Subject: [PATCH] :sparkles: Send warning for email about nitrate orgs with sso (#10413) --- .../resources/app/email/invite-to-org/en.html | 28 ++- .../resources/app/email/invite-to-org/en.txt | 7 +- .../app/email/invite-to-team/en.html | 22 +- .../resources/app/email/invite-to-team/en.txt | 7 +- .../app/email/organization-setup-sso/en.html | 223 ++++++++++++++++++ .../app/email/organization-setup-sso/en.subj | 1 + .../app/email/organization-setup-sso/en.txt | 7 + backend/src/app/email.clj | 25 +- backend/src/app/nitrate.clj | 13 +- backend/src/app/rpc/commands/nitrate.clj | 50 ++-- .../app/rpc/commands/teams_invitations.clj | 6 +- backend/src/app/rpc/management/nitrate.clj | 56 ++--- backend/src/app/rpc/nitrate/emails_helper.clj | 106 +++++++++ .../app/rpc/nitrate/organization_helper.clj | 60 +++++ .../test/backend_tests/email_sending_test.clj | 66 ++++++ .../rpc_management_nitrate_test.clj | 79 +++++++ .../test/backend_tests/rpc_nitrate_test.clj | 118 +++++++++ common/src/app/common/types/organization.cljc | 3 +- 18 files changed, 792 insertions(+), 85 deletions(-) create mode 100644 backend/resources/app/email/organization-setup-sso/en.html create mode 100644 backend/resources/app/email/organization-setup-sso/en.subj create mode 100644 backend/resources/app/email/organization-setup-sso/en.txt create mode 100644 backend/src/app/rpc/nitrate/emails_helper.clj create mode 100644 backend/src/app/rpc/nitrate/organization_helper.clj diff --git a/backend/resources/app/email/invite-to-org/en.html b/backend/resources/app/email/invite-to-org/en.html index ce3a9846b3..fd5ee679b0 100644 --- a/backend/resources/app/email/invite-to-org/en.html +++ b/backend/resources/app/email/invite-to-org/en.html @@ -195,21 +195,39 @@
- +
+ background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}" + style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black"> {% if organization.initials %}{{organization.initials}}{% endif %}
- - {{ organization.name|abbreviate:50 }} + + {{ organization.name|abbreviate:50 }}
+ + {% if organization.sso-active %} + + +
+ "{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its + teams and files goes + through your organization's identity provider. If you can't get in, your account probably isn't + in the directory yet. + To get access, contact the organization owner. +
+ + + {% endif %} + diff --git a/backend/resources/app/email/invite-to-org/en.txt b/backend/resources/app/email/invite-to-org/en.txt index ff8eabf194..4b94fe6331 100644 --- a/backend/resources/app/email/invite-to-org/en.txt +++ b/backend/resources/app/email/invite-to-org/en.txt @@ -1,6 +1,11 @@ Hello! -{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”. +{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:50 }}”. + +{% if organization.sso-active %} +"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes +through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. +{% endif %} Accept invitation using this link: diff --git a/backend/resources/app/email/invite-to-team/en.html b/backend/resources/app/email/invite-to-team/en.html index 9ebc59231f..96fe6de59d 100644 --- a/backend/resources/app/email/invite-to-team/en.html +++ b/backend/resources/app/email/invite-to-team/en.html @@ -186,10 +186,26 @@
- {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %} - part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.
+ {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if + organization %} + part of the organization “{{ organization.name|abbreviate:50 }}”{% endif %}. + {% if organization.sso-active %} + + +
+ "{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to + its + teams and files goes + through your organization's identity provider. If you can't get in, your account probably isn't + in the directory yet. + To get access, contact the organization owner. +
+ + + {% endif %} @@ -241,4 +257,4 @@ - + \ No newline at end of file diff --git a/backend/resources/app/email/invite-to-team/en.txt b/backend/resources/app/email/invite-to-team/en.txt index 3482fab0a5..c688f7d61d 100644 --- a/backend/resources/app/email/invite-to-team/en.txt +++ b/backend/resources/app/email/invite-to-team/en.txt @@ -1,6 +1,11 @@ Hello! -{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}. +{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}. + +{% if organization.sso-active %} +"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes +through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. +{% endif %} Accept invitation using this link: diff --git a/backend/resources/app/email/organization-setup-sso/en.html b/backend/resources/app/email/organization-setup-sso/en.html new file mode 100644 index 0000000000..cfc72548ad --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.html @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + +
+
+ Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %}, +
+
+
+ "{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its + teams and files goes through your organization's identity provider. If you can't get in, your + account probably isn't in the directory yet. To get access, contact the organization owner. +
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + diff --git a/backend/resources/app/email/organization-setup-sso/en.subj b/backend/resources/app/email/organization-setup-sso/en.subj new file mode 100644 index 0000000000..e8e3e42f39 --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.subj @@ -0,0 +1 @@ +“{{ organization-name|abbreviate:25 }}” has set up single sign-on (SSO) in Penpot diff --git a/backend/resources/app/email/organization-setup-sso/en.txt b/backend/resources/app/email/organization-setup-sso/en.txt new file mode 100644 index 0000000000..4521da3160 --- /dev/null +++ b/backend/resources/app/email/organization-setup-sso/en.txt @@ -0,0 +1,7 @@ +Hello! + +"{{ organization-name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files goes +through your organization's identity provider. If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner. + + +The Penpot team. diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index f15c6cbbc1..fbc768e1f6 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -419,10 +419,19 @@ :id ::change-email :schema schema:change-email)) +(def ^:private schema:organization-data + [:map + [:name ::sm/text] + [:initials {:optional true} [:maybe :string]] + [:logo {:optional true} [:maybe ::sm/uri]] + [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe ::sm/boolean]]]) + (def ^:private schema:invite-to-team [:map [:invited-by ::sm/text] [:team ::sm/text] + [:organization {:optional true} [:maybe schema:organization-data]] [:token ::sm/text]]) (def invite-to-team @@ -431,13 +440,6 @@ :id ::invite-to-team :schema schema:invite-to-team)) -(def ^:private schema:organization-data - [:map - [:name ::sm/text] - [:initials [:maybe :string]] - [:logo [:maybe ::sm/uri]] - [:avatar-bg-url [:maybe ::sm/uri]]]) - (def ^:private schema:invite-to-org [:map [:invited-by ::sm/text] @@ -451,7 +453,16 @@ :id ::invite-to-org :schema schema:invite-to-org)) +(def ^:private schema:organization-setup-sso + [:map + [:user-name {:optional true} [:maybe ::sm/text]] + [:organization-name ::sm/text]]) +(def organization-setup-sso + "Email when an organization set up SSO" + (template-factory + :id ::organization-setup-sso + :schema schema:organization-setup-sso)) (def ^:private schema:renewal-notice [:map diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 3da69d29a1..9c9c8339fc 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -22,15 +22,26 @@ [app.rpc :as-alias rpc] [app.setup :as-alias setup] [clojure.core :as c] + [clojure.string :as str] [integrant.core :as ig])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- join-path-segments + "Build a single relative path from Nitrate URI segments, normalizing slashes." + [segments] + (let [path (->> segments (map str) (str/join "/"))] + (->> (str/split path #"/") + (remove str/blank?) + (str/join "/")))) + (defn- join-base-uri + "Join path segments to a base URI." [base-uri & segments] - (apply u/join (u/ensure-path-slash base-uri) segments)) + (u/join (u/ensure-path-slash base-uri) + (join-path-segments segments))) (defn- generate-nitrate-uri "Joins relative path segments to the Nitrate backend URI. diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index cbd64ad43f..2103452c17 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -21,6 +21,8 @@ [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] + [app.rpc.nitrate.emails-helper :as neh] + [app.rpc.nitrate.organization-helper :as noh] [app.rpc.notifications :as notifications] [app.tokens :as tokens] [app.util.services :as sv])) @@ -394,12 +396,6 @@ (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org") nil) -(def ^:private sql:get-team-invitation-emails - "SELECT email_to - FROM team_invitation - WHERE team_id = ? - AND valid_until > now()") - (def ^:private sql:delete-team-external-invitations "DELETE FROM team_invitation WHERE team_id = ? @@ -421,8 +417,7 @@ allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})] (if allows-anybody {:allows-anybody true :external-emails []} - (let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id]) - emails (map :email-to invitation-emails)] + (let [emails (map :email (noh/get-team-invitation-emails conn team-id))] (if (empty? emails) {:allows-anybody false :external-emails []} (let [emails-array (db/create-array conn "text" (vec emails)) @@ -451,7 +446,8 @@ (assert-membership cfg profile-id organization-id) (when (contains? cf/flags :nitrate) - (let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) + (let [org-member-ids-before (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id})) + team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) source-org-id (get-in team-with-org [:organization :id]) source-org-perms (when source-org-id (nitrate/call cfg :get-org-permissions @@ -487,26 +483,32 @@ :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed - :hint "You are not allowed to add teams in this organization"))) + :hint "You are not allowed to add teams in this organization")) - (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] ;; Add teammates to the org if needed - (doseq [{member-id :profile-id} team-members - :when (not= member-id profile-id)] - (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) + (let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) + new-member-ids (->> team-members + (map :profile-id) + (remove #{profile-id}) + (remove org-member-ids-before))] + (doseq [member-id new-member-ids] + (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})] + ;; Api call to nitrate + (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")) - ;; Notify connected users - (notifications/notify-team-change cfg team "dashboard.team-belong-org")) + ;; Delete pending invitations for users who are not members of the target organization + (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] + (when (and (not allows-anybody) (seq external-emails)) + (let [conn (::db/conn cfg) + emails-array (db/create-array conn "text" external-emails)] + (db/exec! conn [sql:delete-team-external-invitations team-id emails-array])))) - ;; Delete pending invitations for users who are not members of the target organization - (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] - (when (and (not allows-anybody) (seq external-emails)) - (let [conn (::db/conn cfg) - emails-array (db/create-array conn "text" external-emails)] - (db/exec! conn [sql:delete-team-external-invitations team-id emails-array]))))) + ;; Send warnings via email if the org has sso + (neh/send-organization-setup-sso-emails-for-team! + cfg organization-id team-id org-member-ids-before))) nil) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 50d3ba691b..34795ba77b 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -94,7 +94,9 @@ [:id ::sm/uuid] [:name :string] [:initials [:maybe :string]] - [:logo ::sm/uri]]] + [:logo ::sm/uri] + [:avatar-bg-url [:maybe ::sm/uri]] + [:sso-active [:maybe ::sm/boolean]]]] [:profile [:map [:id ::sm/uuid] @@ -246,7 +248,7 @@ :to email :invited-by (:fullname profile) :team (:name team) - :organization (dm/get-in team [:organization :name]) + :organization (:organization team) :token itoken :extra-data ptoken}))) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 7e25739d04..9168c0ea63 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -29,6 +29,8 @@ [app.rpc.commands.teams :as teams] [app.rpc.commands.teams-invitations :as ti] [app.rpc.doc :as doc] + [app.rpc.nitrate.emails-helper :as neh] + [app.rpc.nitrate.organization-helper :as noh] [app.rpc.notifications :as notifications] [app.storage :as sto] [app.util.services :as sv] @@ -485,23 +487,6 @@ RETURNING id, deleted_at;") ;; API: get-org-invitations -(def ^:private sql:get-org-invitations - "SELECT DISTINCT ON (email_to) - ti.id, - ti.org_id AS organization_id, - ti.email_to AS email, - ti.created_at AS sent_at, - p.fullname AS name, - p.id AS profile_id, - p.photo_id - FROM team_invitation AS ti -LEFT JOIN profile AS p - ON p.email = ti.email_to - AND p.deleted_at IS NULL - WHERE ti.valid_until >= now() - AND (ti.org_id = ? OR ti.team_id = ANY(?)) - ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") - (def ^:private schema:get-org-invitations-params [:map [:organization-id ::sm/uuid]]) @@ -523,18 +508,13 @@ LEFT JOIN profile AS p ::sm/params schema:get-org-invitations-params ::sm/result schema:get-org-invitations-result} [cfg {:keys [organization-id]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - team-ids (->> (:teams org-summary) - (map :id) - (filter uuid?) - (into []))] + (let [team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] - (let [ids-array (db/create-array conn "uuid" team-ids)] - (->> (db/exec! conn [sql:get-org-invitations organization-id ids-array]) - (mapv (fn [{:keys [photo-id] :as invitation}] - (cond-> (dissoc invitation :photo-id) - photo-id - (assoc :photo-url (files/resolve-public-uri photo-id))))))))))) + (->> (noh/get-org-invitations conn organization-id team-ids) + (mapv (fn [{:keys [photo-id] :as invitation}] + (cond-> (dissoc invitation :photo-id) + photo-id + (assoc :photo-url (files/resolve-public-uri photo-id)))))))))) ;; API: delete-org-invitations @@ -554,12 +534,8 @@ LEFT JOIN profile AS p {::doc/added "2.16" ::sm/params schema:delete-org-invitations-params} [cfg {:keys [organization-id email]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - clean-email (profile/clean-email email) - team-ids (->> (:teams org-summary) - (map :id) - (filter uuid?) - (into []))] + (let [clean-email (profile/clean-email email) + team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] (db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array])))) @@ -585,9 +561,7 @@ LEFT JOIN profile AS p ::sm/params schema:delete-all-org-invitations-params ::rpc/auth false} [cfg {:keys [organization-id]}] - (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) - team-ids (->> (:teams org-summary) - (map :id))] + (let [team-ids (noh/get-org-team-ids cfg organization-id)] (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids-array (db/create-array conn "uuid" team-ids)] (db/exec! conn [sql:delete-all-org-invitations organization-id ids-array])))) @@ -905,17 +879,19 @@ LEFT JOIN profile AS p owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id)))))))))))) ;; ---- API: notify-org-sso-change - (sv/defmethod ::notify-org-sso-change "Nitrate notifies that an organization sso values have changed" {::doc/added "2.19" ::sm/params [:map [:organization-id ::sm/uuid] - [:updated-props ::sm/boolean]] + [:updated-props ::sm/boolean] + [:became-active ::sm/boolean]] ::rpc/auth false} - [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props]}] + [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props became-active]}] (when updated-props (rpc/invalidate-org-sso-cache-by-org! organization-id) (session/clear-org-sso-sessions! pool organization-id)) (notifications/notify-organization-change-sso cfg organization-id) + (when became-active + (neh/send-organization-setup-sso-emails! cfg organization-id)) nil) diff --git a/backend/src/app/rpc/nitrate/emails_helper.clj b/backend/src/app/rpc/nitrate/emails_helper.clj new file mode 100644 index 0000000000..73fab3f6ba --- /dev/null +++ b/backend/src/app/rpc/nitrate/emails_helper.clj @@ -0,0 +1,106 @@ +;; 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 + +(ns app.rpc.nitrate.emails-helper + "Helpers for organization SSO notification emails triggered by Nitrate integration." + (:require + [app.common.data :as d] + [app.config :as cf] + [app.db :as db] + [app.email :as eml] + [app.nitrate :as nitrate] + [app.rpc.commands.teams :as teams] + [app.rpc.nitrate.organization-helper :as neh] + [cuerdas.core :as str])) + +(def ^:private sql:get-profile-emails-by-ids + "SELECT email + FROM profile + WHERE id = ANY(?) + AND deleted_at IS NULL") + +(def ^:private sql:get-profiles-by-emails + "SELECT id, email, fullname, is_muted + FROM profile + WHERE email = ANY(?) + AND deleted_at IS NULL") + +(defn- org-sso-active? + "Return whether SSO is enabled for the organization." + [cfg organization-id] + (when (contains? cf/flags :nitrate) + (true? (:active (nitrate/call cfg :get-org-sso {:organization-id organization-id}))))) + +(def ^:private xf:map-email (map :email)) + +(defn- recipients-by-emails + "Build `{:email :user-name :profile}` maps for a deduplicated email list." + [conn emails] + (let [profiles (if (seq emails) + (let [emails-array (db/create-array conn "text" emails)] + (db/exec! conn [sql:get-profiles-by-emails emails-array])) + []) + profile-by-email (d/index-by (comp str/lower :email) profiles)] + (map (fn [email] + (let [profile (get profile-by-email (str/lower email))] + {:email email + :user-name (:fullname profile) + :profile profile})) + emails))) + +(defn- send-organization-setup-sso-email! + "Send the organization SSO setup email to a single recipient, when allowed." + [conn organization-name {:keys [email user-name profile]}] + (when (or (nil? profile) + (eml/allow-send-emails? conn profile)) + (eml/send! {::eml/conn conn + ::eml/factory eml/organization-setup-sso + :public-uri (cf/get :public-uri) + :to email + :user-name user-name + :organization-name organization-name}))) + +(defn- get-org-sso-notify-recipients + "Unique org members and pending org/team invitees for SSO activation emails." + [conn cfg organization-id org-summary] + (let [member-ids (nitrate/call cfg :get-org-members {:organization-id organization-id}) + team-ids (neh/get-org-team-ids org-summary) + member-emails (if (seq member-ids) + (let [ids-array (db/create-array conn "uuid" member-ids)] + (into #{} (map :email (db/exec! conn [sql:get-profile-emails-by-ids ids-array])))) + #{}) + invite-emails (into #{} (map :email + (neh/get-org-invitations conn organization-id team-ids))) + emails (into #{} (concat member-emails invite-emails))] + (recipients-by-emails conn emails))) + +(defn- get-team-sso-notify-recipients + "Team members who are not in `org-member-ids`, plus pending team invitations." + [conn team-id org-member-ids] + (let [team-members (->> (teams/get-team-members conn team-id) + (remove #(contains? org-member-ids (:id %)))) + invitations (neh/get-team-invitation-emails conn team-id)] + (->> (sequence xf:map-email (concat team-members invitations)) + (recipients-by-emails conn)))) + +(defn send-organization-setup-sso-emails! + "Notify all org members and pending org/team invitees that SSO is active." + [cfg organization-id] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (doseq [recipient (get-org-sso-notify-recipients conn cfg organization-id org-summary)] + (send-organization-setup-sso-email! conn (:name org-summary) recipient)))))) + +(defn send-organization-setup-sso-emails-for-team! + "Notify team members who are not in `org-member-ids-before` and pending team invitees." + [cfg organization-id team-id org-member-ids-before] + (when (org-sso-active? cfg organization-id) + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (doseq [recipient (get-team-sso-notify-recipients conn team-id org-member-ids-before)] + (send-organization-setup-sso-email! conn (:name org-summary) recipient))))))) diff --git a/backend/src/app/rpc/nitrate/organization_helper.clj b/backend/src/app/rpc/nitrate/organization_helper.clj new file mode 100644 index 0000000000..8627a4d10d --- /dev/null +++ b/backend/src/app/rpc/nitrate/organization_helper.clj @@ -0,0 +1,60 @@ +;; 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 + +(ns app.rpc.nitrate.organization-helper + "Shared Nitrate organization query helpers." + (:require + [app.db :as db] + [app.nitrate :as nitrate])) + +(def ^:private sql:get-org-invitations + "SELECT DISTINCT ON (email_to) + ti.id, + ti.org_id AS organization_id, + ti.email_to AS email, + ti.created_at AS sent_at, + p.fullname AS name, + p.id AS profile_id, + p.photo_id + FROM team_invitation AS ti +LEFT JOIN profile AS p + ON p.email = ti.email_to + AND p.deleted_at IS NULL + WHERE ti.valid_until >= now() + AND (ti.org_id = ? OR ti.team_id = ANY(?)) + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(def ^:private sql:get-team-invitation-emails + "SELECT DISTINCT ON (email_to) + ti.email_to AS email + FROM team_invitation AS ti + WHERE ti.team_id = ? + AND ti.valid_until >= now() + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(defn get-org-team-ids + "Return team ids for an organization. + + Accepts either `cfg` and `organization-id` (fetches the org summary from + Nitrate) or an already-resolved org summary map." + ([cfg organization-id] + (get-org-team-ids (nitrate/call cfg :get-org-summary {:organization-id organization-id}))) + ([org-summary] + (->> (:teams org-summary) + (map :id) + (filter uuid?) + (vec)))) + +(defn get-org-invitations + "Fetch valid org-level and team-level invitations for an organization." + [conn organization-id team-ids] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:get-org-invitations organization-id ids-array]))) + +(defn get-team-invitation-emails + "Return distinct valid team invitation recipient emails." + [conn team-id] + (db/exec! conn [sql:get-team-invitation-emails team-id])) diff --git a/backend/test/backend_tests/email_sending_test.clj b/backend/test/backend_tests/email_sending_test.clj index a688814e63..e96fd192ae 100644 --- a/backend/test/backend_tests/email_sending_test.clj +++ b/backend/test/backend_tests/email_sending_test.clj @@ -6,10 +6,12 @@ (ns backend-tests.email-sending-test (:require + [app.config :as cf] [app.db :as db] [app.email :as emails] [backend-tests.helpers :as th] [clojure.test :as t] + [cuerdas.core :as str] [promesa.core :as p])) (t/use-fixtures :once th/state-init) @@ -23,3 +25,67 @@ (t/is (contains? result :to)) #_(t/is (contains? result :reply-to)) (t/is (map? (:body result))))) + +(def ^:private sso-notice-snippet + "has set up single sign-on (SSO) in Penpot") + +(defn- email-text-body + [result] + (get-in result [:body "text/plain"])) + +(defn- invite-email-params + [organization] + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :user-name "Invitee User" + :token "test-token" + :organization organization}) + +(t/deftest invite-to-org-includes-sso-notice-when-active + (let [result (emails/render emails/invite-to-org + (invite-email-params {:name "Acme Inc" + :sso-active true}))] + (t/is (str/includes? (email-text-body result) sso-notice-snippet)) + (t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))) + +(t/deftest invite-to-org-omits-sso-notice-when-inactive + (let [result (emails/render emails/invite-to-org + (invite-email-params {:name "Acme Inc" + :sso-active false}))] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) + +(t/deftest invite-to-team-includes-sso-notice-when-active + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token" + :organization {:name "Acme Inc" + :sso-active true}})] + (t/is (str/includes? (email-text-body result) sso-notice-snippet)) + (t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))) + +(t/deftest invite-to-team-omits-sso-notice-when-inactive + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token" + :organization {:name "Acme Inc" + :sso-active false}})] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) + +(t/deftest invite-to-team-omits-sso-notice-without-organization + (let [result (emails/render emails/invite-to-team + {:to "invitee@example.com" + :public-uri (cf/get :public-uri) + :invited-by "Owner User" + :team "Design Team" + :token "test-token"})] + (t/is (not (str/includes? (email-text-body result) sso-notice-snippet))) + (t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index abba0fa7a1..460471e639 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -11,6 +11,7 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as-alias db] + [app.email :as eml] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -1198,3 +1199,81 @@ (t/is (some? rel1))) (let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] (t/is (some? rel2))))) + +(t/deftest notify-org-sso-change-sends-setup-sso-email-once-per-recipient + (let [owner (th/create-profile* 1 {:is-active true :fullname "Owner"}) + member (th/create-profile* 2 {:is-active true + :fullname "Member" + :email "member@example.com"}) + invited (th/create-profile* 3 {:is-active true + :fullname "Invited User" + :email "invited@example.com"}) + org-id (uuid/random) + org-name "Acme Inc" + team (th/create-team* 1 {:profile-id (:id owner)}) + org-summary {:id org-id + :name org-name + :teams [{:id (:id team)}]} + sent (atom []) + params {::th/type :notify-org-sso-change + :organization-id org-id + :updated-props false + :became-active true}] + + ;; Member also has a pending invitation: should still receive only one email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to (:email member) + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to (:email invited) + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + ;; Invite without an existing profile. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "72h")}) + + (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-members [(:id owner) (:id member)] + :get-org-summary org-summary + nil)) + eml/send! (fn [params] (swap! sent conj params))] + (management-command-with-nitrate! params)) + + (let [emails (->> @sent (map :to) set)] + (t/is (= 4 (count @sent))) + (t/is (= #{"member@example.com" + (:email owner) + "invited@example.com" + "external@example.com"} + emails)) + (doseq [email-params @sent] + (t/is (= org-name (:organization-name email-params))) + (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) + +(t/deftest notify-org-sso-change-skips-email-when-not-active + (let [sent (atom []) + params {::th/type :notify-org-sso-change + :organization-id (uuid/random) + :updated-props false + :became-active false}] + (with-redefs [eml/send! (fn [params] (swap! sent conj params))] + (management-command-with-nitrate! params)) + (t/is (empty? @sent)))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 20e774ec99..28015bb369 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -6,12 +6,15 @@ (ns backend-tests.rpc-nitrate-test (:require + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as-alias db] + [app.email :as eml] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.nitrate] + [app.rpc.commands.teams :as teams] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str])) @@ -815,3 +818,118 @@ (t/is (not (th/success? out))) (t/is (= :validation (th/ex-type (:error out)))) (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?]}] + (fn [_cfg method params] + (case method + :get-org-membership (if (= (:profile-id params) owner-id) + {:is-member true :organization-id org-id} + {:is-member false :organization-id org-id}) + :get-org-members [owner-id] + :get-team-org {:organization nil} + :get-org-permissions org-perms + :set-team-org {: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} + nil))) + +(t/deftest add-team-to-organization-sends-sso-emails-to-new-members-and-invitees + (let [owner (th/create-profile* 301 {:is-active true + :fullname "Owner" + :email "owner301@example.com"}) + member (th/create-profile* 302 {:is-active true + :fullname "Member" + :email "member302@example.com"}) + team (th/create-team* 301 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + org-id (uuid/random) + org-name "SSO Org" + org-summary {:id org-id + :name org-name + :owner-id (:id owner) + :teams []} + org-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}} + sent (atom [])] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external301@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (add-team-to-org-nitrate-mock + {:org-id org-id + :org-summary org-summary + :org-perms org-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? true}) + 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 + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id org-id})] + (t/is (th/success? out)))) + + (let [emails (->> @sent (map :to) set)] + (t/is (= 2 (count @sent))) + (t/is (= #{"member302@example.com" "external301@example.com"} emails)) + (doseq [email-params @sent] + (t/is (= org-name (:organization-name email-params))) + (t/is (= eml/organization-setup-sso (::eml/factory email-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"}) + team (th/create-team* 303 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + org-id (uuid/random) + org-summary {:id org-id + :name "No SSO Org" + :owner-id (:id owner) + :teams []} + org-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}} + sent (atom [])] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "external303@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (add-team-to-org-nitrate-mock + {:org-id org-id + :org-summary org-summary + :org-perms org-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? false}) + 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 + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id org-id})] + (t/is (th/success? out)) + (t/is (empty? @sent)))))) diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 62d77ac14c..ca13e7de5b 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -61,4 +61,5 @@ [:name ::sm/text] [:initials [:maybe :string]] [:logo [:maybe ::sm/uri]] - [:avatar-bg-url [:maybe ::sm/uri]]]) + [:avatar-bg-url [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe :boolean]]])