mirror of
https://github.com/penpot/penpot.git
synced 2025-05-20 01:06:10 +02:00
✨ Add mvp access-token support
This commit is contained in:
parent
b90aef4e1d
commit
890583a13a
23 changed files with 907 additions and 100 deletions
|
@ -8,6 +8,7 @@
|
||||||
- Default naming of text layers [Taiga #2836](https://tree.taiga.io/project/penpot/us/2836)
|
- 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)
|
- 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)
|
- 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
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
|
|
@ -26,12 +26,18 @@
|
||||||
(when token
|
(when token
|
||||||
(tokens/verify props {:token token :iss "access-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]
|
[pool token-id]
|
||||||
(when-not (db/read-only? pool)
|
(when-not (db/read-only? pool)
|
||||||
(when-let [token (db/get* pool :access-token {:id token-id} {:columns [:perms]})]
|
(some-> (db/exec-one! pool [sql:get-token-data token-id])
|
||||||
(some-> (:perms token)
|
(update :perms db/decode-pgarray #{}))))
|
||||||
(db/decode-pgarray #{})))))
|
|
||||||
|
|
||||||
(defn- wrap-soft-auth
|
(defn- wrap-soft-auth
|
||||||
"Soft Authentication, will be executed synchronously on the undertow
|
"Soft Authentication, will be executed synchronously on the undertow
|
||||||
|
@ -56,10 +62,14 @@
|
||||||
"Authorization middleware, will be executed synchronously on vthread."
|
"Authorization middleware, will be executed synchronously on vthread."
|
||||||
[handler {:keys [::db/pool]}]
|
[handler {:keys [::db/pool]}]
|
||||||
(fn [request]
|
(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
|
(handler (cond-> request
|
||||||
(some? perms)
|
(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
|
(def soft-auth
|
||||||
{:name ::soft-auth
|
{:name ::soft-auth
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.http :as-alias http]
|
[app.http :as-alias http]
|
||||||
|
[app.http.access-token :as-alias actoken]
|
||||||
[app.http.client :as http.client]
|
[app.http.client :as http.client]
|
||||||
[app.loggers.audit.tasks :as-alias tasks]
|
[app.loggers.audit.tasks :as-alias tasks]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
|
@ -152,7 +153,11 @@
|
||||||
(dissoc :profile-id)
|
(dissoc :profile-id)
|
||||||
(dissoc :type)))
|
(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)
|
{::type (or (::type resultm)
|
||||||
(::rpc/type cfg))
|
(::rpc/type cfg))
|
||||||
|
@ -161,6 +166,7 @@
|
||||||
::profile-id profile-id
|
::profile-id profile-id
|
||||||
::ip-addr (some-> request parse-client-ip)
|
::ip-addr (some-> request parse-client-ip)
|
||||||
::props props
|
::props props
|
||||||
|
::context context
|
||||||
|
|
||||||
;; NOTE: for batch-key lookup we need the params as-is
|
;; NOTE: for batch-key lookup we need the params as-is
|
||||||
;; because the rpc api does not need to know the
|
;; because the rpc api does not need to know the
|
||||||
|
@ -188,6 +194,7 @@
|
||||||
:type (::type event)
|
:type (::type event)
|
||||||
:profile-id (::profile-id event)
|
:profile-id (::profile-id event)
|
||||||
:ip-addr (::ip-addr event)
|
:ip-addr (::ip-addr event)
|
||||||
|
:context (::context event)
|
||||||
:props (::props event)}]
|
:props (::props event)}]
|
||||||
|
|
||||||
(when (contains? cf/flags :audit-log)
|
(when (contains? cf/flags :audit-log)
|
||||||
|
@ -201,6 +208,7 @@
|
||||||
(db/insert! conn-or-pool :audit-log
|
(db/insert! conn-or-pool :audit-log
|
||||||
(-> params
|
(-> params
|
||||||
(update :props db/tjson)
|
(update :props db/tjson)
|
||||||
|
(update :context db/tjson)
|
||||||
(update :ip-addr db/inet)
|
(update :ip-addr db/inet)
|
||||||
(assoc :created-at now)
|
(assoc :created-at now)
|
||||||
(assoc :tracked-at now)
|
(assoc :tracked-at now)
|
||||||
|
|
|
@ -315,7 +315,8 @@
|
||||||
{:name "0101-mod-server-error-report-table"
|
{:name "0101-mod-server-error-report-table"
|
||||||
:fn (mg/resource "app/migrations/sql/0101-mod-server-error-report-table.sql")}
|
: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!
|
(defn apply-migrations!
|
||||||
[pool name migrations]
|
[pool name migrations]
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE access_token
|
||||||
|
ADD COLUMN expires_at timestamptz NULL;
|
|
@ -112,22 +112,6 @@
|
||||||
:hint "authentication required for this endpoint")
|
:hint "authentication required for this endpoint")
|
||||||
(f cfg params)))))
|
(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
|
(defn- wrap-audit
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
(if (or (contains? cf/flags :webhooks)
|
(if (or (contains? cf/flags :webhooks)
|
||||||
|
@ -157,8 +141,7 @@
|
||||||
(rlimit/wrap cfg $ mdata)
|
(rlimit/wrap cfg $ mdata)
|
||||||
(wrap-audit cfg $ mdata)
|
(wrap-audit cfg $ mdata)
|
||||||
(wrap-spec-conform cfg $ mdata)
|
(wrap-spec-conform cfg $ mdata)
|
||||||
(wrap-authentication cfg $ mdata)
|
(wrap-authentication cfg $ mdata)))
|
||||||
(wrap-access-token cfg $ mdata)))
|
|
||||||
|
|
||||||
(defn- wrap
|
(defn- wrap
|
||||||
[cfg f mdata]
|
[cfg f mdata]
|
||||||
|
|
|
@ -19,18 +19,19 @@
|
||||||
[clojure.spec.alpha :as s]))
|
[clojure.spec.alpha :as s]))
|
||||||
|
|
||||||
(defn- decode-row
|
(defn- decode-row
|
||||||
[{:keys [perms] :as row}]
|
[row]
|
||||||
(cond-> row
|
(dissoc row :perms))
|
||||||
(db/pgarray? perms "text")
|
|
||||||
(assoc :perms (db/decode-pgarray perms #{}))))
|
|
||||||
|
|
||||||
(defn- create-access-token
|
(defn create-access-token
|
||||||
[{:keys [::conn ::main/props]} profile-id name perms]
|
[{:keys [::db/conn ::main/props]} profile-id name expiration]
|
||||||
(let [created-at (dt/now)
|
(let [created-at (dt/now)
|
||||||
token-id (uuid/next)
|
token-id (uuid/next)
|
||||||
token (tokens/generate props {:iss "access-token"
|
token (tokens/generate props {:iss "access-token"
|
||||||
:tid token-id
|
:tid token-id
|
||||||
:iat created-at})]
|
:iat created-at})
|
||||||
|
|
||||||
|
expires-at (some-> expiration dt/in-future)]
|
||||||
|
|
||||||
(db/insert! conn :access-token
|
(db/insert! conn :access-token
|
||||||
{:id token-id
|
{:id token-id
|
||||||
:name name
|
:name name
|
||||||
|
@ -38,33 +39,36 @@
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:created-at created-at
|
:created-at created-at
|
||||||
:updated-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
|
(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]
|
(db/with-atomic [conn pool]
|
||||||
(let [props (:app.setup/props system)]
|
(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
|
profile-id
|
||||||
name
|
name
|
||||||
perms))))
|
expiration))))
|
||||||
|
|
||||||
(s/def ::name ::us/not-empty-string)
|
(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/def ::create-access-token
|
||||||
(s/keys :req [::rpc/profile-id]
|
(s/keys :req [::rpc/profile-id]
|
||||||
:req-un [::name ::perms]))
|
:req-un [::name]
|
||||||
|
:opt-un [::expiration]))
|
||||||
|
|
||||||
(sv/defmethod ::create-access-token
|
(sv/defmethod ::create-access-token
|
||||||
{::doc/added "1.18"}
|
{::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]
|
(db/with-atomic [conn pool]
|
||||||
(let [cfg (assoc cfg ::conn conn)]
|
(let [cfg (assoc cfg ::db/conn conn)]
|
||||||
(quotes/check-quote! conn
|
(quotes/check-quote! conn
|
||||||
{::quotes/id ::quotes/access-tokens-per-profile
|
{::quotes/id ::quotes/access-tokens-per-profile
|
||||||
::quotes/profile-id profile-id})
|
::quotes/profile-id profile-id})
|
||||||
(-> (create-access-token cfg profile-id name perms)
|
(-> (create-access-token cfg profile-id name expiration)
|
||||||
(decode-row)))))
|
(decode-row)))))
|
||||||
|
|
||||||
(s/def ::delete-access-token
|
(s/def ::delete-access-token
|
||||||
|
@ -83,5 +87,8 @@
|
||||||
(sv/defmethod ::get-access-tokens
|
(sv/defmethod ::get-access-tokens
|
||||||
{::doc/added "1.18"}
|
{::doc/added "1.18"}
|
||||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id]}]
|
[{: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)))
|
(mapv decode-row)))
|
||||||
|
|
|
@ -491,6 +491,7 @@
|
||||||
([key default]
|
([key default]
|
||||||
(get data key (get cf/config key default)))))
|
(get data key (get cf/config key default)))))
|
||||||
|
|
||||||
|
|
||||||
(defn reset-mock!
|
(defn reset-mock!
|
||||||
[m]
|
[m]
|
||||||
(swap! m (fn [m]
|
(swap! m (fn [m]
|
||||||
|
|
|
@ -19,12 +19,14 @@
|
||||||
(t/use-fixtures :each th/database-reset)
|
(t/use-fixtures :each th/database-reset)
|
||||||
|
|
||||||
(t/deftest access-tokens-crud
|
(t/deftest access-tokens-crud
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
team-id (:default-team-id prof)
|
team-id (:default-team-id prof)
|
||||||
proj-id (:default-project-id prof)
|
proj-id (:default-project-id prof)
|
||||||
atoken (atom nil)]
|
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
|
(let [params {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:name "token 1"
|
:name "token 1"
|
||||||
|
@ -34,32 +36,65 @@
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
|
|
||||||
(let [result (:result out)]
|
(let [result (:result out)]
|
||||||
(reset! atoken result)
|
(reset! atoken-no-expiration result)
|
||||||
(t/is (contains? result :id))
|
(t/is (contains? result :id))
|
||||||
(t/is (contains? result :created-at))
|
(t/is (contains? result :created-at))
|
||||||
(t/is (contains? result :updated-at))
|
(t/is (contains? result :updated-at))
|
||||||
(t/is (contains? result :token))
|
(t/is (contains? result :token)))))
|
||||||
(t/is (contains? result :perms)))))
|
|
||||||
|
|
||||||
(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
|
(let [params {::th/type :get-access-tokens
|
||||||
::rpc/profile-id (:id prof)}
|
::rpc/profile-id (:id prof)}
|
||||||
out (th/command! params)]
|
out (th/command! params)]
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(let [[result :as results] (:result 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 :id))
|
||||||
(t/is (contains? result :created-at))
|
(t/is (contains? result :created-at))
|
||||||
(t/is (contains? result :updated-at))
|
(t/is (contains? result :updated-at))
|
||||||
(t/is (contains? result :token))
|
(t/is (not (contains? result :token))))))
|
||||||
(t/is (contains? result :perms))
|
|
||||||
(t/is (= @atoken result)))))
|
|
||||||
|
|
||||||
(t/testing "delete access token"
|
(t/testing "delete access token"
|
||||||
(let [params {::th/type :delete-access-token
|
(let [params {::th/type :delete-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:id (:id @atoken)}
|
:id (:id @atoken-no-expiration)}
|
||||||
out (th/command! params)]
|
out (th/command! params)]
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
|
@ -72,5 +107,4 @@
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(let [results (:result out)]
|
(let [results (:result out)]
|
||||||
(t/is (= 0 (count results))))))
|
(t/is (= 2 (count results))))))))
|
||||||
))
|
|
||||||
|
|
4
frontend/resources/images/icons/icon-key.svg
Normal file
4
frontend/resources/images/icons/icon-key.svg
Normal 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 Width: | Height: | Size: 2 KiB |
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -337,6 +337,10 @@ textarea {
|
||||||
border: 1px solid $color-gray-20;
|
border: 1px solid $color-gray-20;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
|
||||||
|
&.focus {
|
||||||
|
border-color: $color-gray-60;
|
||||||
|
}
|
||||||
|
|
||||||
&.invalid {
|
&.invalid {
|
||||||
border-color: $color-danger;
|
border-color: $color-danger;
|
||||||
label {
|
label {
|
||||||
|
|
|
@ -525,4 +525,56 @@
|
||||||
(->> (rp/cmd! :create-demo-profile {})
|
(->> (rp/cmd! :create-demo-profile {})
|
||||||
(rx/map login)))))
|
(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))))))
|
||||||
|
|
|
@ -55,7 +55,8 @@
|
||||||
(:settings-profile
|
(:settings-profile
|
||||||
:settings-password
|
:settings-password
|
||||||
:settings-options
|
:settings-options
|
||||||
:settings-feedback)
|
:settings-feedback
|
||||||
|
:settings-access-tokens)
|
||||||
[:& settings/settings {:route route}]
|
[:& settings/settings {:route route}]
|
||||||
|
|
||||||
:debug-icons-preview
|
:debug-icons-preview
|
||||||
|
|
|
@ -191,25 +191,38 @@
|
||||||
[:span.hint hint])]]))
|
[:span.hint hint])]]))
|
||||||
|
|
||||||
(mf/defc select
|
(mf/defc select
|
||||||
[{:keys [options label form default data-test] :as props
|
[{:keys [options disabled label form default data-test] :as props
|
||||||
:or {default ""}}]
|
:or {default ""}}]
|
||||||
(let [input-name (get props :name)
|
(let [input-name (get props :name)
|
||||||
form (or form (mf/use-ctx form-ctx))
|
form (or form (mf/use-ctx form-ctx))
|
||||||
value (or (get-in @form [:data input-name]) default)
|
value (or (get-in @form [:data input-name]) default)
|
||||||
cvalue (d/seek #(= value (:value %)) options)
|
cvalue (d/seek #(= value (:value %)) options)
|
||||||
on-change (fn [event]
|
focus? (mf/use-state false)
|
||||||
(let [target (dom/get-target event)
|
on-change
|
||||||
value (dom/get-value target)]
|
(fn [event]
|
||||||
(fm/on-input-change form input-name value)))]
|
(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
|
[:div.custom-select
|
||||||
[:select {:value value
|
[:select {:value value
|
||||||
:on-change on-change
|
:on-change on-change
|
||||||
|
:on-focus on-focus
|
||||||
|
:on-blur on-blur
|
||||||
|
:disabled disabled
|
||||||
:data-test data-test}
|
:data-test data-test}
|
||||||
(for [item options]
|
(for [item options]
|
||||||
[:option {:key (:value item) :value (:value item)} (:label item)])]
|
[: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
|
[:div.main-content
|
||||||
[:label label]
|
[:label label]
|
||||||
[:span.value (:label cvalue "")]]
|
[:span.value (:label cvalue "")]]
|
||||||
|
|
|
@ -150,6 +150,7 @@
|
||||||
(def justify-content-row-center (icon-xref :justify-content-row-center))
|
(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-end (icon-xref :justify-content-row-end))
|
||||||
(def justify-content-row-start (icon-xref :justify-content-row-start))
|
(def justify-content-row-start (icon-xref :justify-content-row-start))
|
||||||
|
(def icon-key (icon-xref :icon-key))
|
||||||
(def layers (icon-xref :layers))
|
(def layers (icon-xref :layers))
|
||||||
(def layout-columns (icon-xref :layout-columns))
|
(def layout-columns (icon-xref :layout-columns))
|
||||||
(def layout-rows (icon-xref :layout-rows))
|
(def layout-rows (icon-xref :layout-rows))
|
||||||
|
|
|
@ -33,19 +33,20 @@
|
||||||
|
|
||||||
(def routes
|
(def routes
|
||||||
[["/auth"
|
[["/auth"
|
||||||
["/login" :auth-login]
|
["/login" :auth-login]
|
||||||
["/register" :auth-register]
|
["/register" :auth-register]
|
||||||
["/register/validate" :auth-register-validate]
|
["/register/validate" :auth-register-validate]
|
||||||
["/register/success" :auth-register-success]
|
["/register/success" :auth-register-success]
|
||||||
["/recovery/request" :auth-recovery-request]
|
["/recovery/request" :auth-recovery-request]
|
||||||
["/recovery" :auth-recovery]
|
["/recovery" :auth-recovery]
|
||||||
["/verify-token" :auth-verify-token]]
|
["/verify-token" :auth-verify-token]]
|
||||||
|
|
||||||
["/settings"
|
["/settings"
|
||||||
["/profile" :settings-profile]
|
["/profile" :settings-profile]
|
||||||
["/password" :settings-password]
|
["/password" :settings-password]
|
||||||
["/feedback" :settings-feedback]
|
["/feedback" :settings-feedback]
|
||||||
["/options" :settings-options]]
|
["/options" :settings-options]
|
||||||
|
["/access-tokens" :settings-access-tokens]]
|
||||||
|
|
||||||
["/view/:file-id"
|
["/view/:file-id"
|
||||||
{:name :viewer
|
{:name :viewer
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
(:require
|
(:require
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.store :as st]
|
[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.change-email]
|
||||||
[app.main.ui.settings.delete-account]
|
[app.main.ui.settings.delete-account]
|
||||||
[app.main.ui.settings.feedback :refer [feedback-page]]
|
[app.main.ui.settings.feedback :refer [feedback-page]]
|
||||||
|
@ -55,5 +56,8 @@
|
||||||
[:& password-page {:locale locale}]
|
[:& password-page {:locale locale}]
|
||||||
|
|
||||||
:settings-options
|
:settings-options
|
||||||
[:& options-page {:locale locale}])]]]))
|
[:& options-page {:locale locale}]
|
||||||
|
|
||||||
|
:settings-access-tokens
|
||||||
|
[:& access-tokens-page])]]]))
|
||||||
|
|
||||||
|
|
278
frontend/src/app/main/ui/settings/access_tokens.cljs
Normal file
278
frontend/src/app/main/ui/settings/access_tokens.cljs
Normal 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)}])]])]]))
|
||||||
|
|
||||||
|
|
|
@ -21,44 +21,50 @@
|
||||||
|
|
||||||
(mf/defc sidebar-content
|
(mf/defc sidebar-content
|
||||||
[{:keys [profile section] :as props}]
|
[{:keys [profile section] :as props}]
|
||||||
(let [profile? (= section :settings-profile)
|
(let [profile? (= section :settings-profile)
|
||||||
password? (= section :settings-password)
|
password? (= section :settings-password)
|
||||||
options? (= section :settings-options)
|
options? (= section :settings-options)
|
||||||
feedback? (= section :settings-feedback)
|
feedback? (= section :settings-feedback)
|
||||||
|
access-tokens? (= section :settings-access-tokens)
|
||||||
|
|
||||||
go-dashboard
|
go-dashboard
|
||||||
(mf/use-callback
|
(mf/use-callback
|
||||||
(mf/deps profile)
|
(mf/deps profile)
|
||||||
#(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)})))
|
#(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)})))
|
||||||
|
|
||||||
go-settings-profile
|
go-settings-profile
|
||||||
(mf/use-callback
|
(mf/use-callback
|
||||||
(mf/deps profile)
|
(mf/deps profile)
|
||||||
#(st/emit! (rt/nav :settings-profile)))
|
#(st/emit! (rt/nav :settings-profile)))
|
||||||
|
|
||||||
go-settings-feedback
|
go-settings-feedback
|
||||||
(mf/use-callback
|
(mf/use-callback
|
||||||
(mf/deps profile)
|
(mf/deps profile)
|
||||||
#(st/emit! (rt/nav :settings-feedback)))
|
#(st/emit! (rt/nav :settings-feedback)))
|
||||||
|
|
||||||
go-settings-password
|
go-settings-password
|
||||||
(mf/use-callback
|
(mf/use-callback
|
||||||
(mf/deps profile)
|
(mf/deps profile)
|
||||||
#(st/emit! (rt/nav :settings-password)))
|
#(st/emit! (rt/nav :settings-password)))
|
||||||
|
|
||||||
go-settings-options
|
go-settings-options
|
||||||
(mf/use-callback
|
(mf/use-callback
|
||||||
(mf/deps profile)
|
(mf/deps profile)
|
||||||
#(st/emit! (rt/nav :settings-options)))
|
#(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
|
show-release-notes
|
||||||
(mf/use-callback
|
(mf/use-callback
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(let [version (:main @cf/version)]
|
(let [version (:main @cf/version)]
|
||||||
(st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version}))
|
(st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version}))
|
||||||
(if (and (kbd/alt? event) (kbd/mod? event))
|
(if (and (kbd/alt? event) (kbd/mod? event))
|
||||||
(st/emit! (modal/show {:type :onboarding}))
|
(st/emit! (modal/show {:type :onboarding}))
|
||||||
(st/emit! (modal/show {:type :release-notes :version version}))))))]
|
(st/emit! (modal/show {:type :release-notes :version version}))))))]
|
||||||
|
|
||||||
[:div.sidebar-content
|
[:div.sidebar-content
|
||||||
[:div.sidebar-content-section
|
[:div.sidebar-content-section
|
||||||
|
@ -85,6 +91,13 @@
|
||||||
i/tree
|
i/tree
|
||||||
[:span.element-title (tr "labels.settings")]]
|
[: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]
|
[:hr]
|
||||||
|
|
||||||
[:li {:on-click show-release-notes :data-test "release-notes"}
|
[:li {:on-click show-release-notes :data-test "release-notes"}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
(ns app.util.time
|
(ns app.util.time
|
||||||
(:require
|
(:require
|
||||||
|
["date-fns/format" :default dateFnsFormat]
|
||||||
["date-fns/formatDistanceToNowStrict" :default dateFnsFormatDistanceToNowStrict]
|
["date-fns/formatDistanceToNowStrict" :default dateFnsFormatDistanceToNowStrict]
|
||||||
["date-fns/locale/ar-SA" :default dateFnsLocalesAr]
|
["date-fns/locale/ar-SA" :default dateFnsLocalesAr]
|
||||||
["date-fns/locale/ca" :default dateFnsLocalesCa]
|
["date-fns/locale/ca" :default dateFnsLocalesCa]
|
||||||
|
@ -232,3 +233,13 @@
|
||||||
:addSuffix true
|
:addSuffix true
|
||||||
:locale (obj/get locales locale)}
|
:locale (obj/get locales locale)}
|
||||||
(dateFnsFormatDistanceToNowStrict v))))))
|
(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))))))
|
||||||
|
|
|
@ -271,6 +271,114 @@ msgstr "Add as Shared Library"
|
||||||
msgid "dashboard.change-email"
|
msgid "dashboard.change-email"
|
||||||
msgstr "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
|
#: src/app/main/data/dashboard.cljs, src/app/main/data/dashboard.cljs
|
||||||
msgid "dashboard.copy-suffix"
|
msgid "dashboard.copy-suffix"
|
||||||
msgstr "(copy)"
|
msgstr "(copy)"
|
||||||
|
@ -1429,6 +1537,10 @@ msgstr "Owner"
|
||||||
msgid "labels.password"
|
msgid "labels.password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
|
#: src/app/main/ui/settings/sidebar.cljs
|
||||||
|
msgid "labels.access-tokens"
|
||||||
|
msgstr "Access tokens"
|
||||||
|
|
||||||
#: src/app/main/ui/dashboard/team.cljs
|
#: src/app/main/ui/dashboard/team.cljs
|
||||||
msgid "labels.pending-invitation"
|
msgid "labels.pending-invitation"
|
||||||
msgstr "Pending"
|
msgstr "Pending"
|
||||||
|
@ -2699,6 +2811,10 @@ msgstr "Password - Penpot"
|
||||||
msgid "title.settings.profile"
|
msgid "title.settings.profile"
|
||||||
msgstr "Profile - Penpot"
|
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
|
#: src/app/main/ui/dashboard/team.cljs
|
||||||
msgid "title.team-invitations"
|
msgid "title.team-invitations"
|
||||||
msgstr "Invitations - %s - Penpot"
|
msgstr "Invitations - %s - Penpot"
|
||||||
|
|
|
@ -277,6 +277,115 @@ msgstr "Añadir como Biblioteca Compartida"
|
||||||
msgid "dashboard.change-email"
|
msgid "dashboard.change-email"
|
||||||
msgstr "Cambiar correo"
|
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
|
#: src/app/main/data/dashboard.cljs, src/app/main/data/dashboard.cljs
|
||||||
msgid "dashboard.copy-suffix"
|
msgid "dashboard.copy-suffix"
|
||||||
msgstr "(copia)"
|
msgstr "(copia)"
|
||||||
|
@ -1479,6 +1588,10 @@ msgstr "Propiedad"
|
||||||
msgid "labels.password"
|
msgid "labels.password"
|
||||||
msgstr "Contraseña"
|
msgstr "Contraseña"
|
||||||
|
|
||||||
|
#: src/app/main/ui/settings/sidebar.cljs
|
||||||
|
msgid "labels.access-tokens"
|
||||||
|
msgstr "Access tokens"
|
||||||
|
|
||||||
#: src/app/main/ui/dashboard/team.cljs
|
#: src/app/main/ui/dashboard/team.cljs
|
||||||
msgid "labels.pending-invitation"
|
msgid "labels.pending-invitation"
|
||||||
msgstr "Pendiente"
|
msgstr "Pendiente"
|
||||||
|
@ -2775,6 +2888,10 @@ msgstr "Contraseña - Penpot"
|
||||||
msgid "title.settings.profile"
|
msgid "title.settings.profile"
|
||||||
msgstr "Perfil - Penpot"
|
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
|
#: src/app/main/ui/dashboard/team.cljs
|
||||||
msgid "title.team-invitations"
|
msgid "title.team-invitations"
|
||||||
msgstr "Invitaciones - %s - Penpot"
|
msgstr "Invitaciones - %s - Penpot"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue