diff --git a/AGENTS.md b/AGENTS.md index 842cd15022..dac88e8261 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 +`## (Unreleased)` section in the correct category +(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`). + ## GitHub Operations To obtain the list of repository members/collaborators: diff --git a/CHANGES.md b/CHANGES.md index 149fc49f05..cbba97b15f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index c3d5cdf7eb..9514fc419d 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -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) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index e25be628ad..70f0ca1809 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -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" diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index d4cfedf871..dff16d64af 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -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) diff --git a/docs/img/troubleshooting/troubleshooting-chrome.webp b/docs/img/troubleshooting/troubleshooting-chrome.webp new file mode 100644 index 0000000000..a999f41b2d Binary files /dev/null and b/docs/img/troubleshooting/troubleshooting-chrome.webp differ diff --git a/docs/img/troubleshooting/troubleshooting-firefox.webp b/docs/img/troubleshooting/troubleshooting-firefox.webp new file mode 100644 index 0000000000..93c151fee5 Binary files /dev/null and b/docs/img/troubleshooting/troubleshooting-firefox.webp differ diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk new file mode 100644 index 0000000000..bf241d0b1f --- /dev/null +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -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. +--- + +

Troubleshooting WebGL

+ +
+

Availability note

+

WebGL renderer is currently not available yet in Penpot production (design.penpot.app).

+

Right now, this renderer is available only in testing environments. It is planned for an upcoming release and should be available soon.

+
+ +

Penpot uses WebGL to render the design canvas. If WebGL is unavailable, Penpot cannot open the workspace canvas correctly.

+ +

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.

+ +

Before changing anything

+
    +
  1. Open https://get.webgl.org.
  2. +
  3. Check the result: +
      +
    • If you see a spinning cube, WebGL works at browser level.
    • +
    • If it fails (blank page, error message, or no animation), continue with browser and system checks below.
    • +
    +
  4. +
+ +

Quick checks (2 minutes)

+
    +
  1. Close graphics-heavy tabs/apps (video editors, 3D apps, many design tabs).
  2. +
  3. Reload Penpot.
  4. +
  5. Fully restart the browser.
  6. +
  7. If needed, restart your computer.
  8. +
+

Why this helps: GPU memory or context slots can be temporarily exhausted, even when your configuration is correct.

+ +

Chrome

+
    +
  1. Open chrome://settings/system.
  2. +
  3. Turn on Use graphics acceleration when available.
  4. +
  5. Restart Chrome.
  6. +
  7. Open chrome://gpu and review WebGL-related warnings.
  8. +
+
+ + Chrome system settings with graphics acceleration option + +
+

Why this helps: WebGL depends on hardware acceleration and a healthy GPU process.

+ +

Mozilla Firefox

+
    +
  1. Open Firefox and check that zoom is set to 100% from the top-right menu.
  2. +
  3. From that same menu, open Settings/Preferences.
  4. +
  5. In General settings, confirm Firefox is up to date and run Check for updates if needed.
  6. +
  7. Enable hardware acceleration in Firefox settings.
  8. +
  9. Restart Firefox.
  10. +
  11. Open about:support and review the Graphics/WebGL section.
  12. +
+
+ + Firefox settings showing hardware acceleration configuration + +
+

Why this helps: outdated browser builds, disabled acceleration, or blocked GPU features can prevent context creation.

+ +

Safari

+
    +
  1. Update Safari/macOS to the latest available version.
  2. +
  3. Restart Safari.
  4. +
  5. Re-test in https://get.webgl.org.
  6. +
+

Why this helps: Safari WebGL behavior is strongly tied to OS/browser version and graphics stack updates.

+ +

Settings

+

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.

+ +

About zoom and trackpad settings

+

In some cases, changing browser zoom or trackpad settings is suggested as a workaround.

+

In Penpot, these are not baseline requirements for WebGL. Treat them only as temporary diagnostics if support explicitly asks for them.

+

If you temporarily changed one of these settings and Penpot starts working, you can usually revert it and test again.

+ +

GPU drivers and OS checks

+
    +
  1. Install any pending OS updates.
  2. +
  3. Update GPU drivers (especially on Windows/Linux).
  4. +
  5. Disable graphics overlays/tools (recording overlays, GPU tuning utilities) and test again.
  6. +
+

Why this helps: outdated or conflicting graphics layers can break WebGL context creation.

+ +

Known edge case: Linux + Nvidia

+

Some Linux + Nvidia combinations can report WebGL as available but still fail at runtime in specific browser/driver combinations.

+

In some cases, switching between proprietary and open-source drivers or updating the NVIDIA driver resolves the issue.

+

If this is your setup, collect diagnostics and contact support.

+ +

If the issue persists

+

Please share:

+ + +

Then contact us at support@penpot.app or open a GitHub issue at https://github.com/penpot/penpot/issues.

\ No newline at end of file diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index c92a7cc14e..7c3cc13c0f 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -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 diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 60fd3e0167..29739dff56 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -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")}) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index a93954ace3..b647bcf27d 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -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]