Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2026-08-12 07:30:05 +02:00
commit be9df28b00
14 changed files with 382 additions and 55 deletions

View File

@ -367,7 +367,7 @@
email (str/lower email)
fullname (d/normalize-string (:fullname params))
locale (d/normalize-string locale)
theme (d/normalize-string theme)
theme (some-> theme d/normalize-string not-empty)
photo-id (some->> (or (:oidc/picture props)
(:google/picture props)

View File

@ -0,0 +1,28 @@
{
"~:iss": "~:verify-email",
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
"~:profile": {
"~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
"~:email": "foo@example.com",
"~:fullname": "Princesa Leia",
"~:auth-backend": "penpot",
"~:is-active": true,
"~:is-demo": false,
"~:is-muted": false,
"~:is-blocked": false,
"~:theme": "",
"~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d",
"~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:created-at": "~m1713533116365",
"~:modified-at": "~m1713533116365",
"~:props": {
"~:nudge": {
"~:big": 10,
"~:small": 1
},
"~:v2-info-shown": true,
"~:viewed-tutorial?": false,
"~:viewed-walkthrough?": false
}
}
}

View File

@ -29,6 +29,42 @@ export class RegisterPage extends BasePage {
);
}
/**
* Mocks a successful email-verification token exchange (the link the
* user clicks from the verification email) and every RPC the dashboard
* needs to render right after landing on it, so the flow can be
* exercised end-to-end without a real backend.
*/
async setupEmailVerificationSuccess() {
await this.mockConfigFlags(["disable-onboarding"]);
await this.mockRPC(
"verify-token",
"register/verify-token-email-verified.json",
);
await this.mockRPCs({
"get-teams": "logged-in-user/get-teams-default.json",
"get-font-variants?team-id=*":
"logged-in-user/get-font-variants-empty.json",
"get-projects?team-id=*": "logged-in-user/get-projects-default.json",
"get-team-members?team-id=*":
"logged-in-user/get-team-members-your-penpot.json",
"get-team-users?team-id=*":
"logged-in-user/get-team-users-single-user.json",
"get-unread-comment-threads?team-id=*":
"logged-in-user/get-team-users-single-user.json",
"get-team-recent-files?team-id=*":
"logged-in-user/get-team-recent-files-empty.json",
"get-profiles-for-file-comments":
"logged-in-user/get-profiles-for-file-comments-empty.json",
"get-builtin-templates":
"logged-in-user/get-built-in-templates-empty.json",
});
}
async goToVerifyToken(token = "verify-email-token") {
await this.page.goto(`/#/auth/verify-token?token=${token}`);
}
static async init(page) {
await BasePage.init(page);
}

View File

@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test";
import { RegisterPage } from "../pages/RegisterPage";
// Regression test for the bug where a freshly verified account (whose
// profile never had a theme persisted) ended up with an empty string as
// its theme instead of falling back to the dark default: the workspace
// switched to light mode and Settings > UI Theme showed a blank field.
test.beforeEach(async ({ page }) => {
await RegisterPage.initWithLoggedOutUser(page);
});
test.describe("Email verification", () => {
test("Newly verified account defaults to the dark theme", async ({
page,
}) => {
const registerPage = new RegisterPage(page);
await registerPage.setupEmailVerificationSuccess();
await registerPage.goToVerifyToken();
await page.waitForURL("**/dashboard/**");
// `default` is the body class applied for dark theme, `light` for
// light theme (see app.util.theme/set-color-scheme).
await expect(page.locator("body")).toHaveClass(/default/);
await expect(page.locator("body")).not.toHaveClass(/light/);
});
test("Settings > UI Theme shows Penpot Dark (default) selected, not blank", async ({
page,
}) => {
const registerPage = new RegisterPage(page);
await registerPage.setupEmailVerificationSuccess();
await registerPage.goToVerifyToken();
await page.waitForURL("**/dashboard/**");
await page.goto("/#/settings/options");
// The language select is the first combobox on the page, the theme
// select is the second one.
const themeSelect = page.getByRole("combobox").nth(1);
await expect(themeSelect).toHaveText("Penpot Dark (default)");
});
});

View File

@ -43,28 +43,30 @@
(defn set-profile
"Initialize profile state, only logged-in profile data should be
passed to this event"
[{:keys [id] :as profile}]
(ptk/reify ::set-profile
IDeref
(-deref [_] profile)
[profile]
(let [profile (update profile :theme not-empty)
id (:id profile)]
(ptk/reify ::set-profile
IDeref
(-deref [_] profile)
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc :profile-id id)
(assoc :profile profile)))
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc :profile-id id)
(assoc :profile profile)))
ptk/WatchEvent
(watch [_ state _]
(let [profile (:profile state)]
(->> (rx/from (i18n/set-locale (:lang profile)))
(rx/ignore))))
ptk/WatchEvent
(watch [_ state _]
(let [profile (:profile state)]
(->> (rx/from (i18n/set-locale (:lang profile)))
(rx/ignore))))
ptk/EffectEvent
(effect [_ state _]
(let [profile (:profile state)]
(swap! storage/user assoc :profile profile)
(plugins.register/init)))))
ptk/EffectEvent
(effect [_ state _]
(let [profile (:profile state)]
(swap! storage/user assoc :profile profile)
(plugins.register/init))))))
(def profile-fetched?
(ptk/type? ::profile-fetched))

