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

This commit is contained in:
Andrey Antukh 2026-05-06 15:06:59 +02:00
commit 14a0660352
11 changed files with 540 additions and 129 deletions

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
`.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
To obtain the list of repository members/collaborators:

View File

@ -1,15 +1,5 @@
# CHANGELOG
## 2.17.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
### :sparkles: New features & Enhancements
### :bug: Bugs fixed
## 2.16.0 (Unreleased)
### :boom: Breaking changes & Deprecations
@ -132,6 +122,7 @@
- Fix broken update library notification link in the UI [Github #9070](https://github.com/penpot/penpot/issues/9070)
- Fix plugin API `ShapeBase.component()` returning the outermost component instead of the immediate component in case of nested component instances [Github #9183](https://github.com/penpot/penpot/issues/9183)
## 2.15.0 (Unreleased)
### :sparkles: New features & Enhancements
@ -145,6 +136,16 @@
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
- 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

View File

@ -258,24 +258,44 @@
(validate-register-attempt! cfg params)
(let [email (profile/clean-email 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)]
profile (profile/get-profile-by-email pool email)]
(-> {:token token}
(with-meta {::audit/profile-id uuid/zero}))))
;; SECURITY: refuse to issue a prepared-register token when an active
;; 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
[:map {:title "prepare-register-profile"}
@ -389,25 +409,32 @@
(profile/decode-row))))
(defn send-email-verification!
[{:keys [::db/conn] :as cfg} profile]
(let [vtoken (tokens/generate cfg
{:iss :verify-email
:exp (ct/in-future "72h")
:profile-id (:id profile)
:email (:email profile)})
;; NOTE: this token is mainly used for possible complains
;; identification on the sns webhook
ptoken (tokens/generate cfg
{:iss :profile-identity
:profile-id (:id profile)
: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})))
([cfg profile] (send-email-verification! cfg profile nil))
([{:keys [::db/conn] :as cfg} profile invitation-token]
(let [vclaims (cond-> {:iss :verify-email
:exp (ct/in-future "72h")
:profile-id (:id profile)
:email (:email profile)}
;; If the user registered through a team-invitation flow but
;; their profile is not yet active, we carry the invitation
;; token inside the verify-email JWE so the team-invitation
;; flow can resume after the user clicks the email link.
(some? invitation-token)
(assoc :invitation-token invitation-token))
vtoken (tokens/generate cfg vclaims)
;; NOTE: this token is mainly used for possible complains
;; identification on the sns webhook
ptoken (tokens/generate cfg
{:iss :profile-identity
:profile-id (:id profile)
: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
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
@ -416,23 +443,16 @@
(:accept-newsletter-updates params)
(update :props assoc :newsletter-updates true))
profile (if-let [profile-id (:profile-id claims)]
(profile/get-profile conn profile-id)
;; NOTE: we first try to match existing profile
;; by email, that in normal circumstances will
;; not return anything, but when a user tries to
;; reuse the same token multiple times, we need
;; to detect if the profile is already registered
(or (profile/get-profile-by-email conn (:email claims))
(let [is-active (or (boolean (:is-active claims))
(boolean (:email-verified claims))
(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 cfg))]
(vary-meta profile assoc :created true))))
profile (or (profile/get-profile-by-email conn (:email claims))
(let [is-active (or (boolean (:is-active claims))
(boolean (:email-verified claims))
(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 cfg))]
(vary-meta profile assoc :created true)))
created? (-> profile meta :created true?)
@ -463,48 +483,67 @@
::audit/profile-id (:id profile)
::audit/name "register-profile-retry"}))
;; If invitation token comes in params, this is because the user
;; comes from team-invitation process; in this case, regenerate
;; token and send back to the user a new invitation token (and
;; mark current session as logged). This happens only if the
;; invitation email matches with the register email.
(and (some? invitation)
(= (:email profile)
(:member-email invitation)))
(let [invitation (assoc invitation :member-id (:id profile))
token (tokens/generate cfg invitation)]
(-> {:id (:id profile)
:email (:email profile)
:invitation-token token}
(rph/with-transform (session/create-fn cfg profile claims))
(rph/with-meta {::audit/replace-props props
::audit/context {:action "accept-invitation"}
::audit/profile-id (:id profile)})))
;; When a new user is created and it is already activated by
;; configuration or specified by OIDC, we just mark the profile
;; as logged-in
;; A profile was just created in this call. Invitation handling is a
;; sub-case of "newly created profile": we never honor invitations for
;; pre-existing profiles via this anonymous RPC. The split below mirrors
;; the non-invitation branches but threads the invitation through the
;; appropriate path:
;;
;; - active + matching invitation → mint session and
;; return :invitation-token. The frontend redirects to
;; :auth-verify-token, which immediately accepts the
;; invitation.
;; - active + no/mismatched invitation → mint session
;; ("login" action). New profile, no further action.
;; - not-active + matching invitation → send the
;; verify-email mail with the invitation token EMBEDDED
;; into the verify-email JWE. No session yet. When the
;; user clicks the link, verify-token activates the
;; profile, mints a session, and propagates the
;; invitation token to the frontend so it can complete
;; the team-invitation flow.
;; - not-active + no/mismatched invitation → standard
;; "check your email" verification flow.
created?
(if (:is-active profile)
(-> (profile/strip-private-attrs profile)
(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 "login"}
::audit/profile-id (:id profile)}))
(let [accept-invitation? (and (some? invitation)
(= (:email profile)
(:member-email invitation)))]
(cond
(and (:is-active profile) accept-invitation?)
(let [invitation (assoc invitation :member-id (:id profile))
token (tokens/generate cfg invitation)]
(-> {: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
(when-not (eml/has-reports? conn (:email profile))
(send-email-verification! cfg profile))
(-> {:id (:id profile)
:email (:email profile)}
(:is-active profile)
(-> (profile/strip-private-attrs profile)
(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 "email-verification"}
::audit/profile-id (:id profile)}))))
::audit/context {:action "login"}
::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
(let [elapsed? (elapsed-verify-threshold? profile)

View File

@ -74,6 +74,11 @@
{:is-active true}
{: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
(rph/with-transform (session/create-fn cfg profile))
(rph/with-meta {::audit/name "verify-profile-email"

View File

@ -527,32 +527,89 @@
(t/is (= 0 (:call-count @mock))))))))
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
(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"})
data {::th/type :prepare-register-profile
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
;; With email-verification ENABLED (the default), a brand-new
;; profile created via the invitation flow is NOT active yet, so
;; `register-profile` must NOT mint a session and must NOT echo
;; back the invitation token. Instead it must dispatch the
;; verify-email mail with the invitation token EMBEDDED into the
;; verify-email JWE (so the team-invitation flow can resume after
;; the user clicks the email link).
(with-mocks [mock {:target 'app.email/send! :return nil}]
(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"}
{:keys [result error] :as out} (th/command! data)]
(t/is (nil? error))
(t/is (map? result))
(t/is (string? (:token result)))
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
(t/is (map? prep-result))
(t/is (string? (:token prep-result)))
(let [rtoken (:token result)
data {::th/type :register-profile
:token rtoken}
(let [reg-data {::th/type :register-profile
:token (:token prep-result)}
{:keys [result error] :as out} (th/command! data)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (map? result))
(t/is (string? (:invitation-token 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))
;; 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
(let [itoken (tokens/generate th/*system*
@ -705,6 +762,188 @@
(t/is (= :validation (:type 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
(with-mocks [mock {:target 'app.email/send! :return nil}]
(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

@ -663,14 +663,11 @@
ptk/WatchEvent
(watch [_ state _]
;; 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])
objects (dsh/lookup-page-objects state)
text-editing? (and (some? edition)
(= :text (:type (get objects edition))))]
(if (and (empty? (get state :workspace-editor-state))
(some? token)
(if (and (some? token)
(not text-editing?))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-versa

View File

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

View File

@ -25,11 +25,19 @@
(defmulti handle-token (fn [token] (:iss token)))
(defmethod handle-token :verify-email
[data]
[{:keys [invitation-token] :as data}]
(cf/external-notify-register-success (:profile-id data))
(let [msg (tr "dashboard.notifications.email-verified-successfully")]
(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
[_data]