Compare commits

..

6 Commits

Author SHA1 Message Date
Andrey Antukh
9f513c1d10 🔧 Disable HSTS on devenv 2026-01-27 11:32:48 +01:00
Andrey Antukh
cafc4567b3 Make nitrate module loading conditional to flag
This removes the flag checking on each rpc method
2026-01-27 10:42:44 +01:00
Andrey Antukh
9ea7d0e243 Reuse basic team and profile schemas on nitrate 2026-01-27 10:42:44 +01:00
Andrey Antukh
da029de7e4 🔥 Remove subscriptions related management module 2026-01-27 10:42:44 +01:00
Andrey Antukh
10c08c88c7 📎 Adapt nitrate module to auth changes 2026-01-27 10:42:44 +01:00
Andrey Antukh
877b6630f3 ♻️ Make several improvements to management API authentication 2026-01-27 10:06:19 +01:00
24 changed files with 594 additions and 695 deletions

View File

@@ -1,7 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key export PENPOT_SECRET_KEY=super-secret-devenv-key
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_HOST=devenv export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449 export PENPOT_PUBLIC_URI=https://localhost:3449

View File

@@ -102,6 +102,8 @@
[:http-server-io-threads {:optional true} ::sm/int] [:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int] [:http-server-max-worker-threads {:optional true} ::sm/int]
[:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string] [:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string] [:telemetry-uri {:optional true} :string]

View File

@@ -13,13 +13,13 @@
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http.middleware :as mw]
[app.main :as-alias main] [app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile] [app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[integrant.core :as ig] [integrant.core :as ig]
[yetti.request :as yreq]
[yetti.response :as-alias yres])) [yetti.response :as-alias yres]))
;; ---- ROUTES ;; ---- ROUTES
@@ -49,28 +49,40 @@
(fn [cfg request] (fn [cfg request]
(db/tx-run! cfg handler request)))))}) (db/tx-run! cfg handler request)))))})
(def ^:private shared-key-auth
{:name ::shared-key-auth
:compile
(fn [_ _]
(fn [handler key]
(if key
(fn [request]
(if-let [key' (yreq/get-header request "x-shared-key")]
(if (= key key')
(handler request)
{::yres/status 403})
{::yres/status 403}))
(fn [_ _]
{::yres/status 403}))))})
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::setup/props] :as cfg}] [_ cfg]
(let [management-key (or (cf/get :management-api-key) ["" {:middleware [[shared-key-auth (cf/get :management-api-key)]
(get props :management-key))] [default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["" {:middleware [[mw/shared-key-auth management-key] ["/get-customer"
[default-system cfg] {:handler get-customer
[transaction]]} :transaction true
["/authenticate" :allowed-methods #{:post}}]
{:handler authenticate
:allowed-methods #{:post}}]
["/get-customer" ["/update-customer"
{:handler get-customer {:handler update-customer
:transaction true :allowed-methods #{:post}
:allowed-methods #{:post}}] :transaction true}]])
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
;; ---- HELPERS ;; ---- HELPERS

View File

@@ -16,7 +16,6 @@
[app.http.errors :as errors] [app.http.errors :as errors]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.pointer-map :as pmap] [app.util.pointer-map :as pmap]
[buddy.core.codecs :as bc]
[cuerdas.core :as str] [cuerdas.core :as str]
[yetti.adapter :as yt] [yetti.adapter :as yt]
[yetti.middleware :as ymw] [yetti.middleware :as ymw]
@@ -301,16 +300,20 @@
:compile (constantly wrap-auth)}) :compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth (defn- wrap-shared-key-auth
[handler shared-key] [handler keys]
(if shared-key (if (seq keys)
(let [shared-key (if (string? shared-key) (fn [request]
shared-key (if-let [[key-id key] (some-> (yreq/get-header request "x-shared-key")
(bc/bytes->b64-str shared-key true))] (str/split #"\s+" 2))]
(fn [request] (let [key-id (str/lower key-id)]
(let [key (yreq/get-header request "x-shared-key")] (if (and (string? key)
(if (= key shared-key) (contains? keys key-id)
(handler (assoc request ::http/auth-with-shared-key true)) (= key (get keys key-id)))
{::yres/status 403})))) (-> request
(assoc ::http/auth-key-id key-id)
(handler))
{::yres/status 403}))
{::yres/status 403}))
(fn [_ _] (fn [_ _]
{::yres/status 403}))) {::yres/status 403})))

View File

@@ -140,10 +140,14 @@
client-version (get-client-version request) client-version (get-client-version request)
client-user-agent (get-client-user-agent request) client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request) session-id (get-external-session-id request)
token-id (::actoken/id request)] key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils (d/without-nils
{:external-session-id session-id {:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str) :access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin :client-event-origin client-event-origin
:client-user-agent client-user-agent :client-user-agent client-user-agent
:client-version client-version :client-version client-version

View File

@@ -275,8 +275,7 @@
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)}
::mgmt/routes ::mgmt/routes
{::db/pool (ig/ref ::db/pool) {::db/pool (ig/ref ::db/pool)}
::setup/props (ig/ref ::setup/props)}
:app.http/router :app.http/router
{::session/manager (ig/ref ::session/manager) {::session/manager (ig/ref ::session/manager)
@@ -341,7 +340,8 @@
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/client :app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)} {::http.client/client (ig/ref ::http.client/client)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
:app.rpc/management-methods :app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client) {::http.client/client (ig/ref ::http.client/client)
@@ -357,13 +357,13 @@
::setup/props (ig/ref ::setup/props)} ::setup/props (ig/ref ::setup/props)}
::rpc/routes ::rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods) {::rpc/methods (ig/ref :app.rpc/methods)
::rpc/management-methods (ig/ref :app.rpc/management-methods) ::rpc/management-methods (ig/ref :app.rpc/management-methods)
;; FIXME: revisit if db/pool is necessary here ;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager) ::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)} ::setup/shared-keys (ig/ref ::setup/shared-keys)}
::wrk/registry ::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics) {::mtx/metrics (ig/ref ::mtx/metrics)
@@ -451,6 +451,11 @@
;; module requires the migrations to run before initialize. ;; module requires the migrations to run before initialize.
::migrations (ig/ref :app.migrations/migrations)} ::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
:nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock ::setup/clock
{} {}

View File

@@ -1,9 +1,3 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.nitrate (ns app.nitrate
"Module that make calls to the external nitrate aplication" "Module that make calls to the external nitrate aplication"
(:require (:require
@@ -17,18 +11,17 @@
[clojure.core :as c] [clojure.core :as c]
[integrant.core :as ig])) [integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- request-builder (defn- request-builder
[cfg method uri management-key profile-id] [cfg method uri shared-key profile-id]
(fn [] (fn []
(http/req! cfg {:method method (http/req! cfg {:method method
:headers {"content-type" "application/json" :headers {"content-type" "application/json"
"accept" "application/json" "accept" "application/json"
"x-shared-key" management-key "x-shared-key" shared-key
"x-profile-id" (str profile-id)} "x-profile-id" (str profile-id)}
:uri uri :uri uri
:version :http1.1}))) :version :http1.1})))
@@ -54,9 +47,9 @@
(defn- with-validate [handler uri schema] (defn- with-validate [handler uri schema]
(fn [] (fn []
(let [coercer-http (sm/coercer schema (let [coercer-http (sm/coercer schema
:type :validation :type :validation
:hint (str "invalid data received calling " uri))] :hint (str "invalid data received calling " uri))]
(try (try
(coercer-http (-> (handler) :body json/decode)) (coercer-http (-> (handler) :body json/decode))
(catch Exception e (catch Exception e
@@ -65,8 +58,9 @@
nil))))) nil)))))
(defn- request-to-nitrate (defn- request-to-nitrate
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}] [cfg method uri schema {:keys [::rpc/profile-id] :as params}]
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id) (let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
(with-retries 3) (with-retries 3)
(with-validate uri schema))] (with-validate uri schema))]
(full-http-call))) (full-http-call)))
@@ -103,26 +97,15 @@
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params))) (request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION ;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client (defmethod ig/init-key ::client
[_ {:keys [::setup/props] :as cfg}] [_ cfg]
(if (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
(let [management-key (or (cf/get :management-api-key) {:get-team-org (partial get-team-org cfg)
(get props :management-key)) :is-valid-user (partial is-valid-user cfg)}))
cfg (assoc cfg ::management-key management-key)]
{:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)})
{}))
(defmethod ig/halt-key! ::client
[_ {:keys []}]
(do :stuff))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS ;; UTILS
@@ -144,4 +127,4 @@
[cfg team params] [cfg team params]
(let [params (assoc (or params {}) :team-id (:id team)) (let [params (assoc (or params {}) :team-id (:id team))
org (call cfg :get-team-org params)] org (call cfg :get-team-org params)]
(assoc team :organization-id (:id org) :organization-name (:name org)))) (assoc team :organization-id (:id org) :organization-name (:name org))))

