0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-09 13:31:23 -05:00

Add mvp access-token support

This commit is contained in:
Alejandro Alonso 2023-05-03 07:27:09 +02:00 committed by Andrey Antukh
parent b90aef4e1d
commit 890583a13a
23 changed files with 907 additions and 100 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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]

View file

@ -0,0 +1,2 @@
ALTER TABLE access_token
ADD COLUMN expires_at timestamptz NULL;

View file

@ -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]

View file

@ -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)))

View file

@ -491,6 +491,7 @@
([key default]
(get data key (get cf/config key default)))))
(defn reset-mock!
[m]
(swap! m (fn [m]

View file

@ -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))))))))

View file

@ -0,0 +1,4 @@
<svg width="500" height="500" viewBox="0 0 132.292 132.292" xmlns="http://www.w3.org/2000/svg">
<path d="m 124.4936,4.1479733 c 2.35562,2.3556156 2.35562,6.1748267 0,8.5304757 l -7.79862,7.798611 13.83031,13.83063 c 2.35561,2.355624 2.35561,6.17481 0,8.530434 l -21.11215,21.111814 c -2.35563,2.355624 -6.17473,2.355624 -8.53035,0 L 87.052489,50.119308 70.486062,66.685653 c 1.854567,2.508174 3.40751,5.234628 4.621289,8.123967 2.002817,4.767224 3.042881,9.883616 3.060161,15.054661 0.01736,5.170963 -0.988386,10.293969 -2.959206,15.074669 -1.97082,4.7807 -4.867849,9.12402 -8.524233,12.78024 -3.656384,3.65704 -7.999943,6.55341 -12.780562,8.52456 -4.780618,1.97115 -9.903873,2.97657 -15.074752,2.95921 -5.17088,-0.0174 -10.287272,-1.05751 -15.054578,-3.06008 -4.767307,-2.00258 -9.091352,-4.92871 -12.723181,-8.60973 l -0.04523,-0.0455 C 3.8638091,110.09256 -0.08784955,100.18884 0.00148225,89.908103 0.09081323,79.628273 4.2142341,69.794432 11.483678,62.525071 18.753039,55.255627 28.586797,51.132252 38.866875,51.042872 c 8.280816,-0.07193 16.317554,2.478324 22.995239,7.205861 L 115.96324,4.1479733 c 2.35563,-2.3556231 6.17473,-2.3556231 8.53036,0 z M 58.230947,70.879639 c -0.05614,-0.05036 -0.111622,-0.101947 -0.166275,-0.154781 -5.119368,-4.944413 -11.975896,-7.680376 -19.092955,-7.618529 -7.116977,0.06185 -13.92497,2.916541 -18.957605,7.949176 -5.032717,5.032634 -7.88733,11.840628 -7.949176,18.957604 -0.06177,7.10656 2.665849,13.952671 7.596205,19.069891 2.509661,2.53753 5.49516,4.55498 8.785675,5.93743 3.300437,1.38659 6.842553,2.10675 10.422375,2.11832 3.57982,0.0124 7.126733,-0.68378 10.436348,-2.04804 3.309696,-1.36509 6.316776,-3.37014 8.848099,-5.90188 2.531325,-2.53091 4.536954,-5.53807 5.90138,-8.84785 1.364427,-3.309781 2.060695,-6.85686 2.048706,-10.436185 -0.01199,-3.58015 -0.731987,-7.122185 -2.118572,-10.422622 -1.348965,-3.210892 -3.302835,-6.131319 -5.754205,-8.602534 z m 37.351893,-29.290765 9.56554,9.565371 12.58097,-12.58138 -9.56554,-9.565371 z"/>
</svg>

After

(image error) Size: 2 KiB

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -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))))))

View file

@ -55,7 +55,8 @@
(:settings-profile
:settings-password
:settings-options
:settings-feedback)
:settings-feedback
:settings-access-tokens)
[:& settings/settings {:route route}]
:debug-icons-preview

View file

@ -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 "")]]

View file

@ -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))

View file

@ -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

View file

@ -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])]]]))

View file

@ -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)}])]])]]))

View file

@ -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"}

View file

@ -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))))))

View file

@ -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"

View file

@ -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"