View File

@ -204,20 +204,27 @@
(defn- bind!
[shortcuts]
(->> shortcuts
(remove #(:disabled (second %)))
(run! (fn [[key {:keys [command fn type overwrite]}]]
(let [callback (wrap-cb key fn)
commands (if (vector? command)
(into-array command)
#js [command])]
(if (vector? type)
(do (mousetrap/bind commands callback (nth type 0) overwrite)
(mousetrap/bind commands callback (nth type 1) overwrite))
(let [undefined (js* "(void 0)")]
(if type
(mousetrap/bind commands callback type overwrite)
(mousetrap/bind commands callback undefined overwrite)))))))))
(let [entries (remove #(:disabled (second %)) shortcuts)
bind-fn (fn [[key {:keys [command fn type overwrite]}]]
(let [callback (wrap-cb key fn)
commands (if (vector? command)
(into-array command)
#js [command])]
(if (vector? type)
(do (mousetrap/bind commands callback (nth type 0) overwrite)
(mousetrap/bind commands callback (nth type 1) overwrite))
(let [undefined (js* "(void 0)")]
(if type
(mousetrap/bind commands callback type overwrite)
(mousetrap/bind commands callback undefined overwrite))))))]
;; Bind non-overwrite entries first so that entries flagged with
;; `:overwrite` are bound last and can reliably splice out the
;; colliding callbacks bound earlier (mousetrap's overwrite only
;; removes callbacks that were already registered for the same
;; combo). Map iteration order is hash-based, so we must force the
;; order explicitly.
(run! bind-fn (remove (comp :overwrite second) entries))
(run! bind-fn (filter (comp :overwrite second) entries))))
(defn- reset!
([]

View File

@ -37,6 +37,7 @@
:command "p"
:subsections [:path-editor]
:section [:workspace]
:overwrite true
:fn #(st/emit! (drp/change-edit-mode :draw))}
:add-node {:tooltip (ds/shift "+")
@ -49,7 +50,9 @@
:command ["del" "backspace"]
:subsections [:path-editor]
:section [:workspace]
:fn #(st/emit! (drp/remove-node))}
:overwrite true
:fn #(st/emit!
(drp/remove-node))}
:merge-nodes {:tooltip (ds/meta "J")
:command (ds/c-mod "j")
@ -67,6 +70,7 @@
:command "k"
:subsections [:path-editor]
:section [:workspace]
:overwrite true
:fn #(st/emit! (drp/separate-nodes))}
:make-corner {:tooltip "X"
@ -79,6 +83,7 @@
:command "c"
:subsections [:path-editor]
:section [:workspace]
:overwrite true
:fn #(st/emit! (drp/make-curve))}
:snap-nodes {:tooltip (ds/meta "'")
@ -89,10 +94,22 @@
:fn #(st/emit! (drp/toggle-snap))}
:escape {:tooltip (ds/esc)
:command ["escape" "enter" "v"]
:command ["escape" "v"]
:section [:workspace]
:fn #(st/emit! (esc-pressed))}
;; Reuses the `:start-editing` key (instead of adding "enter" to
;; the `:escape` command above) so that merging this shortcut set
;; on top of the base workspace shortcuts (see `dsc/push-shortcuts`)
;; deterministically replaces the workspace's `enter` binding
;; (which enters path edit mode) instead of both ending up bound
;; to the same physical key at once.
:start-editing {:tooltip (ds/enter)
:command "enter"
:section [:workspace]
:overwrite true
:fn #(st/emit! (esc-pressed))}
:undo {:tooltip (ds/meta "Z")
:command (ds/c-mod "z")
:section [:workspace]

View File

@ -7,6 +7,7 @@
(ns app.main.ui.routes
(:require
[app.common.data.macros :as dm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -21,6 +22,12 @@
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def ^:private sso-authorization-max-age-ms
(* 5 60 1000))
(defonce ^:private sso-authorization-cache
(atom {}))
(def routes
[["/auth"
["/login" :auth-login]
@ -102,26 +109,43 @@
"Authorization filter for dashboard and workspace routes.
Checks if the team being navigated to has an organization with SSO
active. If so, calls :check-nitrate-sso and either proceeds with navigation
or redirects to the SSO provider URL."
or redirects to the SSO provider URL. Successful checks are cached for five
minutes per profile and team; redirect results are never cached."
[match send-event-info? url]
(let [route-name (name (get-in match [:data :name]))
relevant? (and (contains? cf/flags :admin-console)
(or (str/starts-with? route-name "dashboard")
(str/starts-with? route-name "workspace")))
team-id-str (when relevant?
(or (get-in match [:query-params :team-id])
(get-in match [:params :path :team-id])))
team-id (some-> team-id-str uuid/parse*)]
(if (some? team-id)
(let [route-name (name (get-in match [:data :name]))
relevant? (and (contains? cf/flags :admin-console)
(or (str/starts-with? route-name "dashboard")
(str/starts-with? route-name "workspace")))
team-id-str (when relevant?
(or (get-in match [:query-params :team-id])
(get-in match [:params :path :team-id])))
team-id (some-> team-id-str uuid/parse*)
profile-id (get-in @st/state [:profile :id])
cache-key [profile-id team-id]
authorized-at (get @sso-authorization-cache cache-key)
cache-valid? (and (some? authorized-at)
(< (ct/diff-ms authorized-at (ct/now))
sso-authorization-max-age-ms))
navigate #(st/emit! (rt/navigated match send-event-info?))]
(cond
(nil? team-id)
(navigate)
cache-valid?
(navigate)
:else
(->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url url})
(rx/subs!
(fn [{:keys [authorized redirect-uri]}]
(if authorized
(st/emit! (rt/navigated match send-event-info?))
(when redirect-uri (st/emit! (rt/nav-raw :uri (str redirect-uri))))))
(do
(swap! sso-authorization-cache assoc cache-key (ct/now))
(navigate))
(when redirect-uri
(st/emit! (rt/nav-raw :uri (str redirect-uri))))))
(fn [cause]
(errors/on-error cause))))
(st/emit! (rt/navigated match send-event-info?)))))
(errors/on-error cause)))))))
(defn- handle-sso-error-and-navigate
"Check if the current route has an SSO error marker. If so, assign an

View File

@ -21,7 +21,7 @@
.section-title,
.subsection-title {
@include t.use-typography("title-small");
@include t.use-typography("headline-small");
display: flex;
align-items: center;
@ -43,6 +43,7 @@
}
.subsection-title {
block-size: $sz-32;
text-transform: none;
padding-inline-start: var(--sp-m);
}

View File

@ -0,0 +1,78 @@
;; 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 Sucursal en España SL
(ns frontend-tests.data.workspace-path-edition-test
(:require
[app.common.data :as d]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths]
[app.main.data.shortcuts :as dsc]
[app.main.data.workspace :as dw]
[app.main.data.workspace.path.shortcuts :as psc]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shortcuts :as wsc]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.pages :as thp]
[frontend-tests.helpers.state :as ths]
[potok.v2.core :as ptk]))
(t/use-fixtures :each
{:before thp/reset-idmap!})
(defn- enter-command?
[command]
(if (vector? command)
(some #(= % "enter") command)
(= command "enter")))
(t/deftest test-enter-key-is-bound-once-while-path-editing
;; Regression test for the physical "enter" key ending up bound to
;; two different shortcuts at once while path editing is active: one
;; that (re)enters edition mode and one that exits it. `push-shortcuts`
;; merges shortcut groups by map key (see `app.main.data.shortcuts`),
;; not by physical key/command, so two shortcuts under different keys
;; that both claim "enter" survive the merge and both would fire on a
;; single keypress, breaking the toggle.
(let [file (cthf/sample-file :file1)
store (ths/setup-store file)]
(ptk/emit! store (dsc/push-shortcuts ::workspace wsc/shortcuts :workspace))
(ptk/emit! store (dsc/push-shortcuts ::path psc/shortcuts :workspace :merge-shortcuts :auto))
(let [effective (get-in @store [:shortcuts ::path])
matches (->> effective
(filter (fn [[_ sc]] (enter-command? (:command sc))))
(map first))]
(t/is (= 1 (count matches))
(str "expected exactly one shortcut bound to \"enter\" while path editing, got " matches)))))
(defn- run-scenario
[shape-type]
(let [file (-> (cthf/sample-file :file1)
(cths/add-sample-shape :test-shape :type shape-type))
shape-id (:id (cths/get-shape file :test-shape))
store (ths/setup-store file)]
;; Select the shape, then reproduce what a physical Enter keypress
;; now dispatches at each step: `start-editing-selected` to enter
;; path edition mode, `esc-pressed` (-> :interrupt) to exit it, and
;; `start-editing-selected` again to re-enter.
(ptk/emit! store (dws/select-shapes (d/ordered-set shape-id)))
(ptk/emit! store (dw/start-editing-selected))
(t/is (= shape-id (get-in @store [:workspace-local :edition]))
(str "expected " (name shape-type) " to enter path edition mode"))
(ptk/emit! store (psc/esc-pressed))
(t/is (nil? (get-in @store [:workspace-local :edition]))
(str "expected " (name shape-type) " to exit path edition mode"))
(t/is (= #{shape-id} (get-in @store [:workspace-local :selected]))
(str "expected " (name shape-type) " to remain selected after exiting path edition mode"))
(ptk/emit! store (dw/start-editing-selected))
(t/is (= shape-id (get-in @store [:workspace-local :edition]))
(str "expected " (name shape-type) " to enter path edition mode again"))))
(t/deftest test-enter-toggles-path-editing-mode
(doseq [shape-type [:rect :circle :path :image]]
(run-scenario shape-type)))

View File

@ -21,6 +21,7 @@
[frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test]
[frontend-tests.data.workspace-pages-test]
[frontend-tests.data.workspace-path-edition-test]
[frontend-tests.data.workspace-reflow-test]
[frontend-tests.data.workspace-shortcuts-test]
[frontend-tests.data.workspace-texts-test]
@ -72,6 +73,7 @@
[frontend-tests.ui.gradient-handlers-test]
[frontend-tests.ui.layout-container-multiple-test]
[frontend-tests.ui.measures-menu-props-test]
[frontend-tests.ui.routes-test]
[frontend-tests.ui.settings-password-schema-test]
[frontend-tests.ui.settings-shortcuts-test]
[frontend-tests.util-clipboard-test]
@ -114,6 +116,7 @@
'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test
'frontend-tests.data.workspace-pages-test
'frontend-tests.data.workspace-path-edition-test
'frontend-tests.data.workspace-reflow-test
'frontend-tests.data.workspace-shortcuts-test
'frontend-tests.data.workspace-texts-test
@ -163,6 +166,7 @@
'frontend-tests.ui.gradient-handlers-test
'frontend-tests.ui.layout-container-multiple-test
'frontend-tests.ui.measures-menu-props-test
'frontend-tests.ui.routes-test
'frontend-tests.render-dimensions-test
'frontend-tests.text-editor-paste-guard-test
'frontend-tests.ui.settings-password-schema-test

View File

@ -0,0 +1,85 @@
;; 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 Sucursal en España SL
(ns frontend-tests.ui.routes-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.routes :as routes]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
(defn- workspace-match
[team-id]
{:data {:name :workspace}
:params {:path {}}
:query-params {:team-id (str team-id)}})
(t/deftest sso-check-is-cached-for-five-minutes
(let [team-id (uuid/next)
match (workspace-match team-id)
now (atom (ct/inst "2026-08-11T10:00:00Z"))
rpc-calls (atom 0)
events (atom [])]
(with-redefs [cf/flags (conj cf/flags :admin-console)
ct/now (mock/stub (fn [] @now))
rp/cmd! (mock/stub
(fn [command params]
(t/is (= :check-nitrate-sso command))
(t/is (= team-id (:team-id params)))
(swap! rpc-calls inc)
(rx/of {:authorized true})))
st/emit! (mock/stub
(fn [& emitted]
(swap! events into emitted)))]
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(reset! now (ct/plus @now #js {:minutes 4 :seconds 59}))
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 1 @rpc-calls))
(t/is (= 2 (count @events))))))
(t/deftest sso-check-is-refreshed-after-five-minutes
(let [team-id (uuid/next)
match (workspace-match team-id)
now (atom (ct/inst "2026-08-11T10:00:00Z"))
rpc-calls (atom 0)]
(with-redefs [cf/flags (conj cf/flags :admin-console)
ct/now (mock/stub (fn [] @now))
rp/cmd! (mock/stub
(fn [_ _]
(swap! rpc-calls inc)
(rx/of {:authorized true})))
st/emit! mock/noop]
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(reset! now (ct/plus @now #js {:minutes 5}))
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 2 @rpc-calls)))))
(t/deftest sso-redirect-result-is-not-cached
(let [team-id (uuid/next)
match (workspace-match team-id)
rpc-calls (atom 0)
events (atom [])]
(with-redefs [cf/flags (conj cf/flags :admin-console)
rp/cmd! (mock/stub
(fn [_ _]
(swap! rpc-calls inc)
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
st/emit! (mock/stub
(fn [& emitted]
(swap! events into emitted)))]
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 2 @rpc-calls))
(t/is (= 2 (count @events))))))

View File

@ -1243,7 +1243,7 @@ msgstr "Your project has been moved successfully"
#, unused
msgid "dashboard.team-belong-organization"
msgstr "This team now belongs to %s"
msgstr "This team is now part of the organization %s"
#: src/app/main/ui/dashboard/team.cljs:1602
msgid "dashboard.team-info"
@ -1255,7 +1255,7 @@ msgstr "Team members"
#, unused
msgid "dashboard.team-no-longer-belong-organization"
msgstr "This team no longer belongs to the organization %s"
msgstr "This team is no longer part of the organization %s"
#: src/app/main/ui/dashboard/team.cljs:1609
msgid "dashboard.team-organization"

View File

@ -1254,7 +1254,7 @@ msgstr "Tu proyecto ha sido movido con éxito"
#, unused
msgid "dashboard.team-belong-organization"
msgstr "Este equipo ahora pertenece a la organización %s"
msgstr "Este equipo ahora es parte de la organización %s"
#: src/app/main/ui/dashboard/team.cljs:1602
msgid "dashboard.team-info"
@ -1266,7 +1266,7 @@ msgstr "Integrantes del equipo"
#, unused
msgid "dashboard.team-no-longer-belong-organization"
msgstr "Este equipo ya no pertenece a la organización %s"
msgstr "Este equipo ya no es parte de la organización %s"
#: src/app/main/ui/dashboard/team.cljs:1609
msgid "dashboard.team-organization"