From 842463ed1bb93534034e47770badc617449f930d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 13 Dec 2022 15:29:43 +0100 Subject: [PATCH] :tada: Add the ability to copy team invitation link --- backend/src/app/main.clj | 2 +- backend/src/app/rpc/commands/teams.clj | 113 +++++++---- backend/src/app/rpc/commands/verify_token.clj | 2 +- backend/src/app/rpc/mutations/teams.clj | 35 ++-- backend/test/backend_tests/rpc_team_test.clj | 12 +- frontend/src/app/main/data/dashboard.cljs | 34 +++- frontend/src/app/main/ui/dashboard/team.cljs | 182 +++++++++++------- frontend/translations/en.po | 8 + frontend/translations/es.po | 9 + 9 files changed, 262 insertions(+), 135 deletions(-) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index b2145aeb4..c8d3a181a 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -322,7 +322,7 @@ ::http.client/client (ig/ref ::http.client/client) ::db/pool (ig/ref ::db/pool) ::wrk/executor (ig/ref ::wrk/executor) - + ::props (ig/ref :app.setup/props) :pool (ig/ref ::db/pool) :session (ig/ref :app.http.session/manager) :sprops (ig/ref :app.setup/props) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index ec88484e0..5abed23eb 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -15,6 +15,7 @@ [app.db :as db] [app.emails :as eml] [app.loggers.audit :as audit] + [app.main :as-alias main] [app.media :as media] [app.rpc.climit :as climit] [app.rpc.doc :as-alias doc] @@ -260,7 +261,7 @@ (def sql:team-invitations "select email_to as email, role, (valid_until < now()) as expired - from team_invitation where team_id = ? order by valid_until desc") + from team_invitation where team_id = ? order by valid_until desc, created_at desc") (defn get-team-invitations [conn team-id] @@ -628,25 +629,37 @@ "insert into team_invitation(team_id, email_to, role, valid_until) values (?, ?, ?, ?) on conflict(team_id, email_to) do - update set role = ?, valid_until = ?, updated_at = now();") + update set role = ?, updated_at = now();") + +(defn- create-invitation-token + [cfg {:keys [expire profile-id team-id member-id member-email role]}] + (tokens/generate (::main/props cfg) + {:iss :team-invitation + :exp expire + :profile-id profile-id + :role role + :team-id team-id + :member-email member-email + :member-id member-id})) + +(defn- create-profile-identity-token + [cfg profile] + (tokens/generate (::main/props cfg) + {:iss :profile-identity + :profile-id (:id profile) + :exp (dt/in-future {:days 30})})) (defn- create-invitation - [{:keys [conn sprops team profile role email] :as cfg}] - (let [member (profile/retrieve-profile-data-by-email conn email) - token-exp (dt/in-future "168h") ;; 7 days - email (str/lower email) - itoken (tokens/generate sprops - {:iss :team-invitation - :exp token-exp - :profile-id (:id profile) - :role role - :team-id (:id team) - :member-email (:email member email) - :member-id (:id member)}) - ptoken (tokens/generate sprops - {:iss :profile-identity - :profile-id (:id profile) - :exp (dt/in-future {:days 30})})] + [{:keys [::conn] :as cfg} {:keys [team profile role email] :as params}] + (let [member (profile/retrieve-profile-data-by-email conn email) + expire (dt/in-future "168h") ;; 7 days + itoken (create-invitation-token cfg {:profile-id (:id profile) + :expire expire + :team-id (:id team) + :member-email (or (:email member) email) + :member-id (:id member) + :role role}) + ptoken (create-profile-identity-token cfg profile)] (when (and member (not (eml/allow-send-emails? conn member))) (ex/raise :type :validation @@ -687,11 +700,10 @@ {:id (:id member)}))) (do (db/exec-one! conn [sql:upsert-team-invitation - (:id team) (str/lower email) (name role) - token-exp (name role) token-exp]) + (:id team) (str/lower email) (name role) expire (name role)]) (eml/send! {::eml/conn conn ::eml/factory eml/invite-to-team - :public-uri (:public-uri cfg) + :public-uri (cf/get :public-uri) :to email :invited-by (:fullname profile) :team (:name team) @@ -727,15 +739,14 @@ :code :profile-is-muted :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) - (let [invitations (->> emails + (let [cfg (assoc cfg ::conn conn) + invitations (->> emails (map (fn [email] - (assoc cfg - :email email - :conn conn - :team team - :profile profile - :role role))) - (map create-invitation))] + {:email (str/lower email) + :team team + :profile profile + :role role})) + (map (partial create-invitation cfg)))] (with-meta (vec invitations) {::audit/props {:invitations (count invitations)}}))))) @@ -743,26 +754,26 @@ ;; --- Mutation: Create Team & Invite Members (s/def ::emails ::us/set-of-valid-emails) -(s/def ::create-team-and-invitations +(s/def ::create-team-with-invitations (s/merge ::create-team (s/keys :req-un [::emails ::role]))) -(sv/defmethod ::create-team-and-invitations +(sv/defmethod ::create-team-with-invitations {::doc/added "1.17"} [{:keys [pool] :as cfg} {:keys [profile-id emails role] :as params}] (db/with-atomic [conn pool] (let [team (create-team conn params) - profile (db/get-by-id conn :profile profile-id)] + profile (db/get-by-id conn :profile profile-id) + cfg (assoc cfg ::conn conn)] ;; Create invitations for all provided emails. - (doseq [email emails] - (create-invitation - (assoc cfg - :conn conn - :team team - :profile profile - :email email - :role role))) + (->> emails + (map (fn [email] + {:team team + :profile profile + :email (str/lower email) + :role role})) + (run! (partial create-invitation cfg))) (-> team (vary-meta assoc ::audit/props {:invitations (count emails)}) @@ -777,6 +788,28 @@ :profile-id profile-id :invitations (count emails)}}))))))) +;; --- Query: get-team-invitation-token + +(s/def ::get-team-invitation-token + (s/keys :req-un [::profile-id ::team-id ::email])) + +(sv/defmethod ::get-team-invitation-token + {::doc/added "1.17"} + [{:keys [::db/pool] :as cfg} {:keys [profile-id team-id email] :as params}] + (check-read-permissions! pool profile-id team-id) + (let [invit (-> (db/get pool :team-invitation + {:team-id team-id + :email-to (str/lower email)}) + (update :role keyword)) + member (profile/retrieve-profile-data-by-email pool (:email invit)) + token (create-invitation-token cfg {:team-id (:team-id invit) + :profile-id profile-id + :expire (:expire invit) + :role (:role invit) + :member-id (:id member) + :member-email (or (:email member) (:email-to invit))})] + {:token token})) + ;; --- Mutation: Update invitation role (s/def ::update-team-invitation-role diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 3242a8211..66fce865d 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -129,7 +129,7 @@ [{:keys [conn session] :as cfg} {:keys [profile-id token]} {:keys [member-id team-id member-email] :as claims}] - (us/assert ::team-invitation-claims claims) + (us/verify! ::team-invitation-claims claims) (let [invitation (db/get* conn :team-invitation {:team-id team-id :email-to member-email}) diff --git a/backend/src/app/rpc/mutations/teams.clj b/backend/src/app/rpc/mutations/teams.clj index d15a376df..650ac1884 100644 --- a/backend/src/app/rpc/mutations/teams.clj +++ b/backend/src/app/rpc/mutations/teams.clj @@ -153,21 +153,20 @@ :code :profile-is-muted :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) - (let [invitations (->> emails + (let [cfg (assoc cfg ::cmd.teams/conn conn) + invitations (->> emails (map (fn [email] - (assoc cfg - :email email - :conn conn - :team team - :profile profile - :role role))) - (map #'cmd.teams/create-invitation))] + {:email (str/lower email) + :team team + :profile profile + :role role})) + (map (partial #'cmd.teams/create-invitation cfg)))] (with-meta (vec invitations) {::audit/props {:invitations (count invitations)}}))))) ;; --- Mutation: Create Team & Invite Members -(s/def ::create-team-and-invite-members ::cmd.teams/create-team-and-invitations) +(s/def ::create-team-and-invite-members ::cmd.teams/create-team-with-invitations) (sv/defmethod ::create-team-and-invite-members {::doc/added "1.0" @@ -175,17 +174,17 @@ [{:keys [::db/pool] :as cfg} {:keys [profile-id emails role] :as params}] (db/with-atomic [conn pool] (let [team (cmd.teams/create-team conn params) - profile (db/get-by-id conn :profile profile-id)] + profile (db/get-by-id conn :profile profile-id) + cfg (assoc cfg ::cmd.teams/conn conn)] ;; Create invitations for all provided emails. - (doseq [email emails] - (#'cmd.teams/create-invitation - (assoc cfg - :conn conn - :team team - :profile profile - :email email - :role role))) + (->> emails + (map (fn [email] + {:team team + :profile profile + :email (str/lower email) + :role role})) + (run! (partial #'cmd.teams/create-invitation cfg))) (-> team (vary-meta assoc ::audit/props {:invitations (count emails)}) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index cababafae..de66c2436 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -63,6 +63,16 @@ (t/is (th/success? out)) (t/is (= 1 (:call-count (deref mock))))) + ;; get invitation token + (let [params {::th/type :get-team-invitation-token + :profile-id (:id profile1) + :team-id (:id team) + :email "foo@bar.com"} + out (th/command! params)] + (t/is (th/success? out)) + (let [result (:result out)] + (contains? result :token))) + ;; invite user with bounce (th/reset-mock! mock) @@ -235,8 +245,6 @@ ))) - - (t/deftest invite-team-member-with-email-verification-disabled (with-mocks [mock {:target 'app.emails/send! :return nil}] (let [profile1 (th/create-profile* 1 {:is-active true}) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index ca05fbfa2..2aeb562fc 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.spec :as us] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.events :as ev] [app.main.data.fonts :as df] [app.main.data.media :as di] @@ -18,6 +19,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] [app.util.timers :as tm] + [app.util.webapi :as wapi] [beicon.core :as rx] [cljs.spec.alpha :as s] [potok.core :as ptk])) @@ -403,7 +405,7 @@ params {:name name :emails #{emails} :role role}] - (->> (rp/cmd! :create-team-and-invitations params) + (->> (rp/cmd! :create-team-with-invitations params) (rx/tap on-success) (rx/map team-created) (rx/catch on-error)))))) @@ -509,6 +511,36 @@ (rx/tap on-success) (rx/catch on-error)))))) + +(defn copy-invitation-link + [{:keys [email team-id] :as params}] + (us/assert! ::us/email email) + (us/assert! ::us/uuid team-id) + + (ptk/reify ::copy-invitation-link + IDeref + (-deref [_] {:email email :team-id team-id}) + + + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [on-success on-error] + :or {on-success identity + on-error rx/throw}} (meta params) + router (:router state)] + + (->> (rp/cmd! :get-team-invitation-token params) + (rx/map (fn [params] + (rt/resolve router :auth-verify-token {} params))) + (rx/map (fn [fragment] + (assoc @cf/public-uri :fragment fragment))) + (rx/tap (fn [uri] + (wapi/write-to-clipboard (str uri)))) + (rx/tap on-success) + (rx/ignore) + (rx/catch on-error)))))) + + (defn update-team-invitation-role [{:keys [email team-id role] :as params}] (us/assert! ::us/email email) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 8997245e1..1c5a59f66 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -448,81 +448,113 @@ :pending (= status :pending))} [:span.status-label (tr status-label)]])) -(mf/defc invitation-actions [{:keys [can-modify? delete resend] :as props}] - (let [show? (mf/use-state false)] - (when can-modify? - [:* - [:span.icon {:on-click #(reset! show? true)} [i/actions]] - [:& dropdown {:show @show? - :on-close #(reset! show? false)} - [:ul.dropdown.actions-dropdown - [:li {:on-click resend} (tr "labels.resend-invitation")] - [:li {:on-click delete} (tr "labels.delete-invitation")]]]]))) +(mf/defc invitation-actions + [{:keys [invitation team] :as props}] + (let [show? (mf/use-state false) + + team-id (:id team) + email (:email invitation) + role (:role invitation) + + on-resend-success + (mf/use-fn + (fn [] + (st/emit! (msg/success (tr "notifications.invitation-email-sent")) + (modal/hide)))) + + on-copy-success + (mf/use-fn + (fn [] + (st/emit! (msg/success (tr "notifications.invitation-link-copied")) + (modal/hide)))) + + on-error + (mf/use-fn + (mf/deps email) + (fn [{:keys [type code] :as error}] + (cond + (and (= :validation type) + (= :profile-is-muted code)) + (rx/of (msg/error (tr "errors.profile-is-muted"))) + + (and (= :validation type) + (= :member-is-muted code)) + (rx/of (msg/error (tr "errors.member-is-muted"))) + + (and (= :validation type) + (= :email-has-permanent-bounces code)) + (rx/of (msg/error (tr "errors.email-has-permanent-bounces" email))) + + :else + (rx/throw error)))) + + delete-fn + (mf/use-fn + (mf/deps email team-id) + (fn [] + (let [params {:email email :team-id team-id} + mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}] + (st/emit! (dd/delete-team-invitation (with-meta params mdata)))))) + + resend-fn + (mf/use-fn + (mf/deps email team-id) + (fn [] + (let [params (with-meta {:emails [email] + :team-id team-id + :resend? true + :role role} + {:on-success on-resend-success + :on-error on-error})] + (st/emit! + (-> (dd/invite-team-members params) + (with-meta {::ev/origin :team})))))) + + copy-fn + (mf/use-fn + (mf/deps email team-id) + (fn [] + (let [params (with-meta {:email email :team-id team-id} + {:on-success on-copy-success + :on-error on-error})] + (prn "KKK1") + (st/emit! + (-> (dd/copy-invitation-link params) + (with-meta {::ev/origin :team}))))))] + + + [:* + [:span.icon {:on-click #(reset! show? true)} [i/actions]] + [:& dropdown {:show @show? + :on-close #(reset! show? false)} + [:ul.dropdown.actions-dropdown + [:li {:on-click copy-fn} (tr "labels.copy-invitation-link")] + [:li {:on-click resend-fn} (tr "labels.resend-invitation")] + [:li {:on-click delete-fn} (tr "labels.delete-invitation")]]]])) (mf/defc invitation-row {::mf/wrap [mf/memo]} [{:keys [invitation can-invite? team] :as props}] - (let [expired? (:expired invitation) - email (:email invitation) - invitation-role (:role invitation) - status (if expired? - :expired - :pending) - - on-success - #(st/emit! (msg/success (tr "notifications.invitation-email-sent")) - (modal/hide) - (dd/fetch-team-invitations)) - - - on-error - (fn [email {:keys [type code] :as error}] - (cond - (and (= :validation type) - (= :profile-is-muted code)) - (msg/error (tr "errors.profile-is-muted")) - - (and (= :validation type) - (= :member-is-muted code)) - (msg/error (tr "errors.member-is-muted")) - - (and (= :validation type) - (= :email-has-permanent-bounces code)) - (msg/error (tr "errors.email-has-permanent-bounces" email)) - - :else - (msg/error (tr "errors.generic")))) + (let [expired? (:expired invitation) + email (:email invitation) + role (:role invitation) + status (if expired? :expired :pending) change-rol - (fn [role] - (let [params {:email email :team-id (:id team) :role role} - mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}] - (st/emit! (dd/update-team-invitation-role (with-meta params mdata))))) + (mf/use-fn + (mf/deps team email) + (fn [role] + (let [params {:email email :team-id (:id team) :role role} + mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}] + (st/emit! (dd/update-team-invitation-role (with-meta params mdata))))))] - delete-invitation - (fn [] - (let [params {:email email :team-id (:id team)} - mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}] - (st/emit! (dd/delete-team-invitation (with-meta params mdata))))) - - resend-invitation - (fn [] - (let [params {:emails [email] - :team-id (:id team) - :resend? true - :role invitation-role} - mdata {:on-success on-success - :on-error (partial on-error email)}] - (st/emit! (-> (dd/invite-team-members (with-meta params mdata)) - (with-meta {::ev/origin :team})) - (dd/fetch-team-invitations))))] [:div.table-row [:div.table-field.mail email] [:div.table-field.roles [:& invitation-role-selector {:can-invite? can-invite? - :role invitation-role + :role role :status status :change-to-editor (partial change-rol :editor) :change-to-admin (partial change-rol :admin)}]] @@ -530,20 +562,22 @@ [:div.table-field.status [:& invitation-status-badge {:status status}]] [:div.table-field.actions - [:& invitation-actions - {:can-modify? can-invite? - :delete delete-invitation - :resend resend-invitation}]]])) + (when can-invite? + [:& invitation-actions + {:invitation invitation + :team team}])]])) -(mf/defc empty-invitation-table [can-invite?] +(mf/defc empty-invitation-table + [{:keys [can-invite?] :as props}] [:div.empty-invitations [:span (tr "labels.no-invitations")] - (when (:can-invite? can-invite?) [:span (tr "labels.no-invitations-hint")])]) + (when can-invite? + [:span (tr "labels.no-invitations-hint")])]) (mf/defc invitation-section [{:keys [team invitations] :as props}] - (let [owner? (get-in team [:permissions :is-owner]) - admin? (get-in team [:permissions :is-admin]) + (let [owner? (dm/get-in team [:permissions :is-owner]) + admin? (dm/get-in team [:permissions :is-admin]) can-invite? (or owner? admin?)] [:div.dashboard-table.invitations @@ -555,7 +589,11 @@ [:& empty-invitation-table {:can-invite? can-invite?}] [:div.table-rows (for [invitation invitations] - [:& invitation-row {:key (:email invitation) :invitation invitation :can-invite? can-invite? :team team}])])])) + [:& invitation-row + {:key (:email invitation) + :invitation invitation + :can-invite? can-invite? + :team team}])])])) (mf/defc team-invitations-page [{:keys [team] :as props}] @@ -568,7 +606,7 @@ (tr "dashboard.your-penpot") (:name team))))) - (mf/with-effect + (mf/with-effect [] (st/emit! (dd/fetch-team-invitations))) [:* diff --git a/frontend/translations/en.po b/frontend/translations/en.po index de91460a8..ba88e0d55 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1436,6 +1436,10 @@ msgstr "Rename team" msgid "labels.resend-invitation" msgstr "Resend invitation" +#: src/app/main/ui/dashboard/team.cljs +msgid "labels.copy-invitation-link" +msgstr "Copy invitation link" + #: src/app/main/ui/static.cljs, src/app/main/ui/static.cljs, src/app/main/ui/static.cljs msgid "labels.retry" msgstr "Retry" @@ -1957,6 +1961,10 @@ msgstr "Update a component in a shared library" msgid "notifications.invitation-email-sent" msgstr "Invitation sent successfully" +#: src/app/main/ui/dashboard/team.cljs +msgid "notifications.invitation-link-copied" +msgstr "Invitation link copied" + #: src/app/main/ui/settings/delete_account.cljs msgid "notifications.profile-deletion-not-allowed" msgstr "You can't delete you profile. Reassign your teams before proceed." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f06309bb0..2c4076331 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1606,6 +1606,10 @@ msgstr "Renombra el equipo" msgid "labels.resend-invitation" msgstr "Reenviar invitacion" +#: src/app/main/ui/dashboard/team.cljs +msgid "labels.copy-invitation-link" +msgstr "Copiar link de invitación" + #: src/app/main/ui/static.cljs, src/app/main/ui/static.cljs msgid "labels.retry" msgstr "Reintentar" @@ -2172,6 +2176,11 @@ msgstr "Actualizar un componente en biblioteca" msgid "notifications.invitation-email-sent" msgstr "Invitación enviada con éxito" +#: src/app/main/ui/dashboard/team.cljs +msgid "notifications.invitation-link-copied" +msgstr "Enlace de invitacion copiado" + + #: src/app/main/ui/settings/delete_account.cljs msgid "notifications.profile-deletion-not-allowed" msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir."