From 5c761125f3b088cac76bb234d404df217679efdf Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 9 Apr 2026 10:38:13 +0200 Subject: [PATCH] :sparkles: Add invite-to-org to Nitrate API --- .../resources/app/email/invite-to-org/en.html | 259 ++++++++++++++++++ .../resources/app/email/invite-to-org/en.subj | 1 + .../resources/app/email/invite-to-org/en.txt | 10 + .../resources/app/email/invite-to-team/en.txt | 2 +- backend/src/app/email.clj | 15 + backend/src/app/migrations.clj | 6 +- .../sql/0147-mod-team-invitation-table.sql | 13 + backend/src/app/rpc/commands/teams.clj | 51 ++-- .../app/rpc/commands/teams_invitations.clj | 125 +++++++-- backend/src/app/rpc/commands/verify_token.clj | 115 +++++--- backend/src/app/rpc/management/nitrate.clj | 16 ++ .../rpc_management_nitrate_test.clj | 1 + common/src/app/common/data.cljc | 10 + common/test/common_tests/data_test.cljc | 8 + .../src/app/main/ui/auth/verify_token.cljs | 19 +- .../app/main/ui/components/org_avatar.cljs | 12 +- frontend/translations/en.po | 3 + frontend/translations/es.po | 3 + 18 files changed, 552 insertions(+), 117 deletions(-) create mode 100644 backend/resources/app/email/invite-to-org/en.html create mode 100644 backend/resources/app/email/invite-to-org/en.subj create mode 100644 backend/resources/app/email/invite-to-org/en.txt create mode 100644 backend/src/app/migrations/sql/0147-mod-team-invitation-table.sql 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 }}” + +
+
+ + + + +
+ ACCEPT INVITE +
+
+
+ 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"