View File

@@ -92,11 +92,11 @@
(fn [{:keys [params path-params method] :as request}] (fn [{:keys [params path-params method] :as request}]
(let [handler-name (:type path-params) (let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match") etag (yreq/get-header request "if-none-match")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request) profile-id (or (::session/profile-id request)
(::actoken/profile-id request) (::actoken/profile-id request)
(if (::http/auth-with-shared-key request) (if key-id uuid/zero nil))
uuid/zero
nil))
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
@@ -298,11 +298,12 @@
(defn- resolve-management-methods (defn- resolve-management-methods
[cfg] [cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)] (let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)
(->> (sv/scan-ns mods (cond->> (list 'app.rpc.management.exporter)
'app.rpc.management.subscription (contains? cf/flags :nitrate)
'app.rpc.management.nitrate (cons 'app.rpc.management.nitrate))]
'app.rpc.management.exporter)
(->> (apply sv/scan-ns mods)
(map (partial process-method cfg "management" wrap-management)) (map (partial process-method cfg "management" wrap-management))
(into {})))) (into {}))))
@@ -346,23 +347,20 @@
(defmethod ig/assert-key ::routes (defmethod ig/assert-key ::routes
[_ params] [_ params]
(assert (map? (::setup/shared-keys params)))
(assert (db/pool? (::db/pool params)) "expect valid database pool") (assert (db/pool? (::db/pool params)) "expect valid database pool")
(assert (some? (::setup/props params)))
(assert (session/manager? (::session/manager params)) "expect valid session manager") (assert (session/manager? (::session/manager params)) "expect valid session manager")
(assert (valid-methods? (::methods params)) "expect valid methods map") (assert (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map")) (assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}] [_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
(let [public-uri (cf/get :public-uri)
management-key (or (cf/get :management-api-key)
(get props :management-key))]
(let [public-uri (cf/get :public-uri)]
["/api" ["/api"
["/management" ["/management"
["/methods/:type" ["/methods/:type"
{:middleware [[mw/shared-key-auth management-key] {:middleware [[mw/shared-key-auth shared-keys]
[session/authz cfg]] [session/authz cfg]]
:handler (make-rpc-handler management-methods)}] :handler (make-rpc-handler management-methods)}]

View File

@@ -5,13 +5,12 @@
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate (ns app.rpc.management.nitrate
"Internal Nitrate HTTP API. "Internal Nitrate HTTP RPC API. Provides authenticated access to
Provides authenticated access to organization management and token validation endpoints. organization management and token validation endpoints."
All requests must include a valid shared key token in the `x-shared-key` header, and
a cookie `auth-token` with the user token.
They will return `401 Unauthorized` if the shared key or user token are invalid."
(:require (:require
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.profile :refer [schema:profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
@@ -23,22 +22,14 @@
[app.util.services :as sv])) [app.util.services :as sv]))
;; ---- API: authenticate ;; ---- API: authenticate
(def ^:private schema:profile
[:map
[:id ::sm/uuid]
[:name :string]
[:email :string]
[:photo-url :string]])
(sv/defmethod ::authenticate (sv/defmethod ::authenticate
"Authenticate an user "Authenticate the current user"
@api GET /authenticate {::doc/added "2.14"
@returns ::sm/params [:map]
200 OK: Returns the authenticated user."
{::doc/added "2.12"
::sm/result schema:profile} ::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)] (let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id) {:id (get profile :id)
:name (get profile :fullname) :name (get profile :fullname)
:email (get profile :email) :email (get profile :email)
@@ -51,30 +42,22 @@
FROM team AS t FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ? WHERE tpr.profile_id = ?
AND tpr.is_owner = 't' AND tpr.is_owner IS TRUE
AND t.is_default = 'f' AND t.is_default IS FALSE
AND t.deleted_at is null;") AND t.deleted_at IS NULL;")
(def ^:private schema:team
[:map
[:id ::sm/uuid]
[:name :string]])
(def ^:private schema:get-teams-result (def ^:private schema:get-teams-result
[:vector schema:team]) [:vector schema:team])
(sv/defmethod ::get-teams (sv/defmethod ::get-teams
"List teams for which current user is owner. "List teams for which current user is owner"
@api GET /get-teams {::doc/added "2.14"
@returns ::sm/params [:map]
200 OK: Returns the list of teams for the user."
{::doc/added "2.12"
::sm/result schema:get-teams-result} ::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}] [cfg {:keys [::rpc/profile-id]}]
(when (contains? cf/flags :nitrate) (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (->> (db/exec! cfg [sql:get-teams current-user-id])
(->> (db/exec! cfg [sql:get-teams current-user-id]) (map #(select-keys % [:id :name])))))
(map #(select-keys % [:id :name]))))))
;; ---- API: notify-team-change ;; ---- API: notify-team-change
@@ -83,30 +66,18 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:organization-id ::sm/text]]) [:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change (sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate "Notify to Penpot a team change from nitrate"
@api POST /notify-team-change {::doc/added "2.14"
@returns
200 OK"
{::doc/added "2.12"
::sm/params schema:notify-team-change ::sm/params schema:notify-team-change
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}] [cfg {:keys [id organization-id organization-name]}]
(when (contains? cf/flags :nitrate) (let [msgbus (::mbus/msgbus cfg)]
(let [msgbus (::mbus/msgbus cfg)] (mbus/pub! msgbus
(mbus/pub! msgbus ;;TODO There is a bug on dashboard with teams notifications.
;;TODO There is a bug on dashboard with teams notifications. ;;For now we send it to uuid/zero instead of team-id
;;For now we send it to uuid/zero instead of team-id :topic uuid/zero
:topic uuid/zero :message {:type :team-org-change
:message {:type :team-org-change :team-id id
:team-id id :organization-id organization-id
:organization-id organization-id :organization-name organization-name})))
:organization-name organization-name}))))

View File

@@ -1,183 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.subscription
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- RPC METHOD: AUTHENTICATE
(def ^:private
schema:authenticate-params
[:map {:title "authenticate-params"}])
(def ^:private
schema:authenticate-result
[:map {:title "authenticate-result"}
[:profile-id ::sm/uuid]])
(sv/defmethod ::auth
{::doc/added "2.12"
::sm/params schema:authenticate-params
::sm/result schema:authenticate-result}
[_ {:keys [::rpc/profile-id]}]
{:profile-id profile-id})
;; ---- RPC METHOD: GET-CUSTOMER
;; FIXME: move to app.common.time
(def ^:private schema:timestamp
(sm/type-schema
{:type ::timestamp
:pred ct/inst?
:type-properties
{:title "inst"
:description "The same as :app.common.time/inst but encodes to epoch"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (ct/inst v))))
:decode/string #(some-> % ct/inst)
:encode/string #(some-> % inst-ms)
:decode/json #(some-> % ct/inst)
:encode/json #(some-> % inst-ms)}}))
(def ^:private schema:subscription
[:map {:title "Subscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"past_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
[:start-date [:maybe schema:timestamp]]
[:ended-at [:maybe schema:timestamp]]
[:trial-end [:maybe schema:timestamp]]
[:trial-start [:maybe schema:timestamp]]
[:cancel-at [:maybe schema:timestamp]]
[:canceled-at [:maybe schema:timestamp]]
[:current-period-end [:maybe schema:timestamp]]
[:current-period-start [:maybe schema:timestamp]]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private sql:get-customer-slots
"WITH teams AS (
SELECT tpr.team_id AS id,
tpr.profile_id AS profile_id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS true
AND tpr.profile_id = ?
), teams_with_slots AS (
SELECT tpr.team_id AS id,
count(*) AS total
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id FROM teams)
AND tpr.can_edit IS true
GROUP BY 1
ORDER BY 2
)
SELECT max(total) AS total FROM teams_with_slots;")
(defn- get-customer-slots
[cfg profile-id]
(let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])]
(:total result)))
(def ^:private schema:get-customer-params
[:map])
(def ^:private schema:get-customer-result
[:map
[:id ::sm/uuid]
[:name :string]
[:num-editors ::sm/int]
[:subscription {:optional true} schema:subscription]])
(sv/defmethod ::get-customer
{::doc/added "2.12"
::sm/params schema:get-customer-params
::sm/result schema:get-customer-result}
[cfg {:keys [::rpc/profile-id]}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:num-editors (get-customer-slots cfg profile-id)
:subscription (-> profile :props :subscription)}))
;; ---- RPC METHOD: GET-CUSTOMER
(def ^:private schema:update-customer-params
[:map
[:subscription [:maybe schema:subscription]]])
(def ^:private schema:update-customer-result
[:map])
(sv/defmethod ::update-customer
{::doc/added "2.12"
::sm/params schema:update-customer-params
::sm/result schema:update-customer-result}
[cfg {:keys [::rpc/profile-id subscription]}]
(let [{:keys [props] :as profile}
(profile/get-profile cfg profile-id ::db/for-update true)
props
(assoc props :subscription subscription)]
(l/dbg :hint "update customer"
:profile-id (str profile-id)
:subscription-type (get subscription :type)
:subscription-status (get subscription :status)
:subscription-quantity (get subscription :quantity))
(db/update! cfg :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))

View File

@@ -17,6 +17,7 @@
[app.setup.templates] [app.setup.templates]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
[buddy.core.nonce :as bn] [buddy.core.nonce :as bn]
[cuerdas.core :as str]
[integrant.core :as ig])) [integrant.core :as ig]))
(defn- generate-random-key (defn- generate-random-key
@@ -88,7 +89,38 @@
(-> (get-all-props conn) (-> (get-all-props conn)
(assoc :secret-key secret) (assoc :secret-key secret)
(assoc :tokens-key (keys/derive secret :salt "tokens")) (assoc :tokens-key (keys/derive secret :salt "tokens"))
(assoc :management-key (keys/derive secret :salt "management"))
(update :instance-id handle-instance-id conn (db/read-only? pool))))))) (update :instance-id handle-instance-id conn (db/read-only? pool)))))))
(sm/register! ::props [:map-of :keyword ::sm/any]) (sm/register! ::props [:map-of :keyword ::sm/any])
(defmethod ig/init-key ::shared-keys
[_ {:keys [::props] :as cfg}]
(let [secret (get props :secret-key)]
(d/without-nils
{"exporter"
(let [key (or (get cfg :exporter)
(-> (keys/derive secret :salt "exporter")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "exporter key is disabled because empty string found")
nil)
(do
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key)))
"nitrate"
(let [key (or (get cfg :nitrate)
(-> (keys/derive secret :salt "nitrate")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "nitrate key is disabled because empty string found")
nil)
(do
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
key)))})))

View File

@@ -86,7 +86,7 @@
(t/deftest shared-key-auth (t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth (let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200}) (fn [req] {::yres/status 200})
"secret-key")] {"test1" "secret-key"})]
(let [response (handler (->DummyRequest {} {}))] (let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
@@ -95,6 +95,9 @@
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))] (let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
(t/is (= 200 (::yres/status response)))))) (t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz (t/deftest access-token-authz

View File

@@ -19,3 +19,10 @@
(def schema:role (def schema:role
[::sm/one-of {:title "TeamRole"} valid-roles]) [::sm/one-of {:title "TeamRole"} valid-roles])
;; FIXME: specify more fields
(def schema:team
[:map {:title "Team"}
[:id ::sm/uuid]
[:name :string]])

View File

@@ -398,7 +398,6 @@ COPY files/Caddyfile /home/
COPY files/selfsigned.crt /home/ COPY files/selfsigned.crt /home/
COPY files/selfsigned.key /home/ COPY files/selfsigned.key /home/
COPY files/start-tmux.sh /home/start-tmux.sh COPY files/start-tmux.sh /home/start-tmux.sh
COPY files/start-tmux-back.sh /home/start-tmux-back.sh
COPY files/entrypoint.sh /home/entrypoint.sh COPY files/entrypoint.sh /home/entrypoint.sh
COPY files/init.sh /home/init.sh COPY files/init.sh /home/init.sh

View File

@@ -5,6 +5,7 @@
localhost:3449 { localhost:3449 {
reverse_proxy localhost:4449 reverse_proxy localhost:4449
tls /home/selfsigned.crt /home/selfsigned.key tls /home/selfsigned.crt /home/selfsigned.key
header -Strict-Transport-Security
} }
http://localhost:3450 { http://localhost:3450 {

View File

@@ -12,11 +12,14 @@
["node:process" :as process] ["node:process" :as process]
[app.common.data :as d] [app.common.data :as d]
[app.common.flags :as flags] [app.common.flags :as flags]
[app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.version :as v] [app.common.version :as v]
[cljs.core :as c] [cljs.core :as c]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(l/set-level! :info)
(def ^:private defaults (def ^:private defaults
{:public-uri "http://localhost:3449" {:public-uri "http://localhost:3449"
:tenant "default" :tenant "default"
@@ -30,7 +33,7 @@
[:map {:title "config"} [:map {:title "config"}
[:secret-key :string] [:secret-key :string]
[:public-uri {:optional true} ::sm/uri] [:public-uri {:optional true} ::sm/uri]
[:management-api-key {:optional true} :string] [:exporter-shared-key {:optional true} :string]
[:host {:optional true} :string] [:host {:optional true} :string]
[:tenant {:optional true} :string] [:tenant {:optional true} :string]
[:flags {:optional true} [::sm/set :keyword]] [:flags {:optional true} [::sm/set :keyword]]
@@ -98,8 +101,10 @@
(c/get config key default))) (c/get config key default)))
(def management-key (def management-key
(or (c/get config :management-api-key) (let [key (or (c/get config :exporter-shared-key)
(let [secret-key (c/get config :secret-key) (let [secret-key (c/get config :secret-key)
derived-key (crypto/hkdfSync "blake2b512" secret-key, "management" "" 32)] derived-key (crypto/hkdfSync "blake2b512" secret-key, "exporter" "" 32)]
(-> (.from buffer/Buffer derived-key) (-> (.from buffer/Buffer derived-key)
(.toString "base64url"))))) (.toString "base64url"))))]
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key))

View File

@@ -73,7 +73,7 @@
(p/mcat (fn [blob] (p/mcat (fn [blob]
(let [fdata (new http/FormData) (let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}}) agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" cf/management-key headers #js {"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " auth-token)} "Authorization" (str "Bearer " auth-token)}
request #js {:headers headers request #js {:headers headers

View File

@@ -12,118 +12,88 @@ test.beforeEach(async ({ page }) => {
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json"); await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
}); });
const createToken = async (page, type, name, textFieldName, value) => { test.describe("Tokens: Remapping Feature", () => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const { tokensUpdateCreateModal } = await setupTokensFile(page, {
flags: ["enable-token-shadow"],
});
// Create base token
await tokensTabPanel
.getByRole("button", { name: `Add Token: ${type}` })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(name);
const colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: textFieldName,
});
await colorField.fill(value);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
const renameToken = async (page, oldName, newName) => {
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const baseToken = tokensSidebar.getByRole("button", {
name: oldName,
});
await baseToken.click({ button: "right" });
await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(newName);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
};
const createCompositeDerivedToken = async (page, type, name, reference) => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
const { tokensUpdateCreateModal } = await setupTokensFile(page, {
flags: ["enable-token-shadow"],
});
await tokensTabPanel
.getByRole("button", { name: `Add Token: ${type}` })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name",
});
await nameField.fill(name);
const referenceToggle = tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill(reference);
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
};
test.describe("Remapping Tokens", () => {
test.describe("Box Shadow Token Remapping", () => { test.describe("Box Shadow Token Remapping", () => {
test("User renames box shadow token with alias references", async ({ test("User renames box shadow token with alias references", async ({
page, page,
}) => { }) => {
const { tokensSidebar } = await setupTokensFile(page, { const {
flags: ["enable-token-shadow"], tokensUpdateCreateModal,
}); tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base shadow token // Create base shadow token
await createToken(page, "Shadow", "base-shadow", "Color", "#000000"); await tokensTabPanel
.getByRole("button", { name: "Add Token: Shadow" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("base-shadow");
const colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Color",
});
await colorField.fill("#000000");
let submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Create derived shadow token that references base-shadow // Create derived shadow token that references base-shadow
await createCompositeDerivedToken( await tokensTabPanel
page, .getByRole("button", { name: "Add Token: Shadow" })
"Shadow", .click();
"derived-shadow", await expect(tokensUpdateCreateModal).toBeVisible();
"{base-shadow}",
); nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name",
});
await nameField.fill("derived-shadow");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{base-shadow}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Rename base-shadow token // Rename base-shadow token
await renameToken(page, "base-shadow", "foundation-shadow"); const baseToken = tokensSidebar.getByRole("button", {
name: "base-shadow",
});
await baseToken.click({ button: "right" });
await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("foundation-shadow");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
// Check for remapping modal // Check for remapping modal
const remappingModal = page.getByTestId("token-remapping-modal"); const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
await expect(remappingModal).toContainText("base-shadow"); await expect(remappingModal).toContainText("1");
await expect(remappingModal).toContainText("foundation-shadow");
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens", name: /remap/i,
}); });
await confirmButton.click(); await confirmButton.click();
@@ -146,16 +116,51 @@ test.describe("Remapping Tokens", () => {
workspacePage, workspacePage,
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] }); } = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base shadow token // Create base shadow token
await createToken(page, "Shadow", "primary-shadow", "Color", "#000000"); await tokensTabPanel
.getByRole("button", { name: "Add Token: Shadow" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("primary-shadow");
let colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Color",
});
await colorField.fill("#000000");
let submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Create derived shadow token that references base // Create derived shadow token that references base
await createCompositeDerivedToken( await tokensTabPanel
page, .getByRole("button", { name: "Add Token: Shadow" })
"Shadow", .click();
"card-shadow", await expect(tokensUpdateCreateModal).toBeVisible();
"{primary-shadow}",
); nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("card-shadow");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{primary-shadow}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Apply the referenced token to a shape // Apply the referenced token to a shape
await page.getByRole("tab", { name: "Layers" }).click(); await page.getByRole("tab", { name: "Layers" }).click();
@@ -178,16 +183,16 @@ test.describe("Remapping Tokens", () => {
await tokenContextMenuForToken.getByText("Edit token").click(); await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible(); await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name"); nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("main-shadow"); await nameField.fill("main-shadow");
// Update the color value // Update the color value
const colorField = tokensUpdateCreateModal.getByRole("textbox", { colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Color", name: "Color",
}); });
await colorField.fill("#FF0000"); await colorField.fill("#FF0000");
const submitButton = tokensUpdateCreateModal.getByRole("button", { submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save", name: "Save",
}); });
await submitButton.click(); await submitButton.click();
@@ -197,7 +202,7 @@ test.describe("Remapping Tokens", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens", name: /remap/i,
}); });
await confirmButton.click(); await confirmButton.click();
@@ -254,25 +259,73 @@ test.describe("Remapping Tokens", () => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base typography token // Create base typography token
await createToken(page, "Typography", "base-text", "Font size", "16"); await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("base-text");
const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Font size",
});
await fontSizeField.fill("16");
let submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Create derived typography token // Create derived typography token
await createCompositeDerivedToken( await tokensTabPanel
page, .getByRole("button", { name: "Add Token: Typography" })
"Typography", .click();
"body-text", await expect(tokensUpdateCreateModal).toBeVisible();
"{base-text}",
); nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name",
});
await nameField.fill("body-text");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
await referenceField.fill("{base-text}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Rename base token // Rename base token
await renameToken(page, "base-text", "default-text"); const baseToken = tokensSidebar.getByRole("button", {
name: "base-text",
});
await baseToken.click({ button: "right" });
await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("default-text");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
// Check for remapping modal // Check for remapping modal
const remappingModal = page.getByTestId("token-remapping-modal"); const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens", name: /remap/i,
}); });
await confirmButton.click(); await confirmButton.click();
@@ -298,7 +351,24 @@ test.describe("Remapping Tokens", () => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base typography token // Create base typography token
await createToken(page, "Typography", "body-style", "Font size", "16"); await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("body-style");
let fontSizeField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Font size",
});
await fontSizeField.fill("16");
let submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Create derived typography token // Create derived typography token
await tokensTabPanel await tokensTabPanel
@@ -306,7 +376,7 @@ test.describe("Remapping Tokens", () => {
.click(); .click();
await expect(tokensUpdateCreateModal).toBeVisible(); await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByRole("textbox", { nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name", name: "Name",
}); });
await nameField.fill("paragraph-style"); await nameField.fill("paragraph-style");
@@ -320,7 +390,7 @@ test.describe("Remapping Tokens", () => {
}); });
await referenceField.fill("{body-style}"); await referenceField.fill("{body-style}");
let submitButton = tokensUpdateCreateModal.getByRole("button", { submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save", name: "Save",
}); });
await submitButton.click(); await submitButton.click();
@@ -351,7 +421,7 @@ test.describe("Remapping Tokens", () => {
await nameField.fill("text-base"); await nameField.fill("text-base");
// Update the font size value // Update the font size value
const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { fontSizeField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Font size", name: "Font size",
}); });
await fontSizeField.fill("18"); await fontSizeField.fill("18");
@@ -366,7 +436,7 @@ test.describe("Remapping Tokens", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens", name: /remap/i,
}); });
await confirmButton.click(); await confirmButton.click();
@@ -401,29 +471,72 @@ test.describe("Remapping Tokens", () => {
test("User renames border radius token with alias references", async ({ test("User renames border radius token with alias references", async ({
page, page,
}) => { }) => {
const { tokensSidebar } = await setupTokensFile(page); const {
tokensUpdateCreateModal,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base border radius token // Create base border radius token
await createToken(page, "Border Radius", "base-radius", "Value", "4"); await tokensTabPanel
.getByRole("button", { name: "Add Token: Border Radius" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("base-radius");
const valueField = tokensUpdateCreateModal.getByLabel("Value");
await valueField.fill("4");
let submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Create derived border radius token // Create derived border radius token
await createToken( await tokensTabPanel
page, .getByRole("button", { name: "Add Token: Border Radius" })
"Border Radius", .click();
"card-radius", await expect(tokensUpdateCreateModal).toBeVisible();
"Value",
"{base-radius}", nameField = tokensUpdateCreateModal.getByLabel("Name");
); await nameField.fill("card-radius");
const valueField2 = tokensUpdateCreateModal.getByLabel("Value");
await valueField2.fill("{base-radius}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Rename base token // Rename base token
await renameToken(page, "base-radius", "primary-radius"); const baseToken = tokensSidebar.getByRole("button", {
name: "base-radius",
});
await baseToken.click({ button: "right" });
await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("primary-radius");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
// Check for remapping modal // Check for remapping modal
const remappingModal = page.getByTestId("token-remapping-modal"); const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens", name: /remap/i,
}); });
await confirmButton.click(); await confirmButton.click();
@@ -445,17 +558,43 @@ test.describe("Remapping Tokens", () => {
tokenContextMenuForToken, tokenContextMenuForToken,
} = await setupTokensFile(page); } = await setupTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base border radius token // Create base border radius token
await createToken(page, "Border Radius", "radius-sm", "Value", "4"); await tokensTabPanel
.getByRole("button", { name: "Add Token: Border Radius" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("radius-sm");
let valueField = tokensUpdateCreateModal.getByLabel("Value");
await valueField.fill("4");
let submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Create derived border radius token // Create derived border radius token
await createToken( await tokensTabPanel
page, .getByRole("button", { name: "Add Token: Border Radius" })
"Border Radius", .click();
"button-radius", await expect(tokensUpdateCreateModal).toBeVisible();
"Value",
"{radius-sm}", nameField = tokensUpdateCreateModal.getByLabel("Name");
); await nameField.fill("button-radius");
const valueField2 = tokensUpdateCreateModal.getByLabel("Value");
await valueField2.fill("{radius-sm}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Rename and update value of base token // Rename and update value of base token
const radiusToken = tokensSidebar.getByRole("button", { const radiusToken = tokensSidebar.getByRole("button", {
@@ -465,14 +604,14 @@ test.describe("Remapping Tokens", () => {
await tokenContextMenuForToken.getByText("Edit token").click(); await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible(); await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name"); nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("radius-base"); await nameField.fill("radius-base");
// Update the value // Update the value
const valueField = tokensUpdateCreateModal.getByLabel("Value"); valueField = tokensUpdateCreateModal.getByLabel("Value");
await valueField.fill("8"); await valueField.fill("8");
const submitButton = tokensUpdateCreateModal.getByRole("button", { submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save", name: "Save",
}); });
await submitButton.click(); await submitButton.click();
@@ -482,7 +621,7 @@ test.describe("Remapping Tokens", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens", name: /remap/i,
}); });
await confirmButton.click(); await confirmButton.click();
@@ -509,82 +648,4 @@ test.describe("Remapping Tokens", () => {
await expect(currentValue).toHaveValue("{radius-base}"); await expect(currentValue).toHaveValue("{radius-base}");
}); });
}); });
test.describe("Cancel remap", () => {
test("Only rename - breaks reference", async ({ page }) => {
const { tokensSidebar } = await setupTokensFile(page, {
flags: ["enable-token-shadow"],
});
// Create base shadow token
await createToken(page, "Shadow", "base-shadow", "Color", "#000000");
// Create derived shadow token that references base-shadow
await createCompositeDerivedToken(
page,
"Shadow",
"derived-shadow",
"{base-shadow}",
);
// Rename base-shadow token
await renameToken(page, "base-shadow", "foundation-shadow");
// Check for remapping modal
const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const cancelButton = remappingModal.getByRole("button", {
name: "don't remap",
});
await cancelButton.click();
// Verify token was renamed
await expect(
tokensSidebar.getByRole("button", {
name: "foundation-shadow",
}),
).toBeVisible();
await expect(
tokensSidebar.locator('[aria-label="Missing reference"]'),
).toBeVisible();
});
test("Cancel process - no changes applied", async ({ page }) => {
const { tokensSidebar } = await setupTokensFile(page, {
flags: ["enable-token-shadow"],
});
// Create base shadow token
await createToken(page, "Shadow", "base-shadow", "Color", "#000000");
// Create derived shadow token that references base-shadow
await createCompositeDerivedToken(
page,
"Shadow",
"derived-shadow",
"{base-shadow}",
);
// Rename base-shadow token
await renameToken(page, "base-shadow", "foundation-shadow");
// Check for remapping modal
const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const closeButton = remappingModal.getByRole("button", {
name: "close",
});
await closeButton.click();
// Verify original token name still exists
await expect(
tokensSidebar.getByRole("button", { name: "base-shadow" }),
).toBeVisible();
await expect(
tokensSidebar.getByRole("button", { name: "derived-shadow" }),
).toBeVisible();
});
});
}); });

