Add invite-to-org to Nitrate API

This commit is contained in:
Pablo Alba 2026-04-09 10:38:13 +02:00 committed by Pablo Alba
parent 707cc53ca4
commit 5c761125f3
18 changed files with 552 additions and 117 deletions

View File

@ -0,0 +1,259 @@
<!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/uxbox-title.png"
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{{ user-name|abbreviate:25 }},
</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;">
<b>{{invited-by|abbreviate:25}}</b> sent you an invitation to join the organization {{ org-name|abbreviate:25 }}:
</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;">
<span style="display:inline-block;vertical-align:middle;width:20px;height:20px;border-radius: 50%;background-image:url('{{org-logo}}');overflow:hidden;text-align:center;font-weight: bold;font-size: 9px;line-height: 20px;">
{{ org-initials }}
</span>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
“{{ org-name|abbreviate:25 }}”
</span>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> ACCEPT INVITE </a>
</td>
</tr>
</table>
</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;">
Enjoy!</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 @@
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ org-name|abbreviate:25 }}”

View File

@ -0,0 +1,10 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ org-name|abbreviate:25 }}”.
Accept invitation using this link:
{{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy!
The Penpot team.

View File

@ -1,6 +1,6 @@
Hello! Hello!
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”. {{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
Accept invitation using this link: Accept invitation using this link:

View File

@ -412,6 +412,21 @@
:id ::invite-to-team :id ::invite-to-team
:schema schema:invite-to-team)) :schema schema:invite-to-team))
(def ^:private schema:invite-to-org
[:map
[:invited-by ::sm/text]
[:org-name ::sm/text]
[:org-initials ::sm/text]
[:org-logo ::sm/uri]
[:user-name [:maybe ::sm/text]]
[:token ::sm/text]])
(def invite-to-org
"Org member invitation email."
(template-factory
:id ::invite-to-org
:schema schema:invite-to-org))
(def ^:private schema:join-team (def ^:private schema:join-team
[:map [:map
[:invited-by ::sm/text] [:invited-by ::sm/text]

View File

@ -466,7 +466,11 @@
:fn mg0145/migrate} :fn mg0145/migrate}
{:name "0146-mod-access-token-table" {:name "0146-mod-access-token-table"
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}]) :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}
{:name "0147-mod-team-invitation-table"
:fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")}])
(defn apply-migrations! (defn apply-migrations!
[pool name migrations] [pool name migrations]

View File

@ -0,0 +1,13 @@
ALTER TABLE team_invitation
ADD COLUMN org_id uuid NULL;
ALTER TABLE team_invitation
ALTER COLUMN team_id DROP NOT NULL;
ALTER TABLE team_invitation
ADD CONSTRAINT team_invitation_team_or_org_not_null
CHECK (team_id IS NOT NULL OR org_id IS NOT NULL);
CREATE UNIQUE INDEX team_invitation_org_unique
ON team_invitation (org_id, email_to)
WHERE team_id IS NULL;

View File

@ -539,35 +539,29 @@
team (create-team cfg params)] team (create-team cfg params)]
(select-keys team [:id]))) (select-keys team [:id])))
(defn- initialize-user-in-nitrate-org (defn initialize-user-in-nitrate-org
"If needed, create a default team for the user on the organization, "If needed, create a default team for the user on the organization,
and notify Nitrate that an user has been added to an org." and notify Nitrate that an user has been added to an org."
[cfg profile-id team-id] [cfg profile-id org-id]
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
(let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})] (when (contains? cf/flags :nitrate)
;; Only when the team belong to an organization and the user is not a member (db/tx-run!
(when (and cfg
(some? (:organization-id membership)) ;; the team do belong to an organization (fn [{:keys [::db/conn] :as tx-cfg}]
(not (:is-member membership))) ;; the user is not a member of the org yet (let [org-id org-id
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id org-id)
(db/tx-run! default-team-id (:id default-team)
cfg result (nitrate/call tx-cfg :add-profile-to-org {:profile-id profile-id
(fn [{:keys [::db/conn] :as tx-cfg}] :team-id default-team-id
(let [org-id (:organization-id membership) :org-id org-id})]
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id org-id) (when (not (:is-member result))
default-team-id (:id default-team) (ex/raise :type :internal
result (nitrate/call tx-cfg :add-profile-to-org {:profile-id profile-id :code :failed-add-profile-org-nitrate
:team-id default-team-id :context {:profile-id profile-id
:org-id org-id})] :org-id org-id
(when (not (:is-member result)) :default-team-id default-team-id}))
(ex/raise :type :internal default-team-id)))))
:code :failed-add-profile-org-nitrate
:context {:profile-id profile-id
:team-id team-id
:org-id org-id
:default-team-id default-team-id}))
nil))))))
(defn add-profile-to-team! (defn add-profile-to-team!
([cfg params] ([cfg params]
@ -576,7 +570,12 @@
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
(when (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
(initialize-user-in-nitrate-org cfg profile-id team-id)) (let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})]
;; Only when the team belong to an organization and the user is not a member
(when (and
(some? (:organization-id membership)) ;; the team do belong to an organization
(not (:is-member membership))) ;; the user is not a member of the org yet
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
(db/insert! conn :team-profile-rel params options))) (db/insert! conn :team-profile-rel params options)))
(defn create-team (defn create-team

View File

@ -36,20 +36,29 @@
;; --- Mutation: Create Team Invitation ;; --- Mutation: Create Team Invitation
(def sql:upsert-team-invitation (def sql:upsert-team-invitation
"insert into team_invitation(id, team_id, email_to, created_by, role, valid_until) "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, ?, ?, ?, ?, ?) values (?, ?, null, ?, ?, ?, ?)
on conflict(team_id, email_to) do on conflict(team_id, email_to) do
update set role = ?, valid_until = ?, updated_at = now() update set role = ?, valid_until = ?, updated_at = now()
returning *") returning *")
(def sql:upsert-org-invitation
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(defn- create-invitation-token (defn- create-invitation-token
[cfg {:keys [profile-id valid-until team-id member-id member-email role]}] [cfg {:keys [profile-id valid-until org-id org-name team-id member-id member-email role]}]
(tokens/generate cfg (tokens/generate cfg
{:iss :team-invitation {:iss :team-invitation
:exp valid-until :exp valid-until
:profile-id profile-id :profile-id profile-id
:role role :role role
:team-id team-id :team-id team-id
:org-id org-id
:org-name org-name
:member-email member-email :member-email member-email
:member-id member-id})) :member-id member-id}))
@ -75,20 +84,40 @@
[:role types.team/schema:role] [:role types.team/schema:role]
[:email ::sm/email]]) [:email ::sm/email]])
(def ^:private schema:create-org-invitation
[:map {:title "params:create-org-invitation"}
[::rpc/profile-id ::sm/uuid]
[:organization
[:map
[:id ::sm/uuid]
[:name :string]
[:logo ::sm/uri]]]
[:profile
[:map
[:id ::sm/uuid]
[:fullname :string]]]
[:role types.team/schema:role]
[:email ::sm/email]])
(def ^:private check-create-invitation-params (def ^:private check-create-invitation-params
(sm/check-fn schema:create-invitation)) (sm/check-fn schema:create-invitation))
(def ^:private check-create-org-invitation-params
(sm/check-fn schema:create-org-invitation))
(defn- allow-invitation-emails? (defn- allow-invitation-emails?
[member] [member]
(let [notifications (dm/get-in member [:props :notifications])] (let [notifications (dm/get-in member [:props :notifications])]
(not= :none (:email-invites notifications)))) (not= :none (:email-invites notifications))))
(defn- create-invitation (defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}] [{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}]
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
(assert (check-create-invitation-params params)) (if organization
(assert (check-create-org-invitation-params params))
(assert (check-create-invitation-params params)))
(let [email (profile/clean-email email) (let [email (profile/clean-email email)
member (profile/get-profile-by-email conn email)] member (profile/get-profile-by-email conn email)]
@ -105,8 +134,12 @@
:profile-id (:id member)} :profile-id (:id member)}
(get types.team/permissions-for-role role))] (get types.team/permissions-for-role role))]
;; Insert the invited member to the team (if organization
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) ;; Insert the invited member to the org
(when (contains? cf/flags :nitrate)
(teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization)))
;; Insert the invited member to the team
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
;; If profile is not yet verified, mark it as verified because ;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification. ;; accepting an invitation link serves as verification.
@ -123,18 +156,30 @@
(teams/check-email-spam conn email true) (teams/check-email-spam conn email true)
(let [id (uuid/next) (let [id (uuid/next)
expire (ct/in-future "168h") ;; 7 days expire (if organization
invitation (db/exec-one! conn [sql:upsert-team-invitation id (ct/in-future "876000h") ;; Organization invitations doesn't expire
(:id team) (str/lower email) (ct/in-future "168h")) ;; 7 days
(:id profile) invitation (db/exec-one! conn (if organization
(name role) expire [sql:upsert-org-invitation id
(name role) expire]) (:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation)) updated? (not= id (:id invitation))
profile-id (:id profile) profile-id (:id profile)
tprops {:profile-id profile-id tprops {:profile-id profile-id
:invitation-id (:id invitation) :invitation-id (:id invitation)
:valid-until expire :valid-until expire
:team-id (:id team) :team-id (:id team)
:org-id (:id organization)
:org-name (:name organization)
:member-email (:email-to invitation) :member-email (:email-to invitation)
:member-id (:id member) :member-id (:id member)
:role role} :role role}
@ -146,30 +191,54 @@
(let [props (-> (dissoc tprops :profile-id) (let [props (-> (dissoc tprops :profile-id)
(audit/clean-props)) (audit/clean-props))
evname (if updated? evname (cond
"update-team-invitation" (and updated? organization) "update-org-invitation"
"create-team-invitation") updated? "update-team-invitation"
organization "create-org-invitation"
:else "create-team-invitation")
event (-> (audit/event-from-rpc-params params) event (-> (audit/event-from-rpc-params params)
(assoc ::audit/name evname) (assoc ::audit/name evname)
(assoc ::audit/props props))] (assoc ::audit/props props))]
(audit/submit! cfg event)) (audit/submit! cfg event))
(when (allow-invitation-emails? member) (when (allow-invitation-emails? member)
(let [team (if (contains? cf/flags :nitrate) (if organization
(nitrate/add-org-info-to-team cfg team {}) (when (contains? cf/flags :nitrate)
team)] (eml/send! {::eml/conn conn
(eml/send! {::eml/conn conn ::eml/factory eml/invite-to-org
::eml/factory eml/invite-to-team :public-uri (cf/get :public-uri)
:public-uri (cf/get :public-uri) :to email
:to email :invited-by (:fullname profile)
:invited-by (:fullname profile) :user-name (:fullname member)
:team (:name team) :org-name (:name organization)
:organization (:organization-name team) :org-logo (:logo organization)
:token itoken :org-initials (d/get-initials (:name organization))
:extra-data ptoken}))) :token itoken
:extra-data ptoken}))
(let [team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {})
team)]
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (:organization-name team)
:token itoken
:extra-data ptoken}))))
itoken))))) itoken)))))
(defn create-org-invitation
[cfg {:keys [::rpc/profile-id id name logo] :as params}]
(let [profile (db/get-by-id cfg :profile profile-id)]
(create-invitation cfg
(assoc params
:organization {:id id :name name :logo logo}
:profile profile
:role :editor))))
(defn- add-member-to-team (defn- add-member-to-team
[{:keys [::db/conn] :as cfg} profile team role member] [{:keys [::db/conn] :as cfg} profile team role member]
(assert (db/connection-map? cfg) (assert (db/connection-map? cfg)

View File

@ -87,52 +87,74 @@
;; --- Team Invitation ;; --- Team Invitation
(defn- accept-invitation (defn- accept-invitation
[{:keys [::db/conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member] [{:keys [::db/conn] :as cfg}
{:keys [team-id org-id role member-email] :as claims} invitation member]
(let [;; Update the role if there is an invitation (let [;; Update the role if there is an invitation
role (or (some-> invitation :role keyword) role) role (or (some-> invitation :role keyword) role)
params (merge id-member (:id member)]
{:team-id team-id
:profile-id (:id member)}
(get types.team/permissions-for-role role))]
;; Do not allow blocked users accept invitations. ;; Do not allow blocked users accept invitations.
(when (:is-blocked member) (when (:is-blocked member)
(ex/raise :type :restriction (ex/raise :type :restriction
:code :profile-blocked)) :code :profile-blocked))
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team (when team-id
::quotes/profile-id (:id member) (quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
::quotes/team-id team-id}) ::quotes/profile-id id-member
::quotes/team-id team-id}))
;; Insert the invited member to the team (let [params (merge
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) {:team-id team-id
:profile-id id-member}
(get types.team/permissions-for-role role))
;; If profile is not yet verified, mark it as verified because accepted-team-id (if org-id
;; accepting an invitation link serves as verification. ;; Insert the invited member to the org
(when-not (:is-active member) (when (contains? cf/flags :nitrate)
(db/update! conn :profile (teams/initialize-user-in-nitrate-org cfg id-member org-id))
{:is-active true} ;; Insert the invited member to the team
{:id (:id member)})) (do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
team-id))]
;; Delete the invitation (when-not accepted-team-id
(db/delete! conn :team-invitation (ex/raise :type :internal
{:team-id team-id :email-to member-email}) :code :accept-invitation-failed
:hint "the accept invitation has failed"))
;; Delete any request
(db/delete! conn :team-access-request
{:team-id team-id :requester-id (:id member)})
(assoc member :is-active true))) ;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification.
(when-not (:is-active member)
(db/update! conn :profile
{:is-active true}
{:id id-member}))
;; Delete the invitation
(db/delete! conn :team-invitation
(cond-> {:email-to member-email}
team-id (assoc :team-id team-id)
org-id (assoc :org-id org-id)))
;; Delete any request (only applicable for team invitations)
(when team-id
(db/delete! conn :team-access-request
{:team-id team-id :requester-id id-member}))
accepted-team-id)))
(def schema:team-invitation-claims (def schema:team-invitation-claims
[:map {:title "TeamInvitationClaims"} [:and
[:iss :keyword] [:map {:title "TeamInvitationClaims"}
[:exp ::ct/inst] [:iss :keyword]
[:profile-id ::sm/uuid] [:exp ::ct/inst]
[:role types.team/schema:role] [:profile-id ::sm/uuid]
[:team-id ::sm/uuid] [:role types.team/schema:role]
[:member-email ::sm/email] [:team-id {:optional true} ::sm/uuid]
[:member-id {:optional true} ::sm/uuid]]) [:org-id {:optional true} ::sm/uuid]
[:member-email ::sm/email]
[:member-id {:optional true} ::sm/uuid]]
[:fn {:error/message "team-id or org-id must be present"}
(fn [m] (or (:team-id m) (:org-id m)))]])
(def valid-team-invitation-claims? (def valid-team-invitation-claims?
(sm/lazy-validator schema:team-invitation-claims)) (sm/lazy-validator schema:team-invitation-claims))
@ -140,7 +162,7 @@
(defmethod process-token :team-invitation (defmethod process-token :team-invitation
[{:keys [::db/conn] :as cfg} [{:keys [::db/conn] :as cfg}
{:keys [::rpc/profile-id token] :as params} {:keys [::rpc/profile-id token] :as params}
{:keys [member-id team-id member-email] :as claims}] {:keys [member-id team-id org-id member-email] :as claims}]
(when-not (valid-team-invitation-claims? claims) (when-not (valid-team-invitation-claims? claims)
(ex/raise :type :validation (ex/raise :type :validation
@ -148,19 +170,27 @@
:hint "invitation token contains unexpected data")) :hint "invitation token contains unexpected data"))
(let [invitation (db/get* conn :team-invitation (let [invitation (db/get* conn :team-invitation
{:team-id team-id :email-to member-email}) (cond-> {:email-to member-email}
team-id (assoc :team-id team-id)
org-id (assoc :org-id org-id)))
profile (db/get* conn :profile profile (db/get* conn :profile
{:id profile-id} {:id profile-id}
{:columns [:id :email]}) {:columns [:id :email]})
registration-disabled? (not (contains? cf/flags :registration))] registration-disabled? (not (contains? cf/flags :registration))]
(when (nil? invitation) (when (nil? invitation)
(ex/raise :type :validation (ex/raise :type :validation
:code :invalid-token :code :invalid-token
:hint "no invitation associated with the token")) :hint "no invitation associated with the token"))
(if (some? profile) (if profile
(if (or (= member-id profile-id) (do
(= member-email (:email profile))) (when-not (or (= member-id profile-id)
(= member-email (:email profile)))
(ex/raise :type :validation
:code :invalid-token
:hint "logged-in user does not matches the invitation"))
;; if we have logged-in user and it matches the invitation we proceed ;; if we have logged-in user and it matches the invitation we proceed
;; with accepting the invitation and joining the current profile to the ;; with accepting the invitation and joining the current profile to the
@ -188,17 +218,16 @@
:profile-id (:id profile) :profile-id (:id profile)
:email (:email profile)))))) :email (:email profile))))))
(accept-invitation cfg claims invitation profile) (let [accepted-team-id (accept-invitation cfg claims invitation profile)]
(assoc claims :state :created)) (cond-> (assoc claims :state :created)
;; when the invitation is to an org, instead of a team, add the
(ex/raise :type :validation ;; accepted-team-id as :org-team-id
:code :invalid-token (:org-id claims)
:hint "logged-in user does not matches the invitation")) (assoc :org-team-id accepted-team-id)))))
;; If we have not logged-in user, and invitation comes with member-id we ;; If we have not logged-in user, and invitation comes with member-id we
;; redirect user to login, if no memeber-id is present and in the invitation ;; redirect user to login, if no memeber-id is present and in the invitation
;; token and registration is enabled, we redirect user the the register page. ;; token and registration is enabled, we redirect user the the register page.
{:invitation-token token {:invitation-token token
:iss :team-invitation :iss :team-invitation
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)

View File

@ -21,6 +21,7 @@
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.commands.teams-invitations :as ti]
[app.rpc.doc :as doc] [app.rpc.doc :as doc]
[app.util.services :as sv])) [app.util.services :as sv]))
@ -294,3 +295,18 @@ RETURNING id, name;")
:id id)) :id id))
(profile-to-map profile))) (profile-to-map profile)))
;; API: invite-to-org
(sv/defmethod ::invite-to-org
"Invite to organization"
{::doc/added "2.15"
::sm/params [:map
[:email ::sm/email]
[:id ::sm/uuid]
[:name ::sm/text]
[:logo ::sm/uri]]}
[cfg params]
(db/tx-run! cfg ti/create-org-invitation params)
nil)

