Merge branch 'main' into niwinz-main-svg-attrs-migration

This commit is contained in:
Andrey Antukh 2026-05-07 11:18:19 +02:00 committed by GitHub
commit af71daffe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 545 additions and 121 deletions

View File

@ -20,6 +20,9 @@ everything they need to know: which files to touch for each task, code, testing,
docs they might need to check, how to test it. Give them the whole plan as docs they might need to check, how to test it. Give them the whole plan as
bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
Assume they are a skilled developer, but know almost nothing about our toolset Assume they are a skilled developer, but know almost nothing about our toolset
or problem domain. Assume they don't know good test design very well. or problem domain. Assume they don't know good test design very well.

View File

@ -32,6 +32,17 @@ precision while maintaining a strong focus on maintainability and performance.
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects 5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
`.gitignore` by default. `.gitignore` by default.
## Changelogs
The project has two changelogs:
- **Main project changelog**: `CHANGES.md` (root of the repository). Tracks changes for the core Penpot application (backend, frontend, common, render-wasm, exporter, mcp).
- **Plugins changelog**: `plugins/CHANGELOG.md`. Tracks changes for the plugins subproject only.
When making changes, add a changelog entry to the appropriate file under the
`## <version> (Unreleased)` section in the correct category
(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`).
## GitHub Operations ## GitHub Operations
To obtain the list of repository members/collaborators: To obtain the list of repository members/collaborators:

View File

@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
## 2.15.0 (Unreleased) ## 2.15.0 (Unreleased)
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements
@ -10,7 +11,16 @@
### :bug: Bugs fixed ### :bug: Bugs fixed
- Fix incorrect invitation token handling on register process [Github #9380](https://github.com/penpot/penpot/pull/9380)
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041) - Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
- Fix false “text editing” warning when applying tokens [Github #6346](https://github.com/penpot/penpot/issues/9346)
## 2.14.5
### :bug: Bugs fixed
- Fix incorrect invitation token handling on register process [Github #9380](https://github.com/penpot/penpot/pull/9380)
## 2.14.4 ## 2.14.4

View File

@ -258,24 +258,44 @@
(validate-register-attempt! cfg params) (validate-register-attempt! cfg params)
(let [email (profile/clean-email email) (let [email (profile/clean-email email)
profile (profile/get-profile-by-email pool email) profile (profile/get-profile-by-email pool email)]
props (-> (audit/extract-utm-params params)
(cond-> (:accept-newsletter-updates params)
(assoc :newsletter-updates true)))
params {:email email
:fullname fullname
:password (:password params)
:invitation-token (:invitation-token params)
:backend "penpot"
:iss :prepared-register
:profile-id (:id profile)
:exp (ct/in-future {:days 7})
:props props}
params (d/without-nils params)
token (tokens/generate cfg params)]
(-> {:token token} ;; SECURITY: refuse to issue a prepared-register token when an active
(with-meta {::audit/profile-id uuid/zero})))) ;; profile already exists for this email.
;;
;; Active accounts must use the standard login flow; existing-but-
;; not-yet-active profiles fall through to the duplicate-detection branch in
;; `register-profile`, which never creates a session.
(when (and (some? profile)
(true? (:is-active profile)))
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"))
(let [props (-> (audit/extract-utm-params params)
(cond-> (:accept-newsletter-updates params)
(assoc :newsletter-updates true)))
;; SECURITY: do NOT embed `:profile-id` of an existing
;; profile into the prepared-register JWE. Doing so would
;; let an anonymous caller, in possession of a valid
;; team-invitation JWE, ask `register-profile` to load that
;; profile by id and mint a session for it without password
;; verification. `register-profile` independently re-detects
;; duplicates by email and handles them in the
;; "repeated-registry" branch.
params {:email email
:fullname fullname
:password (:password params)
:invitation-token (:invitation-token params)
:backend "penpot"
:iss :prepared-register
:exp (ct/in-future {:days 7})
:props props}
params (d/without-nils params)
token (tokens/generate cfg params)]
(-> {:token token}
(with-meta {::audit/profile-id uuid/zero})))))
(def schema:prepare-register-profile (def schema:prepare-register-profile
[:map {:title "prepare-register-profile"} [:map {:title "prepare-register-profile"}
@ -387,25 +407,32 @@
(profile/decode-row)))) (profile/decode-row))))
(defn send-email-verification! (defn send-email-verification!
[{:keys [::db/conn] :as cfg} profile] ([cfg profile] (send-email-verification! cfg profile nil))
(let [vtoken (tokens/generate cfg ([{:keys [::db/conn] :as cfg} profile invitation-token]
{:iss :verify-email (let [vclaims (cond-> {:iss :verify-email
:exp (ct/in-future "72h") :exp (ct/in-future "72h")
:profile-id (:id profile) :profile-id (:id profile)
:email (:email profile)}) :email (:email profile)}
;; NOTE: this token is mainly used for possible complains ;; If the user registered through a team-invitation flow but
;; identification on the sns webhook ;; their profile is not yet active, we carry the invitation
ptoken (tokens/generate cfg ;; token inside the verify-email JWE so the team-invitation
{:iss :profile-identity ;; flow can resume after the user clicks the email link.
:profile-id (:id profile) (some? invitation-token)
:exp (ct/in-future {:days 30})})] (assoc :invitation-token invitation-token))
(eml/send! {::eml/conn conn vtoken (tokens/generate cfg vclaims)
::eml/factory eml/register ;; NOTE: this token is mainly used for possible complains
:public-uri (cf/get :public-uri) ;; identification on the sns webhook
:to (:email profile) ptoken (tokens/generate cfg
:name (:fullname profile) {:iss :profile-identity
:token vtoken :profile-id (:id profile)
:extra-data ptoken}))) :exp (ct/in-future {:days 30})})]
(eml/send! {::eml/conn conn
::eml/factory eml/register
:public-uri (cf/get :public-uri)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken}))))
(defn register-profile (defn register-profile
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}] [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
@ -414,23 +441,16 @@
(:accept-newsletter-updates params) (:accept-newsletter-updates params)
(update :props assoc :newsletter-updates true)) (update :props assoc :newsletter-updates true))
profile (if-let [profile-id (:profile-id claims)] profile (or (profile/get-profile-by-email conn (:email claims))
(profile/get-profile conn profile-id) (let [is-active (or (boolean (:is-active claims))
;; NOTE: we first try to match existing profile (boolean (:email-verified claims))
;; by email, that in normal circumstances will (not (contains? cf/flags :email-verification)))
;; not return anything, but when a user tries to params (-> params
;; reuse the same token multiple times, we need (assoc :is-active is-active)
;; to detect if the profile is already registered (update :password auth/derive-password))
(or (profile/get-profile-by-email conn (:email claims)) profile (->> (create-profile cfg params)
(let [is-active (or (boolean (:is-active claims)) (create-profile-rels cfg))]
(boolean (:email-verified claims)) (vary-meta profile assoc :created true)))
(not (contains? cf/flags :email-verification)))
params (-> params
(assoc :is-active is-active)
(update :password auth/derive-password))
profile (->> (create-profile cfg params)
(create-profile-rels conn))]
(vary-meta profile assoc :created true))))
created? (-> profile meta :created true?) created? (-> profile meta :created true?)
@ -461,48 +481,67 @@
::audit/profile-id (:id profile) ::audit/profile-id (:id profile)
::audit/name "register-profile-retry"})) ::audit/name "register-profile-retry"}))
;; If invitation token comes in params, this is because the user ;; A profile was just created in this call. Invitation handling is a
;; comes from team-invitation process; in this case, regenerate ;; sub-case of "newly created profile": we never honor invitations for
;; token and send back to the user a new invitation token (and ;; pre-existing profiles via this anonymous RPC. The split below mirrors
;; mark current session as logged). This happens only if the ;; the non-invitation branches but threads the invitation through the
;; invitation email matches with the register email. ;; appropriate path:
(and (some? invitation) ;;
(= (:email profile) ;; - active + matching invitation → mint session and
(:member-email invitation))) ;; return :invitation-token. The frontend redirects to
(let [invitation (assoc invitation :member-id (:id profile)) ;; :auth-verify-token, which immediately accepts the
token (tokens/generate cfg invitation)] ;; invitation.
(-> {:id (:id profile) ;; - active + no/mismatched invitation → mint session
:email (:email profile) ;; ("login" action). New profile, no further action.
:invitation-token token} ;; - not-active + matching invitation → send the
(rph/with-transform (session/create-fn cfg profile claims)) ;; verify-email mail with the invitation token EMBEDDED
(rph/with-meta {::audit/replace-props props ;; into the verify-email JWE. No session yet. When the
::audit/context {:action "accept-invitation"} ;; user clicks the link, verify-token activates the
::audit/profile-id (:id profile)}))) ;; profile, mints a session, and propagates the
;; invitation token to the frontend so it can complete
;; When a new user is created and it is already activated by ;; the team-invitation flow.
;; configuration or specified by OIDC, we just mark the profile ;; - not-active + no/mismatched invitation → standard
;; as logged-in ;; "check your email" verification flow.
created? created?
(if (:is-active profile) (let [accept-invitation? (and (some? invitation)
(-> (profile/strip-private-attrs profile) (= (:email profile)
(rph/with-transform (session/create-fn cfg profile claims)) (:member-email invitation)))]
(rph/with-defer create-welcome-file-when-needed) (cond
(rph/with-meta (and (:is-active profile) accept-invitation?)
{::audit/replace-props props (let [invitation (assoc invitation :member-id (:id profile))
::audit/context {:action "login"} token (tokens/generate cfg invitation)]
::audit/profile-id (:id profile)})) (-> {:id (:id profile)
:email (:email profile)
:invitation-token token}
(rph/with-transform (session/create-fn cfg profile claims))
(rph/with-defer create-welcome-file-when-needed)
(rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"}
::audit/profile-id (:id profile)})))
(do (:is-active profile)
(when-not (eml/has-reports? conn (:email profile)) (-> (profile/strip-private-attrs profile)
(send-email-verification! cfg profile)) (rph/with-transform (session/create-fn cfg profile claims))
(-> {:id (:id profile)
:email (:email profile)}
(rph/with-defer create-welcome-file-when-needed) (rph/with-defer create-welcome-file-when-needed)
(rph/with-meta (rph/with-meta
{::audit/replace-props props {::audit/replace-props props
::audit/context {:action "email-verification"} ::audit/context {:action "login"}
::audit/profile-id (:id profile)})))) ::audit/profile-id (:id profile)}))
:else
(do
(when-not (eml/has-reports? conn (:email profile))
(send-email-verification! cfg profile
(when accept-invitation?
(:invitation-token params))))
(-> {:id (:id profile)
:email (:email profile)}
(rph/with-defer create-welcome-file-when-needed)
(rph/with-meta
{::audit/replace-props props
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)})))))
:else :else
(let [elapsed? (elapsed-verify-threshold? profile) (let [elapsed? (elapsed-verify-threshold? profile)

View File

@ -72,6 +72,11 @@
{:is-active true} {:is-active true}
{:id (:id profile)})) {:id (:id profile)}))
;; NOTE: `claims` is returned verbatim (besides :profile). When the
;; verify-email JWE was minted by `register-profile` for a not-yet-
;; active profile that came from an invitation flow, `:invitation-
;; token` will be present here and the frontend will use it to
;; complete the team-invitation flow after login.
(-> claims (-> claims
(rph/with-transform (session/create-fn cfg profile)) (rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/name "verify-profile-email" (rph/with-meta {::audit/name "verify-profile-email"

View File

@ -514,32 +514,89 @@
(t/is (= 0 (:call-count @mock)))))))) (t/is (= 0 (:call-count @mock))))))))
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1 (t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
(let [itoken (tokens/generate th/*system* ;; With email-verification ENABLED (the default), a brand-new
{:iss :team-invitation ;; profile created via the invitation flow is NOT active yet, so
:exp (ct/in-future "48h") ;; `register-profile` must NOT mint a session and must NOT echo
:role :editor ;; back the invitation token. Instead it must dispatch the
:team-id uuid/zero ;; verify-email mail with the invitation token EMBEDDED into the
:member-email "user@example.com"}) ;; verify-email JWE (so the team-invitation flow can resume after
data {::th/type :prepare-register-profile ;; the user clicks the email link).
:invitation-token itoken (with-mocks [mock {:target 'app.email/send! :return nil}]
:fullname "foobar" (let [itoken (tokens/generate th/*system*
:email "user@example.com" {:iss :team-invitation
:password "foobar"} :exp (ct/in-future "48h")
:role :editor
:team-id uuid/zero
:member-email "user@example.com"})
prep-data {::th/type :prepare-register-profile
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
{:keys [result error] :as out} (th/command! data)] {prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? error)) (t/is (nil? prep-error))
(t/is (map? result)) (t/is (map? prep-result))
(t/is (string? (:token result))) (t/is (string? (:token prep-result)))
(let [rtoken (:token result) (let [reg-data {::th/type :register-profile
data {::th/type :register-profile :token (:token prep-result)}
:token rtoken}
{:keys [result error] :as out} (th/command! data)] {reg-result :result reg-error :error} (th/command! reg-data)
;; (th/print-result! out) mdata (meta reg-result)]
(t/is (nil? error)) (t/is (nil? reg-error))
(t/is (map? result)) (t/is (map? reg-result))
(t/is (string? (:invitation-token result))))))
;; No invitation token echoed back, no session minted.
(t/is (nil? (:invitation-token reg-result)))
(t/is (empty? (:app.rpc/response-transform-fns mdata)))
;; The verify-email mail was dispatched, and its token claims
;; carry the invitation-token through to the verification step.
(t/is (= 1 (:call-count @mock)))
(let [send-args (-> @mock :call-args)
email-token (->> send-args (some (fn [m] (when (map? m) (:token m)))))
vclaims (tokens/decode th/*system* email-token)]
(t/is (= :verify-email (:iss vclaims)))
(t/is (= itoken (:invitation-token vclaims))))))))
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1b
;; With email-verification DISABLED, the brand-new profile is
;; immediately active, so `register-profile` mints a session and
;; returns the regenerated invitation token in the body — the
;; frontend then redirects to :auth-verify-token to complete the
;; team-invitation flow.
(with-redefs [app.config/flags #{:registration :login-with-password}]
(let [itoken (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
:role :editor
:team-id uuid/zero
:member-email "user@example.com"})
prep-data {::th/type :prepare-register-profile
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
(t/is (string? (:token prep-result)))
(let [reg-data {::th/type :register-profile
:token (:token prep-result)}
{reg-result :result reg-error :error} (th/command! reg-data)
mdata (meta reg-result)]
(t/is (nil? reg-error))
(t/is (map? reg-result))
;; Active branch: invitation-token is echoed back and a session
;; is minted via `session/create-fn`.
(t/is (string? (:invitation-token reg-result)))
(t/is (seq (:app.rpc/response-transform-fns mdata)))
(t/is (= "accept-invitation"
(get-in mdata [:app.loggers.audit/context :action])))))))
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-2 (t/deftest prepare-and-register-with-invitation-and-enabled-registration-2
(let [itoken (tokens/generate th/*system* (let [itoken (tokens/generate th/*system*
@ -692,6 +749,188 @@
(t/is (= :validation (:type edata))) (t/is (= :validation (:type edata)))
(t/is (= :email-as-password (:code edata)))))) (t/is (= :email-as-password (:code edata))))))
(t/deftest prepare-register-rejects-active-profile-email
;; SECURITY: `prepare-register` must reject any attempt to prepare a
;; registration for an email that already belongs to an *active*
;; profile, regardless of whether an invitation token is supplied.
;; Active profiles must use the standard login flow.
(let [_victim (th/create-profile* 1 {:is-active true
:email "victim@corp.tld"})]
;; Without invitation token.
(let [out (th/command! {::th/type :prepare-register-profile
:fullname "Mallory"
:email "victim@corp.tld"
:password "Whatever1!"})]
(t/is (not (th/success? out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata)))
(t/is (= :email-already-exists (:code edata)))))
;; With invitation token (the GHSA-4937-35vc-hqjj exploit shape).
(let [itoken (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
:role :editor
:team-id uuid/zero
:member-email "victim@corp.tld"})
out (th/command! {::th/type :prepare-register-profile
:invitation-token itoken
:fullname "Mallory"
:email "victim@corp.tld"
:password "Whatever1!"})]
(t/is (not (th/success? out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata)))
(t/is (= :email-already-exists (:code edata)))))))
(t/deftest prepare-register-must-not-leak-existing-profile-id
;; Victim is a pre-existing profile that has not yet activated (e.g.
;; freshly registered, has not clicked the email verification link).
;; `prepare-register` allows the call (no active profile exists), but
;; the issued JWE must NOT carry the existing profile's id.
(let [_victim (th/create-profile* 1 {:is-active false
:email "victim@corp.tld"})
;; Attacker holds a cryptographically valid `:team-invitation` JWE
;; for the victim's email. (In a real exploit this is obtained
;; from `create-team-invitations` or `get-team-invitation-token`
;; on a team the attacker owns.)
itoken (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
:role :editor
:team-id uuid/zero
:member-email "victim@corp.tld"})
;; Anonymous request — no ::rpc/profile-id.
data {::th/type :prepare-register-profile
:invitation-token itoken
:fullname "Mallory"
:email "victim@corp.tld"
:password "Whatever1!"}
out (th/command! data)]
;; The current behaviour either returns a token or rejects the request;
;; what MUST hold is that the issued prepared-register JWE does not
;; carry the victim's profile id.
(t/is (th/success? out))
(let [token (-> out :result :token)
claims (tokens/decode th/*system* token)]
(t/is (= :prepared-register (:iss claims)))
;; This is the root-cause assertion: an anonymous prepare-register
;; call must NEVER embed an existing profile's id.
(t/is (nil? (:profile-id claims))
"prepare-register must not embed existing profile id of an anonymous caller"))))
(t/deftest register-profile-with-invitation-must-not-take-over-existing-account
(with-mocks [_mock {:target 'app.email/send! :return nil}]
(let [;; Victim profile exists but is not yet active (e.g. registered
;; but has not clicked the verification link). This is the
;; remaining attack surface after fix 1b: `prepare-register`
;; will not reject this case, so the `register-profile` path
;; must enforce the security invariants on its own.
victim (th/create-profile* 1 {:is-active false
:email "victim@corp.tld"})
;; Attacker mints a valid `:team-invitation` JWE for the victim's
;; email. No member-id is included (matches what an attacker
;; obtains via `create-team-invitations` against their own team
;; before the victim has joined).
itoken (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
:role :editor
:team-id uuid/zero
:member-email "victim@corp.tld"})
;; Step 1 (anonymous): prepare-register-profile with the victim's
;; email + the invitation token.
prep-out (th/command! {::th/type :prepare-register-profile
:invitation-token itoken
:fullname "Mallory"
:email "victim@corp.tld"
:password "Whatever1!"})
rtoken (-> prep-out :result :token)
;; Step 2 (anonymous): register-profile with the prepared token.
reg-out (th/command! {::th/type :register-profile
:token rtoken})
result (:result reg-out)
mdata (meta result)]
;; The first call may succeed; the issue is what the second call
;; produces. We assert the security invariants on its result.
(t/is (th/success? prep-out))
;; INVARIANT 1: register-profile must NOT install a session for the
;; victim. `session/create-fn` is wired via
;; `rph/with-transform`, which appends to
;; `:app.rpc/response-transform-fns`. If that vector is non-empty
;; for an anonymous register that targets an EXISTING profile, the
;; server is about to mint an `auth-token` cookie bound to the
;; victim — i.e. account takeover.
(t/is (empty? (:app.rpc/response-transform-fns mdata))
"register-profile must not create a session for an existing victim profile")
;; INVARIANT 2: register-profile must NOT echo back an invitation
;; token that authenticates as the victim. When the response
;; contains both `:id` matching the victim and `:invitation-token`,
;; the frontend treats the user as logged-in for that profile.
(when (and (map? result)
(= (:id victim) (:id result)))
(t/is (not (contains? result :invitation-token))
"register-profile must not return an invitation-token bound to an existing victim profile"))
;; INVARIANT 3: the server must NOT have taken the
;; "accept-invitation" branch (which is the one that mints a
;; session). For an existing victim profile, the operation
;; should fall through to the harmless "repeated registry" path.
(t/is (not= "accept-invitation"
(get-in mdata [:app.loggers.audit/context :action]))
"register-profile must not run the accept-invitation branch for an existing victim profile")
;; The victim must remain inactive: nothing in this anonymous
;; flow should have flipped `is-active` to true.
(let [reloaded (th/db-get :profile {:id (:id victim)})]
(t/is (false? (:is-active reloaded))
"register-profile must not activate the victim profile")))))
(t/deftest verify-email-with-invitation-token-propagates-it
;; A `:verify-email` JWE that carries `:invitation-token` (as
;; produced by `register-profile` for the not-active+invitation
;; case) must propagate that token through the verify-token RPC
;; result so the frontend can resume the team-invitation flow.
(let [profile (th/create-profile* 1 {:is-active false})
itoken (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
:role :editor
:team-id uuid/zero
:member-email (:email profile)})
vtoken (tokens/generate th/*system*
{:iss :verify-email
:exp (ct/in-future "72h")
:profile-id (:id profile)
:email (:email profile)
:invitation-token itoken})
out (th/command! {::th/type :verify-token
:token vtoken})
result (:result out)]
(t/is (th/success? out))
(t/is (= :verify-email (:iss result)))
(t/is (= itoken (:invitation-token result))
"verify-token must echo back the invitation-token from the verify-email JWE")
;; And the profile must now be active.
(let [reloaded (th/db-get :profile {:id (:id profile)})]
(t/is (true? (:is-active reloaded))))))
(t/deftest email-change-request (t/deftest email-change-request
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,107 @@
---
title: Troubleshooting WebGL
order: 5
desc: Diagnose and fix common WebGL issues in Penpot with browser, GPU, and system checks so you can open the workspace canvas correctly.
---
<h1 id="troubleshooting-webgl">Troubleshooting WebGL</h1>
<div class="advice">
<h3>Availability note</h3>
<p>WebGL renderer is currently not available yet in Penpot production (<code>design.penpot.app</code>).</p>
<p>Right now, this renderer is available only in <strong>testing environments</strong>. It is planned for an upcoming release and should be available soon.</p>
</div>
<p class="main-paragraph">Penpot uses WebGL to render the design canvas. If WebGL is unavailable, Penpot cannot open the workspace canvas correctly.</p>
<p>Sometimes WebGL appears enabled in your browser, but Penpot still cannot create a graphics context. This is usually related to browser settings, GPU acceleration, drivers, or temporary GPU overload.</p>
<h2>Before changing anything</h2>
<ol>
<li>Open <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li>
<li>Check the result:
<ul>
<li>If you see a spinning cube, WebGL works at browser level.</li>
<li>If it fails (blank page, error message, or no animation), continue with browser and system checks below.</li>
</ul>
</li>
</ol>
<h2>Quick checks (2 minutes)</h2>
<ol>
<li>Close graphics-heavy tabs/apps (video editors, 3D apps, many design tabs).</li>
<li>Reload Penpot.</li>
<li>Fully restart the browser.</li>
<li>If needed, restart your computer.</li>
</ol>
<p>Why this helps: GPU memory or context slots can be temporarily exhausted, even when your configuration is correct.</p>
<h2>Chrome</h2>
<ol>
<li>Open <code>chrome://settings/system</code>.</li>
<li>Turn on <strong>Use graphics acceleration when available</strong>.</li>
<li>Restart Chrome.</li>
<li>Open <code>chrome://gpu</code> and review WebGL-related warnings.</li>
</ol>
<figure>
<a href="/img/troubleshooting/troubleshooting-chrome.webp" target="_blank">
<img src="/img/troubleshooting/troubleshooting-chrome.webp" alt="Chrome system settings with graphics acceleration option" />
</a>
</figure>
<p>Why this helps: WebGL depends on hardware acceleration and a healthy GPU process.</p>
<h2>Mozilla Firefox</h2>
<ol>
<li>Open Firefox and check that zoom is set to <strong>100%</strong> from the top-right menu.</li>
<li>From that same menu, open Settings/Preferences.</li>
<li>In General settings, confirm Firefox is up to date and run <strong>Check for updates</strong> if needed.</li>
<li>Enable hardware acceleration in Firefox settings.</li>
<li>Restart Firefox.</li>
<li>Open <code>about:support</code> and review the Graphics/WebGL section.</li>
</ol>
<figure>
<a href="/img/troubleshooting/troubleshooting-firefox.webp" target="_blank">
<img src="/img/troubleshooting/troubleshooting-firefox.webp" alt="Firefox settings showing hardware acceleration configuration" />
</a>
</figure>
<p>Why this helps: outdated browser builds, disabled acceleration, or blocked GPU features can prevent context creation.</p>
<h2>Safari</h2>
<ol>
<li>Update Safari/macOS to the latest available version.</li>
<li>Restart Safari.</li>
<li>Re-test in <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li>
</ol>
<p>Why this helps: Safari WebGL behavior is strongly tied to OS/browser version and graphics stack updates.</p>
<h2>Settings</h2>
<p>Some advanced browser configurations or experimental settings can interfere with WebGL. If you have modified these in the past, consider restoring default browser settings or testing in a fresh profile.</p>
<h2>About zoom and trackpad settings</h2>
<p>In some cases, changing browser zoom or trackpad settings is suggested as a workaround.</p>
<p>In Penpot, these are <strong>not baseline requirements</strong> for WebGL. Treat them only as temporary diagnostics if support explicitly asks for them.</p>
<p>If you temporarily changed one of these settings and Penpot starts working, you can usually revert it and test again.</p>
<h2>GPU drivers and OS checks</h2>
<ol>
<li>Install any pending OS updates.</li>
<li>Update GPU drivers (especially on Windows/Linux).</li>
<li>Disable graphics overlays/tools (recording overlays, GPU tuning utilities) and test again.</li>
</ol>
<p>Why this helps: outdated or conflicting graphics layers can break WebGL context creation.</p>
<h2>Known edge case: Linux + Nvidia</h2>
<p>Some Linux + Nvidia combinations can report WebGL as available but still fail at runtime in specific browser/driver combinations.</p>
<p>In some cases, switching between proprietary and open-source drivers or updating the NVIDIA driver resolves the issue.</p>
<p>If this is your setup, collect diagnostics and contact support.</p>
<h2>If the issue persists</h2>
<p>Please share:</p>
<ul>
<li>Browser and version.</li>
<li>Operating system and version.</li>
<li>Result from <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li>
<li>A screenshot of browser graphics diagnostics (<code>chrome://gpu</code> or <code>about:support</code>).</li>
</ul>
<p>Then contact us at <a href="mailto:support@penpot.app">support@penpot.app</a> or open a GitHub issue at <a href="https://github.com/penpot/penpot/issues" target="_blank">https://github.com/penpot/penpot/issues</a>.</p>

View File

@ -85,7 +85,7 @@
(update state :mcp assoc :connected-tab id) (update state :mcp assoc :connected-tab id)
(and (= "disconnected" (:connection-status data)) (and (= "disconnected" (:connection-status data))
(= id (:connection-status mcp-state))) (= id (:connected-tab mcp-state)))
(update state :mcp dissoc :connected-tab) (update state :mcp dissoc :connected-tab)
:else :else
@ -185,11 +185,11 @@
{:content (tr "notifications.mcp.active-in-another-tab") {:content (tr "notifications.mcp.active-in-another-tab")
:cancel {:label (tr "labels.dismiss") :cancel {:label (tr "labels.dismiss")
:callback #(st/emit! (ntf/hide) :callback #(st/emit! (ntf/hide)
(ev/event {::ev/name "confirm-mcp-tab-switch" (ev/event {::ev/name "dismiss-mcp-tab-switch"
::ev/origin "workspace-notification"}))} ::ev/origin "workspace-notification"}))}
:accept {:label (tr "labels.switch") :accept {:label (tr "labels.switch")
:callback #(st/emit! (connect-mcp) :callback #(st/emit! (connect-mcp)
(ev/event {::ev/name "dismiss-mcp-tab-switch" (ev/event {::ev/name "confirm-mcp-tab-switch"
::ev/origin "workspace-notification"}))}}))) ::ev/origin "workspace-notification"}))}})))
(rx/of (ntf/hide))))))) (rx/of (ntf/hide)))))))

View File

@ -660,13 +660,11 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
;; We do not allow to apply tokens while text editor is open. ;; We do not allow to apply tokens while text editor is open.
;; The classic text editor sets :workspace-editor-state; the WASM text editor
;; does not, so we also check :workspace-local :edition for text shapes.
(let [edition (get-in state [:workspace-local :edition]) (let [edition (get-in state [:workspace-local :edition])
objects (dsh/lookup-page-objects state) objects (dsh/lookup-page-objects state)
text-editing? (and (some? edition) text-editing? (and (some? edition)
(= :text (:type (get objects edition))))] (= :text (:type (get objects edition))))]
(if (and (empty? (get state :workspace-editor-state)) (if (and (some? token)
(not text-editing?)) (not text-editing?))
(let [attributes-to-remove (let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca ;; Remove atomic typography tokens when applying composite and vice-verca

View File

@ -81,6 +81,7 @@
on-error on-error
(mf/use-fn (mf/use-fn
(fn [cause] (fn [cause]
(reset! submitted? false)
(let [{:keys [type code] :as edata} (ex-data cause)] (let [{:keys [type code] :as edata} (ex-data cause)]
(condp = [type code] (condp = [type code]
[:restriction :email-does-not-match-invitation] [:restriction :email-does-not-match-invitation]
@ -98,6 +99,9 @@
[:restriction :email-has-complaints] [:restriction :email-has-complaints]
(st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata)))) (st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata))))
[:validation :email-already-exists]
(st/emit! (ntf/error (tr "errors.email-already-exists")))
[:validation :email-as-password] [:validation :email-as-password]
(swap! form assoc-in [:errors :password] (swap! form assoc-in [:errors :password]
{:message (tr "errors.email-as-password")}) {:message (tr "errors.email-as-password")})

View File

@ -25,11 +25,19 @@
(defmulti handle-token (fn [token] (:iss token))) (defmulti handle-token (fn [token] (:iss token)))
(defmethod handle-token :verify-email (defmethod handle-token :verify-email
[data] [{:keys [invitation-token] :as data}]
(cf/external-notify-register-success (:profile-id data)) (cf/external-notify-register-success (:profile-id data))
(let [msg (tr "dashboard.notifications.email-verified-successfully")] (let [msg (tr "dashboard.notifications.email-verified-successfully")]
(ts/schedule 1000 #(st/emit! (ntf/success msg))) (ts/schedule 1000 #(st/emit! (ntf/success msg)))
(st/emit! (da/login-from-token data)))) ;; If the verify-email JWE carries an :invitation-token, it means
;; the user registered via a team-invitation flow but had to verify
;; their email first. Log them in and then redirect to
;; :auth-verify-token with the invitation token, which will accept
;; the invitation as a logged-in user.
(if invitation-token
(st/emit! (da/login-from-token data)
(rt/nav :auth-verify-token {:token invitation-token}))
(st/emit! (da/login-from-token data)))))
(defmethod handle-token :change-email (defmethod handle-token :change-email
[_data] [_data]