View File

@@ -28,6 +28,7 @@
[app.main.ui.forms :as fc] [app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls] [app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.validators :refer [default-validate-token]] [app.main.ui.workspace.tokens.management.forms.validators :refer [default-validate-token]]
[app.main.ui.workspace.tokens.remapping-modal :as remapping-modal]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.forms :as fm] [app.util.forms :as fm]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
@@ -181,33 +182,9 @@
(when (or (k/enter? e) (k/space? e)) (when (or (k/enter? e) (k/space? e))
(on-cancel e)))) (on-cancel e))))
on-remap-token
(mf/use-fn
(mf/deps token)
(fn [valid-token name old-name description]
(st/emit!
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description})
(remap/remap-tokens old-name name)
(dwtp/propagate-workspace-tokens)
(modal/hide!))))
on-rename-token
(mf/use-fn
(mf/deps token)
(fn [valid-token name description]
(st/emit!
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description})
(modal/hide!))))
on-submit on-submit
(mf/use-fn (mf/use-fn
(mf/deps validate-token token tokens token-type value-subfield type active-tab on-remap-token on-rename-token is-create) (mf/deps validate-token token tokens token-type value-subfield type active-tab)
(fn [form _event] (fn [form _event]
(let [name (get-in @form [:clean-data :name]) (let [name (get-in @form [:clean-data :name])
path (str (d/name token-type) "." name) path (str (d/name token-type) "." name)
@@ -225,15 +202,22 @@
file-data (dh/lookup-file-data state) file-data (dh/lookup-file-data state)
old-name (:name token) old-name (:name token)
is-rename (and (= action "edit") (not= name old-name)) is-rename (and (= action "edit") (not= name old-name))
references-count (remap/count-token-references file-data old-name) references-count (remap/count-token-references file-data old-name)]
on-remap #(on-remap-token valid-token name old-name description)
on-rename #(on-rename-token valid-token name description)]
(if (and is-rename (> references-count 0)) (if (and is-rename (> references-count 0))
(st/emit! (modal/show :tokens/remapping-confirmation {:old-token-name old-name (remapping-modal/show-remapping-modal
:new-token-name name {:old-token-name old-name
:references-count references-count :new-token-name name
:on-remap on-remap :references-count references-count
:on-rename on-rename})) :on-confirm (fn []
(st/emit!
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description})
(remap/remap-tokens old-name name)
(dwtp/propagate-workspace-tokens)
(modal/hide!)))
:on-cancel #(modal/hide!)})
(st/emit! (st/emit!
(if is-create (if is-create
(dwtl/create-token (ctob/make-token {:name name (dwtl/create-token (ctob/make-token {:name name

View File

@@ -295,8 +295,7 @@
errors? errors?
[:> icon* [:> icon*
{:icon-id i/broken-link {:icon-id i/broken-link
:class (stl/css :token-pill-icon) :class (stl/css :token-pill-icon)}]
:aria-label (tr "workspace.tokens.missing-reference")}]
color color
[:> swatch* {:background color [:> swatch* {:background color

View File

@@ -11,15 +11,22 @@
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[app.util.keyboard :as kbd]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(defn show-remapping-modal
"Show the token remapping confirmation modal"
[{:keys [old-token-name new-token-name references-count on-confirm on-cancel]}]
(let [props {:old-token-name old-token-name
:new-token-name new-token-name
:references-count references-count
:on-confirm on-confirm
:on-cancel on-cancel}]
(st/emit! (modal/show :tokens/remapping-confirmation props))))
(defn hide-remapping-modal (defn hide-remapping-modal
"Hide the token remapping confirmation modal" "Hide the token remapping confirmation modal"
[] []
@@ -27,73 +34,73 @@
;; Remapping Modal Component ;; Remapping Modal Component
(mf/defc token-remapping-modal (mf/defc token-remapping-modal
{::mf/register modal/components {::mf/wrap-props false
::mf/register modal/components
::mf/register-as :tokens/remapping-confirmation} ::mf/register-as :tokens/remapping-confirmation}
[{:keys [old-token-name new-token-name on-remap on-rename]}] [{:keys [old-token-name new-token-name references-count on-confirm on-cancel]}]
(let [remap-modal (get @st/state :remap-modal) (let [remapping-in-progress* (mf/use-state false)
remapping-in-progress? (deref remapping-in-progress*)
;; Remap logic on confirm ;; Remap logic on confirm
confirm-remap on-confirm-remap
(mf/use-fn (mf/use-fn
(mf/deps on-remap remap-modal) (mf/deps on-confirm remapping-in-progress*)
(fn [] (fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(reset! remapping-in-progress* true)
;; Call shared remapping logic ;; Call shared remapping logic
(let [old-token-name (:old-token-name remap-modal) (let [state @st/state
remap-modal (:remap-modal state)
old-token-name (:old-token-name remap-modal)
new-token-name (:new-token-name remap-modal)] new-token-name (:new-token-name remap-modal)]
(st/emit! [:tokens/remap-tokens old-token-name new-token-name])) (st/emit! [:tokens/remap-tokens old-token-name new-token-name]))
(when (fn? on-remap) (when (fn? on-confirm)
(on-remap)))) (on-confirm))))
rename-token on-cancel-remap
(mf/use-fn (mf/use-fn
(mf/deps on-rename) (mf/deps on-cancel)
(fn [] (fn [e]
(when (fn? on-rename) (dom/prevent-default e)
(on-rename)))) (dom/stop-propagation e)
(modal/hide!)
cancel-action (when (fn? on-cancel)
(mf/use-fn (on-cancel))))]
(fn []
(hide-remapping-modal)))
;; Close modal on Escape key if not in progress
on-key-down
(mf/use-fn
(mf/deps cancel-action)
(fn [event]
(when (kbd/enter? event)
(cancel-action))))]
[:div {:class (stl/css :modal-overlay)
:on-key-down on-key-down
:role "alertdialog"
:aria-modal "true"
:aria-labelledby "modal-title"}
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog) [:div {:class (stl/css :modal-dialog)
:data-testid "token-remapping-modal"} :data-testid "token-remapping-modal"}
[:> icon-button* {:on-click cancel-action
:class (stl/css :close-btn)
:icon i/close
:variant "action"
:aria-label (tr "labels.close")}]
[:div {:class (stl/css :modal-header)} [:div {:class (stl/css :modal-header)}
[:> heading* {:level 2 [:> heading* {:level 2
:id "modal-title" :typography "headline-medium"
:typography "headline-large"
:class (stl/css :modal-title)} :class (stl/css :modal-title)}
(tr "workspace.tokens.remap-token-references-title" old-token-name new-token-name)]] (tr "workspace.tokens.remap-token-references")]]
[:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-content)}
[:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-effects")] [:> heading* {:level 3
[:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-time")]] :typography "title-medium"
:class (stl/css :modal-msg)}
(tr "workspace.tokens.renaming-token-from-to" old-token-name new-token-name)]
[:div {:class (stl/css :modal-scd-msg)}
(if (> references-count 0)
(tr "workspace.tokens.references-found" references-count)
(tr "workspace.tokens.no-references-found"))]
(when remapping-in-progress?
[:> context-notification*
{:level :info
:appearance :ghost}
(tr "workspace.tokens.remapping-in-progress")])]
[:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)} [:div {:class (stl/css :action-buttons)}
[:> button* {:on-click rename-token [:> button* {:on-click on-cancel-remap
:type "button" :type "button"
:variant "secondary"} :variant "secondary"
(tr "workspace.tokens.not-remap")] :disabled remapping-in-progress?}
[:> button* {:on-click confirm-remap (tr "labels.cancel")]
[:> button* {:on-click on-confirm-remap
:type "button" :type "button"
:variant "primary"} :variant "primary"
(tr "workspace.tokens.remap")]]]]])) :disabled remapping-in-progress?}
(if (> references-count 0)
(tr "workspace.tokens.remap-and-rename")
(tr "workspace.tokens.rename-only"))]]]]]))

View File

@@ -10,9 +10,6 @@
@use "refactor/common-refactor.scss" as deprecated; @use "refactor/common-refactor.scss" as deprecated;
.modal-overlay { .modal-overlay {
--modal-title-foreground-color: var(--color-foreground-primary);
--modal-text-foreground-color: var(--color-foreground-secondary);
@extend .modal-overlay-base; @extend .modal-overlay-base;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -20,22 +17,16 @@
position: fixed; position: fixed;
inset-inline-start: 0; inset-inline-start: 0;
inset-block-start: 0; inset-block-start: 0;
block-size: 100%; height: 100%;
inline-size: 100%; width: 100%;
background-color: var(--overlay-color); background-color: var(--overlay-color);
} }
.close-btn {
position: absolute;
inset-block-start: $sz-6;
inset-inline-end: $sz-6;
}
.modal-dialog { .modal-dialog {
@extend .modal-container-base; @extend .modal-container-base;
inline-size: 100%; width: 100%;
max-inline-size: 32rem; max-width: 32rem;
max-block-size: unset; max-height: unset;
user-select: none; user-select: none;
position: relative; position: relative;
} }
@@ -54,7 +45,11 @@
.modal-content { .modal-content {
@include t.use-typography("body-large"); @include t.use-typography("body-large");
color: var(--modal-text-foreground-color); margin-block-end: var(--sp-xxl);
padding: var(--sp-xxl) 0;
display: flex;
flex-direction: column;
gap: var(--sp-l);
} }
.modal-footer { .modal-footer {

View File

@@ -8072,10 +8072,6 @@ msgid "workspace.tokens.missing-references"
msgstr "Missing token references: " msgstr "Missing token references: "
#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:123 #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:123
msgid "workspace.tokens.missing-reference"
msgstr "Missing reference"
#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:299
msgid "workspace.tokens.more-options" msgid "workspace.tokens.more-options"
msgstr "Right click to see options" msgstr "Right click to see options"
@@ -8166,29 +8162,36 @@ msgstr "Enter a token shadow alias"
msgid "workspace.tokens.reference-error" msgid "workspace.tokens.reference-error"
msgstr "Reference Errors: " msgstr "Reference Errors: "
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86
msgid "workspace.tokens.remap-token-references-title" msgid "workspace.tokens.references-found"
msgstr "Remap all tokens that use `%s` to `%s`?" msgstr "%s references found in your design"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105
msgid "workspace.tokens.remap-and-rename"
msgstr "Remap & Rename"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs
#, unused
msgid "workspace.tokens.remap-explanation"
msgstr ""
"All references to this token will be automatically updated to use the new "
"name."
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.remap-warning-effects" msgid "workspace.tokens.remap-token-references"
msgstr "This will change all layers and references that use the old token name." msgstr "Remap Token References"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.remap-warning-time"
msgstr "This action could take a while."
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92
msgid "workspace.tokens.remapping-in-progress" msgid "workspace.tokens.remapping-in-progress"
msgstr "Remapping token references..." msgstr "Remapping token references..."
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:106 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:106
msgid "workspace.tokens.not-remap" msgid "workspace.tokens.rename-only"
msgstr "Don't remap" msgstr "Rename"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:83
msgid "workspace.tokens.remap" msgid "workspace.tokens.renaming-token-from-to"
msgstr "Remap tokens" msgstr "Renaming token from '%s' to '%s'"
#: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:271, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:445, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:169, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:304, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:225, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:332, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:432, src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:271, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:445, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:169, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:304, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:225, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:332, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:432, src/app/main/ui/workspace/tokens/management/token_pill.cljs:121
#, fuzzy #, fuzzy

View File

@@ -7994,10 +7994,6 @@ msgstr "Line height (multiplicador, px o %) o {alias}"
msgid "workspace.tokens.missing-references" msgid "workspace.tokens.missing-references"
msgstr "Referencias de tokens no encontradas: " msgstr "Referencias de tokens no encontradas: "
#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:123
msgid "workspace.tokens.missing-reference"
msgstr "Referencia no encontrada"
#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:123 #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:123
msgid "workspace.tokens.more-options" msgid "workspace.tokens.more-options"
msgstr "Click derecho para ver opciones" msgstr "Click derecho para ver opciones"
@@ -8067,29 +8063,36 @@ msgstr "La referencia no es válida o no se encuentra en ningún set activo."
msgid "workspace.tokens.reference-error" msgid "workspace.tokens.reference-error"
msgstr "Errores en referencias: " msgstr "Errores en referencias: "
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86
msgid "workspace.tokens.remap-token-references-title" msgid "workspace.tokens.references-found"
msgstr "¿Actualizar todas las referencias de `%s` a `%s`?" msgstr "%s referencias encontradas en tu diseño"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105
msgid "workspace.tokens.remap-and-rename"
msgstr "Actualizar referencias y renombrar"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs
#, unused
msgid "workspace.tokens.remap-explanation"
msgstr ""
"Todas las referencias a este token se actualizarán automáticamente para "
"usar el nuevo nombre."
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.remap-warning-effects" msgid "workspace.tokens.remap-token-references"
msgstr "Esta acción actualizará todas las capas y referencias que usen el token antiguo" msgstr "Actualizar referencias de token"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.remap-warning-time"
msgstr "Este proceso puede durar un poco"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92
msgid "workspace.tokens.remapping-in-progress" msgid "workspace.tokens.remapping-in-progress"
msgstr "Actualizando referencias de token..." msgstr "Actualizando referencias de token..."
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:106 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:106
msgid "workspace.tokens.not-remap" msgid "workspace.tokens.rename-only"
msgstr "No actualizar" msgstr "Renombrar"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:83
msgid "workspace.tokens.remap" msgid "workspace.tokens.renaming-token-from-to"
msgstr "Actualizar tokens" msgstr "Renombrando el token de '%s' a '%s'"
#: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:271, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:445, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:169, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:304, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:225, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:332, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:432, src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:271, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:445, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:169, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:304, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:225, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:332, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:432, src/app/main/ui/workspace/tokens/management/token_pill.cljs:121
#, fuzzy #, fuzzy