diff --git a/.gitignore b/.gitignore index e7dfcc7462..cd9785f406 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ .rebel_readline_history .repl .shadow-cljs +.pnpm-store/ /*.jpg /*.md /*.png @@ -71,6 +72,7 @@ /library/target/ /library/*.zip /external +/penpot-nitrate clj-profiler/ node_modules diff --git a/backend/scripts/_env b/backend/scripts/_env index f5cca5538b..56fd3885ab 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -36,7 +36,8 @@ export PENPOT_FLAGS="\ enable-file-validation \ enable-file-schema-validation \ enable-redis-cache \ - enable-subscriptions"; + enable-subscriptions \ + enable-nitrate"; # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 1fa26fabe1..cb650dfdc3 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -323,6 +323,7 @@ {::http.client/client (ig/ref ::http.client/client) ::db/pool (ig/ref ::db/pool) ::rds/pool (ig/ref ::rds/pool) + :app.nitrate/instance (ig/ref :app.nitrate/instance) ::wrk/executor (ig/ref ::wrk/netty-executor) ::session/manager (ig/ref ::session/manager) ::ldap/provider (ig/ref ::ldap/provider) @@ -339,6 +340,9 @@ ::email/blacklist (ig/ref ::email/blacklist) ::email/whitelist (ig/ref ::email/whitelist)} + :app.nitrate/instance + {::http.client/client (ig/ref ::http.client/client)} + :app.rpc/management-methods {::http.client/client (ig/ref ::http.client/client) ::db/pool (ig/ref ::db/pool) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj new file mode 100644 index 0000000000..15f66d1cd7 --- /dev/null +++ b/backend/src/app/nitrate.clj @@ -0,0 +1,97 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.nitrate + "The msgbus abstraction implemented using redis as underlying backend." + (:require + [app.common.schema :as sm] + [app.config :as cf] + [app.http.client :as http] + [app.rpc :as-alias rpc] + [app.setup :as-alias setup] + [app.util.json :as json] + [clojure.core :as c] + [integrant.core :as ig])) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- coercer + [schema & {:as opts}] + (let [decode-fn (sm/decoder schema sm/json-transformer) + check-fn (sm/check-fn schema opts)] + (fn [data] + (-> data decode-fn check-fn)))) + +(defn http-validated-call + [{:keys [::setup/props] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}] + (let [coercer-http (coercer schema + :type :validation + :hint (str "invalid data received calling " uri)) + management-key (or (cf/get :management-api-key) + (get props :management-key)) + ;; TODO cache + ;; TODO retries + ;; TODO error handling + rsp (try + (http/req! cfg {:method method + :headers {"User-Agent" "curl/7.85.0" + "content-type" "application/json" + "accept" "application/json" + "x-shared-key" management-key + "x-profile-id" (str profile-id)} + :uri uri + :version :http1.1}) + (catch Exception e + (println "Error:" (.getMessage e))))] + (try + (coercer-http (-> rsp :body json/decode)) + (catch Exception e + (println "Error:" (.getMessage e)) + nil)))) + + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PUBLIC API +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn call + [cfg method params] + (when (contains? cf/flags :nitrate) + (let [instance (get cfg ::instance) + method (get instance method)] + (method params)))) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; INITIALIZATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; TODO Extract to env +(def baseuri "http://localhost:3000") + + +(def ^:private schema:organization + [:map + [:id ::sm/int] + [:name ::sm/text]]) + +(defn- get-team-org + [cfg {:keys [team-id] :as params}] + (http-validated-call cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params)) + +(defmethod ig/init-key ::instance + [_ cfg] + (if (contains? cf/flags :nitrate) + {:get-team-org (partial get-team-org cfg)} + {})) + +(defmethod ig/halt-key! ::instance + [_ {:keys []}] + (do :stuff)) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 782c91b042..8116e4e9f9 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -296,6 +296,7 @@ (let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)] (->> (sv/scan-ns 'app.rpc.management.subscription + 'app.rpc.management.nitrate 'app.rpc.management.exporter) (map (partial process-method cfg "management" wrap-management)) (into {})))) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 3cd69c5b76..89c6d75f5c 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -23,6 +23,7 @@ [app.main :as-alias main] [app.media :as media] [app.msgbus :as mbus] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] @@ -172,6 +173,12 @@ (map decode-row) (map process-permissions))) +(defn- add-org-to-team + [cfg team params] + (let [params (assoc (or params {}) :team-id (:id team)) + org (nitrate/call cfg :get-team-org params)] + (assoc team :organization-id (:id org) :organization-name (:name org)))) + (defn get-teams [conn profile-id] (let [profile (profile/get-profile conn profile-id) @@ -190,7 +197,9 @@ ::sm/params schema:get-teams} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (dm/with-open [conn (db/open pool)] - (get-teams conn profile-id))) + (cond->> (get-teams conn profile-id) + (contains? cf/flags :nitrate) + (map #(add-org-to-team cfg % params))))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj new file mode 100644 index 0000000000..77aac81d92 --- /dev/null +++ b/backend/src/app/rpc/management/nitrate.clj @@ -0,0 +1,119 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.rpc.management.nitrate + "Internal Nitrate HTTP API. + Provides authenticated access to organization management and token validation endpoints. + All requests must include a valid shared key token in the `x-shared-key` header, and + a cookie `auth-token` with the user token. + They will return `401 Unauthorized` if the shared key or user token are invalid." + (:require + [app.common.schema :as sm] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.msgbus :as mbus] + [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] + [app.rpc.doc :as doc] + [app.tokens :as tokens] + [app.util.services :as sv] + [cuerdas.core :as str])) + +;; ---- API: authenticate +(def ^:private schema:authenticate-params + [:map + [:token :string]]) + +(def ^:private schema:profile + [:map + [:id ::sm/uuid] + [:name :string] + [:email :string]]) + +(sv/defmethod ::authenticate + "Authenticate an user by token + @api GET /authenticate + @returns + 200 OK: Returns the authenticated user." + {::doc/added "2.12" + ::sm/params schema:authenticate-params + ::sm/result schema:profile} + [cfg {:keys [token]}] + (let [token (str/replace-first token #"^auth-token=" "") + claims (tokens/verify cfg {:token token :iss "authentication"}) + profile-id (:uid claims) + profile (profile/get-profile cfg profile-id)] + ;; TODO Manage bad token + {:id (get profile :id) + :name (get profile :fullname) + :email (get profile :email)})) + +;; ---- API: get-teams + +(def ^:private sql:get-teams + "SELECT t.* + FROM team AS t + JOIN team_profile_rel AS tpr ON t.id = tpr.team_id + WHERE tpr.profile_id = ? + AND tpr.is_owner = 't' + AND t.is_default = 'f';") + +(def ^:private schema:team + [:map + [:id ::sm/uuid] + [:name :string]]) + +(def ^:private schema:get-teams-result + [:vector schema:team]) + +(sv/defmethod ::get-teams + "List teams for which current user is owner. + @api GET /get-teams + @returns + 200 OK: Returns the list of teams for the user." + {::doc/added "2.12" + ::sm/result schema:get-teams-result} + [cfg {:keys [::rpc/profile-id]}] + (when (contains? cf/flags :nitrate) + (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] + (->> (db/exec! cfg [sql:get-teams current-user-id]) + (map #(select-keys % [:id :name])))))) + +;; ---- API: notify-team-change + +(def ^:private schema:notify-team-change + [:map + [:id ::sm/uuid] + [:organization-id ::sm/int]]) + + +(sv/defmethod ::notify-team-change + "Notify to Penpot a team change from nitrate + @api POST /notify-team-change + @returns + 200 OK" + {::doc/added "2.12" + ::sm/params schema:notify-team-change} + [cfg {:keys [id organization-id organization-name]}] + (when (contains? cf/flags :nitrate) + (let [msgbus (::mbus/msgbus cfg)] + (prn "Team" id "has changed to org" organization-name "(" organization-id ")") + (mbus/pub! msgbus + ;;TODO There is a bug on dashboard with teams notifications. + ;;For now we send it to uuid/zero instead of team-id + :topic uuid/zero + :message {:type :team-org-change + :team-id id + :organization-id organization-id + :organization-name organization-name})))) + + + + + + + diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 60a3b09d48..d603b2e74d 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -145,7 +145,10 @@ ;; A temporal flag, enables backend code use more extensivelly ;; redis for caching data - :redis-cache}) + :redis-cache + + ;; Activates the nitrate module + :nitrate}) (def all-flags (set/union email login varia)) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 8937c3180d..385b8b5114 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -649,10 +649,22 @@ (rx/of (dcm/change-team-role params) (modal/hide))))) +(defn handle-change-team-org + [{:keys [team-id organization-id organization-name] :as message}] + (ptk/reify ::handle-change-team-org + ptk/UpdateEvent + (update [_ state] + (if (contains? (:teams state) team-id) + (-> state + (assoc-in [:teams team-id :organization-id] organization-id) + (assoc-in [:teams team-id :organization-name] organization-name)) + state)))) + (defn- process-message [{:keys [type] :as msg}] (case type :notification (dcm/handle-notification msg) :team-role-change (handle-change-team-role msg) :team-membership-change (dcm/team-membership-change msg) + :team-org-change (handle-change-team-org msg) nil)) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 8e7ccae588..def4b2e252 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -280,7 +280,7 @@ (mf/defc teams-selector-dropdown* {::mf/private true} - [{:keys [team profile teams] :rest props}] + [{:keys [team profile teams show-default-team allow-create] :rest props}] (let [on-create-click (mf/use-fn #(st/emit! (modal/show :team-form {}))) @@ -294,14 +294,15 @@ [:> dropdown-menu* props - [:> dropdown-menu-item* {:on-click on-team-click - :data-value (:default-team-id profile) - :class (stl/css :team-dropdown-item)} - [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] + (when show-default-team + [:> dropdown-menu-item* {:on-click on-team-click + :data-value (:default-team-id profile) + :class (stl/css :team-dropdown-item)} + [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")] - (when (= (:default-team-id profile) (:id team)) - tick-icon)] + [:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")] + (when (= (:default-team-id profile) (:id team)) + tick-icon)]) (for [team-item (remove :is-default (vals teams))] [:> dropdown-menu-item* {:on-click on-team-click @@ -322,11 +323,12 @@ (when (= (:id team-item) (:id team)) tick-icon)]) - [:hr {:role "separator" :class (stl/css :team-separator)}] - [:> dropdown-menu-item* {:on-click on-create-click - :class (stl/css :team-dropdown-item :action)} - [:span {:class (stl/css :icon-wrapper)} add-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]])) + (when allow-create + [:hr {:role "separator" :class (stl/css :team-separator)}] + [:> dropdown-menu-item* {:on-click on-create-click + :class (stl/css :team-dropdown-item :action)} + [:span {:class (stl/css :icon-wrapper)} add-icon] + [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]])])) (mf/defc team-options-dropdown* {::mf/private true} @@ -476,9 +478,79 @@ :data-testid "delete-team"} (tr "dashboard.delete-team")])])) + +(mf/defc sidebar-org-switch* + [{:keys [team profile]}] + (let [teams (->> (mf/deref refs/teams) + vals + (group-by :organization-id) + (map (fn [[_group entries]] (first entries))) + vec + (d/index-by :id)) + + teams (update-vals teams + (fn [t] + (assoc t :name (str "ORG: " (:organization-name t))))) + + team (assoc team :name (str "ORG: " (:organization-name team))) + + show-teams-menu* + (mf/use-state false) + + show-teams-menu? + (deref show-teams-menu*) + + on-show-teams-click + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-teams-menu* not))) + + on-show-teams-keydown + (mf/use-fn + (fn [event] + (when (or (kbd/space? event) + (kbd/enter? event)) + (dom/prevent-default event) + (dom/stop-propagation event) + (some-> (dom/get-current-target event) + (dom/click!))))) + close-teams-menu + (mf/use-fn #(reset! show-teams-menu* false))] + + [:div {:class (stl/css :sidebar-team-switch)} + [:div {:class (stl/css :switch-content)} + [:button {:class (stl/css :current-team) + :on-click on-show-teams-click + :on-key-down on-show-teams-keydown} + + [:div {:class (stl/css :team-name)} + [:img {:src (cf/resolve-team-photo-url team) + :class (stl/css :team-picture) + :alt (:name team)}] + [:span {:class (stl/css :team-text) :title (:name team)} (:name team)]] + + arrow-icon]] + + ;; Teams Dropdown + + [:> teams-selector-dropdown* {:show show-teams-menu? + :on-close close-teams-menu + :id "organizations-list" + :class (stl/css :dropdown :teams-dropdown) + :team team + :profile profile + :teams teams + :show-default-team false + :allow-create false}]])) + (mf/defc sidebar-team-switch* [{:keys [team profile]}] - (let [teams (mf/deref refs/teams) + (let [nitrate? (contains? cf/flags :nitrate) + org-id (when nitrate? (:organization-id team)) + teams (cond->> (mf/deref refs/teams) + nitrate? + (filter #(= (-> % val :organization-id) org-id))) subscription (get team :subscription) @@ -586,7 +658,9 @@ :class (stl/css :dropdown :teams-dropdown) :team team :profile profile - :teams teams}] + :teams teams + :show-default-team true + :allow-create true}] [:> team-options-dropdown* {:show show-team-options-menu? :on-close close-team-options-menu @@ -703,6 +777,8 @@ [:* [:div {:class (stl/css-case :sidebar-content true) :ref container} + (when (contains? cf/flags :nitrate) + [:> sidebar-org-switch* {:team team :profile profile}]) [:> sidebar-team-switch* {:team team :profile profile}] [:> sidebar-search* {:search-term search-term