mirror of
https://github.com/penpot/penpot.git
synced 2026-08-12 15:58:38 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
0de47302a6
@ -1,11 +1,308 @@
|
||||
;; Example rlimit.edn file
|
||||
^{:refresh "30s"}
|
||||
{:default
|
||||
[[:default :window "200000/h"]]
|
||||
|
||||
;; #{:main/get-teams}
|
||||
;; [[:burst :bucket "5/5/5s"]]
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Auth & Identity — public, unauthenticated
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/login-with-password}
|
||||
[[:auth-password :bucket "100/50/1m"]]
|
||||
|
||||
;; #{:main/get-profile}
|
||||
;; [[:burst :bucket "60/60/1m"]]
|
||||
}
|
||||
#{:main/login-with-ldap}
|
||||
[[:auth-ldap :bucket "20/10/5m"]]
|
||||
|
||||
#{:main/register-profile}
|
||||
[[:auth-register :bucket "20/10/15m"]]
|
||||
|
||||
#{:main/request-profile-recovery
|
||||
:main/prepare-register-profile}
|
||||
[[:auth-recovery :bucket "100/50/5m"]]
|
||||
|
||||
#{:main/recover-profile
|
||||
:main/verify-token}
|
||||
[[:auth-token :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; SSRF vectors — URL fetch endpoints
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-file-media-object-from-url}
|
||||
[[:url-fetch :bucket "100/50/5m"]]
|
||||
|
||||
#{:main/create-webhook
|
||||
:main/update-webhook}
|
||||
[[:webhook-validation :bucket "20/10/5m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Search — full sequential scan risk
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/search-files}
|
||||
[[:search :bucket "60/30/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Feedback & Invitations — email-sending
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/send-user-feedback
|
||||
:main/create-team-invitations}
|
||||
[[:email-send :bucket "30/15/5m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Media & File heavy ops
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/upload-file-media-object}
|
||||
[[:image-upload :bucket "200/100/1m"]]
|
||||
|
||||
#{:main/create-file-object-thumbnail
|
||||
:main/delete-file-object-thumbnails
|
||||
:main/get-file-object-thumbnails}
|
||||
[[:thumbnail-ops :bucket "5000/3000/1m"]]
|
||||
|
||||
#{:main/get-file-data-for-thumbnail
|
||||
:main/create-file-thumbnail}
|
||||
[[:thumbnail-data :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; UI navigation reads — high frequency
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/get-teams}
|
||||
[[:get-teams :bucket "5000/2500/30s"]]
|
||||
|
||||
#{:main/get-team-members}
|
||||
[[:get-team-members :bucket "4000/2000/30s"]]
|
||||
|
||||
#{:main/get-profile}
|
||||
[[:get-profile :bucket "500/250/30s"]]
|
||||
|
||||
#{:main/get-font-variants}
|
||||
[[:get-font-variants :bucket "250/125/30s"]]
|
||||
|
||||
#{:main/get-comment-threads}
|
||||
[[:get-comment-threads :bucket "500/250/30s"]]
|
||||
|
||||
#{:main/get-profiles-for-file-comments}
|
||||
[[:get-profiles-for-file-comments :bucket "300/150/30s"]]
|
||||
|
||||
#{:main/get-file-libraries}
|
||||
[[:get-file-libraries :bucket "200/100/30s"]]
|
||||
|
||||
#{:main/get-projects}
|
||||
[[:get-projects :bucket "120/60/30s"]]
|
||||
|
||||
#{:main/get-team-recent-files
|
||||
:main/get-unread-comment-threads}
|
||||
[[:get-team-recent :bucket "120/60/30s"]]
|
||||
|
||||
#{:main/get-page}
|
||||
[[:get-page :bucket "150/75/30s"]]
|
||||
|
||||
#{:main/get-access-tokens
|
||||
:main/get-subscription-usage}
|
||||
[[:get-access-tokens :bucket "150/75/30s"]]
|
||||
|
||||
#{:main/get-enabled-flags}
|
||||
[[:get-enabled-flags :bucket "250/125/30s"]]
|
||||
|
||||
#{:main/get-builtin-templates}
|
||||
[[:get-builtin-templates :bucket "200/100/30s"]]
|
||||
|
||||
#{:main/get-project
|
||||
:main/get-project-files}
|
||||
[[:get-project-info :bucket "80/40/30s"]]
|
||||
|
||||
#{:main/get-file}
|
||||
[[:get-file :bucket "180/90/1m"]]
|
||||
|
||||
#{:main/get-team-shared-files
|
||||
:main/get-team-info
|
||||
:main/get-team-users
|
||||
:main/get-team-invitations
|
||||
:main/get-team-deleted-files
|
||||
:main/get-sso-provider}
|
||||
[[:get-team-info :bucket "60/30/30s"]]
|
||||
|
||||
#{:main/get-comments
|
||||
:main/get-file-snapshots
|
||||
:main/get-library-usage
|
||||
:main/has-file-libraries}
|
||||
[[:get-misc-list :bucket "300/150/30s"]]
|
||||
|
||||
#{:main/get-comment-thread
|
||||
:main/get-library-file-references}
|
||||
[[:get-misc-single :bucket "60/30/30s"]]
|
||||
|
||||
#{:main/get-file-info
|
||||
:main/get-view-only-bundle
|
||||
:main/get-all-projects
|
||||
:main/get-owned-teams
|
||||
:main/get-team-stats
|
||||
:main/get-file-summary
|
||||
:main/get-file-stats
|
||||
:main/get-file-fragment}
|
||||
[[:get-light :bucket "60/30/30s"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; File mutations — editing active
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/update-file}
|
||||
[[:update-file :bucket "1000/500/1m"]]
|
||||
|
||||
#{:main/create-file
|
||||
:main/rename-file
|
||||
:main/duplicate-file
|
||||
:main/move-files}
|
||||
[[:file-create :bucket "60/30/1m"]]
|
||||
|
||||
#{:main/delete-file}
|
||||
[[:file-delete :bucket "80/40/1m"]]
|
||||
|
||||
#{:main/set-file-shared
|
||||
:main/update-file-library-sync-status
|
||||
:main/ignore-file-library-sync-status
|
||||
:main/link-file-to-library
|
||||
:main/unlink-file-from-library
|
||||
:main/create-file-snapshot
|
||||
:main/restore-file-snapshot
|
||||
:main/update-file-snapshot
|
||||
:main/delete-file-snapshot
|
||||
:main/lock-file-snapshot
|
||||
:main/unlock-file-snapshot}
|
||||
[[:file-mutations :bucket "80/40/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Project mutations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-project}
|
||||
[[:project-create :bucket "100/50/1m"]]
|
||||
|
||||
#{:main/delete-project
|
||||
:main/rename-project
|
||||
:main/duplicate-project
|
||||
:main/move-project
|
||||
:main/update-project-pin}
|
||||
[[:project-mutations :bucket "40/20/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Team mutations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-team
|
||||
:main/update-team
|
||||
:main/delete-team
|
||||
:main/update-team-photo
|
||||
:main/update-team-member-role
|
||||
:main/delete-team-member
|
||||
:main/leave-team
|
||||
:main/create-team-with-invitations
|
||||
:main/create-team-access-request
|
||||
:main/permanently-delete-team-files
|
||||
:main/restore-deleted-team-files}
|
||||
[[:team-mutations :bucket "60/30/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Comment operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-comment-thread
|
||||
:main/create-comment
|
||||
:main/update-comment
|
||||
:main/delete-comment
|
||||
:main/mark-all-threads-as-read}
|
||||
[[:comment-basic :bucket "30/15/1m"]]
|
||||
|
||||
#{:main/update-comment-thread
|
||||
:main/update-comment-thread-status
|
||||
:main/update-comment-thread-position
|
||||
:main/update-comment-thread-frame
|
||||
:main/delete-comment-thread}
|
||||
[[:comment-thread :bucket "80/40/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Profile operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/update-profile
|
||||
:main/update-profile-props
|
||||
:main/update-profile-photo
|
||||
:main/update-profile-password
|
||||
:main/update-profile-notifications
|
||||
:main/delete-profile
|
||||
:main/delete-profile-photo
|
||||
:main/request-email-change}
|
||||
[[:profile-mutations :bucket "30/15/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Font operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-font-variant
|
||||
:main/delete-font
|
||||
:main/delete-font-variant
|
||||
:main/update-font
|
||||
:main/download-font
|
||||
:main/download-font-family}
|
||||
[[:font-ops :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Access tokens
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-access-token
|
||||
:main/delete-access-token}
|
||||
[[:access-token :bucket "60/30/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Export / Import
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/export-binfile
|
||||
:main/import-binfile
|
||||
:main/clone-template}
|
||||
[[:export-import :bucket "80/40/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Upload sessions
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-upload-session
|
||||
:main/upload-chunk
|
||||
:main/assemble-file-media-object}
|
||||
[[:upload-session :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Webhooks
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/get-webhooks
|
||||
:main/delete-webhook}
|
||||
[[:webhook-read :bucket "20/10/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Share links
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-share-link
|
||||
:main/delete-share-link}
|
||||
[[:share-link :bucket "10/5/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Organization operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/add-team-to-organization
|
||||
:main/remove-team-from-org
|
||||
:main/all-org-members-in-team
|
||||
:main/all-team-members-in-orgs
|
||||
:main/get-owned-organizations-summary
|
||||
:main/get-leave-org-summary
|
||||
:main/leave-org
|
||||
:main/check-org-members
|
||||
:main/get-team-invitation-token
|
||||
:main/delete-team-invitation
|
||||
:main/check-team-external-invitations}
|
||||
[[:org-ops :bucket "20/10/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Audit & stats
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/push-audit-events}
|
||||
[[:audit-events :bucket "1000/500/1m"]]
|
||||
|
||||
#{:main/logout
|
||||
:main/get-error-report
|
||||
:main/get-error-reports
|
||||
:main/get-current-mcp-token
|
||||
:main/get-nitrate-connectivity
|
||||
:main/check-nitrate-sso
|
||||
:main/redeem-nitrate-activation-code
|
||||
:main/create-demo-profile
|
||||
:main/get-subscription-warning}
|
||||
[[:misc-light :bucket "100/50/1m"]]}
|
||||
|
||||
@ -650,6 +650,13 @@
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
(redirect-response uri))))
|
||||
|
||||
(defn- redirect-with-organization-sso-error
|
||||
[{:keys [dest-url organization-id]}]
|
||||
(-> (str (or dest-url (cf/get :public-uri)))
|
||||
(u/append-query-param :sso-error true)
|
||||
(u/append-query-param :organization-id organization-id)
|
||||
(redirect-response)))
|
||||
|
||||
(defn- redirect-to-register
|
||||
[cfg info provider]
|
||||
(let [info (assoc info
|
||||
@ -887,6 +894,39 @@
|
||||
{::yres/status 200
|
||||
::yres/body {:redirect-uri uri}}))
|
||||
|
||||
(defn- organization-sso-callback-handler
|
||||
"Handle the organization-SSO branch of the OIDC callback: state carries
|
||||
:dest-url — exchange the authorization code with the OIDC provider to
|
||||
verify authentication actually occurred, then redirect back to dest-url."
|
||||
[cfg request state code]
|
||||
(let [dest-url (:dest-url state)]
|
||||
(try
|
||||
(let [organization-id (:organization-id state)
|
||||
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
(session/update-session (::session/manager cfg) (assoc session :props props))))
|
||||
(redirect-response dest-url))
|
||||
(catch Throwable cause
|
||||
(let [{:keys [code]} (ex-data cause)]
|
||||
(binding [l/*context* (errors/request->context request)]
|
||||
(if (some? code)
|
||||
(l/warn :hint "organization sso callback failed"
|
||||
:code code
|
||||
:message (ex-message cause)
|
||||
:organization-id (:organization-id state))
|
||||
(l/err :hint "unexpected error on organization sso callback"
|
||||
:organization-id (:organization-id state)
|
||||
:cause cause))))
|
||||
(redirect-with-organization-sso-error
|
||||
{:dest-url dest-url
|
||||
:organization-id (:organization-id state)})))))
|
||||
|
||||
(defn- callback-handler
|
||||
[cfg {:keys [params] :as request}]
|
||||
(if-let [error (get params :error)]
|
||||
@ -898,18 +938,8 @@
|
||||
|
||||
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; code with the OIDC provider to verify authentication actually occurred.
|
||||
(if-let [dest-url (:dest-url state)]
|
||||
(let [organization-id (:organization-id state)
|
||||
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
(session/update-session (::session/manager cfg) (assoc session :props props))))
|
||||
(redirect-response dest-url))
|
||||
(if (:dest-url state)
|
||||
(organization-sso-callback-handler cfg request state code)
|
||||
|
||||
(let [provider (resolve-provider cfg state)
|
||||
info (get-info cfg provider state code)
|
||||
|
||||
@ -24,7 +24,8 @@
|
||||
(:import
|
||||
io.undertow.server.RequestTooBigException
|
||||
java.io.InputStream
|
||||
java.io.OutputStream))
|
||||
java.io.OutputStream
|
||||
java.security.MessageDigest))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@ -329,6 +330,11 @@
|
||||
{:name ::auth
|
||||
:compile (constantly wrap-auth)})
|
||||
|
||||
(defn- constant-time-eq?
|
||||
"Compare strings in constant time to prevent timing attacks."
|
||||
[^String a ^String b]
|
||||
(MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8")))
|
||||
|
||||
(defn- wrap-shared-key-auth
|
||||
[handler keys]
|
||||
(if (seq keys)
|
||||
@ -338,7 +344,7 @@
|
||||
(let [key-id (-> key-id str/lower keyword)]
|
||||
(if (and (string? key)
|
||||
(contains? keys key-id)
|
||||
(= key (get keys key-id)))
|
||||
(constant-time-eq? key (get keys key-id)))
|
||||
(-> request
|
||||
(assoc ::http/auth-key-id key-id)
|
||||
(handler))
|
||||
|
||||
@ -226,6 +226,14 @@
|
||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||
(db/get-update-count))))
|
||||
|
||||
(defn invalidate-all
|
||||
"Delete all sessions for a given profile. Used when a profile is deleted
|
||||
to ensure immediate access revocation across all devices."
|
||||
[cfg profile-id]
|
||||
(let [sql "delete from http_session_v2 where profile_id = ?"]
|
||||
(-> (db/exec-one! cfg [sql profile-id])
|
||||
(db/get-update-count))))
|
||||
|
||||
(def ^:private sql:clear-organization-sso-sessions
|
||||
(str "UPDATE http_session_v2 "
|
||||
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.http.websocket
|
||||
"A penpot notification service for file cooperative edition."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
@ -17,6 +18,8 @@
|
||||
[app.http.session :as session]
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.util.websocket :as ws]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec.csp :as sp]
|
||||
@ -131,8 +134,9 @@
|
||||
(mbus/pub! msgbus :topic topic :message msg))))
|
||||
|
||||
(defmethod handle-message :subscribe-team
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
|
||||
(teams/check-read-permissions! pool profile-id team-id)
|
||||
(let [prev-subs (get @state ::team-subscription)
|
||||
channel (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
@ -150,8 +154,10 @@
|
||||
|
||||
|
||||
(defmethod handle-message :subscribe-file
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
|
||||
(bfc/check-file-exists pool file-id)
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
(let [psub (::file-subscription @state)
|
||||
fch (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
|
||||
@ -167,6 +167,9 @@
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:owner-id ::sm/uuid]
|
||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||
[:sso-active {:optional true} [:maybe ::sm/boolean]]
|
||||
[:teams
|
||||
[:vector
|
||||
[:map
|
||||
@ -259,6 +262,14 @@
|
||||
(generate-nitrate-uri "api/teams/" team-id)
|
||||
cto/schema:team-with-organization params))
|
||||
|
||||
(defn- get-teams-organizations-api
|
||||
[cfg {:keys [team-ids] :as params}]
|
||||
(let [params (assoc params :request-params {:team-ids team-ids})]
|
||||
(request-to-nitrate cfg :post
|
||||
(generate-nitrate-uri "api/teams/organizations")
|
||||
[:vector cto/schema:team-with-organization]
|
||||
params)))
|
||||
|
||||
(defn- get-organization-membership-api
|
||||
[cfg {:keys [profile-id organization-id] :as params}]
|
||||
(request-to-nitrate cfg :get
|
||||
@ -489,6 +500,7 @@
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :admin-console)
|
||||
{:get-team-organization (partial get-team-organization-api cfg)
|
||||
:get-teams-organizations (partial get-teams-organizations-api cfg)
|
||||
:set-team-organization (partial set-team-organization-api cfg)
|
||||
:get-organization-membership (partial get-organization-membership-api cfg)
|
||||
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
|
||||
@ -596,22 +608,25 @@
|
||||
:cause cause)
|
||||
profile)))))
|
||||
|
||||
(defn- apply-organization-info-to-team
|
||||
[team team-with-organization]
|
||||
(let [organization (:organization team-with-organization)]
|
||||
(if (some? organization)
|
||||
(-> (cto/apply-organization team (assoc organization :custom-photo
|
||||
(when-let [logo-id (:logo-id organization)]
|
||||
(generate-public-uri "assets/by-id/" logo-id))))
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
||||
team)))
|
||||
|
||||
(defn add-organization-info-to-team
|
||||
"Enriches a team map with organization information from Nitrate.
|
||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||
Returns the original team unchanged if the request fails or organization data is nil.
|
||||
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
||||
[cfg team params]
|
||||
(try
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
team-with-organization (call cfg :get-team-organization params)
|
||||
organization (:organization team-with-organization)]
|
||||
(if (some? organization)
|
||||
(-> (cto/apply-organization team (assoc organization :custom-photo
|
||||
(when-let [logo-id (:logo-id organization)]
|
||||
(generate-public-uri "assets/by-id/" logo-id))))
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
||||
team))
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
team-with-organization (call cfg :get-team-organization params)]
|
||||
(apply-organization-info-to-team team team-with-organization))
|
||||
(catch Throwable cause
|
||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||
(throw cause)
|
||||
@ -621,6 +636,23 @@
|
||||
:cause cause)
|
||||
team)))))
|
||||
|
||||
(defn add-organization-info-to-teams
|
||||
"Enriches teams with organization information using one batched Nitrate request.
|
||||
Teams absent from the Nitrate response are returned unchanged.
|
||||
Rejects the request when Nitrate does not return a valid batch response."
|
||||
[cfg teams params]
|
||||
(let [request-params (assoc (or params {}) :team-ids (mapv :id teams))
|
||||
teams-with-organization (call cfg :get-teams-organizations request-params)]
|
||||
(when (nil? teams-with-organization)
|
||||
(ex/raise :type :nitrate-unavailable
|
||||
:hint "nitrate did not return a valid teams organization response"))
|
||||
(let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)]
|
||||
(mapv (fn [{:keys [id] :as team}]
|
||||
(if-let [team-with-organization (get organizations-by-team id)]
|
||||
(apply-organization-info-to-team team team-with-organization)
|
||||
team))
|
||||
teams))))
|
||||
|
||||
(defn set-team-organization
|
||||
"Associates a team with an organization in Nitrate.
|
||||
Requires organization-id and is-default in params.
|
||||
@ -637,3 +669,17 @@
|
||||
:context {:team-id (:id team)
|
||||
:organization-id (:organization-id params)}))
|
||||
team))
|
||||
|
||||
(defn assert-membership
|
||||
"Verifies that the user is a member of the organization.
|
||||
Raises an exception if the organization doesn't exist or the user is not a member."
|
||||
[cfg profile-id organization-id]
|
||||
(let [membership (call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(when-not (:organization-id membership)
|
||||
(ex/raise :type :validation
|
||||
:code :organization-does-not-exist))
|
||||
|
||||
(when-not (:is-member membership)
|
||||
(ex/raise :type :validation
|
||||
:code :user-doesnt-belong-organization))))
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
[conn {:keys [profile-id file-id pages who-comment who-inspect]}]
|
||||
(let [pages (db/create-array conn "uuid" pages)
|
||||
slink (db/insert! conn :share-link
|
||||
{:id (uuid/next)
|
||||
{:id (uuid/random)
|
||||
:file-id file-id
|
||||
:who-comment who-comment
|
||||
:who-inspect who-inspect
|
||||
|
||||
@ -41,17 +41,6 @@
|
||||
(ex/raise :type :validation
|
||||
:code :cant-move-default-team))))
|
||||
|
||||
(defn assert-membership [cfg profile-id organization-id]
|
||||
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(when-not (:organization-id membership)
|
||||
(ex/raise :type :validation
|
||||
:code :organization-does-not-exist))
|
||||
|
||||
(when-not (:is-member membership)
|
||||
(ex/raise :type :validation
|
||||
:code :user-doesnt-belong-organization))))
|
||||
|
||||
|
||||
(def schema:connectivity
|
||||
[:map {:title "nitrate-connectivity"}
|
||||
@ -335,7 +324,7 @@
|
||||
(when-not skip-validation
|
||||
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
|
||||
|
||||
(assert-membership cfg profile-id id)
|
||||
(nitrate/assert-membership cfg profile-id id)
|
||||
|
||||
;; delete only eligible teams (non-protected and without files)
|
||||
(doseq [id deletable-team-ids]
|
||||
@ -421,7 +410,7 @@
|
||||
|
||||
(assert-is-owner cfg profile-id team-id)
|
||||
(assert-not-default-team cfg team-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
;; Check moveTeams permission on the source organization
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
@ -491,7 +480,7 @@
|
||||
|
||||
(assert-is-owner cfg profile-id team-id)
|
||||
(assert-not-default-team cfg team-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||
@ -575,7 +564,7 @@
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
||||
(or (when (contains? cf/flags :admin-console)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(let [emails-array (db/create-array conn "text" emails)
|
||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
||||
@ -603,7 +592,7 @@
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||
organization-member-ids (into #{} organization-members)
|
||||
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
@ -631,7 +620,7 @@
|
||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
team-member-ids (into #{} (map :profile-id team-members))]
|
||||
;; Validate requester membership in all organizations before fetching members.
|
||||
(run! #(assert-membership cfg profile-id %) organization-ids)
|
||||
(run! #(nitrate/assert-membership cfg profile-id %) organization-ids)
|
||||
|
||||
(into {}
|
||||
(map (fn [organization-id]
|
||||
@ -664,7 +653,7 @@
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
||||
{:has-external-invitations (boolean (seq external-emails))
|
||||
:allows-anybody allows-anybody}))
|
||||
|
||||
@ -534,6 +534,10 @@
|
||||
:deleted-at deleted-at
|
||||
:id profile-id}})
|
||||
|
||||
;; Invalidate all sessions for this profile to ensure immediate
|
||||
;; access revocation across all devices
|
||||
(session/invalidate-all cfg profile-id)
|
||||
|
||||
(-> (rph/wrap nil)
|
||||
(rph/with-transform (session/delete-fn cfg)))))
|
||||
|
||||
|
||||
@ -196,11 +196,11 @@
|
||||
::sm/params schema:get-teams}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(cond->> (get-teams conn profile-id)
|
||||
(contains? cf/flags :admin-console)
|
||||
(map #(nitrate/add-organization-info-to-team cfg % params))
|
||||
(contains? cf/flags :admin-console)
|
||||
(remove #(get-in % [:organization :expired-license])))))
|
||||
(let [teams (get-teams conn profile-id)]
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(->> (nitrate/add-organization-info-to-teams cfg teams params)
|
||||
(remove #(get-in % [:organization :expired-license])))
|
||||
teams))))
|
||||
|
||||
(def ^:private sql:get-owned-teams
|
||||
"SELECT t.id, t.name,
|
||||
@ -538,6 +538,9 @@
|
||||
;; When creating inside an organization, verify the user has permission to do so.
|
||||
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
|
||||
(when (and organization-id (contains? cf/flags :admin-console))
|
||||
;; Verify caller is a member of the organization
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? organization-perms)
|
||||
|
||||
@ -488,6 +488,21 @@ RETURNING id, deleted_at;")
|
||||
|
||||
;; API: invite-to-organization
|
||||
|
||||
(defn- get-invitation-organization
|
||||
[cfg profile-id organization-id]
|
||||
(let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]}
|
||||
(nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
|
||||
(when-not (= profile-id owner-id)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))
|
||||
{:id id
|
||||
:name name
|
||||
:initials (if logo-id "" (d/get-initials name))
|
||||
:logo (when logo-id (files/resolve-public-uri logo-id))
|
||||
:avatar-bg-url (when-not logo-id avatar-bg-url)
|
||||
:sso-active (true? sso-active)}))
|
||||
|
||||
(sv/defmethod ::invite-to-organization
|
||||
"Invite to organization"
|
||||
{::doc/added "2.15"
|
||||
@ -495,8 +510,11 @@ RETURNING id, deleted_at;")
|
||||
[:email ::sm/email]
|
||||
[:organization cto/schema:organization-with-avatar]]
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/tx-run! cfg ti/create-organization-invitation params)
|
||||
[cfg {profile-id ::rpc/profile-id
|
||||
:keys [organization]
|
||||
:as params}]
|
||||
(let [organization (get-invitation-organization cfg profile-id (:id organization))]
|
||||
(db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization)))
|
||||
nil)
|
||||
|
||||
|
||||
|
||||
@ -190,6 +190,7 @@
|
||||
:allowed allowed?
|
||||
:remaining remaining)
|
||||
(-> limit
|
||||
(assoc ::lresult/now now)
|
||||
(assoc ::lresult/allowed allowed?)
|
||||
(assoc ::lresult/reset (ct/plus now reset))
|
||||
(assoc ::lresult/remaining remaining))))
|
||||
@ -212,6 +213,7 @@
|
||||
:allowed allowed?
|
||||
:remaining remaining)
|
||||
(-> limit
|
||||
(assoc ::lresult/now now)
|
||||
(assoc ::lresult/allowed allowed?)
|
||||
(assoc ::lresult/timestamp ts)
|
||||
(assoc ::lresult/remaining remaining)
|
||||
|
||||
@ -733,7 +733,7 @@
|
||||
(t/is (= 2 (count rows)))
|
||||
(t/is (= 1 (count (remove (comp some? :deleted-at) rows))))
|
||||
(t/is (= (thc/fmt-object-id file-id page-id frame-id-1 "frame")
|
||||
(-> rows first :object-id))))
|
||||
(->> rows (remove (comp some? :deleted-at)) first :object-id))))
|
||||
|
||||
;; Now that file-gc have marked for deletion the object
|
||||
;; thumbnail lets execute the objects-gc task which remove
|
||||
@ -2377,8 +2377,6 @@
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
;; --- Security Fix Tests ---
|
||||
|
||||
(t/deftest link-file-to-library-circular-reference
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
@ -2448,3 +2446,24 @@
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata))))))
|
||||
|
||||
(t/deftest get-file-libraries-nonexistent-file
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-file-libraries
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-file-libraries-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)})
|
||||
out (th/command! {::th/type :get-file-libraries
|
||||
::rpc/profile-id (:id other)
|
||||
:file-id (:id file)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
@ -650,3 +650,24 @@
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))
|
||||
(t/is (= :object-not-found (-> out :error ex-data :code)))))))
|
||||
|
||||
(t/deftest get-font-variants-nonexistent-file
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-font-variants
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-font-variants-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)})
|
||||
out (th/command! {::th/type :get-font-variants
|
||||
::rpc/profile-id (:id other)
|
||||
:file-id (:id file)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
@ -50,45 +50,138 @@
|
||||
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
||||
|
||||
(t/deftest create-and-update-organization-invitations-audit-props
|
||||
(let [owner-id-ref (atom nil)]
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
audit-mock {:target 'app.loggers.audit/submit :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
:return (fn [_cfg method params]
|
||||
(when (= method :get-organization-summary)
|
||||
{:id (:organization-id params)
|
||||
:name "Acme"
|
||||
:owner-id @owner-id-ref
|
||||
:teams []}))}]
|
||||
(binding [cf/flags (conj cf/flags :email-verification)]
|
||||
(let [owner (th/create-profile* 101 {:is-active true})
|
||||
invitee (th/create-profile* 102 {:is-active true})
|
||||
organization {:id (uuid/random)
|
||||
:name "Acme"
|
||||
:initials "AC"
|
||||
:logo nil
|
||||
:avatar-bg-url nil}
|
||||
_ (reset! owner-id-ref (:id owner))
|
||||
params {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id owner)
|
||||
:email (:email invitee)
|
||||
:organization organization}
|
||||
create-out (th/management-command! params)
|
||||
update-out (th/management-command! params)
|
||||
external-out (th/management-command! (assoc params :email "external@example.com"))
|
||||
events (mapv second (:call-args-list @audit-mock))
|
||||
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
|
||||
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
|
||||
external-event
|
||||
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
|
||||
(t/is (th/success? create-out))
|
||||
(t/is (th/success? update-out))
|
||||
(t/is (th/success? external-out))
|
||||
|
||||
(doseq [event [create-event update-event]]
|
||||
(t/is (not (contains? (:props event) :event-origin)))
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (= (:id organization)
|
||||
(get-in event [:props :organization-id])))
|
||||
(t/is (= (:email invitee)
|
||||
(get-in event [:props :member-email])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in event [:props :member-id]))))
|
||||
|
||||
(t/is (not (contains? (:props external-event) :member-id))))))))
|
||||
|
||||
(t/deftest invite-to-organization-rejects-non-owner
|
||||
(let [organization-summary-ref (atom nil)]
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
:return (fn [_cfg method _params]
|
||||
(when (= method :get-organization-summary)
|
||||
@organization-summary-ref))}]
|
||||
(let [owner (th/create-profile* 103 {:is-active true})
|
||||
attacker (th/create-profile* 104 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
organization {:id organization-id
|
||||
:name "Trusted Organization"
|
||||
:initials "TO"
|
||||
:logo nil
|
||||
:avatar-bg-url nil}
|
||||
_ (reset! organization-summary-ref
|
||||
{:id organization-id
|
||||
:name "Trusted Organization"
|
||||
:owner-id (:id owner)
|
||||
:teams []})
|
||||
out (th/management-command! {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id attacker)
|
||||
:email "victim@example.com"
|
||||
:organization organization})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))
|
||||
(t/is (= :object-not-found (th/ex-code (:error out))))
|
||||
(t/is (not (:called? @email-mock)))))))
|
||||
|
||||
(t/deftest invite-to-organization-rejects-unknown-organization
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
audit-mock {:target 'app.loggers.audit/submit :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call :return nil}]
|
||||
(binding [cf/flags (conj cf/flags :email-verification)]
|
||||
(let [owner (th/create-profile* 101 {:is-active true})
|
||||
invitee (th/create-profile* 102 {:is-active true})
|
||||
organization {:id (uuid/random)
|
||||
:name "Acme"
|
||||
:initials "AC"
|
||||
:logo nil
|
||||
:avatar-bg-url nil}
|
||||
params {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id owner)
|
||||
:email (:email invitee)
|
||||
:organization organization}
|
||||
create-out (th/management-command! params)
|
||||
update-out (th/management-command! params)
|
||||
external-out (th/management-command! (assoc params :email "external@example.com"))
|
||||
events (mapv second (:call-args-list @audit-mock))
|
||||
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
|
||||
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
|
||||
external-event
|
||||
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
|
||||
(t/is (th/success? create-out))
|
||||
(t/is (th/success? update-out))
|
||||
(t/is (th/success? external-out))
|
||||
(let [profile (th/create-profile* 105 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
out (th/management-command! {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id profile)
|
||||
:email "victim@example.com"
|
||||
:organization {:id organization-id
|
||||
:name "Fabricated Organization"
|
||||
:initials "FO"
|
||||
:logo "https://evil.example/logo.png"
|
||||
:avatar-bg-url nil}})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))
|
||||
(t/is (= :object-not-found (th/ex-code (:error out))))
|
||||
(t/is (not (:called? @email-mock))))))
|
||||
|
||||
(doseq [event [create-event update-event]]
|
||||
(t/is (not (contains? (:props event) :event-origin)))
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (= (:id organization)
|
||||
(get-in event [:props :organization-id])))
|
||||
(t/is (= (:email invitee)
|
||||
(get-in event [:props :member-email])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in event [:props :member-id]))))
|
||||
|
||||
(t/is (not (contains? (:props external-event) :member-id)))))))
|
||||
(t/deftest invite-to-organization-uses-authoritative-branding
|
||||
(let [organization-summary-ref (atom nil)]
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
:return (fn [_cfg method _params]
|
||||
(when (= method :get-organization-summary)
|
||||
@organization-summary-ref))}]
|
||||
(binding [cf/flags (conj cf/flags :email-verification)]
|
||||
(let [owner (th/create-profile* 106 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
logo-id (uuid/random)
|
||||
_ (reset! organization-summary-ref
|
||||
{:id organization-id
|
||||
:name "Trusted Organization"
|
||||
:owner-id (:id owner)
|
||||
:logo-id logo-id
|
||||
:avatar-bg-url "https://trusted.example/avatar.svg"
|
||||
:sso-active true
|
||||
:teams []})
|
||||
out (th/management-command! {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id owner)
|
||||
:email "victim@example.com"
|
||||
:organization {:id organization-id
|
||||
:name "Fabricated Bank"
|
||||
:initials "FB"
|
||||
:logo "https://evil.example/logo.png"
|
||||
:avatar-bg-url "https://evil.example/avatar.svg"
|
||||
:sso-active false}})
|
||||
email-params (first (:call-args @email-mock))
|
||||
organization (:organization email-params)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= "Trusted Organization" (:name organization)))
|
||||
(t/is (= "" (:initials organization)))
|
||||
(t/is (str/ends-with? (:logo organization)
|
||||
(str "/assets/by-id/" logo-id)))
|
||||
(t/is (nil? (:avatar-bg-url organization)))
|
||||
(t/is (true? (:sso-active organization))))))))
|
||||
|
||||
(t/deftest get-penpot-version
|
||||
(let [out (th/management-command! {::th/type :get-penpot-version})
|
||||
|
||||
@ -50,7 +50,16 @@
|
||||
:organization (organization-data organization-id organization-owner-id)}
|
||||
{:id (:team-id params)
|
||||
:is-your-penpot false
|
||||
:organization nil})))]
|
||||
:organization nil})
|
||||
|
||||
:get-teams-organizations
|
||||
(->> (:team-ids params)
|
||||
(keep (fn [candidate-team-id]
|
||||
(when (= team-id candidate-team-id)
|
||||
{:id team-id
|
||||
:is-your-penpot false
|
||||
:organization (organization-data organization-id organization-owner-id)})))
|
||||
vec)))]
|
||||
(f)))
|
||||
|
||||
(defn- with-captured-messages
|
||||
|
||||
@ -388,6 +388,31 @@
|
||||
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 10 (:processed result))))))
|
||||
|
||||
(t/deftest profile-deletion-invalidates-all-sessions
|
||||
(let [prof (th/create-profile* 1)
|
||||
|
||||
;; Insert 3 sessions for this profile directly into the database
|
||||
session-ids (doall
|
||||
(for [i (range 3)]
|
||||
(let [sid (uuid/random)]
|
||||
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
|
||||
sid (:id prof) (str "user-agent-" i)])
|
||||
sid)))]
|
||||
|
||||
;; Verify sessions exist
|
||||
(let [count-before (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
|
||||
(t/is (= 3 count-before)))
|
||||
|
||||
;; Request profile to be deleted
|
||||
(let [params {::th/type :delete-profile
|
||||
::rpc/profile-id (:id prof)}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; Verify ALL sessions were invalidated (not just one)
|
||||
(let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
|
||||
(t/is (= 0 count-after)))))
|
||||
|
||||
|
||||
(t/deftest email-blacklist-1
|
||||
(t/is (false? (email.blacklist/enabled? th/*system*)))
|
||||
|
||||
@ -241,3 +241,24 @@
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found))))))
|
||||
|
||||
(t/deftest get-project-nonexistent
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-project
|
||||
::rpc/profile-id (:id prof)
|
||||
:id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-project-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
proj (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:default-team-id owner)})
|
||||
out (th/command! {::th/type :get-project
|
||||
::rpc/profile-id (:id other)
|
||||
:id (:id proj)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
@ -719,6 +719,67 @@
|
||||
(t/is (not= (:default-team-id profile1) (:id item1))))))
|
||||
|
||||
|
||||
(t/deftest get-teams-fetches-organizations-in-one-batch
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
organization-team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
plain-team (th/create-team* 2 {:profile-id (:id profile)})
|
||||
expired-team (th/create-team* 3 {:profile-id (:id profile)})
|
||||
organization-id (uuid/random)
|
||||
calls (atom [])
|
||||
organization {:id organization-id
|
||||
:name "Acme"
|
||||
:slug "acme"
|
||||
:owner-id (:id profile)
|
||||
:avatar-bg-url "https://example.com/avatar.svg"}
|
||||
nitrate-call (fn [_cfg method params]
|
||||
(swap! calls conj [method params])
|
||||
[{:id (:id organization-team)
|
||||
:is-your-penpot false
|
||||
:organization organization}
|
||||
{:id (:id expired-team)
|
||||
:is-your-penpot false
|
||||
:organization (assoc organization :expired-license true)}])
|
||||
params {::th/type :get-teams
|
||||
::rpc/profile-id (:id profile)}]
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call nitrate-call]
|
||||
(let [out (th/command! params)
|
||||
teams (:result out)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count @calls)))
|
||||
(t/is (= :get-teams-organizations (ffirst @calls)))
|
||||
(t/is (= #{(:default-team-id profile)
|
||||
(:id organization-team)
|
||||
(:id plain-team)
|
||||
(:id expired-team)}
|
||||
(-> @calls first second :team-ids set)))
|
||||
(t/is (= #{(:default-team-id profile)
|
||||
(:id organization-team)
|
||||
(:id plain-team)}
|
||||
(into #{} (map :id) teams)))
|
||||
(t/is (= organization
|
||||
(->> teams
|
||||
(filter #(= (:id organization-team) (:id %)))
|
||||
first
|
||||
:organization)))))))
|
||||
|
||||
|
||||
(t/deftest get-teams-rejects-invalid-organization-batch-response
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
calls (atom [])
|
||||
params {::th/type :get-teams
|
||||
::rpc/profile-id (:id profile)}]
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call (fn [_cfg method call-params]
|
||||
(swap! calls conj [method call-params])
|
||||
nil)]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :nitrate-unavailable (th/ex-type (:error out))))
|
||||
(t/is (= 1 (count @calls)))
|
||||
(t/is (= :get-teams-organizations (ffirst @calls)))))))
|
||||
|
||||
|
||||
(t/deftest team-deletion-1
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile1)})
|
||||
@ -1096,3 +1157,53 @@
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
(t/deftest create-team-in-organization-regression
|
||||
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
||||
(let [owner (th/create-profile* 401 {:is-active true})
|
||||
non-member (th/create-profile* 402 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
params {::th/type :create-team
|
||||
::rpc/profile-id (:id owner)
|
||||
:name "Test Team"
|
||||
:organization-id organization-id}
|
||||
|
||||
nitrate-call-fn
|
||||
(fn [_cfg method p]
|
||||
(case method
|
||||
:get-organization-membership
|
||||
(if (= (:profile-id p) (:id non-member))
|
||||
{:organization-id organization-id :is-member false}
|
||||
{:organization-id organization-id :is-member true})
|
||||
|
||||
:get-organization-permissions
|
||||
{:owner-id (:id owner)
|
||||
:permissions {:create-teams "any"}}
|
||||
|
||||
:set-team-organization
|
||||
(let [team-id (:team-id p)]
|
||||
{:id team-id
|
||||
:name "Test Team"
|
||||
:organization-id organization-id
|
||||
:default-project-id (uuid/random)})
|
||||
|
||||
nil))]
|
||||
|
||||
;; Non-member should be denied with :user-doesnt-belong-organization
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call nitrate-call-fn]
|
||||
(let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))]
|
||||
(t/is (not (th/success? out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :user-doesnt-belong-organization (:code edata))))))
|
||||
|
||||
;; Authorized member should succeed
|
||||
(th/reset-mock! audit-mock)
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call nitrate-call-fn]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (th/success? out))
|
||||
(let [team (:result out)]
|
||||
(t/is (uuid? (:id team)))
|
||||
(t/is (= "Test Team" (:name team)))))))))
|
||||
|
||||
@ -147,7 +147,6 @@
|
||||
:render-switch
|
||||
:hide-release-modal
|
||||
:subscriptions
|
||||
:subscriptions-old
|
||||
:inspect-styles
|
||||
;; Enable performance logs in devconsole (disabled by default)
|
||||
:perf-logs
|
||||
|
||||
@ -2345,7 +2345,12 @@
|
||||
updated-sync-groups (into #{}
|
||||
(keep #(ctk/resolve-sync-group (:type previous-shape) %))
|
||||
updated-attrs)
|
||||
new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups)
|
||||
text-sub-touched #{:text-content-text :text-content-attribute :text-content-structure}
|
||||
new-touched (set/union (or (:touched current-shape) #{})
|
||||
updated-sync-groups
|
||||
(when (contains? updated-sync-groups :content-group)
|
||||
(set/intersection (or (:touched previous-shape) #{})
|
||||
text-sub-touched)))
|
||||
roperations (into [{:type :set-touched :touched new-touched}] roperations)
|
||||
uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)]
|
||||
(cond-> changes
|
||||
|
||||
@ -67,6 +67,36 @@
|
||||
path
|
||||
(str path "/")))))
|
||||
|
||||
(defn- update-query-params
|
||||
"Apply `f` to the query-params map of `url`, returning the updated URL string.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url f]
|
||||
(let [transform (fn [parsed]
|
||||
(update parsed :query
|
||||
(fn [q]
|
||||
(-> (query-string->map (or q ""))
|
||||
f
|
||||
map->query-string))))
|
||||
parsed (uri url)
|
||||
fragment (:fragment parsed)]
|
||||
(if (str/blank? fragment)
|
||||
(str (transform parsed))
|
||||
(-> parsed
|
||||
(assoc :fragment (str (transform (parse fragment))))
|
||||
str))))
|
||||
|
||||
(defn append-query-param
|
||||
"Return a new URL string with the given query parameter added or replaced.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url key value]
|
||||
(update-query-params url #(assoc % key value)))
|
||||
|
||||
(defn remove-query-param
|
||||
"Return a new URL string with the given query parameter removed.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url key]
|
||||
(update-query-params url #(dissoc % key)))
|
||||
|
||||
#?(:clj
|
||||
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
|
||||
(.write writer "#")
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.logic.libraries :as cll]
|
||||
[app.common.logic.shapes :as cls]
|
||||
[app.common.test-helpers.components :as thc]
|
||||
[app.common.test-helpers.compositions :as tho]
|
||||
@ -3101,3 +3102,82 @@
|
||||
(t/is (= 150 (:width rect02')))
|
||||
(t/is (= (+ (:y copy02') 70) (:y rect02')))
|
||||
(t/is (= (:y rect02') (get-in rect02' [:selrect :y])))))
|
||||
|
||||
;; ============================================================
|
||||
;; PRESERVE TEXT SUB-TOUCHED FLAGS ACROSS VARIANT SWITCH
|
||||
;; ============================================================
|
||||
|
||||
(t/deftest test-switch-preserves-text-sub-touched-flags
|
||||
;; 1. Creates a component with text "hello world" + font-size "14", variant with font-size "20"
|
||||
;; 2. Overrides only text on the copy → verifies :text-content-text in touched
|
||||
;; 3. Switches to variant → verifies text override preserved, font-size updated, :text-content-text preserved
|
||||
;; 4. Updates main font-size to "30" and syncs → verifies font-size synced but text override preserved
|
||||
(let [;; ==== Setup
|
||||
file (-> (thf/sample-file :file1)
|
||||
;; c01 has text "hello world" font-size "14"
|
||||
;; c02 has text "hello world" font-size "20" (same text, different font-size)
|
||||
(thv/add-variant-with-text
|
||||
:v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world")
|
||||
(update-attr :t02 font-size-path-0 "20")
|
||||
(thc/instantiate-component :c01
|
||||
:copy01
|
||||
:children-labels [:copy-t01]))
|
||||
|
||||
;; Override only the TEXT on the copy (not font-size)
|
||||
file (update-attr file :copy-t01 text-path-0 "custom text")
|
||||
copy-t01 (ths/get-shape file :copy-t01)]
|
||||
|
||||
;; Verify the copy has the text override and correct touched flags
|
||||
(t/is (= (get-in copy-t01 text-path-0) "custom text"))
|
||||
(t/is (= (get-in copy-t01 font-size-path-0) "14"))
|
||||
(t/is (contains? (:touched copy-t01) :content-group))
|
||||
(t/is (contains? (:touched copy-t01) :text-content-text))
|
||||
(t/is (not (contains? (:touched copy-t01) :text-content-attribute)))
|
||||
(t/is (not (contains? (:touched copy-t01) :text-content-structure)))
|
||||
|
||||
;; ==== Action: Switch copy to c02 variant (same text, different font-size)
|
||||
(let [file' (tho/swap-component-in-shape file :copy01 :c02
|
||||
{:new-shape-label :copy02
|
||||
:keep-touched? true})
|
||||
page' (thf/current-page file')
|
||||
copy02' (ths/get-shape file' :copy02)
|
||||
copy-t02' (get-in page' [:objects (-> copy02' :shapes first)])]
|
||||
|
||||
;; After switch: text override preserved (same text between variants),
|
||||
;; font-size updated from variant, touched preserves text-content-text
|
||||
(t/is (= (get-in copy-t02' text-path-0) "custom text"))
|
||||
(t/is (= (get-in copy-t02' font-size-path-0) "20"))
|
||||
(t/is (contains? (:touched copy-t02') :content-group))
|
||||
(t/is (contains? (:touched copy-t02') :text-content-text))
|
||||
(t/is (not (contains? (:touched copy-t02') :text-content-attribute)))
|
||||
|
||||
;; ==== Now test subsequent component sync
|
||||
;; Modify the main component's font-size to "30" (keeping text "hello world")
|
||||
(let [main-text (ths/get-shape file' :t02)
|
||||
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page'))
|
||||
#{(:id main-text)}
|
||||
(fn [shape]
|
||||
(assoc-in shape font-size-path-0 "30"))
|
||||
(:objects page')
|
||||
{})
|
||||
updated-file (thf/apply-changes file' changes1)
|
||||
|
||||
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
|
||||
nil
|
||||
:components
|
||||
(:id updated-file)
|
||||
(thi/id :c02)
|
||||
(:id updated-file)
|
||||
{(:id updated-file) updated-file}
|
||||
(:id updated-file))
|
||||
|
||||
synced-file (thf/apply-changes updated-file changes2)
|
||||
synced-copy (ths/get-shape synced-file :copy02)
|
||||
synced-t (get-in (thf/current-page synced-file)
|
||||
[:objects (-> synced-copy :shapes first)])]
|
||||
|
||||
;; The text override is preserved and font-size is synced
|
||||
(t/is (= (get-in synced-t text-path-0) "custom text"))
|
||||
(t/is (= (get-in synced-t font-size-path-0) "30"))
|
||||
(t/is (contains? (:touched synced-t) :content-group))
|
||||
(t/is (contains? (:touched synced-t) :text-content-text))))))
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
location /mcp/ws {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_pass $PENPOT_MCP_URI_WS;
|
||||
set $mcp_ws_backend $PENPOT_MCP_URI_WS;
|
||||
proxy_pass $mcp_ws_backend;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/stream {
|
||||
proxy_pass $PENPOT_MCP_URI/mcp;
|
||||
set $mcp_stream_backend $PENPOT_MCP_URI/mcp$is_args$args;
|
||||
proxy_pass $mcp_stream_backend;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/sse {
|
||||
proxy_pass $PENPOT_MCP_URI/sse;
|
||||
set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args;
|
||||
proxy_pass $mcp_sse_backend;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BUG 10925 - Font family typography asset must not persist across files in
|
||||
// newly created text layers.
|
||||
//
|
||||
// `save-font` writes the current font (plus the typography refs of the edited
|
||||
// shape, when it uses one) into the session-global `:workspace-global
|
||||
// :default-font`. That state is what seeds the content of brand-new text
|
||||
// shapes via `v2-default-text-content`. Because it is session-global it
|
||||
// survives a file switch, so a text created in file B could end up referencing
|
||||
// a typography asset that only exists in file A (see workspace/texts.cljs
|
||||
// save-font and workspace.cljs initialize/finalize-workspace).
|
||||
//
|
||||
// This E2E reproduces the leak faithfully in a single SPA session:
|
||||
// 1. Open file A (has a text shape linked to a typography asset).
|
||||
// 2. Change a font attribute on that shape (triggers `emit-update!` ->
|
||||
// `save-font` with the current text-node attrs, typography refs included).
|
||||
// 3. Switch to file B (same session, fragment navigation keeps JS state).
|
||||
// 4. Create a brand-new text layer in file B.
|
||||
// 5. Assert the new text uses the DEFAULT Penpot font ("Source Sans Pro"),
|
||||
// not the typography font-family carried over from file A.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILE_A = {
|
||||
id: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef",
|
||||
pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0",
|
||||
// "Text with typography asset one" carries a ref to in-file typography whose
|
||||
// font-family is "IM Fell French Canon SC" (multiselection-typography.json).
|
||||
};
|
||||
|
||||
const FILE_B = {
|
||||
id: "434b0541-fa2f-802f-8006-59827d964a9b",
|
||||
pageId: "434b0541-fa2f-802f-8006-59827d964a9c",
|
||||
// render-wasm/get-file-text-custom-fonts.json - a mostly empty file whose
|
||||
// only text uses the default font (no typography asset).
|
||||
};
|
||||
|
||||
async function serveTwoFiles(page) {
|
||||
const fileABody = await readFile(
|
||||
"playwright/data/workspace/multiselection-typography.json",
|
||||
"utf-8",
|
||||
);
|
||||
const fileBBody = await readFile(
|
||||
"playwright/data/render-wasm/get-file-text-custom-fonts.json",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Dispatch on the `id` query param of the `get-file` RPC so each file gets
|
||||
// its own fixture while keeping a single SPA session alive.
|
||||
await page.route(/get\-file\?/, (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const fileId = url.searchParams.get("id");
|
||||
const body = fileId === FILE_A.id ? fileABody : fileBBody;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/transit+json",
|
||||
body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WasmWorkspacePage.init(page);
|
||||
// WASM_FLAGS already enables the v2 text editor / render-wasm. Add the WASM
|
||||
// text editor on top so typography styles are read through the current text
|
||||
// values path.
|
||||
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]);
|
||||
});
|
||||
|
||||
test("BUG 10925 - typography font does not leak into new text in a different file", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page, { textEditor: true });
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockRPC(
|
||||
"get-font-variants?team-id=*",
|
||||
"render-wasm/get-font-variants-custom-fonts.json",
|
||||
);
|
||||
|
||||
await serveTwoFiles(page);
|
||||
|
||||
// ---- File A: select the text linked to a typography and change a font ----
|
||||
await workspace.goToWorkspace({ fileId: FILE_A.id, pageId: FILE_A.pageId });
|
||||
await workspace.waitForFirstRender();
|
||||
await workspace.doubleClickLeafLayer("Text with typography asset one");
|
||||
await workspace.textEditor.startEditing();
|
||||
|
||||
// Changing a font attribute triggers save-font with the current text-node
|
||||
// attrs (including the typography refs) storing them into default-font.
|
||||
await workspace.textEditor.changeFontSize(24);
|
||||
await workspace.textEditor.stopEditing();
|
||||
|
||||
// ---- File B: same SPA session, switch to a file with no typography ----
|
||||
await workspace.goToWorkspace({ fileId: FILE_B.id, pageId: FILE_B.pageId });
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
// Create a brand-new text layer in file B and query its font-family.
|
||||
await workspace.createTextShape(100, 100, 300, 200, "hello");
|
||||
await workspace.textEditor.stopEditing();
|
||||
await workspace.clickLeafLayer("hello");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.page.keyboard.press("ControlOrMeta+a");
|
||||
|
||||
const fontFamily = workspace.rightSidebar.getByTitle("Font Family");
|
||||
await expect(fontFamily).toContainText("Source Sans Pro");
|
||||
// The custom typography family from file A (IM Fell French Canon SC) must NOT
|
||||
// be carried over.
|
||||
await expect(fontFamily).not.toContainText("IM Fell");
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
(ns app.main.data.nitrate
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.organization :as cto]
|
||||
[app.common.uri :as u]
|
||||
@ -351,6 +352,24 @@
|
||||
(rx/empty)))))))))))
|
||||
|
||||
|
||||
(defn retry-organization-sso
|
||||
"Retries the organization SSO login flow after a failed attempt, reusing
|
||||
the same check-nitrate-sso RPC used elsewhere to move the user through
|
||||
the organization's identity provider. Passing `team-id` enables the
|
||||
backend's non-member short-circuit. Falls back to navigating straight
|
||||
to `dest-url` when no fresh SSO redirect is needed or available."
|
||||
[{:keys [team-id organization-id dest-url]}]
|
||||
(ptk/reify ::retry-organization-sso
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id
|
||||
:organization-id organization-id
|
||||
:url dest-url}))
|
||||
(rx/map (fn [{:keys [redirect-uri]}]
|
||||
(rt/nav-raw :uri (or redirect-uri dest-url))))
|
||||
(rx/catch (fn [_]
|
||||
(rx/of (rt/nav-raw :uri dest-url))))))))
|
||||
|
||||
(defn- fetch-organizations-allowed
|
||||
"Returns an rx observable of an `organizations-allowed` map (organization-id -> boolean).
|
||||
Organizations where :add-anybody-to-team is permitted are pre-approved;
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
:uri plugin-url
|
||||
:omit-default-headers true
|
||||
:response-type :json})
|
||||
(rx/timeout 15000)
|
||||
(rx/map :body)
|
||||
(rx/map #(preg/parse-manifest plugin-url %))))
|
||||
|
||||
|
||||
@ -346,7 +346,8 @@
|
||||
(assoc :recent-colors (:recent-colors storage/user))
|
||||
(assoc :recent-fonts (:recent-fonts storage/user))
|
||||
(assoc :current-file-id file-id)
|
||||
(assoc :workspace-presence {})))
|
||||
(assoc :workspace-presence {})
|
||||
(update :workspace-global dissoc :default-font)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
@ -544,7 +545,7 @@
|
||||
:workspace-tokens
|
||||
:workspace-undo
|
||||
:workspace-versions)
|
||||
(update :workspace-global dissoc :read-only?)
|
||||
(update :workspace-global dissoc :read-only? :default-font)
|
||||
(assoc-in [:workspace-global :options-mode] :design)
|
||||
(update :files d/update-vals #(dissoc % :data))))
|
||||
|
||||
|
||||
@ -831,7 +831,8 @@
|
||||
(let [multiple? (->> data vals (d/seek #(= % :multiple)))]
|
||||
(cond-> state
|
||||
(not multiple?)
|
||||
(assoc-in [:workspace-global :default-font] data))))))
|
||||
(assoc-in [:workspace-global :default-font]
|
||||
(dissoc data :typography-ref-id :typography-ref-file)))))))
|
||||
|
||||
(defn apply-text-modifier
|
||||
[shape text-modifier]
|
||||
|
||||
@ -1063,6 +1063,14 @@
|
||||
(fn [content]
|
||||
(st/emit! (dcm/add-comment thread content))))
|
||||
|
||||
on-key-down
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(when (kbd/esc? event)
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(st/emit! (dcm/close-thread)))))
|
||||
|
||||
on-cancel
|
||||
(mf/use-fn #(st/emit! (dcm/close-thread)))]
|
||||
|
||||
@ -1086,6 +1094,7 @@
|
||||
:style {:left (str pos-x "px")
|
||||
:top (str pos-y "px")
|
||||
"--comment-height" (str max-height "px")}
|
||||
:on-key-down on-key-down
|
||||
:on-click dom/stop-propagation}
|
||||
|
||||
[:div {:class (stl/css :floating-thread-header)}
|
||||
|
||||
@ -211,7 +211,7 @@
|
||||
|
||||
(mf/with-layout-effect
|
||||
[plugin-url team-id project-id]
|
||||
(when plugin-url
|
||||
(when (and plugin-url project-id)
|
||||
(->> (dp/fetch-manifest plugin-url)
|
||||
(rx/subs!
|
||||
(fn [plugin]
|
||||
|
||||
@ -1392,13 +1392,7 @@
|
||||
on-sub-menu-pointer-enter
|
||||
(mf/use-fn
|
||||
(fn [_]
|
||||
(mf/set-ref-val! hovering?* true)))
|
||||
|
||||
on-power-up-click
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"}))
|
||||
(dom/open-new-window "https://penpot.app/pricing")))]
|
||||
(mf/set-ref-val! hovering?* true)))]
|
||||
|
||||
(mf/with-effect [teams]
|
||||
(when (and (contains? cf/flags :admin-console)
|
||||
@ -1420,17 +1414,6 @@
|
||||
[:> subscription-sidebar* {:profile profile}])))
|
||||
|
||||
|
||||
;; TODO remove this block when subscriptions is full implemented
|
||||
(when (contains? cf/flags :subscriptions-old)
|
||||
[:button {:class (stl/css :upgrade-plan-section)
|
||||
:on-click on-power-up-click}
|
||||
[:div {:class (stl/css :penpot-free)}
|
||||
[:span (tr "dashboard.upgrade-plan.penpot-free")]
|
||||
[:span {:class (stl/css :no-limits)}
|
||||
(tr "dashboard.upgrade-plan.no-limits")]]
|
||||
[:div {:class (stl/css :power-up)}
|
||||
(tr "subscription.dashboard.upgrade-plan.power-up")]])
|
||||
|
||||
(when (and team profile)
|
||||
[:& comments-section
|
||||
{:profile profile
|
||||
|
||||
@ -123,6 +123,29 @@
|
||||
(errors/on-error cause))))
|
||||
(st/emit! (rt/navigated match send-event-info?)))))
|
||||
|
||||
(defn- handle-sso-error-and-navigate
|
||||
"Check if the current route has an SSO error marker. If so, assign an
|
||||
exception with type :sso-error and organization-id from query params,
|
||||
and deliberately do NOT proceed with normal navigation: emitting
|
||||
`rt/navigated` would clear the exception that was just assigned.
|
||||
Otherwise, delegate to `check-sso-and-navigate`."
|
||||
[match send-event-info? url]
|
||||
(let [route-name (name (get-in match [:data :name]))
|
||||
sso-error? (some? (get-in match [:query-params :sso-error]))
|
||||
organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*)
|
||||
team-id-str (or (get-in match [:query-params :team-id])
|
||||
(get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes
|
||||
team-id (some-> team-id-str uuid/parse*)
|
||||
is-workspace? (str/starts-with? route-name "workspace")
|
||||
is-dashboard? (str/starts-with? route-name "dashboard")]
|
||||
(if sso-error?
|
||||
(st/emit! (rt/assign-exception {:type :sso-error
|
||||
:organization-id organization-id
|
||||
:team-id team-id
|
||||
:is-workspace is-workspace?
|
||||
:is-dashboard is-dashboard?}))
|
||||
(check-sso-and-navigate match send-event-info? url))))
|
||||
|
||||
(defn on-navigate
|
||||
[router path send-event-info?]
|
||||
(let [location (.-location js/document)
|
||||
@ -138,7 +161,7 @@
|
||||
(st/emit! (rt/assign-exception {:type :not-found}))
|
||||
|
||||
(some? match)
|
||||
(check-sso-and-navigate match send-event-info? (rt/get-current-href))
|
||||
(handle-sso-error-and-navigate match send-event-info? (rt/get-current-href))
|
||||
|
||||
:else
|
||||
;; We just recheck with an additional profile request; this
|
||||
|
||||
@ -117,7 +117,8 @@
|
||||
:data-testid "settings-profile"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.settings")]]
|
||||
|
||||
(when (contains? cf/flags :subscriptions)
|
||||
(when (or (contains? cf/flags :subscriptions)
|
||||
(contains? cf/flags :admin-console))
|
||||
[:li {:class (stl/css-case :current subscription?
|
||||
:settings-item true)
|
||||
:on-click go-settings-subscription
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.auth :refer [is-authenticated?]]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.nitrate :as dnt]
|
||||
[app.main.errors :as errors]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.repo :as rp]
|
||||
@ -433,43 +434,6 @@
|
||||
(rx/of default)
|
||||
(rx/throw cause)))))))
|
||||
|
||||
(mf/defc exception-section*
|
||||
{::mf/private true}
|
||||
[{:keys [data] :as props}]
|
||||
(let [type (get data :type)
|
||||
cause (get data ::errors/instance)
|
||||
|
||||
report (mf/with-memo [cause]
|
||||
(when (ex/exception? cause)
|
||||
(errors/generate-report cause)))
|
||||
|
||||
props (mf/spread-props props {:report report})]
|
||||
|
||||
(mf/with-effect [report type cause]
|
||||
(when (and (ex/exception? cause)
|
||||
(not (contains? #{:not-found :authentication} type)))
|
||||
(errors/submit-report :event-name "exception-page"
|
||||
:report report
|
||||
:hint (ex/get-hint cause))))
|
||||
|
||||
(case type
|
||||
:not-found
|
||||
[:> not-found* {}]
|
||||
|
||||
:authentication
|
||||
[:> not-found* {}]
|
||||
|
||||
:bad-gateway
|
||||
[:> bad-gateway* props]
|
||||
|
||||
:service-unavailable
|
||||
[:> service-unavailable*]
|
||||
|
||||
:nitrate-unavailable
|
||||
[:> nitrate-unavailable*]
|
||||
|
||||
[:> internal-error* props])))
|
||||
|
||||
(mf/defc context-wrapper*
|
||||
[{:keys [is-workspace is-dashboard is-viewer profile children]}]
|
||||
[:*
|
||||
@ -515,6 +479,99 @@
|
||||
|
||||
children])
|
||||
|
||||
(mf/defc sso-error-section*
|
||||
"Shown in place of the dashboard/workspace (same static skeleton and
|
||||
`request-dialog*` used by the no-permission dialogs) when the organization
|
||||
SSO exchange with the identity provider fails."
|
||||
{::mf/private true}
|
||||
[{:keys [organization-id team-id profile is-workspace is-dashboard]}]
|
||||
(let [clean-url
|
||||
(mf/with-memo []
|
||||
(-> (rt/get-current-href)
|
||||
(dom/remove-query-param :sso-error)
|
||||
(dom/remove-query-param :organization-id)))
|
||||
|
||||
_ (mf/with-effect []
|
||||
;; Consume the marker once: scrub it from the URL bar so a
|
||||
;; browser refresh doesn't keep re-showing this dialog.
|
||||
(dom/replace-history-state! clean-url))
|
||||
|
||||
on-close
|
||||
(mf/use-fn
|
||||
(mf/deps profile)
|
||||
(fn []
|
||||
;; Land on the user's own default team
|
||||
(st/emit! (rt/assign-exception nil)
|
||||
(dcm/go-to-dashboard-recent :team-id (:default-team-id profile)))))
|
||||
|
||||
on-retry
|
||||
(mf/use-fn
|
||||
(mf/deps organization-id team-id clean-url)
|
||||
(fn []
|
||||
(st/emit! (rt/assign-exception nil))
|
||||
(if (or team-id organization-id)
|
||||
;; Retry with team-id and/or organization-id to trigger SSO check
|
||||
(st/emit! (dnt/retry-organization-sso {:team-id team-id
|
||||
:organization-id organization-id
|
||||
:dest-url clean-url}))
|
||||
;; Fallback: just navigate to clean URL
|
||||
(st/emit! (rt/nav-raw :uri clean-url)))))]
|
||||
|
||||
[:> context-wrapper* {:is-dashboard (or is-dashboard (not is-workspace))
|
||||
:is-workspace is-workspace
|
||||
:profile profile}
|
||||
[:> request-dialog* {:title (tr "labels.sso-error.title")
|
||||
:content [(tr "labels.sso-error.desc-message")]
|
||||
:button-text (tr "labels.sso-error.retry")
|
||||
:on-button-click on-retry
|
||||
:cancel-text (tr "not-found.no-permission.go-dashboard")
|
||||
:on-close on-close}]]))
|
||||
|
||||
(mf/defc exception-section*
|
||||
{::mf/private true}
|
||||
[{:keys [data] :as props}]
|
||||
(let [type (get data :type)
|
||||
cause (get data ::errors/instance)
|
||||
organization-id (get data :organization-id)
|
||||
|
||||
report (mf/with-memo [cause]
|
||||
(when (ex/exception? cause)
|
||||
(errors/generate-report cause)))
|
||||
|
||||
props (mf/spread-props props {:report report})]
|
||||
|
||||
(mf/with-effect [report type cause]
|
||||
(when (and (ex/exception? cause)
|
||||
(not (contains? #{:not-found :authentication} type)))
|
||||
(errors/submit-report :event-name "exception-page"
|
||||
:report report
|
||||
:hint (ex/get-hint cause))))
|
||||
|
||||
(case type
|
||||
:not-found
|
||||
[:> not-found* {}]
|
||||
|
||||
:authentication
|
||||
[:> not-found* {}]
|
||||
|
||||
:bad-gateway
|
||||
[:> bad-gateway* props]
|
||||
|
||||
:service-unavailable
|
||||
[:> service-unavailable*]
|
||||
|
||||
:nitrate-unavailable
|
||||
[:> nitrate-unavailable*]
|
||||
|
||||
:sso-error
|
||||
[:> sso-error-section* {:organization-id organization-id
|
||||
:team-id (get data :team-id)
|
||||
:profile (mf/deref refs/profile)
|
||||
:is-workspace (get data :is-workspace false)
|
||||
:is-dashboard (get data :is-dashboard true)}]
|
||||
|
||||
[:> internal-error* props])))
|
||||
|
||||
(mf/defc exception-page*
|
||||
[{:keys [data route] :as props}]
|
||||
|
||||
|
||||
@ -69,6 +69,7 @@
|
||||
// SVG inside deco-before — no class available on the raw element
|
||||
.deco-before svg {
|
||||
position: absolute;
|
||||
fill: var(--color-foreground-secondary);
|
||||
block-size: 1537px;
|
||||
inline-size: px2rem(80);
|
||||
inset-block-end: 0;
|
||||
@ -76,6 +77,7 @@
|
||||
|
||||
// SVG inside deco-after2 — no class available on the raw element
|
||||
.deco-after2 svg {
|
||||
fill: var(--color-foreground-secondary);
|
||||
block-size: 1537px;
|
||||
inline-size: px2rem(80);
|
||||
}
|
||||
|
||||
@ -927,13 +927,6 @@
|
||||
(keyword))]
|
||||
(reset! selected-sub-menu* menu))))
|
||||
|
||||
on-power-up-click
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (ev/event {::ev/name "explore-pricing-click"
|
||||
::ev/origin "workspace:menu"}))
|
||||
(dom/open-new-window "https://penpot.app/pricing")))
|
||||
|
||||
toggle-flag
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
@ -1130,21 +1123,10 @@
|
||||
[:> icon* {:icon-id i/arrow-right
|
||||
:class (stl/css :item-arrow)}]]
|
||||
|
||||
(when (and (contains? cf/flags :subscriptions)
|
||||
(not= "enterprise" subscription-type))
|
||||
[:> main-menu-power-up* {:close-sub-menu close-sub-menu}])
|
||||
|
||||
;; TODO remove this block when subscriptions is full implemented
|
||||
(when (contains? cf/flags :subscriptions-old)
|
||||
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item)
|
||||
:on-click on-power-up-click
|
||||
:on-key-down (fn [event]
|
||||
(when (kbd/enter? event)
|
||||
(on-power-up-click)))
|
||||
:on-pointer-enter close-sub-menu
|
||||
:id "file-menu-power-up"}
|
||||
[:span {:class (stl/css :item-name)}
|
||||
(tr "subscription.workspace.header.menu.option.power-up")]])]
|
||||
(when (or (and (contains? cf/flags :subscriptions)
|
||||
(not= "enterprise" subscription-type))
|
||||
(contains? cf/flags :admin-console))
|
||||
[:> main-menu-power-up* {:close-sub-menu close-sub-menu}])]
|
||||
|
||||
(case selected-sub-menu
|
||||
:file
|
||||
|
||||
@ -363,7 +363,8 @@
|
||||
:page-id page-id
|
||||
:file-id file-id
|
||||
:vport vport
|
||||
:zoom zoom}])
|
||||
:zoom zoom
|
||||
:show-rulers show-rulers?}])
|
||||
|
||||
(when picking-color?
|
||||
[:> pixel-overlay/pixel-overlay* {:vport vport
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.comments :as cmt]
|
||||
[app.main.ui.workspace.viewport.rulers :as rulers]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; Pin transform for the bubble's frame so it follows the frame during a drag,
|
||||
@ -70,7 +71,7 @@
|
||||
|
||||
(mf/defc comments-layer*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [vbox vport zoom file-id page-id]}]
|
||||
[{:keys [vbox vport zoom file-id page-id show-rulers]}]
|
||||
(let [vbox-x (dm/get-prop vbox :x)
|
||||
vbox-y (dm/get-prop vbox :y)
|
||||
vport-w (dm/get-prop vport :width)
|
||||
@ -114,7 +115,15 @@
|
||||
{:id "comments"
|
||||
:class (stl/css :workspace-comments-container)
|
||||
:style {:width (dm/str vport-w "px")
|
||||
:height (dm/str vport-h "px")}}
|
||||
:height (dm/str vport-h "px")
|
||||
;; This layer sits above the canvas, so without clipping the
|
||||
;; bubbles paint over the rulers as they pan past them. Keep
|
||||
;; them out of the ruler bars, like `clip-handlers` does for
|
||||
;; the selection handlers.
|
||||
:clip-path (when show-rulers
|
||||
(dm/fmt "inset(%px 0 0 %px)"
|
||||
rulers/ruler-area-size
|
||||
rulers/ruler-area-size))}}
|
||||
[:div {:class (stl/css :threads)
|
||||
:style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}}
|
||||
|
||||
|
||||
@ -666,7 +666,8 @@
|
||||
:page-id page-id
|
||||
:file-id file-id
|
||||
:vport vport
|
||||
:zoom zoom}])
|
||||
:zoom zoom
|
||||
:show-rulers show-rulers?}])
|
||||
|
||||
(when picking-color?
|
||||
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
|
||||
|
||||
@ -875,35 +875,17 @@
|
||||
[url]
|
||||
(.replaceState (.-history globals/window) nil "" url))
|
||||
|
||||
(defn- update-query-params
|
||||
"Apply `f` to the query-params map of `url`, returning the updated URL string.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url f]
|
||||
(let [transform (fn [parsed]
|
||||
(update parsed :query
|
||||
(fn [q]
|
||||
(-> (u/query-string->map (or q ""))
|
||||
f
|
||||
u/map->query-string))))
|
||||
parsed (u/uri url)
|
||||
fragment (:fragment parsed)]
|
||||
(if (str/blank? fragment)
|
||||
(str (transform parsed))
|
||||
(-> parsed
|
||||
(assoc :fragment (str (transform (u/parse fragment))))
|
||||
str))))
|
||||
|
||||
(defn append-query-param
|
||||
"Return a new URL string with the given query parameter added or replaced.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url key value]
|
||||
(update-query-params url #(assoc % key value)))
|
||||
(u/append-query-param url key value))
|
||||
|
||||
(defn remove-query-param
|
||||
"Return a new URL string with the given query parameter removed.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url key]
|
||||
(update-query-params url #(dissoc % key)))
|
||||
(u/remove-query-param url key))
|
||||
|
||||
(defn reload-current-window
|
||||
([]
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
|
||||
[cljs.test :as t :include-macros true]
|
||||
@ -377,6 +378,62 @@
|
||||
(t/is (= "0.1" (:letter-spacing (first typographies)))
|
||||
"float letter-spacing is normalised to 2-decimal string")))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tests: save-font must not persist typography refs into the global default font
|
||||
;;
|
||||
;; Root cause of #10925: typography assets are file-specific references, but
|
||||
;; save-font used to write :typography-ref-id / :typography-ref-file into the
|
||||
;; session-global [:workspace-global :default-font]. That state survives a file
|
||||
;; switch, and v2-default-text-content bakes it into brand-new text shapes in
|
||||
;; the other file, so they got a non-existent typography asset instead of the
|
||||
;; default Penpot font. save-font now strips those two keys.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest save-font-strips-typography-refs-from-default-font
|
||||
(t/async
|
||||
done
|
||||
(let [file (-> (cthf/sample-file :file1)
|
||||
(cths/add-sample-shape :text1
|
||||
:type :text
|
||||
:x 0 :y 0
|
||||
:content (txt/change-text nil "hello")))
|
||||
store (ths/setup-store file)
|
||||
attrs {:font-id "roboto"
|
||||
:font-family "Roboto"
|
||||
:font-variant-id "regular"
|
||||
:font-size "14"
|
||||
:typography-ref-id (uuid/next)
|
||||
:typography-ref-file (:id file)}]
|
||||
(ths/run-store
|
||||
store done [(dwt/save-font attrs)]
|
||||
(fn [new-state]
|
||||
(let [default-font (get-in new-state [:workspace-global :default-font])]
|
||||
(t/is (some? default-font))
|
||||
(t/is (= "roboto" (:font-id default-font)))
|
||||
(t/is (nil? (:typography-ref-id default-font)))
|
||||
(t/is (nil? (:typography-ref-file default-font)))))))))
|
||||
|
||||
(t/deftest save-font-preserves-other-font-attrs
|
||||
(t/async
|
||||
done
|
||||
(let [store (ths/setup-store (cthf/sample-file :file1))
|
||||
attrs {:font-family "Open Sans"
|
||||
:font-id "opensans"
|
||||
:font-variant-id "regular"
|
||||
:font-size "18"
|
||||
:line-height "1.5"
|
||||
:letter-spacing "0"
|
||||
:typography-ref-id (uuid/next)
|
||||
:typography-ref-file (uuid/next)}]
|
||||
(ths/run-store store done [(dwt/save-font attrs)]
|
||||
(fn [new-state]
|
||||
(let [default-font (get-in new-state [:workspace-global :default-font])]
|
||||
(t/is (= "Open Sans" (:font-family default-font)))
|
||||
(t/is (= "18" (:font-size default-font)))
|
||||
(t/is (= "1.5" (:line-height default-font)))
|
||||
(t/is (nil? (:typography-ref-id default-font)))
|
||||
(t/is (nil? (:typography-ref-file default-font)))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tests: fix-position with degenerate selrect
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@ -10339,3 +10339,12 @@ msgstr "Click to close the path"
|
||||
|
||||
msgid "notifications.invitation-canceled"
|
||||
msgstr "This invitation is no longer available."
|
||||
|
||||
msgid "labels.sso-error.title"
|
||||
msgstr "We couldn't sign you in to your organization"
|
||||
|
||||
msgid "labels.sso-error.desc-message"
|
||||
msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet. Your Penpot account isn't affected."
|
||||
|
||||
msgid "labels.sso-error.retry"
|
||||
msgstr "Try again"
|
||||
|
||||
@ -9989,3 +9989,12 @@ msgstr "Pulsar para cerrar la ruta"
|
||||
|
||||
msgid "notifications.invitation-canceled"
|
||||
msgstr "Esta invitación ya no está disponible."
|
||||
|
||||
msgid "labels.sso-error.title"
|
||||
msgstr "No pudimos iniciar sesión en tu organización"
|
||||
|
||||
msgid "labels.sso-error.desc-message"
|
||||
msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio. Tu cuenta de Penpot no se ha visto afectada."
|
||||
|
||||
msgid "labels.sso-error.retry"
|
||||
msgstr "Intentar de nuevo"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user