diff --git a/backend/resources/app/email/invite-to-org/en.html b/backend/resources/app/email/invite-to-org/en.html
new file mode 100644
index 0000000000..912b67746b
--- /dev/null
+++ b/backend/resources/app/email/invite-to-org/en.html
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Hi{{ user-name|abbreviate:25 }},
+
+ |
+
+
+ |
+
+ {{invited-by|abbreviate:25}} sent you an invitation to join the organization {{ org-name|abbreviate:25 }}:
+
+ |
+
+
+ |
+
+
+ {{ org-initials }}
+
+
+
+ “{{ org-name|abbreviate:25 }}”
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+ Enjoy!
+ |
+
+
+ |
+
+ The Penpot team.
+ |
+
+
+
+
+ |
+
+
+
+
+
+ {% include "app/email/includes/footer.html" %}
+
+
+
+
+
diff --git a/backend/resources/app/email/invite-to-org/en.subj b/backend/resources/app/email/invite-to-org/en.subj
new file mode 100644
index 0000000000..d61232bfd3
--- /dev/null
+++ b/backend/resources/app/email/invite-to-org/en.subj
@@ -0,0 +1 @@
+{{invited-by|abbreviate:25}} has invited you to join the organization “{{ org-name|abbreviate:25 }}”
\ No newline at end of file
diff --git a/backend/resources/app/email/invite-to-org/en.txt b/backend/resources/app/email/invite-to-org/en.txt
new file mode 100644
index 0000000000..65317deb6e
--- /dev/null
+++ b/backend/resources/app/email/invite-to-org/en.txt
@@ -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.
diff --git a/backend/resources/app/email/invite-to-team/en.txt b/backend/resources/app/email/invite-to-team/en.txt
index 55e61d8e23..3482fab0a5 100644
--- a/backend/resources/app/email/invite-to-team/en.txt
+++ b/backend/resources/app/email/invite-to-team/en.txt
@@ -1,6 +1,6 @@
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:
diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj
index 44d5cd7e67..bd68ec92ee 100644
--- a/backend/src/app/email.clj
+++ b/backend/src/app/email.clj
@@ -412,6 +412,21 @@
:id ::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
[:map
[:invited-by ::sm/text]
diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj
index 4c9199a6f5..2551f29fff 100644
--- a/backend/src/app/migrations.clj
+++ b/backend/src/app/migrations.clj
@@ -466,7 +466,11 @@
:fn mg0145/migrate}
{: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!
[pool name migrations]
diff --git a/backend/src/app/migrations/sql/0147-mod-team-invitation-table.sql b/backend/src/app/migrations/sql/0147-mod-team-invitation-table.sql
new file mode 100644
index 0000000000..6c60428f06
--- /dev/null
+++ b/backend/src/app/migrations/sql/0147-mod-team-invitation-table.sql
@@ -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;
diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj
index b849ba0aa7..25cbe48640 100644
--- a/backend/src/app/rpc/commands/teams.clj
+++ b/backend/src/app/rpc/commands/teams.clj
@@ -539,35 +539,29 @@
team (create-team cfg params)]
(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,
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)
"expected cfg with valid connection")
- (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
-
- (db/tx-run!
- cfg
- (fn [{:keys [::db/conn] :as tx-cfg}]
- (let [org-id (:organization-id membership)
- default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id org-id)
- default-team-id (:id default-team)
- result (nitrate/call tx-cfg :add-profile-to-org {:profile-id profile-id
- :team-id default-team-id
- :org-id org-id})]
- (when (not (:is-member result))
- (ex/raise :type :internal
- :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))))))
+ (when (contains? cf/flags :nitrate)
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as tx-cfg}]
+ (let [org-id org-id
+ default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id org-id)
+ default-team-id (:id default-team)
+ result (nitrate/call tx-cfg :add-profile-to-org {:profile-id profile-id
+ :team-id default-team-id
+ :org-id org-id})]
+ (when (not (:is-member result))
+ (ex/raise :type :internal
+ :code :failed-add-profile-org-nitrate
+ :context {:profile-id profile-id
+ :org-id org-id
+ :default-team-id default-team-id}))
+ default-team-id)))))
(defn add-profile-to-team!
([cfg params]
@@ -576,7 +570,12 @@
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(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)))
(defn create-team
diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj
index d4a2f96ded..730a3c8887 100644
--- a/backend/src/app/rpc/commands/teams_invitations.clj
+++ b/backend/src/app/rpc/commands/teams_invitations.clj
@@ -36,20 +36,29 @@
;; --- Mutation: Create Team Invitation
(def sql:upsert-team-invitation
- "insert into team_invitation(id, team_id, email_to, created_by, role, valid_until)
- values (?, ?, ?, ?, ?, ?)
+ "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
+ values (?, ?, null, ?, ?, ?, ?)
on conflict(team_id, email_to) do
update set role = ?, valid_until = ?, updated_at = now()
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
- [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
{:iss :team-invitation
:exp valid-until
:profile-id profile-id
:role role
:team-id team-id
+ :org-id org-id
+ :org-name org-name
:member-email member-email
:member-id member-id}))
@@ -75,20 +84,40 @@
[:role types.team/schema:role]
[: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
(sm/check-fn schema:create-invitation))
+(def ^:private check-create-org-invitation-params
+ (sm/check-fn schema:create-org-invitation))
+
(defn- allow-invitation-emails?
[member]
(let [notifications (dm/get-in member [:props :notifications])]
(not= :none (:email-invites notifications))))
(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)
"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)
member (profile/get-profile-by-email conn email)]
@@ -105,8 +134,12 @@
:profile-id (:id member)}
(get types.team/permissions-for-role role))]
- ;; Insert the invited member to the team
- (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
+ (if organization
+ ;; 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
;; accepting an invitation link serves as verification.
@@ -123,18 +156,30 @@
(teams/check-email-spam conn email true)
(let [id (uuid/next)
- expire (ct/in-future "168h") ;; 7 days
- invitation (db/exec-one! conn [sql:upsert-team-invitation id
- (:id team) (str/lower email)
- (:id profile)
- (name role) expire
- (name role) expire])
+ expire (if organization
+ (ct/in-future "876000h") ;; Organization invitations doesn't expire
+ (ct/in-future "168h")) ;; 7 days
+ invitation (db/exec-one! conn (if organization
+ [sql:upsert-org-invitation id
+ (: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))
profile-id (:id profile)
tprops {:profile-id profile-id
:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
+ :org-id (:id organization)
+ :org-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
@@ -146,30 +191,54 @@
(let [props (-> (dissoc tprops :profile-id)
(audit/clean-props))
- evname (if updated?
- "update-team-invitation"
- "create-team-invitation")
+ evname (cond
+ (and updated? organization) "update-org-invitation"
+ updated? "update-team-invitation"
+ organization "create-org-invitation"
+ :else "create-team-invitation")
event (-> (audit/event-from-rpc-params params)
(assoc ::audit/name evname)
(assoc ::audit/props props))]
(audit/submit! cfg event))
(when (allow-invitation-emails? member)
- (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})))
+ (if organization
+ (when (contains? cf/flags :nitrate)
+ (eml/send! {::eml/conn conn
+ ::eml/factory eml/invite-to-org
+ :public-uri (cf/get :public-uri)
+ :to email
+ :invited-by (:fullname profile)
+ :user-name (:fullname member)
+ :org-name (:name organization)
+ :org-logo (:logo organization)
+ :org-initials (d/get-initials (:name organization))
+ :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)))))
+(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
[{:keys [::db/conn] :as cfg} profile team role member]
(assert (db/connection-map? cfg)
diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj
index 9f78ef3e12..9a6fa8de9b 100644
--- a/backend/src/app/rpc/commands/verify_token.clj
+++ b/backend/src/app/rpc/commands/verify_token.clj
@@ -87,52 +87,74 @@
;; --- Team 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
role (or (some-> invitation :role keyword) role)
- params (merge
- {:team-id team-id
- :profile-id (:id member)}
- (get types.team/permissions-for-role role))]
+ id-member (:id member)]
;; Do not allow blocked users accept invitations.
(when (:is-blocked member)
(ex/raise :type :restriction
:code :profile-blocked))
- (quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
- ::quotes/profile-id (:id member)
- ::quotes/team-id team-id})
+ (when team-id
+ (quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
+ ::quotes/profile-id id-member
+ ::quotes/team-id team-id}))
- ;; Insert the invited member to the team
- (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
+ (let [params (merge
+ {: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
- ;; accepting an invitation link serves as verification.
- (when-not (:is-active member)
- (db/update! conn :profile
- {:is-active true}
- {:id (:id member)}))
+ accepted-team-id (if org-id
+ ;; Insert the invited member to the org
+ (when (contains? cf/flags :nitrate)
+ (teams/initialize-user-in-nitrate-org cfg id-member org-id))
+ ;; Insert the invited member to the team
+ (do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
+ team-id))]
- ;; Delete the invitation
- (db/delete! conn :team-invitation
- {:team-id team-id :email-to member-email})
+ (when-not accepted-team-id
+ (ex/raise :type :internal
+ :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
- [:map {:title "TeamInvitationClaims"}
- [:iss :keyword]
- [:exp ::ct/inst]
- [:profile-id ::sm/uuid]
- [:role types.team/schema:role]
- [:team-id ::sm/uuid]
- [:member-email ::sm/email]
- [:member-id {:optional true} ::sm/uuid]])
+ [:and
+ [:map {:title "TeamInvitationClaims"}
+ [:iss :keyword]
+ [:exp ::ct/inst]
+ [:profile-id ::sm/uuid]
+ [:role types.team/schema:role]
+ [:team-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?
(sm/lazy-validator schema:team-invitation-claims))
@@ -140,7 +162,7 @@
(defmethod process-token :team-invitation
[{:keys [::db/conn] :as cfg}
{: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)
(ex/raise :type :validation
@@ -148,19 +170,27 @@
:hint "invitation token contains unexpected data"))
(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
{:id profile-id}
{:columns [:id :email]})
registration-disabled? (not (contains? cf/flags :registration))]
+
(when (nil? invitation)
(ex/raise :type :validation
:code :invalid-token
:hint "no invitation associated with the token"))
- (if (some? profile)
- (if (or (= member-id profile-id)
- (= member-email (:email profile)))
+ (if profile
+ (do
+ (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
;; with accepting the invitation and joining the current profile to the
@@ -188,17 +218,16 @@
:profile-id (:id profile)
:email (:email profile))))))
- (accept-invitation cfg claims invitation profile)
- (assoc claims :state :created))
-
- (ex/raise :type :validation
- :code :invalid-token
- :hint "logged-in user does not matches the invitation"))
+ (let [accepted-team-id (accept-invitation cfg claims invitation profile)]
+ (cond-> (assoc claims :state :created)
+ ;; when the invitation is to an org, instead of a team, add the
+ ;; accepted-team-id as :org-team-id
+ (:org-id claims)
+ (assoc :org-team-id accepted-team-id)))))
;; 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
;; token and registration is enabled, we redirect user the the register page.
-
{:invitation-token token
:iss :team-invitation
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj
index 440b3022b4..338a59f28b 100644
--- a/backend/src/app/rpc/management/nitrate.clj
+++ b/backend/src/app/rpc/management/nitrate.clj
@@ -21,6 +21,7 @@
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
+ [app.rpc.commands.teams-invitations :as ti]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
@@ -294,3 +295,18 @@ RETURNING id, name;")
:id id))
(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)
+
diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj
index f6d65a675d..bceafbc72e 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 email]
[app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[backend-tests.helpers :as th]
diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc
index aa06e14e9d..6537a281e2 100644
--- a/common/src/app/common/data.cljc
+++ b/common/src/app/common/data.cljc
@@ -1181,6 +1181,16 @@
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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc
index 1854158b74..1a582c0c52 100644
--- a/common/test/common_tests/data_test.cljc
+++ b/common/test/common_tests/data_test.cljc
@@ -28,6 +28,14 @@
(t/is (not (d/in-range? 5 -1)))
(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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs
index 334303ade4..9a3a33c47b 100644
--- a/frontend/src/app/main/ui/auth/verify_token.cljs
+++ b/frontend/src/app/main/ui/auth/verify_token.cljs
@@ -41,19 +41,22 @@
(st/emit! (da/login-from-token tdata)))
(defmethod handle-token :team-invitation
- [tdata]
- (case (:state tdata)
+ [{:keys [state team-id org-team-id org-name invitation-token] :as tdata}]
+ (case state
:created
- (let [team-id (:team-id tdata)]
+ (if org-team-id
(st/emit!
- (ntf/success (tr "auth.notifications.team-invitation-accepted"))
(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
- (let [token (:invitation-token tdata)
- route-id (:redirect-to tdata :auth-register)]
- (st/emit! (rt/nav route-id {:invitation-token token})))))
+ (let [route-id (:redirect-to tdata :auth-register)]
+ (st/emit! (rt/nav route-id {:invitation-token invitation-token})))))
(defmethod handle-token :default
[_tdata]
diff --git a/frontend/src/app/main/ui/components/org_avatar.cljs b/frontend/src/app/main/ui/components/org_avatar.cljs
index 45095ec770..c4430af12d 100644
--- a/frontend/src/app/main/ui/components/org_avatar.cljs
+++ b/frontend/src/app/main/ui/components/org_avatar.cljs
@@ -7,24 +7,16 @@
(ns app.main.ui.components.org-avatar
(:require-macros [app.main.style :as stl])
(:require
- [cuerdas.core :as str]
+ [app.common.data :as d]
[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/props :obj}
[{:keys [org size]}]
(let [name (:name org)
custom-photo (:organization-custom-photo org)
avatar-bg (:organization-avatar-bg-url org)
- initials (get-org-initials name)]
+ initials (d/get-initials name)]
(if custom-photo
[:img {:src custom-photo
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 6e7bb1b24d..5fc069d7fe 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -108,6 +108,9 @@ msgstr "Password recovery link sent to your inbox."
msgid "auth.notifications.team-invitation-accepted"
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
msgid "auth.password"
msgstr "Password"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 3d74830dce..59b7eb0bd5 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -114,6 +114,9 @@ msgstr "Hemos enviado a tu buzón un enlace para recuperar tu contraseña."
msgid "auth.notifications.team-invitation-accepted"
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
msgid "auth.password"
msgstr "Contraseña"