diff --git a/CHANGES.md b/CHANGES.md index ce587c767..4f440ef44 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ - Default naming of text layers [Taiga #2836](https://tree.taiga.io/project/penpot/us/2836) - Create typography style from a selected text layer[Taiga #3041](https://tree.taiga.io/project/penpot/us/3041) - Board as ruler origin [Taiga #4833](https://tree.taiga.io/project/penpot/us/4833) +- Access tokens support [Taiga #4460](https://tree.taiga.io/project/penpot/us/4460) ### :bug: Bugs fixed diff --git a/backend/src/app/http/access_token.clj b/backend/src/app/http/access_token.clj index 35ef96ce2..3f39e4121 100644 --- a/backend/src/app/http/access_token.clj +++ b/backend/src/app/http/access_token.clj @@ -26,12 +26,18 @@ (when token (tokens/verify props {:token token :iss "access-token"}))) -(defn- get-token-perms +(def sql:get-token-data + "SELECT perms, profile_id, expires_at + FROM access_token + WHERE id = ? + AND (expires_at IS NULL + OR (expires_at > now()));") + +(defn- get-token-data [pool token-id] (when-not (db/read-only? pool) - (when-let [token (db/get* pool :access-token {:id token-id} {:columns [:perms]})] - (some-> (:perms token) - (db/decode-pgarray #{}))))) + (some-> (db/exec-one! pool [sql:get-token-data token-id]) + (update :perms db/decode-pgarray #{})))) (defn- wrap-soft-auth "Soft Authentication, will be executed synchronously on the undertow @@ -56,10 +62,14 @@ "Authorization middleware, will be executed synchronously on vthread." [handler {:keys [::db/pool]}] (fn [request] - (let [perms (some->> (::id request) (get-token-perms pool))] + (let [{:keys [perms profile-id expires-at]} (some->> (::id request) (get-token-data pool))] (handler (cond-> request (some? perms) - (assoc ::perms perms)))))) + (assoc ::perms perms) + (some? profile-id) + (assoc ::profile-id profile-id) + (some? expires-at) + (assoc ::expires-at expires-at)))))) (def soft-auth {:name ::soft-auth diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index d37ba4601..d98c91c9b 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -17,6 +17,7 @@ [app.config :as cf] [app.db :as db] [app.http :as-alias http] + [app.http.access-token :as-alias actoken] [app.http.client :as http.client] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] @@ -152,7 +153,11 @@ (dissoc :profile-id) (dissoc :type))) - (clean-props))] + (clean-props)) + + token-id (::actoken/id request) + context (d/without-nils + {:access-token-id (some-> token-id str)})] {::type (or (::type resultm) (::rpc/type cfg)) @@ -161,6 +166,7 @@ ::profile-id profile-id ::ip-addr (some-> request parse-client-ip) ::props props + ::context context ;; NOTE: for batch-key lookup we need the params as-is ;; because the rpc api does not need to know the @@ -188,6 +194,7 @@ :type (::type event) :profile-id (::profile-id event) :ip-addr (::ip-addr event) + :context (::context event) :props (::props event)}] (when (contains? cf/flags :audit-log) @@ -201,6 +208,7 @@ (db/insert! conn-or-pool :audit-log (-> params (update :props db/tjson) + (update :context db/tjson) (update :ip-addr db/inet) (assoc :created-at now) (assoc :tracked-at now) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index b8b41d0e9..bd9c9e63a 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -315,7 +315,8 @@ {:name "0101-mod-server-error-report-table" :fn (mg/resource "app/migrations/sql/0101-mod-server-error-report-table.sql")} - ]) + {:name "0102-mod-access-token-table" + :fn (mg/resource "app/migrations/sql/0102-mod-access-token-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0102-mod-access-token-table.sql b/backend/src/app/migrations/sql/0102-mod-access-token-table.sql new file mode 100644 index 000000000..becbbd782 --- /dev/null +++ b/backend/src/app/migrations/sql/0102-mod-access-token-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE access_token + ADD COLUMN expires_at timestamptz NULL; diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index c49801ead..054beee6c 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -112,22 +112,6 @@ :hint "authentication required for this endpoint") (f cfg params))))) -(defn- wrap-access-token - "Wraps service method with access token validation." - [_ f {:keys [::sv/name] :as mdata}] - (if (contains? cf/flags :access-tokens) - (fn [cfg params] - (let [request (::http/request params)] - (if (contains? request ::actoken/id) - (let [perms (::actoken/perms request #{})] - (if (contains? perms name) - (f cfg params) - (ex/raise :type :authorization - :code :operation-not-allowed - :allowed perms))) - (f cfg params)))) - f)) - (defn- wrap-audit [_ f mdata] (if (or (contains? cf/flags :webhooks) @@ -157,8 +141,7 @@ (rlimit/wrap cfg $ mdata) (wrap-audit cfg $ mdata) (wrap-spec-conform cfg $ mdata) - (wrap-authentication cfg $ mdata) - (wrap-access-token cfg $ mdata))) + (wrap-authentication cfg $ mdata))) (defn- wrap [cfg f mdata] diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 9abf99c49..dd10f3371 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -19,18 +19,19 @@ [clojure.spec.alpha :as s])) (defn- decode-row - [{:keys [perms] :as row}] - (cond-> row - (db/pgarray? perms "text") - (assoc :perms (db/decode-pgarray perms #{})))) + [row] + (dissoc row :perms)) -(defn- create-access-token - [{:keys [::conn ::main/props]} profile-id name perms] +(defn create-access-token + [{:keys [::db/conn ::main/props]} profile-id name expiration] (let [created-at (dt/now) token-id (uuid/next) token (tokens/generate props {:iss "access-token" :tid token-id - :iat created-at})] + :iat created-at}) + + expires-at (some-> expiration dt/in-future)] + (db/insert! conn :access-token {:id token-id :name name @@ -38,33 +39,36 @@ :profile-id profile-id :created-at created-at :updated-at created-at - :perms (db/create-array conn "text" perms)}))) + :expires-at expires-at + :perms (db/create-array conn "text" [])}))) + (defn repl-create-access-token - [{:keys [::db/pool] :as system} profile-id name perms] + [{:keys [::db/pool] :as system} profile-id name expiration] (db/with-atomic [conn pool] (let [props (:app.setup/props system)] - (create-access-token {::conn conn ::main/props props} + (create-access-token {::db/conn conn ::main/props props} profile-id name - perms)))) + expiration)))) (s/def ::name ::us/not-empty-string) -(s/def ::perms ::us/set-of-strings) +(s/def ::expiration ::dt/duration) (s/def ::create-access-token (s/keys :req [::rpc/profile-id] - :req-un [::name ::perms])) + :req-un [::name] + :opt-un [::expiration])) (sv/defmethod ::create-access-token {::doc/added "1.18"} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name perms]}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name expiration]}] (db/with-atomic [conn pool] - (let [cfg (assoc cfg ::conn conn)] + (let [cfg (assoc cfg ::db/conn conn)] (quotes/check-quote! conn {::quotes/id ::quotes/access-tokens-per-profile ::quotes/profile-id profile-id}) - (-> (create-access-token cfg profile-id name perms) + (-> (create-access-token cfg profile-id name expiration) (decode-row))))) (s/def ::delete-access-token @@ -83,5 +87,8 @@ (sv/defmethod ::get-access-tokens {::doc/added "1.18"} [{:keys [::db/pool]} {:keys [::rpc/profile-id]}] - (->> (db/query pool :access-token {:profile-id profile-id}) + (->> (db/query pool :access-token + {:profile-id profile-id} + {:order-by [[:expires-at :asc] [:created-at :asc]] + :columns [:id :name :perms :created-at :updated-at :expires-at]}) (mapv decode-row))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 7c99f12d7..363ee61d3 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -491,6 +491,7 @@ ([key default] (get data key (get cf/config key default))))) + (defn reset-mock! [m] (swap! m (fn [m] diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index 7868eb179..30e12c028 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -19,12 +19,14 @@ (t/use-fixtures :each th/database-reset) (t/deftest access-tokens-crud - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - atoken (atom nil)] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + atoken-no-expiration (atom nil) + atoken-future-expiration (atom nil) + atoken-past-expiration (atom nil)] - (t/testing "create access token" + (t/testing "create access token without expiration date" (let [params {::th/type :create-access-token ::rpc/profile-id (:id prof) :name "token 1" @@ -34,32 +36,65 @@ (t/is (nil? (:error out))) (let [result (:result out)] - (reset! atoken result) + (reset! atoken-no-expiration result) (t/is (contains? result :id)) (t/is (contains? result :created-at)) (t/is (contains? result :updated-at)) - (t/is (contains? result :token)) - (t/is (contains? result :perms))))) + (t/is (contains? result :token))))) - (t/testing "get access token" + (t/testing "create access token with expiration date in the future" + (let [params {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :name "token 1" + :perms ["get-profile"] + :expiration "130h"} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + + (let [result (:result out)] + (reset! atoken-past-expiration result) + (t/is (contains? result :id)) + (t/is (contains? result :created-at)) + (t/is (contains? result :updated-at)) + (t/is (contains? result :expires-at)) + (t/is (contains? result :token))))) + + (t/testing "create access token with expiration date in the past" + (let [params {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :name "token 1" + :perms ["get-profile"] + :expiration "-130h"} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + + (let [result (:result out)] + (reset! atoken-future-expiration result) + (t/is (contains? result :id)) + (t/is (contains? result :created-at)) + (t/is (contains? result :updated-at)) + (t/is (contains? result :expires-at)) + (t/is (contains? result :token))))) + + (t/testing "get access tokens" (let [params {::th/type :get-access-tokens ::rpc/profile-id (:id prof)} out (th/command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) (let [[result :as results] (:result out)] - (t/is (= 1 (count results))) + (t/is (= 3 (count results))) (t/is (contains? result :id)) (t/is (contains? result :created-at)) (t/is (contains? result :updated-at)) - (t/is (contains? result :token)) - (t/is (contains? result :perms)) - (t/is (= @atoken result))))) + (t/is (not (contains? result :token)))))) (t/testing "delete access token" (let [params {::th/type :delete-access-token ::rpc/profile-id (:id prof) - :id (:id @atoken)} + :id (:id @atoken-no-expiration)} out (th/command! params)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -72,5 +107,4 @@ ;; (th/print-result! out) (t/is (nil? (:error out))) (let [results (:result out)] - (t/is (= 0 (count results)))))) - )) + (t/is (= 2 (count results)))))))) diff --git a/frontend/resources/images/icons/icon-key.svg b/frontend/resources/images/icons/icon-key.svg new file mode 100644 index 000000000..01f6a28de --- /dev/null +++ b/frontend/resources/images/icons/icon-key.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/resources/styles/main/partials/dashboard-settings.scss b/frontend/resources/styles/main/partials/dashboard-settings.scss index 0476df978..0cd114687 100644 --- a/frontend/resources/styles/main/partials/dashboard-settings.scss +++ b/frontend/resources/styles/main/partials/dashboard-settings.scss @@ -156,3 +156,148 @@ } } } + +.dashboard-access-tokens { + display: flex; + flex-direction: column; + align-items: center; + + .access-tokens-hero-container { + max-width: 1000px; + width: 100%; + display: flex; + flex-direction: column; + } + + .access-tokens-hero { + font-size: $fs14; + padding: $size-6; + background-color: $color-white; + margin-top: $size-6; + display: flex; + justify-content: space-between; + + .desc { + width: 80%; + color: $color-gray-40; + h2 { + margin-bottom: $size-4; + color: $color-black; + } + p { + font-size: $fs16; + } + } + + .btn-primary { + flex-shrink: 0; + } + } + + .access-tokens-empty { + text-align: center; + max-width: 1000px; + width: 100%; + padding: $size-6; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border: 1px dashed $color-gray-20; + color: $color-gray-40; + margin-top: 12px; + min-height: 136px; + } + + .table-row { + background-color: $color-white; + display: grid; + grid-template-columns: 1fr 43% 12px; + height: 63px; + &:not(:first-child) { + margin-top: 8px; + } + } + + .table-field { + &.name { + color: $color-gray-60; + } + + &.expiration-date { + color: $color-gray-40; + font-size: $fs14; + .content { + padding: 2px 5px; + &.expired { + background-color: $color-warning-lighter; + border-radius: $br4; + color: $color-gray-40; + } + } + } + + &.access-token-created { + word-break: break-all; + } + + &.actions { + position: relative; + } + } +} + +.access-tokens-modal { + .action-buttons { + gap: 10px; + + .cancel-button { + border: 1px solid $color-gray-30; + background: $color-canvas; + border-radius: $br3; + padding: 0.5rem 1rem; + cursor: pointer; + margin-right: 8px; + + &:hover { + background: $color-gray-20; + } + } + } + .access-token-created { + position: relative; + word-break: break-all; + + .custom-input input { + background-color: $color-success-lighter; + border: 0; + padding: 0 0 0 15px; + } + } + + .help-icon { + border: none; + height: 40px; + width: 40px; + position: absolute; + top: 0; + right: 0; + cursor: pointer; + background-color: $color-success-lighter; + + svg { + fill: $color-gray-30; + } + + &:hover { + svg { + fill: $color-gray-60; + } + } + } + + .token-created-info { + font-size: $fs12; + color: $color-gray-40; + } +} diff --git a/frontend/resources/styles/main/partials/forms.scss b/frontend/resources/styles/main/partials/forms.scss index 7146bacbd..6299fe81f 100644 --- a/frontend/resources/styles/main/partials/forms.scss +++ b/frontend/resources/styles/main/partials/forms.scss @@ -337,6 +337,10 @@ textarea { border: 1px solid $color-gray-20; height: 40px; + &.focus { + border-color: $color-gray-60; + } + &.invalid { border-color: $color-danger; label { diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index 6ad1b7880..0f86b3c63 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -525,4 +525,56 @@ (->> (rp/cmd! :create-demo-profile {}) (rx/map login))))) +;; --- EVENT: fetch-team-webhooks +(defn access-tokens-fetched + [access-tokens] + (ptk/reify ::access-tokens-fetched + ptk/UpdateEvent + (update [_ state] + (assoc state :access-tokens access-tokens)))) + +(defn fetch-access-tokens + [] + (ptk/reify ::fetch-access-tokens + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/command! :get-access-tokens) + (rx/map access-tokens-fetched))))) + +;; --- EVENT: create-access-token + +(defn access-token-created + [access-token] + (ptk/reify ::access-token-created + ptk/UpdateEvent + (update [_ state] + (assoc state :access-token-created access-token)))) + +(defn create-access-token + [{:keys [] :as params}] + (ptk/reify ::create-access-token + ptk/WatchEvent + (watch [_ _ _] + (let [{:keys [on-success on-error] + :or {on-success identity + on-error rx/throw}} (meta params)] + (->> (rp/command! :create-access-token params) + (rx/map access-token-created) + (rx/tap on-success) + (rx/catch on-error)))))) + +;; --- EVENT: delete-access-token + +(defn delete-access-token + [{:keys [id] :as params}] + (us/assert! ::us/uuid id) + (ptk/reify ::delete-access-token + ptk/WatchEvent + (watch [_ _ _] + (let [{:keys [on-success on-error] + :or {on-success identity + on-error rx/throw}} (meta params)] + (->> (rp/command! :delete-access-token params) + (rx/tap on-success) + (rx/catch on-error)))))) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 5808325fd..9d4a4673c 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -55,7 +55,8 @@ (:settings-profile :settings-password :settings-options - :settings-feedback) + :settings-feedback + :settings-access-tokens) [:& settings/settings {:route route}] :debug-icons-preview diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 55e63f9e4..f431ccfe0 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -191,25 +191,38 @@ [:span.hint hint])]])) (mf/defc select - [{:keys [options label form default data-test] :as props + [{:keys [options disabled label form default data-test] :as props :or {default ""}}] (let [input-name (get props :name) - form (or form (mf/use-ctx form-ctx)) - value (or (get-in @form [:data input-name]) default) - cvalue (d/seek #(= value (:value %)) options) - on-change (fn [event] - (let [target (dom/get-target event) - value (dom/get-value target)] - (fm/on-input-change form input-name value)))] + form (or form (mf/use-ctx form-ctx)) + value (or (get-in @form [:data input-name]) default) + cvalue (d/seek #(= value (:value %)) options) + focus? (mf/use-state false) + on-change + (fn [event] + (let [target (dom/get-target event) + value (dom/get-value target)] + (fm/on-input-change form input-name value))) + + on-focus + (fn [_] + (reset! focus? true)) + + on-blur + (fn [_] + (reset! focus? false))] [:div.custom-select [:select {:value value :on-change on-change + :on-focus on-focus + :on-blur on-blur + :disabled disabled :data-test data-test} (for [item options] [:option {:key (:value item) :value (:value item)} (:label item)])] - [:div.input-container + [:div.input-container {:class (dom/classnames :disabled disabled :focus @focus?)} [:div.main-content [:label label] [:span.value (:label cvalue "")]] diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 3a228cf64..e83f008c0 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -150,6 +150,7 @@ (def justify-content-row-center (icon-xref :justify-content-row-center)) (def justify-content-row-end (icon-xref :justify-content-row-end)) (def justify-content-row-start (icon-xref :justify-content-row-start)) +(def icon-key (icon-xref :icon-key)) (def layers (icon-xref :layers)) (def layout-columns (icon-xref :layout-columns)) (def layout-rows (icon-xref :layout-rows)) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 0c18d47ba..2b718d916 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -33,19 +33,20 @@ (def routes [["/auth" - ["/login" :auth-login] - ["/register" :auth-register] + ["/login" :auth-login] + ["/register" :auth-register] ["/register/validate" :auth-register-validate] - ["/register/success" :auth-register-success] - ["/recovery/request" :auth-recovery-request] - ["/recovery" :auth-recovery] - ["/verify-token" :auth-verify-token]] + ["/register/success" :auth-register-success] + ["/recovery/request" :auth-recovery-request] + ["/recovery" :auth-recovery] + ["/verify-token" :auth-verify-token]] ["/settings" - ["/profile" :settings-profile] - ["/password" :settings-password] - ["/feedback" :settings-feedback] - ["/options" :settings-options]] + ["/profile" :settings-profile] + ["/password" :settings-password] + ["/feedback" :settings-feedback] + ["/options" :settings-options] + ["/access-tokens" :settings-access-tokens]] ["/view/:file-id" {:name :viewer diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs index 4b221c96f..2c890d51e 100644 --- a/frontend/src/app/main/ui/settings.cljs +++ b/frontend/src/app/main/ui/settings.cljs @@ -8,6 +8,7 @@ (:require [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.settings.access-tokens :refer [access-tokens-page]] [app.main.ui.settings.change-email] [app.main.ui.settings.delete-account] [app.main.ui.settings.feedback :refer [feedback-page]] @@ -55,5 +56,8 @@ [:& password-page {:locale locale}] :settings-options - [:& options-page {:locale locale}])]]])) + [:& options-page {:locale locale}] + + :settings-access-tokens + [:& access-tokens-page])]]])) diff --git a/frontend/src/app/main/ui/settings/access_tokens.cljs b/frontend/src/app/main/ui/settings/access_tokens.cljs new file mode 100644 index 000000000..06ffa7ffd --- /dev/null +++ b/frontend/src/app/main/ui/settings/access_tokens.cljs @@ -0,0 +1,278 @@ +;; 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.main.ui.settings.access-tokens + (:require + [app.common.spec :as us] + [app.main.data.messages :as dm] + [app.main.data.modal :as modal] + [app.main.data.users :as du] + [app.main.store :as st] + [app.main.ui.components.context-menu-a11y.context-menu-a11y :refer [context-menu-a11y]] + [app.main.ui.components.forms :as fm] + [app.main.ui.icons :as i] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr]] + [app.util.keyboard :as kbd] + [app.util.time :as dt] + [app.util.webapi :as wapi] + [cljs.spec.alpha :as s] + [cuerdas.core :as str] + [okulary.core :as l] + [rumext.v2 :as mf])) + +(def tokens-ref + (l/derived :access-tokens st/state)) + +(def token-created-ref + (l/derived :access-token-created st/state)) + +(s/def ::name ::us/not-empty-string) +(s/def ::expiration-date ::us/not-empty-string) +(s/def ::access-token-form + (s/keys :req-un [::name ::expiration-date])) + +(defn- name-validator + [errors data] + (let [name (:name data)] + (cond-> errors + (str/blank? name) + (assoc :name {:message (tr "dashboard.access-tokens.errors-required-name")})))) + + +(def initial-data + {:name "" :expiration-date "never"}) + +(mf/defc access-token-modal + {::mf/register modal/components + ::mf/register-as :access-token} + [] + (let [form (fm/use-form + :initial initial-data + :spec ::access-token-form + :validators [name-validator]) + created (mf/deref token-created-ref) + created? (mf/use-state false) + locale (mf/deref i18n/locale) + + on-success + (mf/use-fn + (mf/deps created) + (fn [_] + (let [message (tr "dashboard.access-tokens.create.success")] + (st/emit! (du/fetch-access-tokens) + (dm/success message) + (reset! created? true))))) + + on-close + (mf/use-fn + (mf/deps created) + (fn [_] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-error + (mf/use-fn + (fn [_] + (st/emit! (dm/error (tr "errors.generic")) + (modal/hide)))) + + on-submit + (mf/use-fn + (fn [form] + (let [cdata (:clean-data @form) + mdata {:on-success (partial on-success form) + :on-error (partial on-error form)} + expiration (:expiration-date cdata) + params (cond-> {:name (:name cdata) + :perms (:perms cdata)} + (not= "never" expiration) (assoc :expiration expiration))] + (st/emit! (du/create-access-token + (with-meta params mdata)))))) + + copy-token + (fn [event] + (dom/prevent-default event) + (wapi/write-to-clipboard (:token created)) + (st/emit! (dm/show {:type :info + :content (tr "dashboard.access-tokens.copied-success") + :timeout 1000})))] + + [:div.modal-overlay + [:div.modal-container.access-tokens-modal + [:& fm/form {:form form :on-submit on-submit} + + [:div.modal-header + [:div.modal-header-title + [:h2 (tr "modals.create-access-token.title")]] + + [:div.modal-close-button + {:on-click on-close} i/close]] + + [:div.modal-content.generic-form + [:div.fields-container + [:div.fields-row + [:& fm/input {:type "text" + :auto-focus? true + :form form + :name :name + :disabled @created? + :label (tr "modals.create-access-token.name.label") + :placeholder (tr "modals.create-access-token.name.placeholder")}]] + + [:div.fields-row + [:& fm/select {:options [{:label (tr "dashboard.access-tokens.expiration-never") :value "never" :key "never"} + {:label (tr "dashboard.access-tokens.expiration-30-days") :value "720h" :key "720h"} + {:label (tr "dashboard.access-tokens.expiration-60-days") :value "1440h" :key "1440h"} + {:label (tr "dashboard.access-tokens.expiration-90-days") :value "2160h" :key "2160h"} + {:label (tr "dashboard.access-tokens.expiration-180-days") :value "4320h" :key "4320h"}] + :label (tr "modals.create-access-token.expiration-date.label") + :default "never" + :disabled @created? + :name :expiration-date}] + (when @created? + [:span.token-created-info + (if (:expires-at created) + (tr "dashboard.access-tokens.token-will-expire" (dt/format-date-locale (:expires-at created) {:locale locale})) + (tr "dashboard.access-tokens.token-will-not-expire"))])] + + [:div.fields-row.access-token-created + (when @created? + [:div.custom-input.with-icon + [:input {:type "text" + :value (:token created "") + :placeholder (tr "modals.create-access-token.token") + :read-only true}] + [:button.help-icon {:title (tr "modals.create-access-token.copy-token") + :on-click copy-token} + + i/copy]])]]] + + [:div.modal-footer + [:div.action-buttons + (if @created? + [:input.cancel-button + {:type "button" + :value (tr "labels.close") + :on-click #(modal/hide!)}] + [:* + [:input.cancel-button + {:type "button" + :value (tr "labels.cancel") + :on-click #(modal/hide!)}] + [:& fm/submit-button + {:label (tr "modals.create-access-token.submit-label")}]])]]]]])) + +(mf/defc access-tokens-hero + [] + (let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))] + [:div.access-tokens-hero-container + [:div.access-tokens-hero + [:div.desc + [:h2 (tr "dashboard.access-tokens.personal")] + [:p (tr "dashboard.access-tokens.personal.description")]] + + [:button.btn-primary + {:on-click on-click} + [:span (tr "dashboard.access-tokens.create")]]]])) + +(mf/defc access-token-actions + [{:keys [on-delete] :as props}] + (let [local (mf/use-state {:menu-open false}) + show? (:menu-open @local) + menu-ref (mf/use-ref) + options [{:option-name (tr "labels.delete") + :id "access-token-delete" + :option-handler on-delete}] + + on-menu-close + (mf/use-fn + #(swap! local assoc :menu-open false)) + + on-menu-click + (mf/use-fn + (fn [event] + (dom/prevent-default event) + (swap! local assoc :menu-open true)))] + + [:div.icon + {:tab-index "0" + :ref menu-ref + :on-click on-menu-click + :on-key-down (fn [event] + (when (kbd/enter? event) + (dom/stop-propagation event) + (on-menu-click event)))} + i/actions + [:& context-menu-a11y + {:on-close on-menu-close + :show show? + :fixed? true + :min-width? true + :top "auto" + :left "auto" + :options options}]])) + +(mf/defc access-token-item + {::mf/wrap [mf/memo]} + [{:keys [token] :as props}] + (let [locale (mf/deref i18n/locale) + expires-at (:expires-at token) + expires-txt (some-> expires-at (dt/format-date-locale {:locale locale})) + expired? (and (some? expires-at) (> (dt/now) expires-at)) + + delete-fn + (mf/use-fn + (mf/deps token) + (fn [] + (let [params {:id (:id token)} + mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] + (st/emit! (du/delete-access-token (with-meta params mdata)))))) + + on-delete + (mf/use-fn + (mf/deps delete-fn) + (fn [] + (st/emit! (modal/show + {:type :confirm + :title (tr "modals.delete-acces-token.title") + :message (tr "modals.delete-acces-token.message") + :accept-label (tr "modals.delete-acces-token.accept") + :on-accept delete-fn}))))] + + [:div.table-row + [:div.table-field.name + (str (:name token))] + [:div.table-field.expiration-date + [:span.content {:class (when expired? "expired")} + (cond + (nil? expires-at) (tr "dashboard.access-tokens.no-expiration") + expired? (tr "dashboard.access-tokens.expired-on" expires-txt) + :else (tr "dashboard.access-tokens.expires-on" expires-txt))]] + [:div.table-field.actions + [:& access-token-actions + {:on-delete on-delete :key (:id token)}]]])) + +(mf/defc access-tokens-page + [] + (mf/with-effect [] + (dom/set-html-title (tr "title.settings.access-tokens")) + (st/emit! (du/fetch-access-tokens))) + + (let [tokens (mf/deref tokens-ref)] + [:div.dashboard-access-tokens + [:div + [:& access-tokens-hero] + (if (empty? tokens) + [:div.access-tokens-empty + [:div (tr "dashboard.access-tokens.empty.no-access-tokens")] + [:div (tr "dashboard.access-tokens.empty.add-one")]] + [:div.dashboard-table + [:div.table-rows + (for [token tokens] + [:& access-token-item {:token token :key (:id token)}])]])]])) + + diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 5ea4316be..f2adb5712 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -21,44 +21,50 @@ (mf/defc sidebar-content [{:keys [profile section] :as props}] - (let [profile? (= section :settings-profile) - password? (= section :settings-password) - options? (= section :settings-options) - feedback? (= section :settings-feedback) + (let [profile? (= section :settings-profile) + password? (= section :settings-password) + options? (= section :settings-options) + feedback? (= section :settings-feedback) + access-tokens? (= section :settings-access-tokens) go-dashboard (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)}))) + (mf/deps profile) + #(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)}))) go-settings-profile (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-profile))) + (mf/deps profile) + #(st/emit! (rt/nav :settings-profile))) go-settings-feedback (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-feedback))) + (mf/deps profile) + #(st/emit! (rt/nav :settings-feedback))) go-settings-password (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-password))) + (mf/deps profile) + #(st/emit! (rt/nav :settings-password))) go-settings-options (mf/use-callback - (mf/deps profile) - #(st/emit! (rt/nav :settings-options))) + (mf/deps profile) + #(st/emit! (rt/nav :settings-options))) + go-settings-access-tokens + (mf/use-callback + (mf/deps profile) + #(st/emit! (rt/nav :settings-access-tokens))) + show-release-notes (mf/use-callback - (fn [event] - (let [version (:main @cf/version)] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) - (if (and (kbd/alt? event) (kbd/mod? event)) - (st/emit! (modal/show {:type :onboarding})) - (st/emit! (modal/show {:type :release-notes :version version}))))))] + (fn [event] + (let [version (:main @cf/version)] + (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) + (if (and (kbd/alt? event) (kbd/mod? event)) + (st/emit! (modal/show {:type :onboarding})) + (st/emit! (modal/show {:type :release-notes :version version}))))))] [:div.sidebar-content [:div.sidebar-content-section @@ -85,6 +91,13 @@ i/tree [:span.element-title (tr "labels.settings")]] + (when (contains? @cf/flags :access-tokens) + [:li {:class (when access-tokens? "current") + :on-click go-settings-access-tokens + :data-test "settings-access-tokens"} + i/icon-key + [:span.element-title (tr "labels.access-tokens")]]) + [:hr] [:li {:on-click show-release-notes :data-test "release-notes"} diff --git a/frontend/src/app/util/time.cljs b/frontend/src/app/util/time.cljs index c6ed948ad..3b2b38c7f 100644 --- a/frontend/src/app/util/time.cljs +++ b/frontend/src/app/util/time.cljs @@ -6,6 +6,7 @@ (ns app.util.time (:require + ["date-fns/format" :default dateFnsFormat] ["date-fns/formatDistanceToNowStrict" :default dateFnsFormatDistanceToNowStrict] ["date-fns/locale/ar-SA" :default dateFnsLocalesAr] ["date-fns/locale/ca" :default dateFnsLocalesCa] @@ -232,3 +233,13 @@ :addSuffix true :locale (obj/get locales locale)} (dateFnsFormatDistanceToNowStrict v)))))) + +(defn format-date-locale + ([v] (format-date-locale v nil)) + ([v {:keys [locale] :or {locale "en"}}] + (when v + (let [v (if (datetime? v) (format v :date) v) + locale (obj/get locales locale) + f (.date (.-formatLong locale) v)] + (->> #js {:locale locale} + (dateFnsFormat v f)))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 6b3b15d8d..8e996d873 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -271,6 +271,114 @@ msgstr "Add as Shared Library" msgid "dashboard.change-email" msgstr "Change email" +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.personal" +msgstr "Personal access tokens" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.personal.description" +msgstr "Personal access tokens function like an alternative to our login/password authentication system and can be used to allow an application to access the internal Penpot API" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.create" +msgstr "Generate new token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.empty.no-access-tokens" +msgstr "You have no tokens so far." + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.empty.add-one" +msgstr "Press the button \"Generate new token\" to generate one." + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.title" +msgstr "Generate access token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.name.label" +msgstr "Name" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.name.placeholder" +msgstr "The name can help to know what's the token for" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.expiration-date.label" +msgstr "Expiration date" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.submit-label" +msgstr "Create token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.delete-acces-token.title" +msgstr "Delete token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.delete-acces-token.message" +msgstr "Are you sure you want to delete this token?" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.delete-acces-token.accept" +msgstr "Delete token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.create.success" +msgstr "Access token created successfully." + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expires-on" +msgstr "Expires on %s" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expired-on" +msgstr "Expired on %s" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.no-expiration" +msgstr "No expiration date" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.copy-token" +msgstr "Copy token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.copied-success" +msgstr "Copied token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-never" +msgstr "Never" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-30-days" +msgstr "30 days" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-60-days" +msgstr "60 days" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-90-days" +msgstr "90 days" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-180-days" +msgstr "180 days" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.errors-required-name" +msgstr "The name is required" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.token-will-expire" +msgstr "The token will expire on %s" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.token-will-not-expire" +msgstr "The token has no expiration date" + #: src/app/main/data/dashboard.cljs, src/app/main/data/dashboard.cljs msgid "dashboard.copy-suffix" msgstr "(copy)" @@ -1429,6 +1537,10 @@ msgstr "Owner" msgid "labels.password" msgstr "Password" +#: src/app/main/ui/settings/sidebar.cljs +msgid "labels.access-tokens" +msgstr "Access tokens" + #: src/app/main/ui/dashboard/team.cljs msgid "labels.pending-invitation" msgstr "Pending" @@ -2699,6 +2811,10 @@ msgstr "Password - Penpot" msgid "title.settings.profile" msgstr "Profile - Penpot" +#: src/app/main/ui/settings/access-tokens.cljs +msgid "title.settings.access-tokens" +msgstr "Profile - Access tokens" + #: src/app/main/ui/dashboard/team.cljs msgid "title.team-invitations" msgstr "Invitations - %s - Penpot" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index cf4006f53..d427e58bb 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -277,6 +277,115 @@ msgstr "Añadir como Biblioteca Compartida" msgid "dashboard.change-email" msgstr "Cambiar correo" +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.personal" +msgstr "Access tokens personales" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.personal.description" +msgstr "Los access tokens personales funcionan como una alternativa a nuestro sistema de autenticación " +"usuario/password y se pueden usar para permitir a otras aplicaciones acceso a la API interna de Penpot" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.create" +msgstr "Generar nuevo token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.empty.no-access-tokens" +msgstr "Todavía no tienes ningún token." + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.empty.add-one" +msgstr "Pulsa el botón \"Generar nuevo token\" para generar uno." + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.title" +msgstr "Generar access token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.name.label" +msgstr "Nombre" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.name.placeholder" +msgstr "El nombre te pude ayudar a saber para qué se utiliza el token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.expiration-date.label" +msgstr "Fecha de expiración" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.submit-label" +msgstr "Crear token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.delete-acces-token.title" +msgstr "Borrar token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.delete-acces-token.message" +msgstr "¿Seguro que deseas borrar este token?" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.delete-acces-token.accept" +msgstr "Borrar token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.create.success" +msgstr "Access token creado con éxito." + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expires-on" +msgstr "Expira el %s" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expired-on" +msgstr "Expiró el %s" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.no-expiration" +msgstr "Sin fecha de expiración" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "modals.create-access-token.copy-token" +msgstr "Copiar token" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.copied-success" +msgstr "Token copiado" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-never" +msgstr "Nunca" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-30-days" +msgstr "30 días" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-60-days" +msgstr "60 días" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-90-days" +msgstr "90 días" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.expiration-180-days" +msgstr "180 días" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.errors-required-name" +msgstr "El nombre es obligatorio" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.token-will-expire" +msgstr "El token expirará el %s" + +#: src/app/main/ui/settings/access-tokens.cljs +msgid "dashboard.access-tokens.token-will-not-expire" +msgstr "El token no tiene fecha de expiración" + #: src/app/main/data/dashboard.cljs, src/app/main/data/dashboard.cljs msgid "dashboard.copy-suffix" msgstr "(copia)" @@ -1479,6 +1588,10 @@ msgstr "Propiedad" msgid "labels.password" msgstr "Contraseña" +#: src/app/main/ui/settings/sidebar.cljs +msgid "labels.access-tokens" +msgstr "Access tokens" + #: src/app/main/ui/dashboard/team.cljs msgid "labels.pending-invitation" msgstr "Pendiente" @@ -2775,6 +2888,10 @@ msgstr "Contraseña - Penpot" msgid "title.settings.profile" msgstr "Perfil - Penpot" +#: src/app/main/ui/settings/access-tokens.cljs +msgid "title.settings.access-tokens" +msgstr "Perfil - Access tokens" + #: src/app/main/ui/dashboard/team.cljs msgid "title.team-invitations" msgstr "Invitaciones - %s - Penpot"