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
28 changed files with 604 additions and 755 deletions

View File

@@ -1,7 +1,12 @@
#!/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
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449

View File

@@ -102,6 +102,8 @@
[:http-server-io-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]
[:telemetry-uri {:optional true} :string]

View File

@@ -13,13 +13,13 @@
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.http.middleware :as mw]
[app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.worker :as-alias wrk]
[integrant.core :as ig]
[yetti.request :as yreq]
[yetti.response :as-alias yres]))
;; ---- ROUTES
@@ -49,28 +49,40 @@
(fn [cfg 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
[_ {:keys [::setup/props] :as cfg}]
[_ cfg]
(let [management-key (or (cf/get :management-api-key)
(get props :management-key))]
["" {:middleware [[shared-key-auth (cf/get :management-api-key)]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["" {:middleware [[mw/shared-key-auth management-key]
[default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
["/get-customer"
{:handler get-customer
:transaction true
:allowed-methods #{:post}}]
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]])
;; ---- HELPERS

View File

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

View File

@@ -140,10 +140,14 @@
client-version (get-client-version request)
client-user-agent (get-client-user-agent 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
{:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version

View File

@@ -275,8 +275,7 @@
::email/whitelist (ig/ref ::email/whitelist)}
::mgmt/routes
{::db/pool (ig/ref ::db/pool)
::setup/props (ig/ref ::setup/props)}
{::db/pool (ig/ref ::db/pool)}
:app.http/router
{::session/manager (ig/ref ::session/manager)
@@ -341,7 +340,8 @@
::email/whitelist (ig/ref ::email/whitelist)}
: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
{::http.client/client (ig/ref ::http.client/client)
@@ -357,13 +357,13 @@
::setup/props (ig/ref ::setup/props)}
::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)
;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
::setup/shared-keys (ig/ref ::setup/shared-keys)}
::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics)
@@ -451,6 +451,11 @@
;; module requires the migrations to run before initialize.
::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
{}

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
"Module that make calls to the external nitrate aplication"
(:require
@@ -17,18 +11,17 @@
[clojure.core :as c]
[integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- request-builder
[cfg method uri management-key profile-id]
[cfg method uri shared-key profile-id]
(fn []
(http/req! cfg {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" management-key
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1})))
@@ -54,9 +47,9 @@
(defn- with-validate [handler uri schema]
(fn []
(let [coercer-http (sm/coercer schema
:type :validation
:hint (str "invalid data received calling " uri))]
(let [coercer-http (sm/coercer schema
:type :validation
:hint (str "invalid data received calling " uri))]
(try
(coercer-http (-> (handler) :body json/decode))
(catch Exception e
@@ -65,8 +58,9 @@
nil)))))
(defn- request-to-nitrate
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}]
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id)
[cfg method uri schema {:keys [::rpc/profile-id] :as params}]
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
(with-retries 3)
(with-validate uri schema))]
(full-http-call)))
@@ -103,26 +97,15 @@
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client
[_ {:keys [::setup/props] :as cfg}]
(if (contains? cf/flags :nitrate)
(let [management-key (or (cf/get :management-api-key)
(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))
[_ cfg]
(when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS
@@ -144,4 +127,4 @@
[cfg team params]
(let [params (assoc (or params {}) :team-id (:id team))
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}]
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request)
(::actoken/profile-id request)
(if (::http/auth-with-shared-key request)
uuid/zero
nil))
(if key-id uuid/zero nil))
ip-addr (inet/parse-request request)
@@ -298,11 +298,12 @@
(defn- resolve-management-methods
[cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription
'app.rpc.management.nitrate
'app.rpc.management.exporter)
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)
mods (cond->> (list 'app.rpc.management.exporter)
(contains? cf/flags :nitrate)
(cons 'app.rpc.management.nitrate))]
(->> (apply sv/scan-ns mods)
(map (partial process-method cfg "management" wrap-management))
(into {}))))
@@ -346,23 +347,20 @@
(defmethod ig/assert-key ::routes
[_ params]
(assert (map? (::setup/shared-keys params)))
(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 (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes
[_ {: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))]
[_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
(let [public-uri (cf/get :public-uri)]
["/api"
["/management"
["/methods/:type"
{:middleware [[mw/shared-key-auth management-key]
{:middleware [[mw/shared-key-auth shared-keys]
[session/authz cfg]]
:handler (make-rpc-handler management-methods)}]

View File

@@ -5,13 +5,12 @@
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate
"Internal Nitrate HTTP API.
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."
"Internal Nitrate HTTP RPC API. Provides authenticated access to
organization management and token validation endpoints."
(:require
[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.config :as cf]
[app.db :as db]
@@ -23,22 +22,14 @@
[app.util.services :as sv]))
;; ---- API: authenticate
(def ^:private schema:profile
[:map
[:id ::sm/uuid]
[:name :string]
[:email :string]
[:photo-url :string]])
(sv/defmethod ::authenticate
"Authenticate an user
@api GET /authenticate
@returns
200 OK: Returns the authenticated user."
{::doc/added "2.12"
"Authenticate the current user"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:profile}
[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)
:name (get profile :fullname)
:email (get profile :email)
@@ -51,30 +42,22 @@
FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ?
AND tpr.is_owner = 't'
AND t.is_default = 'f'
AND t.deleted_at is null;")
(def ^:private schema:team
[:map
[:id ::sm/uuid]
[:name :string]])
AND tpr.is_owner IS TRUE
AND t.is_default IS FALSE
AND t.deleted_at IS NULL;")
(def ^:private schema:get-teams-result
[:vector schema:team])
(sv/defmethod ::get-teams
"List teams for which current user is owner.
@api GET /get-teams
@returns
200 OK: Returns the list of teams for the user."
{::doc/added "2.12"
"List teams for which current user is owner"
{::doc/added "2.14"
::sm/params [:map]
::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}]
(when (contains? cf/flags :nitrate)
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name]))))))
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name])))))
;; ---- API: notify-team-change
@@ -83,30 +66,18 @@
[:id ::sm/uuid]
[:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate
@api POST /notify-team-change
@returns
200 OK"
{::doc/added "2.12"
"Notify to Penpot a team change from nitrate"
{::doc/added "2.14"
::sm/params schema:notify-team-change
::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}]
(when (contains? cf/flags :nitrate)
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team-id id
:organization-id organization-id
:organization-name organization-name}))))
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team-id id
:organization-id organization-id
: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]
[buddy.core.codecs :as bc]
[buddy.core.nonce :as bn]
[cuerdas.core :as str]
[integrant.core :as ig]))
(defn- generate-random-key
@@ -88,7 +89,38 @@
(-> (get-all-props conn)
(assoc :secret-key secret)
(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)))))))
(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
(let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200})
"secret-key")]
{"test1" "secret-key"})]
(let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response))))
@@ -95,6 +95,9 @@
(t/is (= 403 (::yres/status response))))
(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/deftest access-token-authz

View File

@@ -241,20 +241,7 @@
(with-out-str
(print-all cause)))))
(defn print-throwable
[cause & {:as opts}]
#?(:clj
(println (format-throwable cause opts))
:cljs
(let [prefix (get opts :prefix "exception")
title (str prefix ": " (ex-message cause))
exdata (ex-data cause)]
(js/console.group title)
(when-let [explain (get exdata ::sm/explain)]
(println (sm/humanize-explain explain)))
(js/console.log "\nData:")
(pp/pprint (dissoc exdata ::sm/explain))
(js/console.log "\nTrace:")
(js/console.error (.-stack cause)))))
#?(:clj
(defn print-throwable
[cause & {:as opts}]
(println (format-throwable cause opts))))