View File

@ -11,6 +11,7 @@
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as-alias db] [app.db :as-alias db]
[app.email :as email]
[app.msgbus :as mbus] [app.msgbus :as mbus]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]

View File

@ -1181,6 +1181,16 @@
str/trim) str/trim)
"")) ""))
(defn get-initials
"Returns up to two uppercase initials extracted from a string.
Non-letter prefixes in each token are ignored."
[name]
(->> (str/split (str/trim (or name "")) #"\s+")
(keep #(first (re-seq #"[a-zA-Z]" %)))
(take 2)
(map str/upper)
(apply str)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Util protocols ;; Util protocols
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -28,6 +28,14 @@
(t/is (not (d/in-range? 5 -1))) (t/is (not (d/in-range? 5 -1)))
(t/is (not (d/in-range? 0 0)))) (t/is (not (d/in-range? 0 0))))
(t/deftest get-initials-test
(t/is (= "JD" (d/get-initials "John Doe")))
(t/is (= "A" (d/get-initials "acme")))
(t/is (= "AB" (d/get-initials "123 Alpha ## beta")))
(t/is (= "PD" (d/get-initials " penpot design tool ")))
(t/is (= "" (d/get-initials nil)))
(t/is (= "" (d/get-initials "!!! ???"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ordered Data Structures ;; Ordered Data Structures
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -41,19 +41,22 @@
(st/emit! (da/login-from-token tdata))) (st/emit! (da/login-from-token tdata)))
(defmethod handle-token :team-invitation (defmethod handle-token :team-invitation
[tdata] [{:keys [state team-id org-team-id org-name invitation-token] :as tdata}]
(case (:state tdata) (case state
:created :created
(let [team-id (:team-id tdata)] (if org-team-id
(st/emit! (st/emit!
(ntf/success (tr "auth.notifications.team-invitation-accepted"))
(du/refresh-profile) (du/refresh-profile)
(dcm/go-to-dashboard-recent :team-id team-id))) (dcm/go-to-dashboard-recent :team-id org-team-id)
(ntf/success (tr "auth.notifications.org-invitation-accepted" org-name)))
(st/emit!
(du/refresh-profile)
(dcm/go-to-dashboard-recent :team-id team-id)
(ntf/success (tr "auth.notifications.team-invitation-accepted"))))
:pending :pending
(let [token (:invitation-token tdata) (let [route-id (:redirect-to tdata :auth-register)]
route-id (:redirect-to tdata :auth-register)] (st/emit! (rt/nav route-id {:invitation-token invitation-token})))))
(st/emit! (rt/nav route-id {:invitation-token token})))))
(defmethod handle-token :default (defmethod handle-token :default
[_tdata] [_tdata]

View File

@ -7,24 +7,16 @@
(ns app.main.ui.components.org-avatar (ns app.main.ui.components.org-avatar
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[cuerdas.core :as str] [app.common.data :as d]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(defn- get-org-initials
[name]
(->> (str/split (str/trim (or name "")) #"\s+")
(keep #(first (re-seq #"[a-zA-Z]" %)))
(take 2)
(map str/upper)
(apply str)))
(mf/defc org-avatar* (mf/defc org-avatar*
{::mf/props :obj} {::mf/props :obj}
[{:keys [org size]}] [{:keys [org size]}]
(let [name (:name org) (let [name (:name org)
custom-photo (:organization-custom-photo org) custom-photo (:organization-custom-photo org)
avatar-bg (:organization-avatar-bg-url org) avatar-bg (:organization-avatar-bg-url org)
initials (get-org-initials name)] initials (d/get-initials name)]
(if custom-photo (if custom-photo
[:img {:src custom-photo [:img {:src custom-photo

View File

@ -108,6 +108,9 @@ msgstr "Password recovery link sent to your inbox."
msgid "auth.notifications.team-invitation-accepted" msgid "auth.notifications.team-invitation-accepted"
msgstr "Joined the team successfully" msgstr "Joined the team successfully"
msgid "auth.notifications.org-invitation-accepted"
msgstr "You're now part of %s"
#: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174
msgid "auth.password" msgid "auth.password"
msgstr "Password" msgstr "Password"

View File

@ -114,6 +114,9 @@ msgstr "Hemos enviado a tu buzón un enlace para recuperar tu contraseña."
msgid "auth.notifications.team-invitation-accepted" msgid "auth.notifications.team-invitation-accepted"
msgstr "Te uniste al equipo" msgstr "Te uniste al equipo"
msgid "auth.notifications.org-invitation-accepted"
msgstr "Te uniste a la organización %s"
#: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174
msgid "auth.password" msgid "auth.password"
msgstr "Contraseña" msgstr "Contraseña"