Compare commits

..

2 Commits

Author SHA1 Message Date
Eva Marco
2523096fdd 🐛 Fix css rule (#8206) 2026-01-27 12:30:14 +01:00
Xaviju
8e63c4e3e8 ♻️ Review remap interface and interaction (#8168)
* ♻️ Review remap interface and interaction
* ♻️ Fix remapping feature tests
2026-01-27 11:18:34 +01:00
24 changed files with 695 additions and 594 deletions

View File

@@ -1,12 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
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
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-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,8 +102,6 @@
[: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,40 +49,28 @@
(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
[_ cfg] [_ {:keys [::setup/props] :as cfg}]
["" {:middleware [[shared-key-auth (cf/get :management-api-key)] (let [management-key (or (cf/get :management-api-key)
[default-system cfg] (get props :management-key))]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["/get-customer" ["" {:middleware [[mw/shared-key-auth management-key]
{:handler get-customer [default-system cfg]
:transaction true [transaction]]}
:allowed-methods #{:post}}] ["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["/update-customer" ["/get-customer"
{:handler update-customer {:handler get-customer
:allowed-methods #{:post} :transaction true
:transaction true}]]) :allowed-methods #{:post}}]
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
;; ---- HELPERS ;; ---- HELPERS

View File

@@ -16,6 +16,7 @@
[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]
@@ -300,20 +301,16 @@
:compile (constantly wrap-auth)}) :compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth (defn- wrap-shared-key-auth
[handler keys] [handler shared-key]
(if (seq keys) (if shared-key
(fn [request] (let [shared-key (if (string? shared-key)
(if-let [[key-id key] (some-> (yreq/get-header request "x-shared-key") shared-key
(str/split #"\s+" 2))] (bc/bytes->b64-str shared-key true))]
(let [key-id (str/lower key-id)] (fn [request]
(if (and (string? key) (let [key (yreq/get-header request "x-shared-key")]
(contains? keys key-id) (if (= key shared-key)
(= key (get keys key-id))) (handler (assoc request ::http/auth-with-shared-key true))
(-> request {::yres/status 403}))))
(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,14 +140,10 @@
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)
key-id (::http/auth-key-id request) token-id (::actoken/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,7 +275,8 @@
::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)
@@ -340,8 +341,7 @@
::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/shared-keys (ig/ref ::setup/shared-keys)} ::setup/props (ig/ref ::setup/props)}
::wrk/registry ::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics) {::mtx/metrics (ig/ref ::mtx/metrics)
@@ -451,11 +451,6 @@
;; 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,3 +1,9 @@
;; 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
@@ -11,17 +17,18 @@
[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 shared-key profile-id] [cfg method uri management-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" shared-key "x-shared-key" management-key
"x-profile-id" (str profile-id)} "x-profile-id" (str profile-id)}
:uri uri :uri uri
:version :http1.1}))) :version :http1.1})))
@@ -47,9 +54,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
@@ -58,9 +65,8 @@
nil))))) nil)))))
(defn- request-to-nitrate (defn- request-to-nitrate
[cfg method uri schema {:keys [::rpc/profile-id] :as params}] [{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}]
(let [shared-key (-> cfg ::setup/shared-keys :nitrate) (let [full-http-call (-> (request-builder cfg method uri management-key profile-id)
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)))
@@ -97,15 +103,26 @@
(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
[_ cfg] [_ {:keys [::setup/props] :as cfg}]
(when (contains? cf/flags :nitrate) (if (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org cfg) (let [management-key (or (cf/get :management-api-key)
:is-valid-user (partial is-valid-user cfg)})) (get props :management-key))
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
@@ -127,4 +144,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 key-id uuid/zero nil)) (if (::http/auth-with-shared-key request)
uuid/zero
nil))
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
@@ -298,12 +298,11 @@
(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)]
mods (cond->> (list 'app.rpc.management.exporter) (->> (sv/scan-ns
(contains? cf/flags :nitrate) 'app.rpc.management.subscription
(cons 'app.rpc.management.nitrate))] '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 {}))))
@@ -347,20 +346,23 @@
(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/shared-keys] :as cfg}] [_ {:keys [::methods ::management-methods ::setup/props] :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 shared-keys] {:middleware [[mw/shared-key-auth management-key]
[session/authz cfg]] [session/authz cfg]]
:handler (make-rpc-handler management-methods)}] :handler (make-rpc-handler management-methods)}]

