Send warning for email about nitrate orgs with sso (#10413)

This commit is contained in:
Pablo Alba 2026-06-25 09:53:07 +02:00 committed by GitHub
parent 2eb9423963
commit 2a5b6a69ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 792 additions and 85 deletions

View File

@ -195,21 +195,39 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20"
style="display:inline-block;vertical-align:middle;">
<tr>
<td width="20" height="20" align="center" valign="middle"
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">
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 %}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
<span
style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
</span>
</div>
</td>
</tr>
{% if organization.sso-active %}
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ 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.
</div>
</td>
</tr>
{% endif %}
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">

View File

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

View File

@ -186,10 +186,26 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
{{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 %}.</div>
</td>
</tr>
{% if organization.sso-active %}
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ 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.
</div>
</td>
</tr>
{% endif %}
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
@ -241,4 +257,4 @@
</div>
</body>
</html>
</html>

View File

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

View File

@ -0,0 +1,223 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
"{{ 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.
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1 @@
“{{ organization-name|abbreviate:25 }}” has set up single sign-on (SSO) in Penpot

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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