From e39bf0b43997dc2b7422ccebed86796577c3214b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Mon, 29 Sep 2025 13:18:57 +0200 Subject: [PATCH] :sparkles: Invitations management improvements (#7230) * :sparkles: Invitations management improvements * :paperclip: Change invite email subject * :paperclip: Update icon usage * :recycle: Fix css file --------- Co-authored-by: Eva Marco --- CHANGES.md | 1 + .../app/email/invite-to-team/en.subj | 2 +- .../app/rpc/commands/teams_invitations.clj | 113 +++- frontend/src/app/main/data/team.cljs | 35 +- frontend/src/app/main/ui/dashboard/team.cljs | 310 +++++++-- frontend/src/app/main/ui/dashboard/team.scss | 588 +++++++++++++----- frontend/src/app/main/ui/ds/_sizes.scss | 3 + frontend/translations/en.po | 30 + frontend/translations/es.po | 30 + 9 files changed, 837 insertions(+), 275 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b2a565c81e..48b7cc70dc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -31,6 +31,7 @@ ### :sparkles: New features & Enhancements - Show current Penpot version [Taiga #11603](https://tree.taiga.io/project/penpot/us/11603) - Switch several variant copies at the same time [Taiga #11411](https://tree.taiga.io/project/penpot/us/11411) +- Invitations management improvements [Taiga #3479](https://tree.taiga.io/project/penpot/us/3479) ### :bug: Bugs fixed diff --git a/backend/resources/app/email/invite-to-team/en.subj b/backend/resources/app/email/invite-to-team/en.subj index d4d00f7316..0e0e08d26b 100644 --- a/backend/resources/app/email/invite-to-team/en.subj +++ b/backend/resources/app/email/invite-to-team/en.subj @@ -1 +1 @@ -Invitation to join {{team}} +{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}” \ No newline at end of file diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 3559a3e7ed..8dc7f70d44 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -224,62 +224,112 @@ (def ^:private xf:map-email (map :email)) (defn- create-team-invitations - [{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}] - (let [emails (set emails) + "Unified function to handle both create and resend team invitations. + Accepts either: + - emails (set) + role (single role for all emails) + - invitations (vector of {:email :role} maps)" + [{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}] + (let [;; Normalize input to a consistent format: [{:email :role}] + invitation-data (cond + ;; Case 1: emails + single role (create invitations style) + (and emails role) + (map (fn [email] {:email email :role role}) emails) - join-requests (->> (get-valid-access-request-profiles conn (:id team)) - (d/index-by :email)) + ;; Case 2: invitations with individual roles (resend invitations style) + (some? invitations) + invitations - team-members (into #{} xf:map-email - (teams/get-team-members conn (:id team))) + :else + (throw (ex-info "Invalid parameters: must provide either emails+role or invitations" {}))) - invitations (into #{} - (comp - ;; We don't re-send inviation to - ;; already existing members - (remove team-members) + invitation-emails (into #{} (map :email) invitation-data) + + join-requests (->> (get-valid-access-request-profiles conn (:id team)) + (d/index-by :email)) + + team-members (into #{} xf:map-email + (teams/get-team-members conn (:id team))) + + invitations (into #{} + (comp + ;; We don't re-send invitations to + ;; already existing members + (remove #(contains? team-members (:email %))) ;; We don't send invitations to ;; join-requested members - (remove join-requests) - (map (fn [email] (assoc params :email email))) - (keep (partial create-invitation cfg))) - emails)] + (remove #(contains? join-requests (:email %))) + (map (fn [{:keys [email role]}] + (create-invitation cfg + (-> params + (assoc :email email) + (assoc :role role))))) + (remove nil?)) + invitation-data)] ;; For requested invitations, do not send invitation emails, add ;; the user directly to the team (->> join-requests - (filter #(contains? emails (key %))) - (map val) - (run! (partial add-member-to-team conn profile team role))) + (filter #(contains? invitation-emails (key %))) + (map (fn [[email member]] + (let [role (:role (first (filter #(= (:email %) email) invitation-data)))] + (add-member-to-team conn profile team role member)))) + (doall)) invitations)) (def ^:private schema:create-team-invitations - [:map {:title "create-team-invitations"} - [:team-id ::sm/uuid] - [:role types.team/schema:role] - [:emails [::sm/set ::sm/email]]]) + [:and + [:map {:title "create-team-invitations"} + [:team-id ::sm/uuid] + ;; Support both formats: + ;; 1. emails (set) + role (single role for all) + ;; 2. invitations (vector of {:email :role} maps) + [:emails {:optional true} [::sm/set ::sm/email]] + [:role {:optional true} types.team/schema:role] + [:invitations {:optional true} [:vector [:map + [:email ::sm/email] + [:role types.team/schema:role]]]]] + + ;; Ensure exactly one format is provided + [:fn (fn [params] + (let [has-emails-role (and (contains? params :emails) + (contains? params :role)) + has-invitations (contains? params :invitations)] + (and (or has-emails-role has-invitations) + (not (and has-emails-role has-invitations)))))]]) (def ^:private max-invitations-by-request-threshold "The number of invitations can be sent in a single rpc request" 25) (sv/defmethod ::create-team-invitations - "A rpc call that allow to send a single or multiple invitations to - join the team." + "A rpc call that allows to send single or multiple invitations to join the team. + + Supports two parameter formats: + 1. emails (set) + role (single role for all emails) + 2. invitations (vector of {:email :role} maps for individual roles)" {::doc/added "1.17" ::doc/module :teams ::sm/params schema:create-team-invitations} - [cfg {:keys [::rpc/profile-id team-id emails] :as params}] + [cfg {:keys [::rpc/profile-id team-id role emails] :as params}] (let [perms (teams/get-permissions cfg profile-id team-id) profile (db/get-by-id cfg :profile profile-id) - emails (into #{} (map profile/clean-email) emails)] + ;; Determine which format is being used + using-emails-format? (and emails role) + ;; Handle both parameter formats + emails (if using-emails-format? + (into #{} (map profile/clean-email) emails) + #{}) + ;; Calculate total invitation count for both formats + invitation-count (if using-emails-format? + (count emails) + (count (:invitations params)))] (when-not (:is-admin perms) (ex/raise :type :validation :code :insufficient-permissions)) - (when (> (count emails) max-invitations-by-request-threshold) + (when (> invitation-count max-invitations-by-request-threshold) (ex/raise :type :validation :code :max-invitations-by-request :hint "the maximum of invitation on single request is reached" @@ -288,7 +338,7 @@ (-> cfg (assoc ::quotes/profile-id profile-id) (assoc ::quotes/team-id team-id) - (assoc ::quotes/incr (count emails)) + (assoc ::quotes/incr invitation-count) (quotes/check! {::quotes/id ::quotes/invitations-per-team} {::quotes/id ::quotes/profiles-per-team})) @@ -304,7 +354,12 @@ (-> params (assoc :profile profile) (assoc :team team) - (assoc :emails emails)))] + ;; Pass parameters in the correct format for the unified function + (cond-> using-emails-format? + ;; If using emails+role format, ensure both are present + (assoc :emails emails :role role) + ;; If using invitations format, the :invitations key is already in params + (not using-emails-format?) identity)))] (with-meta {:total (count invitations) :invitations invitations} diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 3b63bdd5af..059ac58140 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -23,6 +23,7 @@ [app.util.storage :as storage] [app.util.webapi :as wapi] [beicon.v2.core :as rx] + [clojure.string :as str] [potok.v2.core :as ptk])) (log/set-level! :warn) @@ -351,26 +352,46 @@ (rx/catch on-error)))))) (defn create-invitations - [{:keys [emails role team-id resend?] :as params}] + "Unified function to create invitations. Supports two parameter formats: + 1. {:emails #{...} :role :admin :team-id uuid} - single role for all emails + 2. {:invitations [{:email ... :role ...}] :team-id uuid} - individual roles per email" + [{:keys [emails role team-id invitations resend?] :as params}] - (assert (keyword? role)) (assert (uuid? team-id)) - (assert (sm/check-set-of-emails emails)) + ;; Validate input format - must have either emails+role OR invitations + (assert (or (and emails role (sm/check-set-of-emails emails) (keyword? role)) + (and invitations + (sm/check-set-of-emails (map :email invitations)) + (every? #(contains? ctt/valid-roles (:role %)) invitations))) + "Must provide either emails+role or invitations with individual roles") (ptk/reify ::create-invitations ev/Event (-data [_] - {:role role + {:role (if invitations + (->> invitations (map :role) distinct (map name) (str/join ", ")) + (name role)) :team-id team-id - :resend resend?}) + :resend (boolean resend?)}) ptk/WatchEvent (watch [it _ _] (let [{:keys [on-success on-error] :or {on-success identity on-error rx/throw}} (meta params) - params (dissoc params :resend?)] - (->> (rp/cmd! :create-team-invitations (with-meta params (meta it))) + ;; Prepare parameters based on format + rpc-params (cond + ;; Format 1: emails + single role + (and emails role) + {:emails emails :role role :team-id team-id} + + ;; Format 2: invitations with individual roles + invitations + {:invitations invitations :team-id team-id} + + :else + (throw (ex-info " Invalid parameters " params)))] + (->> (rp/cmd! :create-team-invitations (with-meta rpc-params (meta it))) (rx/tap on-success) (rx/catch on-error)))))) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index b883736094..718b5b33d4 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -22,10 +22,12 @@ [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.forms :as fm] [app.main.ui.dashboard.change-owner] - [app.main.ui.dashboard.subscription :refer [team* - members-cta* - show-subscription-members-banner?]] + [app.main.ui.dashboard.subscription :refer [members-cta* + show-subscription-members-banner? + team*]] [app.main.ui.dashboard.team-form] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.badge :refer [badge-notification]] @@ -604,11 +606,7 @@ {::mf/props :obj ::mf/private true} [{:keys [invitation team-id]}] - (let [show? (mf/use-state false) - - email (:email invitation) - role (:role invitation) - + (let [email (:email invitation) on-error (mf/use-fn (mf/deps email) @@ -631,35 +629,6 @@ :else (rx/throw cause))))) - on-delete - (mf/use-fn - (mf/deps email team-id) - (fn [] - (let [params {:email email :team-id team-id} - mdata {:on-success #(st/emit! (dtm/fetch-invitations))}] - (st/emit! (dtm/delete-invitation (with-meta params mdata)))))) - - on-resend-success - (mf/use-fn - (fn [] - (st/emit! (ntf/success (tr "notifications.invitation-email-sent")) - (modal/hide) - (dtm/fetch-invitations)))) - - on-resend - (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! - (-> (dtm/create-invitations params) - (with-meta {::ev/origin :team})))))) - on-copy-success (mf/use-fn (fn [] @@ -675,33 +644,18 @@ :on-error on-error})] (st/emit! (-> (dtm/copy-invitation-link params) - (with-meta {::ev/origin :team})))))) + (with-meta {::ev/origin :team}))))))] - on-hide (mf/use-fn #(reset! show? false)) - on-show (mf/use-fn #(reset! show? true))] - - [:* - [:button {:class (stl/css :menu-btn) - :on-click on-show} - menu-icon] - - [:& dropdown {:show @show? :on-close on-hide :dropdown-id "invitation-actions"} - [:ul {:class (stl/css :actions-dropdown :invitations-dropdown)} - [:li {:on-click on-copy - :class (stl/css :action-dropdown-item)} - (tr "labels.copy-invitation-link")] - [:li {:on-click on-resend - :class (stl/css :action-dropdown-item)} - (tr "labels.resend-invitation")] - [:li {:on-click on-delete - :class (stl/css :action-dropdown-item)} - (tr "labels.delete-invitation")]]]])) + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.copy-invitation-link") + :on-click on-copy + :icon "clipboard"}])) (mf/defc invitation-row* {::mf/wrap [mf/memo] ::mf/private true ::mf/props :obj} - [{:keys [invitation can-invite team-id]}] + [{:keys [invitation can-invite team-id selected on-select-change]}] (let [expired? (:expired invitation) email (:email invitation) @@ -714,6 +668,17 @@ (tr "labels.expired-invitation") (tr "labels.pending-invitation")) + is-selected? (fn [email] + (contains? @selected email)) + + on-change + (mf/use-fn + (mf/deps on-select-change) + (fn [event] + (let [email (-> (dom/get-current-target event) + (dom/get-data "attr"))] + (on-select-change email)))) + on-change-role (mf/use-fn (mf/deps email team-id) @@ -723,7 +688,20 @@ (st/emit! (dtm/update-invitation-role (with-meta params mdata))))))] [:div {:class (stl/css :table-row :table-row-invitations)} - [:div {:class (stl/css :table-field :field-email)} email] + [:div {:class (stl/css :table-field :field-email)} + [:div {:class (stl/css :input-wrapper)} + [:label {:for (str "email-" email)} + [:span {:class (stl/css-case :input-checkbox true + :global/checked (is-selected? email))} + deprecated-icon/status-tick] + + [:input {:type "checkbox" + :id (str "email-" email) + :data-attr email + :value email + :checked (is-selected? email) + :on-change on-change}]]] + email] [:div {:class (stl/css :table-field :field-roles)} [:> invitation-role-selector* @@ -766,33 +744,233 @@ (tr "dashboard.invite-profile")] [:div {:class (stl/css :blank-space)}]])])) +(mf/defc invitation-modal + {::mf/register modal/components + ::mf/register-as :invitation-modal} + [{:keys [selected delete on-confirm]}] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-invitation-container :modal-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-title)} + (if delete + (tr "dashboard.invitation-modal.title.delete-invitations") + (tr "dashboard.invitation-modal.title.resend-invitations"))] + + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + + [:div {:class (stl/css :modal-invitation-content)} + [:p + (if delete + (tr "dashboard.invitation-modal.delete") + (tr "dashboard.invitation-modal.resend"))] + [:div {:class (stl/css :invitation-list)} + (for [{:keys [email role]} selected] + [:p {:key email} + (str "- " email " (" (tr (str "labels." (name role))) ")")])]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)} + (when-not delete + [:> button* + {:class (stl/css :cancel-button) + :variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")]) + [:> button* + {:class (stl/css :accept-btn) + :variant "primary" + :type "button" + :on-click on-confirm} + (if delete + (tr "labels.continue") + (tr "labels.resend"))]]]]]) + (mf/defc invitation-section* {::mf/props :obj ::mf/private true} [{:keys [team]}] (let [permissions (get team :permissions) - invitations (get team :invitations) + invitations (mf/use-state (get team :invitations)) team-id (get team :id) owner? (get permissions :is-owner) admin? (get permissions :is-admin) - can-invite? (or owner? admin?)] + can-invite? (or owner? admin?) + + selected (mf/use-state #{}) + + ;; Sort state: {:field :status/:role, :direction :asc/:desc} + sort-state (mf/use-state {:field nil :direction :asc}) + + selected-invitations (mf/with-memo [selected invitations] + (filterv #(contains? @selected (:email %)) @invitations)) + + on-select-change + (mf/use-fn + (mf/deps selected) + (fn [email] + (if (contains? @selected email) + (swap! selected disj email) + (swap! selected conj email)))) + + on-confirm-delete + (mf/use-fn + (mf/deps selected team-id) + (fn [] + (doseq [email @selected] + (let [params {:email email :team-id team-id} + mdata {:on-success #(st/emit! (ntf/success (tr "notifications.invitation-deleted")) + (dtm/fetch-invitations) + (modal/hide))}] + (st/emit! (dtm/delete-invitation (with-meta params mdata))))) + (reset! selected #{}))) + + on-delete + (mf/use-fn + (mf/deps selected-invitations team-id) + (fn [] + (st/emit! (modal/show :invitation-modal {:selected selected-invitations :delete true :on-confirm on-confirm-delete})))) + + on-error + (fn [form] + (let [{:keys [type code] :as error} (ex-data form)] + (println form) + (cond + (and (= :validation type) + (= :profile-is-muted code)) + (st/emit! (ntf/error (tr "errors.profile-is-muted")) + (modal/hide)) + + (and (= :validation type) + (= :max-invitations-by-request code)) + (st/emit! (ntf/error (tr "errors.maximum-invitations-by-request-reached" (:threshold error)))) + + (and (= :restriction type) + (= :max-quote-reached code)) + (st/emit! (ntf/error (tr "errors.max-quote-reached" (:target error)))) + + (or (= :member-is-muted code) + (= :email-has-permanent-bounces code) + (= :email-has-complaints code)) + (st/emit! (ntf/error (tr "errors.email-spam-or-permanent-bounces" (:email error)))) + + :else + (st/emit! (ntf/error (tr "errors.generic")) + (modal/hide))))) + + on-resend-success + (mf/use-fn + (fn [] + (st/emit! (ntf/success (tr "notifications.invitation-email-sent")) + (modal/hide) + (dtm/fetch-invitations)) + (reset! selected #{}))) + + on-confirm-resend + (mf/use-fn + (mf/deps selected-invitations team-id on-resend-success) + (fn [] + (modal/hide!) + (let [params (with-meta {:invitations selected-invitations + :team-id team-id + :resend? true} + {:on-success on-resend-success + :on-error on-error})] + + (st/emit! + (-> (dtm/create-invitations params) + (with-meta {::ev/origin :team})))))) + + on-resend + (mf/use-fn + (mf/deps team-id selected-invitations) + (fn [] + (st/emit! (modal/show :invitation-modal {:selected selected-invitations :on-confirm on-confirm-resend})))) + + on-order-by-status + (mf/use-fn + (mf/deps sort-state) + (fn [] + (let [current-field (:field @sort-state) + current-direction (:direction @sort-state) + new-direction (if (= current-field :status) + (if (= current-direction :asc) :desc :asc) + :asc)] + (println @invitations) + (swap! sort-state assoc :field :status :direction new-direction) + (swap! invitations #(let [sorted (sort-by (juxt :expired :email) %)] + (if (= new-direction :desc) + (reverse sorted) + sorted)))))) + + on-order-by-role + (mf/use-fn + (mf/deps sort-state) + (fn [] + (let [current-field (:field @sort-state) + current-direction (:direction @sort-state) + new-direction (if (= current-field :role) + (if (= current-direction :asc) :desc :asc) + :asc)] + (swap! sort-state assoc :field :role :direction new-direction) + (swap! invitations #(let [sorted (sort-by (juxt :role :email) %)] + (if (= new-direction :desc) + (reverse sorted) + sorted))))))] + + (mf/with-effect [team] + (reset! invitations (get team :invitations)) + (reset! sort-state {:field nil :direction :asc})) [:div {:class (stl/css :invitations)} + (when (> (count @selected) 0) + [:* + [:div {:class (stl/css :invitations-actions)} + [:div + (str (count @selected) " invitations selected")] + [:div + [:> button* {:variant "secondary" + :type "button" + :on-click on-resend} + (tr "labels.resend-invitation")]] + [:> icon-button* {:on-click on-delete + :variant "destructive" + :aria-label (tr "labels.delete-invitation") + :icon "delete"}]]]) [:div {:class (stl/css :table-header)} [:div {:class (stl/css :title-field-name)} (tr "labels.invitations")] - [:div {:class (stl/css :title-field-role)} (tr "labels.role")] - [:div {:class (stl/css :title-field-status)} (tr "labels.status")]] - (if (empty? invitations) + [:div {:class (stl/css :title-field-role)} (tr "labels.role") + [:> icon-button* {:variant "action" + :class (stl/css-case :sort-active (= (:field @sort-state) :role) + :sort-inactive (not= (:field @sort-state) :role)) + :aria-label (tr "dashboard.order-invitations-by-role") + :icon (if (= (:field @sort-state) :role) + (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") + "arrow-down") + :on-click on-order-by-role}]] + [:div {:class (stl/css :title-field-status)} (tr "labels.status") + [:> icon-button* {:variant "action" + :class (stl/css-case :sort-active (= (:field @sort-state) :status) + :sort-inactive (not= (:field @sort-state) :status)) + :aria-label (tr "dashboard.order-invitations-by-status") + :icon (if (= (:field @sort-state) :status) + (if (= (:direction @sort-state) :asc) "arrow-down" "arrow-up") + "arrow-down") + :on-click on-order-by-status}]]] + (if (empty? @invitations) [:> empty-invitation-table* {:can-invite can-invite? :team team}] [:div {:class (stl/css :table-rows)} - (for [invitation invitations] + (for [invitation @invitations] [:> invitation-row* {:key (:email invitation) :invitation invitation :can-invite can-invite? - :team-id team-id}])])])) + :team-id team-id + :selected selected + :on-select-change on-select-change}])])])) (mf/defc team-invitations-page* {::mf/props :obj} diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 3e2ad7432e..f7918faa32 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -6,6 +6,11 @@ @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/mixins.scss" as *; // Dashboard team settings .dashboard-team-settings { @@ -13,68 +18,75 @@ flex-direction: column; align-items: center; width: 100%; - border-top: deprecated.$s-1 solid var(--panel-border-color); + border-top: $b-1 solid var(--color-background-quaternary); overflow-y: auto; - padding-inline-start: deprecated.$s-20; - padding-block-start: deprecated.$s-20; + padding-inline-start: var(--sp-xl); + padding-block-start: var(--sp-xl); } .settings-container { display: flex; flex-direction: column; align-items: center; - gap: deprecated.$s-24; + gap: var(--sp-xxl); } .block { display: grid; grid-auto-rows: min-content; - gap: deprecated.$s-8; - max-width: deprecated.$s-1000; + gap: var(--sp-s); + max-width: $sz-1000; width: 100%; } .info-block { - margin-block-start: deprecated.$s-16; + margin-block-start: var(--sp-l); } .block-label { - @include deprecated.headlineSmallTypography; - color: var(--title-foreground-color); + @include t.use-typography("headline-small"); + color: var(--color-foreground-secondary); } .block-text { - color: var(--title-foreground-color-hover); + color: var(--color-foreground-primary); } .block-content { display: grid; - grid-template-columns: deprecated.$s-32 1fr; + grid-template-columns: var(--sp-xxxl) 1fr; align-items: center; - gap: deprecated.$s-12; + gap: var(--sp-m); } .owner-icon { - width: deprecated.$s-32; - height: deprecated.$s-32; + width: $sz-32; + height: $sz-32; border-radius: 50%; } .user-icon, .document-icon, .group-icon { - @extend .button-icon; + display: flex; + justify-content: center; + align-items: center; + height: $sz-16; + width: $sz-16; + color: transparent; + fill: none; + stroke-width: $b-1; margin: 0 auto; - stroke: var(--icon-foreground); + stroke: var(--color-foreground-secondary); } .team-icon { --update-button-opacity: 0; position: relative; - height: deprecated.$s-120; - width: deprecated.$s-120; - padding: deprecated.$s-16; - margin-block-end: deprecated.$s-32; + height: $sz-120; + width: $sz-120; + padding: var(--sp-l); + margin-block-end: var(--sp-xxxl); &:hover { --update-button-opacity: 1; @@ -86,29 +98,40 @@ top: 0; left: 0; border-radius: 50%; - width: deprecated.$s-120; - height: deprecated.$s-120; + width: $sz-120; + height: $sz-120; } .update-overlay { opacity: var(--update-button-opacity); - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + border: none; + background: none; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; position: absolute; top: 0; left: 0; height: 100%; width: 100%; - z-index: deprecated.$z-index-modal; - border-radius: deprecated.$br-circle; + z-index: var(--z-index-set); + border-radius: $br-circle; background-color: var(--color-accent-primary); } .image-icon { - @extend .button-icon; - min-width: deprecated.$s-24; - min-height: deprecated.$s-24; - stroke: var(--icon-foreground-hover); + display: flex; + justify-content: center; + align-items: center; + height: $sz-16; + width: $sz-16; + color: transparent; + fill: none; + stroke-width: $b-1; + min-width: $sz-24; + min-height: $sz-24; + stroke: var(--color-foreground-primary); } // TEAM MEMBERS PAGE @@ -117,9 +140,9 @@ justify-content: center; width: 100%; height: 100%; - padding-inline-start: deprecated.$s-20; - padding-block-start: deprecated.$s-20; - border-top: deprecated.$s-1 solid var(--panel-border-color); + padding-inline-start: var(--sp-xl); + padding-block-start: var(--sp-xl); + border-top: $b-1 solid var(--color-background-quaternary); overflow-y: auto; scrollbar-gutter: stable; } @@ -133,62 +156,69 @@ display: grid; grid-template-rows: auto 1fr; height: fit-content; - max-width: deprecated.$s-1000; + max-width: $sz-1000; width: 100%; } .table-header { - @include deprecated.headlineSmallTypography; + @include t.use-typography("headline-small"); display: grid; align-items: center; - grid-template-columns: 43% 1fr deprecated.$s-108 deprecated.$s-12; - height: deprecated.$s-40; + grid-template-columns: 43% 1fr px2rem(108) var(--sp-m); + height: $sz-40; width: 100%; - max-width: deprecated.$s-1000; - padding: 0 deprecated.$s-16; + max-width: $sz-1000; + padding: 0 var(--sp-l); user-select: none; - color: var(--title-foreground-color); + color: var(--color-foreground-secondary); } .table-rows { display: grid; - grid-auto-rows: deprecated.$s-64; - gap: deprecated.$s-16; + grid-auto-rows: px2rem(64); + gap: var(--sp-l); width: 100%; height: 100%; - max-width: deprecated.$s-1000; - margin-top: deprecated.$s-16; - color: var(--title-foreground-color); + max-width: $sz-1000; + margin-top: var(--sp-l); + color: var(--color-foreground-secondary); } .table-row { display: grid; grid-template-columns: 43% 1fr auto; align-items: center; - height: deprecated.$s-64; + height: px2rem(64); width: 100%; - padding: 0 deprecated.$s-16; - border-radius: deprecated.$br-8; - background-color: var(--dashboard-list-background-color); - color: var(--dashboard-list-foreground-color); + padding: 0 var(--sp-l); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); } .title-field-name { width: 43%; - min-width: deprecated.$s-300; + min-width: px2rem(300); } -.title-field-roles { +.title-field-role { position: relative; cursor: default; } +.title-field-role, +.title-field-status { + display: flex; + align-items: center; + gap: var(--sp-s); +} + .field-name { display: grid; grid-template-columns: auto 1fr; - gap: deprecated.$s-16; + gap: var(--sp-l); width: 43%; - min-width: deprecated.$s-300; + min-width: px2rem(300); } .field-roles { @@ -202,9 +232,9 @@ // MEMBER INFO .member-image { - height: deprecated.$s-32; - width: deprecated.$s-32; - border-radius: deprecated.$br-circle; + height: $sz-32; + width: $sz-32; + border-radius: $br-circle; } .member-info { @@ -215,34 +245,34 @@ .member-name, .member-email { - @include deprecated.textEllipsis; - @include deprecated.bodyLargeTypography; + @include textEllipsis; + @include t.use-typography("body-large"); } .member-email { - @include deprecated.bodySmallTypography; - color: var(--dashboard-list-text-foreground-color); + @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); } .you { - color: var(--dashboard-list-text-foreground-color); - margin-left: deprecated.$s-6; + color: var(--color-foreground-secondary); + margin-left: px2rem(6); } // ROL INFO .rol-selector { + @include t.use-typography("body-medium"); position: relative; display: grid; grid-template-columns: 1fr auto; align-items: center; - height: deprecated.$s-32; - min-width: deprecated.$s-160; + height: $sz-32; + min-width: $sz-160; width: fit-content; - padding: deprecated.$s-4 deprecated.$s-8; - border-radius: deprecated.$br-8; - border-color: var(--menu-background-color-hover); - background-color: var(--menu-background-color-hover); - font-size: deprecated.$fs-14; + padding: var(--sp-xs) var(--sp-s); + border-radius: $br-8; + border-color: var(--color-background-quaternary); + background-color: var(--color-background-quaternary); } .has-priv { @@ -254,37 +284,96 @@ } .roles-dropdown { - @extend .menu-dropdown; - bottom: calc(-1 * deprecated.$s-76); + box-shadow: var(--el-shadow-dark); + display: flex; + flex-direction: column; + gap: var(--sp-xs); + position: absolute; + padding: var(--sp-xs); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + color: var(--color-foreground-primary); + background-color: var(--color-background-tertiary); + border: $b-2 solid var(--color-background-quaternary); + margin: 0; + bottom: calc(-1 * px2rem(76)); width: fit-content; - min-width: deprecated.$s-160; + min-width: $sz-160; } .rol-dropdown-item { - @extend .menu-item-base; + @include t.use-typography("body-small"); + display: flex; + align-items: center; + justify-content: space-between; + height: $sz-28; + width: 100%; + padding: px2rem(6); + border-radius: $br-8; + cursor: pointer; + &:hover { + background-color: var(--color-background-quaternary); + } } // MEMBER ACTIONS .menu-icon { - @extend .button-icon; + display: flex; + justify-content: center; + align-items: center; + height: $sz-16; + width: $sz-16; + color: transparent; + fill: none; + stroke-width: $b-1; stroke: var(--color-foreground-primary); } .menu-btn { - @include deprecated.buttonStyle; + border: none; + background: none; + cursor: pointer; +} + +.input-checkbox { + // TODO: remove this extended class. + @extend .input-checkbox; + cursor: pointer; } .actions-dropdown { - @extend .menu-dropdown; - bottom: calc(-1 * deprecated.$s-32); + box-shadow: var(--el-shadow-dark); + display: flex; + flex-direction: column; + gap: var(--sp-xs); + position: absolute; + padding: var(--sp-xs); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + color: var(--color-foreground-primary); + background-color: var(--color-background-tertiary); + border: $b-2 solid var(--color-background-quaternary); + margin: 0; + bottom: calc(-1 * var(--sp-xxxl)); right: 0; left: unset; width: fit-content; - min-width: deprecated.$s-160; + min-width: $sz-160; } .action-dropdown-item { - @extend .menu-item-base; + @include t.use-typography("body-small"); + display: flex; + align-items: center; + justify-content: space-between; + height: $sz-28; + width: 100%; + padding: px2rem(6); + border-radius: $br-8; + cursor: pointer; + &:hover { + background-color: var(--color-background-quaternary); + } } // TEAM INVITATION PAGE @@ -293,9 +382,9 @@ justify-content: center; width: 100%; height: 100%; - padding-inline-start: deprecated.$s-20; - padding-block-start: deprecated.$s-20; - border-top: deprecated.$s-1 solid var(--panel-border-color); + padding-inline-start: var(--sp-xl); + padding-block-start: var(--sp-xl); + border-top: $b-1 solid var(--color-background-quaternary); overflow-y: auto; scrollbar-gutter: stable; } @@ -304,12 +393,27 @@ display: grid; grid-template-rows: auto 1fr; height: fit-content; - max-width: deprecated.$s-1000; + max-width: $sz-1000; width: 100%; } +.invitations-actions { + @include t.use-typography("body-medium"); + display: flex; + justify-content: end; + align-items: center; + gap: var(--sp-l); + color: var(--title-foreground-color); + height: $sz-40; + margin-block-end: px2rem(36); +} + +.error-notification { + margin-block-end: var(--sp-xxl); +} + .table-row-invitations { - grid-template-columns: 43% 1fr deprecated.$s-108 deprecated.$s-12; + grid-template-columns: 43% 1fr $sz-88 var(--sp-xxl); align-items: center; } @@ -317,19 +421,20 @@ display: grid; place-items: center; align-content: center; - height: deprecated.$s-156; - max-width: deprecated.$s-1000; + height: px2rem(156); + max-width: $sz-1000; width: 100%; - margin-top: deprecated.$s-16; - border: deprecated.$s-1 solid var(--panel-border-color); - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); + margin-top: var(--sp-l); + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; + color: var(--color-foreground-secondary); } .btn-empty-invitations { + // TODO: Remove this extend add DS component @extend .button-primary; - margin-block-start: deprecated.$s-16; - padding-inline: deprecated.$s-12; + margin-block-start: var(--sp-l); + padding-inline: var(--sp-m); } .title-field-status { @@ -337,16 +442,25 @@ cursor: default; } +.sort-inactive { + opacity: 0.5; +} + +.sort-active { + opacity: 1; +} + .field-email { - @include deprecated.textEllipsis; - @include deprecated.bodyLargeTypography; - display: grid; + @include textEllipsis; + @include t.use-typography("body-large"); + display: flex; + gap: var(--sp-l); align-items: center; } .invitations-dropdown { - bottom: calc(-1 * deprecated.$s-112); - right: calc(-1 * deprecated.$s-20); + bottom: calc(-1 * px2rem(112)); + right: calc(-1 * var(--sp-xl)); } // WEBHOOKS SECTION @@ -354,63 +468,64 @@ display: grid; grid-template-rows: auto 1fr; justify-items: center; - gap: deprecated.$s-24; + gap: var(--sp-xxl); width: 100%; height: 100%; - padding-inline-start: deprecated.$s-20; - padding-block-start: deprecated.$s-20; - border-top: deprecated.$s-1 solid var(--panel-border-color); + padding-inline-start: var(--sp-xl); + padding-block-start: var(--sp-xl); + border-top: $b-1 solid var(--color-background-quaternary); overflow-y: auto; } .webhooks-hero-container { display: grid; grid-template-rows: auto 1fr; - margin: deprecated.$s-80 auto deprecated.$s-20 auto; - gap: deprecated.$s-24; + margin: px2rem(80) auto var(--sp-xl) auto; + gap: var(--sp-xxl); } .webhooks-empty { display: grid; place-items: center; align-content: center; - height: deprecated.$s-156; - max-width: deprecated.$s-1000; + height: px2rem(156); + max-width: $sz-1000; width: 100%; - padding: deprecated.$s-32; - border: deprecated.$s-1 solid var(--panel-border-color); - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); + padding: var(--sp-xxxl); + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; + color: var(--color-foreground-secondary); } .webhooks-hero { - font-size: deprecated.$fs-14; + @include t.use-typography("body-medium"); display: grid; grid-template-rows: auto 1fr auto; - gap: deprecated.$s-32; - margin-top: deprecated.$s-32; + gap: var(--sp-xxxl); + margin-top: var(--sp-xxxl); margin: 0; - padding: deprecated.$s-32; + padding: var(--sp-xxxl); padding: 0; - width: deprecated.$s-468; + width: px2rem(468); } .hero-title { - @include deprecated.bigTitleTipography; - color: var(--dashboard-list-foreground-color); + @include t.use-typography("title-large"); + color: var(--color-foreground-primary); } .hero-desc { + @include t.use-typography("body-large"); color: var(--color-foreground-secondary); margin-bottom: 0; - font-size: deprecated.$fs-16; - max-width: deprecated.$s-512; + max-width: $sz-512; } .hero-btn { + //TODO: Remove this extended class using a DS component @extend .button-primary; - height: deprecated.$s-32; - max-width: deprecated.$s-512; + height: $sz-32; + max-width: $sz-512; } .webhook-table { @@ -421,7 +536,7 @@ display: grid; align-items: center; grid-template-columns: auto 1fr auto auto; - gap: deprecated.$s-16; + gap: var(--sp-l); } .actions { @@ -429,90 +544,143 @@ } .menu-disabled { - color: var(--icon-foreground); - width: deprecated.$s-28; + color: var(--color-foreground-secondary); + width: $sz-28; display: flex; justify-content: center; align-items: center; } .webhook-actions-dropdown { - @extend .menu-dropdown; - right: calc(-1 * deprecated.$s-16); - bottom: calc(-1 * deprecated.$s-40); + box-shadow: var(--el-shadow-dark); + display: flex; + flex-direction: column; + gap: var(--sp-xs); + position: absolute; + padding: var(--sp-xs); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + color: var(--color-foreground-primary); + background-color: var(--color-background-tertiary); + border: $b-2 solid var(--color-background-quaternary); + margin: 0; + right: calc(-1 * var(--sp-l)); + bottom: calc(-1 * $sz-40); width: fit-content; - min-width: deprecated.$s-160; + min-width: $sz-160; } .webhook-dropdown-item { - @extend .menu-item-base; + @include t.use-typography("body-small"); + display: flex; + align-items: center; + justify-content: space-between; + height: $sz-28; + width: 100%; + padding: px2rem(6); + border-radius: $br-8; + cursor: pointer; + &:hover { + background-color: var(--color-background-quaternary); + } } .success-icon { - @extend .button-icon; - stroke: var(--alert-icon-foreground-color-success); + display: flex; + justify-content: center; + align-items: center; + height: $sz-16; + width: $sz-16; + color: transparent; + fill: none; + stroke-width: $b-1; + stroke: var(--color-accent-success); } .warning-icon { - @extend .button-icon; - stroke: var(--alert-icon-foreground-color-warning); + display: flex; + justify-content: center; + align-items: center; + height: $sz-16; + width: $sz-16; + color: transparent; + fill: none; + stroke-width: $b-1; + stroke: var(--color-accent-warning); } // INVITE MEMBERS MODAL .modal-team-container { - @extend .modal-container-base; - @include deprecated.menuShadow; + position: relative; + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-width: $sz-364; + min-height: $sz-192; + max-width: $sz-512; + max-height: $sz-512; + box-shadow: var(--el-shadow-dark); position: fixed; - top: deprecated.$s-72; - right: deprecated.$s-12; + top: px2rem(72); + right: var(--sp-m); left: unset; - width: deprecated.$s-400; - padding: deprecated.$s-32; - background-color: var(--modal-background-color); + width: $sz-400; + padding: var(--sp-xxxl); + background-color: var(--color-background-primary); &.hero { - top: deprecated.$s-216; - right: deprecated.$s-32; + top: px2rem(216); + right: var(--sp-xxxl); } } .modal-team-container-workspace { - top: deprecated.$s-40; - z-index: deprecated.$z-index-modal; + top: $sz-40; + z-index: var(--z-index-set); } .modal-title { - @include deprecated.headlineMediumTypography; - height: deprecated.$s-32; - color: var(--modal-title-foreground-color); + @include t.use-typography("headline-medium"); + height: $sz-32; + color: var(--color-foreground-primary); } .role-select { - @include deprecated.flexColumn; - row-gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + row-gap: var(--sp-s); } .arrow-icon { - @extend .button-icon; - stroke: var(--icon-foreground); + display: flex; + justify-content: center; + align-items: center; + height: $sz-16; + width: $sz-16; + color: transparent; + fill: none; + stroke-width: $b-1; + stroke: var(--color-foreground-secondary); transform: rotate(90deg); } .invite-team-member-text { - @include deprecated.bodyLargeTypography; - margin: 0 0 deprecated.$s-16 0; - color: var(--modal-title-foreground-color); + @include t.use-typography("body-large"); + margin: 0 0 var(--sp-l) 0; + color: var(--color-foreground-primary); } .role-title { - @include deprecated.bodyLargeTypography; + @include t.use-typography("body-large"); margin: 0; - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } .invitation-row { - margin-top: deprecated.$s-8; - margin-bottom: deprecated.$s-24; + margin-top: var(--sp-s); + margin-bottom: var(--sp-xxl); } .action-buttons { @@ -521,46 +689,68 @@ } .accept-btn { + // TODO: remove this extend class creating a modal component @extend .modal-accept-btn; } // WEBHOOKS MODAL .modal-overlay { - @extend .modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + left: 0; + top: 0; + height: 100%; + width: 100%; + z-index: var(--z-index-set); + background-color: var(--overlay-color); } .modal-container { - @extend .modal-container-base; + position: relative; + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-width: $sz-364; + min-height: $sz-192; + max-width: $sz-512; + max-height: $sz-512; } .modal-header { - margin-bottom: deprecated.$s-24; + margin-bottom: var(--sp-xxl); } .modal-title { - @include deprecated.uppercaseTitleTipography; - color: var(--modal-title-foreground-color); + @include t.use-typography("title-small"); + color: var(--color-foreground-primary); } .modal-close-btn { + // TODO remove extended class creating a modal component @extend .modal-close-btn-base; } .modal-content { - @include deprecated.flexColumn; - gap: deprecated.$s-24; - @include deprecated.bodySmallTypography; - margin-bottom: deprecated.$s-24; + @include t.use-typography("body-small"); + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + margin-bottom: var(--sp-xxl); } .fields-row { - @include deprecated.flexColumn; + display: flex; + flex-direction: column; + gap: var(--sp-xs); } .select-title { - @include deprecated.bodySmallTypography; - color: var(--modal-title-foreground-color); + @include t.use-typography("body-small"); + color: var(--color-foreground-primary); } .custom-input-checkbox { @@ -568,9 +758,10 @@ } .hint { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } +// TODO: Remove this extended classes creating a modal component .action-buttons { @extend .modal-action-btns; @@ -583,8 +774,61 @@ } } +// TODO: Remove this extended class using input component .email-input { + @include t.use-typography("body-small"); @extend .input-base; - @include deprecated.bodySmallTypography; height: auto; } +// TODO: Fix this nested classes. +.input-wrapper { + display: flex; + align-items: center; + label { + @include t.use-typography("body-small"); + display: flex; + align-items: center; + gap: px2rem(6); + cursor: pointer; + color: var(--color-foreground-secondary); + span { + @extend .checkbox-icon; + } + input { + margin: 0; + } + &:hover { + span { + border-color: var(--color-accent-primary-muted); + } + } + + &:focus, + &:focus-within { + span { + border-color: var(--color-accent-primary); + } + } + } +} + +// INVITATION MODAL + +.modal-invitation-container { + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-invitation-content { + overflow: auto; +} + +.invitation-list p { + margin: 0; +} + +.modal-invitation-action-buttons { + margin-block-start: var(--sp-xxxl); + gap: var(--sp-s); +} diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index 9e4d97c17e..b06a80882b 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -20,6 +20,7 @@ $sz-40: px2rem(40); $sz-48: px2rem(48); $sz-80: px2rem(80); $sz-88: px2rem(88); +$sz-120: px2rem(120); $sz-154: px2rem(154); $sz-160: px2rem(160); $sz-192: px2rem(192); @@ -35,6 +36,8 @@ $sz-400: px2rem(400); $sz-430: px2rem(430); $sz-480: px2rem(480); $sz-500: px2rem(500); +$sz-512: px2rem(512); $sz-520: px2rem(520); $sz-712: px2rem(712); $sz-964: px2rem(964); +$sz-1000: px2rem(1000); diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 39bdefd860..1d20bb73ff 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8189,3 +8189,33 @@ msgstr "Autosaved versions will be kept for %s days." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" + +msgid "labels.sources" +msgstr "Sources" + +msgid "labels.pinned-projects" +msgstr "Pinned Projects" + +msgid "labels.resend" +msgstr "Resend" + +msgid "dashboard.invitation-modal.title.delete-invitations" +msgstr "Delete invitations" + +msgid "dashboard.invitation-modal.title.resend-invitations" +msgstr "Resend invitations" + +msgid "dashboard.invitation-modal.delete" +msgstr "You're going to delete the invitations to:" + +msgid "dashboard.invitation-modal.resend" +msgstr "You're going to resend the invitations to:" + +msgid "dashboard.order-invitations-by-status" +msgstr "Order by status" + +msgid "dashboard.order-invitations-by-role" +msgstr "Order by role" + +msgid "notifications.invitation-deleted" +msgstr "Invitation deleted successfully" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 78caa82dea..cc7b7845ee 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8070,3 +8070,33 @@ msgstr "Los autoguardados duran %s días." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" + +msgid "labels.sources" +msgstr "Recursos" + +msgid "labels.pinned-projects" +msgstr "Proyectos fijados" + +msgid "labels.resend" +msgstr "Reenviar" + +msgid "dashboard.invitation-modal.title.delete-invitations" +msgstr "Eliminar invitaciones" + +msgid "dashboard.invitation-modal.title.resend-invitations" +msgstr "Reenviar invitaciones" + +msgid "dashboard.invitation-modal.delete" +msgstr "Vas a eliminar las invitaciones para:" + +msgid "dashboard.invitation-modal.resend" +msgstr "Vas a reenviar las invitaciones para:" + +msgid "dashboard.order-invitations-by-status" +msgstr "Ordenar por estado" + +msgid "dashboard.order-invitations-by-role" +msgstr "Ordenar por rol" + +msgid "notifications.invitation-deleted" +msgstr "Invitación eliminada con éxito"