View File

@@ -19,3 +19,10 @@
(def schema:role
[::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.key /home/
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/init.sh /home/init.sh

View File

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

View File

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

View File

@@ -73,7 +73,7 @@
(p/mcat (fn [blob]
(let [fdata (new http/FormData)
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)}
request #js {:headers headers

View File

@@ -12,118 +12,88 @@ test.beforeEach(async ({ page }) => {
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
});
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("Tokens: Remapping Feature", () => {
test.describe("Box Shadow Token Remapping", () => {
test("User renames box shadow token with alias references", async ({
page,
}) => {
const { tokensSidebar } = await setupTokensFile(page, {
flags: ["enable-token-shadow"],
});
const {
tokensUpdateCreateModal,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// 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
await createCompositeDerivedToken(
page,
"Shadow",
"derived-shadow",
"{base-shadow}",
);
await tokensTabPanel
.getByRole("button", { name: "Add Token: Shadow" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
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
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
const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 });
await expect(remappingModal).toContainText("base-shadow");
await expect(remappingModal).toContainText("foundation-shadow");
await expect(remappingModal).toContainText("1");
const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens",
name: /remap/i,
});
await confirmButton.click();
@@ -146,16 +116,51 @@ test.describe("Remapping Tokens", () => {
workspacePage,
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// 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
await createCompositeDerivedToken(
page,
"Shadow",
"card-shadow",
"{primary-shadow}",
);
await tokensTabPanel
.getByRole("button", { name: "Add Token: Shadow" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
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
await page.getByRole("tab", { name: "Layers" }).click();
@@ -178,16 +183,16 @@ test.describe("Remapping Tokens", () => {
await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("main-shadow");
// Update the color value
const colorField = tokensUpdateCreateModal.getByRole("textbox", {
colorField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Color",
});
await colorField.fill("#FF0000");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
@@ -197,7 +202,7 @@ test.describe("Remapping Tokens", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens",
name: /remap/i,
});
await confirmButton.click();
@@ -254,25 +259,73 @@ test.describe("Remapping Tokens", () => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// 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
await createCompositeDerivedToken(
page,
"Typography",
"body-text",
"{base-text}",
);
await tokensTabPanel
.getByRole("button", { name: "Add Token: Typography" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
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
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
const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens",
name: /remap/i,
});
await confirmButton.click();
@@ -298,7 +351,24 @@ test.describe("Remapping Tokens", () => {
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// 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
await tokensTabPanel
@@ -306,7 +376,7 @@ test.describe("Remapping Tokens", () => {
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
let nameField = tokensUpdateCreateModal.getByRole("textbox", {
nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name",
});
await nameField.fill("paragraph-style");
@@ -320,7 +390,7 @@ test.describe("Remapping Tokens", () => {
});
await referenceField.fill("{body-style}");
let submitButton = tokensUpdateCreateModal.getByRole("button", {
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
@@ -351,7 +421,7 @@ test.describe("Remapping Tokens", () => {
await nameField.fill("text-base");
// Update the font size value
const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", {
fontSizeField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Font size",
});
await fontSizeField.fill("18");
@@ -366,7 +436,7 @@ test.describe("Remapping Tokens", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens",
name: /remap/i,
});
await confirmButton.click();
@@ -401,29 +471,72 @@ test.describe("Remapping Tokens", () => {
test("User renames border radius token with alias references", async ({
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
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
await createToken(
page,
"Border Radius",
"card-radius",
"Value",
"{base-radius}",
);
await tokensTabPanel
.getByRole("button", { name: "Add Token: Border Radius" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
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
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
const remappingModal = page.getByTestId("token-remapping-modal");
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens",
name: /remap/i,
});
await confirmButton.click();
@@ -445,17 +558,43 @@ test.describe("Remapping Tokens", () => {
tokenContextMenuForToken,
} = await setupTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
// 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
await createToken(
page,
"Border Radius",
"button-radius",
"Value",
"{radius-sm}",
);
await tokensTabPanel
.getByRole("button", { name: "Add Token: Border Radius" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
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
const radiusToken = tokensSidebar.getByRole("button", {
@@ -465,14 +604,14 @@ test.describe("Remapping Tokens", () => {
await tokenContextMenuForToken.getByText("Edit token").click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("radius-base");
// Update the value
const valueField = tokensUpdateCreateModal.getByLabel("Value");
valueField = tokensUpdateCreateModal.getByLabel("Value");
await valueField.fill("8");
const submitButton = tokensUpdateCreateModal.getByRole("button", {
submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
@@ -482,7 +621,7 @@ test.describe("Remapping Tokens", () => {
await expect(remappingModal).toBeVisible({ timeout: 5000 });
const confirmButton = remappingModal.getByRole("button", {
name: "remap tokens",
name: /remap/i,
});
await confirmButton.click();
@@ -509,82 +648,4 @@ test.describe("Remapping Tokens", () => {
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

@@ -29,9 +29,6 @@
;; Will contain the latest error report assigned
(def last-report nil)
;; Will contain last uncaught exception
(def last-exception nil)
(defn- print-data!
[data]
(-> data
@@ -341,6 +338,7 @@
(print-data! werror)
(print-explain! werror))))))))
(defonce uncaught-error-handler
(letfn [(is-ignorable-exception? [cause]
(let [message (ex-message cause)]
@@ -351,31 +349,10 @@
(on-unhandled-error [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")]
(set! last-exception cause)
(when-not (is-ignorable-exception? cause)
(ex/print-throwable cause :prefix "uncaught exception")
(st/async-emit!
(ntf/show {:content (tr "errors.unexpected-exception" (ex-message cause))
:type :toast
:level :error
:timeout 3000})))))
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(set! last-exception cause)
(ex/print-throwable cause :prefix "uncaught rejection")
(st/async-emit!
(ntf/show {:content (tr "errors.unexpected-exception" (ex-message cause))
:type :toast
:level :error
:timeout 3000}))))]
(when-let [error (unchecked-get event "error")]
(when-not (is-ignorable-exception? error)
(on-error error))))]
(.addEventListener glob/window "error" on-unhandled-error)
(.addEventListener glob/window "unhandledrejection" on-unhandled-rejection)
(fn []
(.removeEventListener glob/window "error" on-unhandled-error)
(.removeEventListener glob/window "unhandledrejection" on-unhandled-rejection))))
(.removeEventListener glob/window "error" on-unhandled-error))))

View File

@@ -8,7 +8,6 @@
"React error boundary components"
(:require
["react-error-boundary" :as reb]
[app.common.exceptions :as ex]
[app.main.errors :as errors]
[app.main.refs :as refs]
[goog.functions :as gfn]
@@ -35,8 +34,7 @@
;; very small amount of time, so we debounce for 100ms for
;; avoid duplicate and redundant reports
(gfn/debounce (fn [error info]
(set! errors/last-exception error)
(ex/print-throwable error)
(js/console.log "Cause stack: \n" (.-stack error))
(js/console.error
"Component trace: \n"
(unchecked-get info "componentStack")

View File

@@ -28,6 +28,7 @@
[app.main.ui.forms :as fc]
[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.remapping-modal :as remapping-modal]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
@@ -181,33 +182,9 @@
(when (or (k/enter? e) (k/space? 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
(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]
(let [name (get-in @form [:clean-data :name])
path (str (d/name token-type) "." name)
@@ -225,15 +202,22 @@
file-data (dh/lookup-file-data state)
old-name (:name token)
is-rename (and (= action "edit") (not= name 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)]
references-count (remap/count-token-references file-data old-name)]
(if (and is-rename (> references-count 0))
(st/emit! (modal/show :tokens/remapping-confirmation {:old-token-name old-name
:new-token-name name
:references-count references-count
:on-remap on-remap
:on-rename on-rename}))
(remapping-modal/show-remapping-modal
{:old-token-name old-name
:new-token-name name
:references-count references-count
: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!
(if is-create
(dwtl/create-token (ctob/make-token {:name name

View File

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

View File

@@ -11,15 +11,22 @@
[app.main.data.modal :as modal]
[app.main.store :as st]
[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.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.keyboard :as kbd]
[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
"Hide the token remapping confirmation modal"
[]
@@ -27,73 +34,73 @@
;; Remapping Modal Component
(mf/defc token-remapping-modal
{::mf/register modal/components
{::mf/wrap-props false
::mf/register modal/components
::mf/register-as :tokens/remapping-confirmation}
[{:keys [old-token-name new-token-name on-remap on-rename]}]
(let [remap-modal (get @st/state :remap-modal)
[{:keys [old-token-name new-token-name references-count on-confirm on-cancel]}]
(let [remapping-in-progress* (mf/use-state false)
remapping-in-progress? (deref remapping-in-progress*)
;; Remap logic on confirm
confirm-remap
on-confirm-remap
(mf/use-fn
(mf/deps on-remap remap-modal)
(fn []
(mf/deps on-confirm remapping-in-progress*)
(fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(reset! remapping-in-progress* true)
;; 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)]
(st/emit! [:tokens/remap-tokens old-token-name new-token-name]))
(when (fn? on-remap)
(on-remap))))
(when (fn? on-confirm)
(on-confirm))))
rename-token
on-cancel-remap
(mf/use-fn
(mf/deps on-rename)
(fn []
(when (fn? on-rename)
(on-rename))))
cancel-action
(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"}
(mf/deps on-cancel)
(fn [e]
(dom/prevent-default e)
(dom/stop-propagation e)
(modal/hide!)
(when (fn? on-cancel)
(on-cancel))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog)
: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)}
[:> heading* {:level 2
:id "modal-title"
:typography "headline-large"
:typography "headline-medium"
: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)}
[:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-effects")]
[:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-time")]]
[:> heading* {:level 3
: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 :action-buttons)}
[:> button* {:on-click rename-token
[:> button* {:on-click on-cancel-remap
:type "button"
:variant "secondary"}
(tr "workspace.tokens.not-remap")]
[:> button* {:on-click confirm-remap
:variant "secondary"
:disabled remapping-in-progress?}
(tr "labels.cancel")]
[:> button* {:on-click on-confirm-remap
:type "button"
:variant "primary"}
(tr "workspace.tokens.remap")]]]]]))
:variant "primary"
: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;
.modal-overlay {
--modal-title-foreground-color: var(--color-foreground-primary);
--modal-text-foreground-color: var(--color-foreground-secondary);
@extend .modal-overlay-base;
display: flex;
justify-content: center;
@@ -20,22 +17,16 @@
position: fixed;
inset-inline-start: 0;
inset-block-start: 0;
block-size: 100%;
inline-size: 100%;
height: 100%;
width: 100%;
background-color: var(--overlay-color);
}
.close-btn {
position: absolute;
inset-block-start: $sz-6;
inset-inline-end: $sz-6;
}
.modal-dialog {
@extend .modal-container-base;
block-size: 100%;
max-inline-size: 32rem;
max-block-size: unset;
width: 100%;
max-width: 32rem;
max-height: unset;
user-select: none;
position: relative;
}
@@ -54,7 +45,11 @@
.modal-content {
@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 {

View File

@@ -8,7 +8,6 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.repair :as cfr]
[app.common.files.validate :as cfv]
[app.common.json :as json]
@@ -457,8 +456,3 @@
(defn ^:export network-averages
[]
(.log js/console (clj->js @http/network-averages)))
(defn print-last-exception
[]
(some-> errors/last-exception ex/print-throwable))

View File

@@ -1466,9 +1466,6 @@ msgstr ""
msgid "errors.generic"
msgstr "Something wrong has happened."
msgid "errors.unexpected-exception"
msgstr "Unexpected exception: %s"
#: src/app/main/errors.cljs:200
msgid "errors.internal-assertion-error"
msgstr "Internal Assertion Error"
@@ -8075,10 +8072,6 @@ msgid "workspace.tokens.missing-references"
msgstr "Missing token references: "
#: 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"
msgstr "Right click to see options"
@@ -8169,29 +8162,36 @@ msgstr "Enter a token shadow alias"
msgid "workspace.tokens.reference-error"
msgstr "Reference Errors: "
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.remap-token-references-title"
msgstr "Remap all tokens that use `%s` to `%s`?"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86
msgid "workspace.tokens.references-found"
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
msgid "workspace.tokens.remap-warning-effects"
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."
msgid "workspace.tokens.remap-token-references"
msgstr "Remap Token References"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92
msgid "workspace.tokens.remapping-in-progress"
msgstr "Remapping token references..."
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:106
msgid "workspace.tokens.not-remap"
msgstr "Don't remap"
msgid "workspace.tokens.rename-only"
msgstr "Rename"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105
msgid "workspace.tokens.remap"
msgstr "Remap tokens"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:83
msgid "workspace.tokens.renaming-token-from-to"
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
#, fuzzy

View File

@@ -1462,9 +1462,6 @@ msgstr ""
msgid "errors.generic"
msgstr "Ha ocurrido algún error."
msgid "errors.unexpected-exception"
msgstr "Error inesperado: %s"
#: src/app/main/errors.cljs:200
msgid "errors.internal-assertion-error"
msgstr "Error interno de aserción"
@@ -7997,10 +7994,6 @@ msgstr "Line height (multiplicador, px o %) o {alias}"
msgid "workspace.tokens.missing-references"
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
msgid "workspace.tokens.more-options"
msgstr "Click derecho para ver opciones"
@@ -8070,29 +8063,36 @@ msgstr "La referencia no es válida o no se encuentra en ningún set activo."
msgid "workspace.tokens.reference-error"
msgstr "Errores en referencias: "
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78
msgid "workspace.tokens.remap-token-references-title"
msgstr "¿Actualizar todas las referencias de `%s` a `%s`?"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86
msgid "workspace.tokens.references-found"
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
msgid "workspace.tokens.remap-warning-effects"
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"
msgid "workspace.tokens.remap-token-references"
msgstr "Actualizar referencias de token"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92
msgid "workspace.tokens.remapping-in-progress"
msgstr "Actualizando referencias de token..."
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:106
msgid "workspace.tokens.not-remap"
msgstr "No actualizar"
msgid "workspace.tokens.rename-only"
msgstr "Renombrar"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:105
msgid "workspace.tokens.remap"
msgstr "Actualizar tokens"
#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:83
msgid "workspace.tokens.renaming-token-from-to"
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
#, fuzzy