View File

@@ -5,12 +5,13 @@
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate (ns app.rpc.management.nitrate
"Internal Nitrate HTTP RPC API. Provides authenticated access to "Internal Nitrate HTTP API.
organization management and token validation endpoints." Provides authenticated access to organization management and token validation endpoints.
All requests must include a valid shared key token in the `x-shared-key` header, and
a cookie `auth-token` with the user token.
They will return `401 Unauthorized` if the shared key or user token are invalid."
(:require (: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]
@@ -22,14 +23,22 @@
[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 the current user" "Authenticate an user
{::doc/added "2.14" @api GET /authenticate
::sm/params [:map] @returns
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)
@@ -42,22 +51,30 @@
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 IS TRUE AND tpr.is_owner = 't'
AND t.is_default IS FALSE AND t.is_default = 'f'
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.
{::doc/added "2.14" @api GET /get-teams
::sm/params [:map] @returns
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]}]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (when (contains? cf/flags :nitrate)
(->> (db/exec! cfg [sql:get-teams current-user-id]) (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(map #(select-keys % [:id :name]))))) (->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name]))))))
;; ---- API: notify-team-change ;; ---- API: notify-team-change
@@ -66,18 +83,30 @@
[: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
{::doc/added "2.14" @api POST /notify-team-change
@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]}]
(let [msgbus (::mbus/msgbus cfg)] (when (contains? cf/flags :nitrate)
(mbus/pub! msgbus (let [msgbus (::mbus/msgbus cfg)]
;;TODO There is a bug on dashboard with teams notifications. (mbus/pub! msgbus
;;For now we send it to uuid/zero instead of team-id ;;TODO There is a bug on dashboard with teams notifications.
:topic uuid/zero ;;For now we send it to uuid/zero instead of team-id
:message {:type :team-org-change :topic uuid/zero
:team-id id :message {:type :team-org-change
:organization-id organization-id :team-id id
:organization-name organization-name}))) :organization-id organization-id
:organization-name organization-name}))))

View File

@@ -0,0 +1,183 @@
;; 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,7 +17,6 @@
[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
@@ -89,38 +88,7 @@
(-> (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})
{"test1" "secret-key"})] "secret-key")]
(let [response (handler (->DummyRequest {} {}))] (let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
@@ -95,9 +95,6 @@
(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,10 +19,3 @@
(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,6 +398,7 @@ 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,7 +5,6 @@
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,14 +12,11 @@
["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"
@@ -33,7 +30,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]
[:exporter-shared-key {:optional true} :string] [:management-api-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]]
@@ -101,10 +98,8 @@
(c/get config key default))) (c/get config key default)))
(def management-key (def management-key
(let [key (or (c/get config :exporter-shared-key) (or (c/get config :management-api-key)
(let [secret-key (c/get config :secret-key) (let [secret-key (c/get config :secret-key)
derived-key (crypto/hkdfSync "blake2b512" secret-key, "exporter" "" 32)] derived-key (crypto/hkdfSync "blake2b512" secret-key, "management" "" 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" (str "exporter " cf/management-key) headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)} "Authorization" (str "Bearer " auth-token)}
request #js {:headers headers request #js {:headers headers

View File

@@ -12,88 +12,118 @@ test.beforeEach(async ({ page }) => {
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json"); await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
}); });
test.describe("Tokens: Remapping Feature", () => { const createToken = async (page, type, name, textFieldName, value) => {
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 { const { tokensSidebar } = await setupTokensFile(page, {
tokensUpdateCreateModal, flags: ["enable-token-shadow"],
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 tokensTabPanel await createToken(page, "Shadow", "base-shadow", "Color", "#000000");
.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 tokensTabPanel await createCompositeDerivedToken(
.getByRole("button", { name: "Add Token: Shadow" }) page,
.click(); "Shadow",
await expect(tokensUpdateCreateModal).toBeVisible(); "derived-shadow",
"{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
const baseToken = tokensSidebar.getByRole("button", { await renameToken(page, "base-shadow", "foundation-shadow");
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("1"); await expect(remappingModal).toContainText("base-shadow");
await expect(remappingModal).toContainText("foundation-shadow");
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: /remap/i, name: "remap tokens",
}); });
await confirmButton.click(); await confirmButton.click();
@@ -116,51 +146,16 @@ test.describe("Tokens: Remapping Feature", () => {
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 tokensTabPanel await createToken(page, "Shadow", "primary-shadow", "Color", "#000000");
.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 tokensTabPanel await createCompositeDerivedToken(
.getByRole("button", { name: "Add Token: Shadow" }) page,
.click(); "Shadow",
await expect(tokensUpdateCreateModal).toBeVisible(); "card-shadow",
"{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();
@@ -183,16 +178,16 @@ test.describe("Tokens: Remapping Feature", () => {
await tokenContextMenuForToken.getByText("Edit token").click(); await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible(); await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByLabel("Name"); const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("main-shadow"); await nameField.fill("main-shadow");
// Update the color value // Update the color value
colorField = tokensUpdateCreateModal.getByRole("textbox", { const colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Color", name: "Color",
}); });
await colorField.fill("#FF0000"); await colorField.fill("#FF0000");
submitButton = tokensUpdateCreateModal.getByRole("button", { const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save", name: "Save",
}); });
await submitButton.click(); await submitButton.click();
@@ -202,7 +197,7 @@ test.describe("Tokens: Remapping Feature", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: /remap/i, name: "remap tokens",
}); });
await confirmButton.click(); await confirmButton.click();
@@ -259,73 +254,25 @@ test.describe("Tokens: Remapping Feature", () => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base typography token // Create base typography token
await tokensTabPanel await createToken(page, "Typography", "base-text", "Font size", "16");
.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 tokensTabPanel await createCompositeDerivedToken(
.getByRole("button", { name: "Add Token: Typography" }) page,
.click(); "Typography",
await expect(tokensUpdateCreateModal).toBeVisible(); "body-text",
"{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
const baseToken = tokensSidebar.getByRole("button", { await renameToken(page, "base-text", "default-text");
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/i, name: "remap tokens",
}); });
await confirmButton.click(); await confirmButton.click();
@@ -351,24 +298,7 @@ test.describe("Tokens: Remapping Feature", () => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base typography token // Create base typography token
await tokensTabPanel await createToken(page, "Typography", "body-style", "Font size", "16");
.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
@@ -376,7 +306,7 @@ test.describe("Tokens: Remapping Feature", () => {
.click(); .click();
await expect(tokensUpdateCreateModal).toBeVisible(); await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByRole("textbox", { let nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name", name: "Name",
}); });
await nameField.fill("paragraph-style"); await nameField.fill("paragraph-style");
@@ -390,7 +320,7 @@ test.describe("Tokens: Remapping Feature", () => {
}); });
await referenceField.fill("{body-style}"); await referenceField.fill("{body-style}");
submitButton = tokensUpdateCreateModal.getByRole("button", { let submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save", name: "Save",
}); });
await submitButton.click(); await submitButton.click();
@@ -421,7 +351,7 @@ test.describe("Tokens: Remapping Feature", () => {
await nameField.fill("text-base"); await nameField.fill("text-base");
// Update the font size value // Update the font size value
fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Font size", name: "Font size",
}); });
await fontSizeField.fill("18"); await fontSizeField.fill("18");
@@ -436,7 +366,7 @@ test.describe("Tokens: Remapping Feature", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: /remap/i, name: "remap tokens",
}); });
await confirmButton.click(); await confirmButton.click();
@@ -471,72 +401,29 @@ test.describe("Tokens: Remapping Feature", () => {
test("User renames border radius token with alias references", async ({ test("User renames border radius token with alias references", async ({
page, page,
}) => { }) => {
const { const { tokensSidebar } = await setupTokensFile(page);
tokensUpdateCreateModal,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// Create base border radius token // Create base border radius token
await tokensTabPanel await createToken(page, "Border Radius", "base-radius", "Value", "4");
.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 tokensTabPanel await createToken(
.getByRole("button", { name: "Add Token: Border Radius" }) page,
.click(); "Border Radius",
await expect(tokensUpdateCreateModal).toBeVisible(); "card-radius",
"Value",
nameField = tokensUpdateCreateModal.getByLabel("Name"); "{base-radius}",
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
const baseToken = tokensSidebar.getByRole("button", { await renameToken(page, "base-radius", "primary-radius");
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/i, name: "remap tokens",
}); });
await confirmButton.click(); await confirmButton.click();
@@ -558,43 +445,17 @@ test.describe("Tokens: Remapping Feature", () => {
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 tokensTabPanel await createToken(page, "Border Radius", "radius-sm", "Value", "4");
.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 tokensTabPanel await createToken(
.getByRole("button", { name: "Add Token: Border Radius" }) page,
.click(); "Border Radius",
await expect(tokensUpdateCreateModal).toBeVisible(); "button-radius",
"Value",
nameField = tokensUpdateCreateModal.getByLabel("Name"); "{radius-sm}",
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", {
@@ -604,14 +465,14 @@ test.describe("Tokens: Remapping Feature", () => {
await tokenContextMenuForToken.getByText("Edit token").click(); await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible(); await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByLabel("Name"); const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("radius-base"); await nameField.fill("radius-base");
// Update the value // Update the value
valueField = tokensUpdateCreateModal.getByLabel("Value"); const valueField = tokensUpdateCreateModal.getByLabel("Value");
await valueField.fill("8"); await valueField.fill("8");
submitButton = tokensUpdateCreateModal.getByRole("button", { const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save", name: "Save",
}); });
await submitButton.click(); await submitButton.click();
@@ -621,7 +482,7 @@ test.describe("Tokens: Remapping Feature", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 }); await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", { const confirmButton = remappingModal.getByRole("button", {
name: /remap/i, name: "remap tokens",
}); });
await confirmButton.click(); await confirmButton.click();
@@ -648,4 +509,82 @@ test.describe("Tokens: Remapping Feature", () => {
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,7 +28,6 @@
[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]]
@@ -182,9 +181,33 @@
(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) (mf/deps validate-token token tokens token-type value-subfield type active-tab on-remap-token on-rename-token is-create)
(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)
@@ -202,22 +225,15 @@
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))
(remapping-modal/show-remapping-modal (st/emit! (modal/show :tokens/remapping-confirmation {:old-token-name old-name
{:old-token-name old-name :new-token-name name
:new-token-name name :references-count references-count
:references-count references-count :on-remap on-remap
:on-confirm (fn [] :on-rename on-rename}))
(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,7 +295,8 @@
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,22 +11,15 @@
[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.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.foundations.typography.text :refer [text*]]
[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"
[] []
@@ -34,73 +27,73 @@
;; Remapping Modal Component ;; Remapping Modal Component
(mf/defc token-remapping-modal (mf/defc token-remapping-modal
{::mf/wrap-props false {::mf/register modal/components
::mf/register modal/components
::mf/register-as :tokens/remapping-confirmation} ::mf/register-as :tokens/remapping-confirmation}
[{:keys [old-token-name new-token-name references-count on-confirm on-cancel]}] [{:keys [old-token-name new-token-name on-remap on-rename]}]
(let [remapping-in-progress* (mf/use-state false) (let [remap-modal (get @st/state :remap-modal)
remapping-in-progress? (deref remapping-in-progress*)
;; Remap logic on confirm ;; Remap logic on confirm
on-confirm-remap confirm-remap
(mf/use-fn (mf/use-fn
(mf/deps on-confirm remapping-in-progress*) (mf/deps on-remap remap-modal)
(fn [e] (fn []
(dom/prevent-default e)
(dom/stop-propagation e)
(reset! remapping-in-progress* true)
;; Call shared remapping logic ;; Call shared remapping logic
(let [state @st/state (let [old-token-name (:old-token-name remap-modal)
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-confirm) (when (fn? on-remap)
(on-confirm)))) (on-remap))))
on-cancel-remap rename-token
(mf/use-fn (mf/use-fn
(mf/deps on-cancel) (mf/deps on-rename)
(fn [e] (fn []
(dom/prevent-default e) (when (fn? on-rename)
(dom/stop-propagation e) (on-rename))))
(modal/hide!)
(when (fn? on-cancel) cancel-action
(on-cancel))))] (mf/use-fn
(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
:typography "headline-medium" :id "modal-title"
:typography "headline-large"
:class (stl/css :modal-title)} :class (stl/css :modal-title)}
(tr "workspace.tokens.remap-token-references")]] (tr "workspace.tokens.remap-token-references-title" old-token-name new-token-name)]]
[:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-content)}
[:> heading* {:level 3 [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-effects")]
:typography "title-medium" [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-time")]]
: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 on-cancel-remap [:> button* {:on-click rename-token
:type "button" :type "button"
:variant "secondary" :variant "secondary"}
:disabled remapping-in-progress?} (tr "workspace.tokens.not-remap")]
(tr "labels.cancel")] [:> button* {:on-click confirm-remap
[:> button* {:on-click on-confirm-remap
:type "button" :type "button"
:variant "primary" :variant "primary"}
:disabled remapping-in-progress?} (tr "workspace.tokens.remap")]]]]]))
(if (> references-count 0)
(tr "workspace.tokens.remap-and-rename")
(tr "workspace.tokens.rename-only"))]]]]]))

View File

@@ -10,6 +10,9 @@
@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;
@@ -17,16 +20,22 @@
position: fixed; position: fixed;
inset-inline-start: 0; inset-inline-start: 0;
inset-block-start: 0; inset-block-start: 0;
height: 100%; block-size: 100%;
width: 100%; inline-size: 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;
width: 100%; inline-size: 100%;
max-width: 32rem; max-inline-size: 32rem;
max-height: unset; max-block-size: unset;
user-select: none; user-select: none;
position: relative; position: relative;
} }
@@ -45,11 +54,7 @@
.modal-content { .modal-content {
@include t.use-typography("body-large"); @include t.use-typography("body-large");
margin-block-end: var(--sp-xxl); color: var(--modal-text-foreground-color);
padding: var(--sp-xxl) 0;
display: flex;
flex-direction: column;
gap: var(--sp-l);
} }
.modal-footer { .modal-footer {

View File

@@ -8072,6 +8072,10 @@ 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"
@@ -8162,36 +8166,29 @@ 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:86 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.references-found" msgid "workspace.tokens.remap-token-references-title"
msgstr "%s references found in your design" msgstr "Remap all tokens that use `%s` to `%s`?"
#: 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-token-references" msgid "workspace.tokens.remap-warning-effects"
msgstr "Remap Token References" msgstr "This will change all layers and references that use the old token name."
#: 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.rename-only" msgid "workspace.tokens.not-remap"
msgstr "Rename" msgstr "Don't remap"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:83 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105
msgid "workspace.tokens.renaming-token-from-to" msgid "workspace.tokens.remap"
msgstr "Renaming token from '%s' to '%s'" msgstr "Remap tokens"
#: 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,6 +7994,10 @@ 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"
@@ -8063,36 +8067,29 @@ 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:86 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.references-found" msgid "workspace.tokens.remap-token-references-title"
msgstr "%s referencias encontradas en tu diseño" msgstr "¿Actualizar todas las referencias de `%s` a `%s`?"
#: 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-token-references" msgid "workspace.tokens.remap-warning-effects"
msgstr "Actualizar referencias de token" msgstr "Esta acción actualizará todas las capas y referencias que usen el token antiguo"
#: 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.rename-only" msgid "workspace.tokens.not-remap"
msgstr "Renombrar" msgstr "No actualizar"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:83 #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105
msgid "workspace.tokens.renaming-token-from-to" msgid "workspace.tokens.remap"
msgstr "Renombrando el token de '%s' a '%s'" msgstr "Actualizar tokens"
#: 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