Merge pull request #6871 from penpot/niwinz-develop-login-enhancements

 Allow login dialog on settings
This commit is contained in:
Andrey Antukh 2025-07-21 15:19:06 +02:00 committed by GitHub
commit 42ef01b339
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 249 additions and 194 deletions

View File

@ -26,6 +26,7 @@
- Improved text layer resizing: Allow double-click on text bounding box to set auto-width/auto-height [Taiga #11577](https://tree.taiga.io/project/penpot/issue/11577)
- Improve text layer auto-resize: auto-width switches to auto-height on horizontal resize, and only switches to fixed on vertical resize [Taiga #11578](https://tree.taiga.io/project/penpot/issue/11578)
- Highlight first font in font selector search. Apply only on Enter or click. [Taiga #11579](https://tree.taiga.io/project/penpot/issue/11579)
- Add the ability to show login dialog on profile settings [Github #6871](https://github.com/penpot/penpot/pull/6871)
### :bug: Bugs fixed

View File

@ -102,23 +102,27 @@
(print-trace! error)
(print-data! error))))
;; We receive a explicit authentication error;
;; If the uri is for workspace, dashboard or view assign the
;; exception for the 'Oops' page. Otherwise this explicitly clears
;; all profile data and redirect the user to the login page. This is
;; here and not in app.main.errors because of circular dependency.
;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception
;; for show the error page. Otherwise this explicitly clears all
;; profile data and redirect the user to the login page. This is here
;; and not in app.main.errors because of circular dependency.
(defmethod ptk/handle-error :authentication
[e]
(let [msg (tr "errors.auth.unable-to-login")
uri (.-href glob/location)
show-oops? (or (str/includes? uri "workspace")
(str/includes? uri "dashboard")
(str/includes? uri "view"))]
(if show-oops?
(st/async-emit! (rt/assign-exception e))
[error]
(let [message (tr "errors.auth.unable-to-login")
uri (rt/get-current-href)
show-error?
(or (str/includes? uri "workspace")
(str/includes? uri "dashboard")
(str/includes? uri "view")
(str/includes? uri "settings"))]
(if show-error?
(st/async-emit! (rt/assign-exception error))
(do
(st/emit! (da/logout))
(ts/schedule 500 #(st/emit! (ntf/warn msg)))))))
(ts/schedule 500 #(st/emit! (ntf/warn message)))))))
;; Error that happens on an active business model validation does not
;; passes an validation (example: profile can't leave a team). From

View File

@ -11,6 +11,7 @@
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.team :as dtm]
[app.main.errors :as errors]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
@ -130,7 +131,10 @@
(assoc query-params :team-id (:default-team-id profile))))))
:else
(st/emit! (rt/assign-exception {:type :not-found})))))))))
(st/emit! (rt/assign-exception {:type :not-found}))))
(fn [cause]
(errors/on-error cause)))))))
(defn init-routes
[]

View File

@ -42,7 +42,7 @@
(mf/with-effect [profile]
(when (nil? profile)
(st/emit! (rt/nav :auth-login))))
(st/emit! (rt/assign-exception {:type :authentication}))))
[:*
[:> modal-container*]

View File

@ -3,6 +3,7 @@
(:require
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.main.data.auth :as da]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.profile :as du]
@ -96,7 +97,7 @@
handle-accept-dialog (mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "open-subscription-management"
::ev/origin "profile"
::ev/origin "settings"
:section "subscription-management-modal"}))
(let [current-href (rt/get-current-href)
returnUrl (js/encodeURIComponent current-href)
@ -212,43 +213,69 @@
(mf/defc subscription-page*
[{:keys [profile]}]
(let [route (mf/deref refs/route)
params (:params route)
params-subscription (:subscription (:query params))
show-trial-subscription-modal (or (= params-subscription "subscription-to-penpot-unlimited")
(= params-subscription "subscription-to-penpot-enterprise"))
show-subscription-success-modal (or (= params-subscription "subscribed-to-penpot-unlimited")
(= params-subscription "subscribed-to-penpot-enterprise"))
subscription (:subscription (:props profile))
subscription-type (get-subscription-type subscription)
subscription-is-trial (= (:status subscription) "trialing")
teams* (mf/use-state nil)
teams (deref teams*)
locale (mf/deref i18n/locale)
penpot-member (dt/format-date-locale-short (:created-at profile) {:locale locale})
subscription-member (dt/format-date-locale-short (:start-date subscription) {:locale locale})
go-to-pricing-page (mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "settings" :section "subscription"}))
(dom/open-new-window "https://penpot.app/pricing")))
go-to-payments (mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "open-subscription-management"
::ev/origin "profile"
:section "subscription"}))
(let [current-href (rt/get-current-href)
returnUrl (js/encodeURIComponent current-href)
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
(st/emit! (rt/nav-raw :href href)))))
open-subscription-modal (mf/use-fn
(mf/deps teams)
(fn [subscription-type]
(st/emit! (ptk/event ::ev/event {::ev/name "open-subscription-modal"
::ev/origin "settings:in-app"}))
(st/emit!
(modal/show :management-dialog
{:subscription-type subscription-type
:teams teams :subscribe-to-trial (not subscription)}))))]
(let [route (mf/deref refs/route)
authenticated? (da/is-authenticated? profile)
teams* (mf/use-state nil)
teams (deref teams*)
locale (mf/deref i18n/locale)
params-subscription
(-> route :params :query :subscription)
show-trial-subscription-modal?
(or (= params-subscription "subscription-to-penpot-unlimited")
(= params-subscription "subscription-to-penpot-enterprise"))
show-subscription-success-modal?
(or (= params-subscription "subscribed-to-penpot-unlimited")
(= params-subscription "subscribed-to-penpot-enterprise"))
subscription
(-> profile :props :subscription)
subscription-type
(get-subscription-type subscription)
subscription-is-trial?
(= (:status subscription) "trialing")
member-since
(dt/format-date-locale-short (:created-at profile) {:locale locale})
subscribed-since
(dt/format-date-locale-short (:start-date subscription) {:locale locale})
go-to-pricing-page
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "explore-pricing-click"
::ev/origin "settings"
:section "subscription"}))
(dom/open-new-window "https://penpot.app/pricing")))
go-to-payments
(mf/use-fn
(fn []
(st/emit! (ev/event {::ev/name "open-subscription-management"
::ev/origin "settings"
:section "subscription"}))
(let [current-href (rt/get-current-href)
returnUrl (js/encodeURIComponent current-href)
href (dm/str "payments/subscriptions/show?returnUrl=" returnUrl)]
(st/emit! (rt/nav-raw :href href)))))
open-subscription-modal
(mf/use-fn
(mf/deps teams)
(fn [subscription-type]
(st/emit! (ev/event {::ev/name "open-subscription-modal"
::ev/origin "settings:in-app"}))
(st/emit!
(modal/show :management-dialog
{:subscription-type subscription-type
:teams teams :subscribe-to-trial (not subscription)}))))]
(mf/with-effect []
(->> (rp/cmd! :get-owned-teams)
@ -258,33 +285,35 @@
(mf/with-effect []
(dom/set-html-title (tr "subscription.labels")))
(mf/with-effect [show-trial-subscription-modal subscription]
(when show-trial-subscription-modal
(st/emit!
(ptk/event ::ev/event {::ev/name "open-subscription-modal"
::ev/origin "settings:from-pricing-page"})
(modal/show :management-dialog
{:subscription-type (if (= params-subscription "subscription-to-penpot-unlimited")
"unlimited"
"enterprise")
:teams teams
:subscribe-to-trial (not subscription)})
(rt/nav :settings-subscription {} {::rt/replace true}))))
(mf/with-effect [authenticated? show-subscription-success-modal? show-trial-subscription-modal? subscription]
(when ^boolean authenticated?
(cond
^boolean show-trial-subscription-modal?
(mf/with-effect [show-subscription-success-modal subscription]
(when show-subscription-success-modal
(st/emit!
(modal/show :subscription-success
{:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited")
(tr "subscription.settings.unlimited-trial")
(tr "subscription.settings.enterprise-trial"))})
(du/update-profile-props {:subscription
(-> subscription
(assoc :type (if (= params-subscription "subscribed-to-penpot-unlimited")
"unlimited"
"enterprise"))
(assoc :status "trialing"))})
(rt/nav :settings-subscription {} {::rt/replace true}))))
(st/emit!
(ptk/event ::ev/event {::ev/name "open-subscription-modal"
::ev/origin "settings:from-pricing-page"})
(modal/show :management-dialog
{:subscription-type (if (= params-subscription "subscription-to-penpot-unlimited")
"unlimited"
"enterprise")
:teams teams
:subscribe-to-trial (not subscription)})
(rt/nav :settings-subscription {} {::rt/replace true}))
^boolean show-subscription-success-modal?
(st/emit!
(modal/show :subscription-success
{:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited")
(tr "subscription.settings.unlimited-trial")
(tr "subscription.settings.enterprise-trial"))})
(du/update-profile-props {:subscription
(-> subscription
(assoc :type (if (= params-subscription "subscribed-to-penpot-unlimited")
"unlimited"
"enterprise"))
(assoc :status "trialing"))})
(rt/nav :settings-subscription {} {::rt/replace true})))))
[:section {:class (stl/css :dashboard-section)}
[:div {:class (stl/css :dashboard-content)}
@ -301,7 +330,7 @@
(tr "subscription.settings.professional.storage")]}]
"unlimited"
(if subscription-is-trial
(if subscription-is-trial?
[:> plan-card* {:card-title (tr "subscription.settings.unlimited-trial")
:card-title-icon i/character-u
:benefits-title (tr "subscription.settings.benefits.all-professional-benefits")
@ -325,7 +354,7 @@
:editors (-> profile :props :subscription :quantity)}])
"enterprise"
(if subscription-is-trial
(if subscription-is-trial?
[:> plan-card* {:card-title (tr "subscription.settings.enterprise-trial")
:card-title-icon i/character-e
:benefits-title (tr "subscription.settings.benefits.all-professional-benefits")
@ -344,13 +373,16 @@
:cta-link go-to-payments}]))
[:div {:class (stl/css :membership-container)}
(when subscription-member [:div {:class (stl/css :membership)}
[:span {:class (stl/css :subscription-member)} i/crown]
[:span {:class (stl/css :membership-date)} (tr "subscription.settings.support-us-since" subscription-member)]])
(when subscribed-since
[:div {:class (stl/css :membership)}
[:span {:class (stl/css :subscription-member)} i/crown]
[:span {:class (stl/css :membership-date)}
(tr "subscription.settings.support-us-since" subscribed-since)]])
[:div {:class (stl/css :membership)}
[:span {:class (stl/css :penpot-member)} i/user]
[:span {:class (stl/css :membership-date)} (tr "subscription.settings.member-since" penpot-member)]]]]
[:span {:class (stl/css :membership-date)}
(tr "subscription.settings.member-since" member-since)]]]]
[:div {:class (stl/css :other-subscriptions)}
[:h3 {:class (stl/css :plan-section-title)} (tr "subscription.settings.other-plans")]

View File

@ -69,9 +69,8 @@
[:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")]
[:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]])
(mf/defc login-dialog
{::mf/props :obj}
[{:keys [show-dialog]}]
(mf/defc login-dialog*
[]
(let [current-section (mf/use-state :login)
user-email (mf/use-state "")
register-token (mf/use-state "")
@ -94,9 +93,7 @@
success-login
(mf/use-fn
(fn []
(reset! show-dialog false)
(st/emit! (rt/reload true))))
#(st/emit! (rt/reload true)))
success-register
(mf/use-fn
@ -117,7 +114,7 @@
(reset! current-section :recovery-email-sent)))
on-nav-root
(mf/use-fn #(st/emit! (rt/nav-root)))]
(mf/use-fn #(st/emit! (rt/nav :auth-login {})))]
[:div {:class (stl/css :overlay)}
[:div {:class (stl/css :dialog-login)}
@ -203,11 +200,9 @@
[:button {:on-click on-click} button-text]]]]))
(mf/defc request-access*
[{:keys [file-id team-id is-default is-workspace]}]
(let [profile (mf/deref refs/profile)
requested* (mf/use-state {:sent false :already-requested false})
[{:keys [file-id team-id is-default is-workspace profile]}]
(let [requested* (mf/use-state {:sent false :already-requested false})
requested (deref requested*)
show-dialog (mf/use-state true)
on-close
(mf/use-fn
@ -237,90 +232,47 @@
(st/emit! (dcm/create-team-access-request
(with-meta params mdata))))))]
[:*
(if (some? file-id)
(if is-workspace
[:div {:class (stl/css :workspace)}
[:div {:class (stl/css :workspace-left)}
i/logo-icon
[:div
[:div {:class (stl/css :project-name)} (tr "not-found.no-permission.project-name")]
[:div {:class (stl/css :file-name)} (tr "not-found.no-permission.penpot-file")]]]
[:div {:class (stl/css :workspace-right)}]]
(cond
is-default
[:& request-dialog {:title (tr "not-found.no-permission.project")
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
[:div {:class (stl/css :viewer)}
;; FIXME: the viewer header was never designed to be reused
;; from other parts of the application, and this code looks
;; like a fast workaround reusing it as-is without a proper
;; component adaptation for be able to use it easily it on
;; viewer context or static error page context
[:& viewer.header/header {:project
{:name (tr "not-found.no-permission.project-name")}
:index 0
:file {:name (tr "not-found.no-permission.penpot-file")}
:page nil
:frame nil
:permissions {:is-logged true}
:zoom 1
:section :interactions
:shown-thumbnails false
:interactions-mode nil}]])
(and (some? file-id) (:already-requested requested))
[:& request-dialog {:title (tr "not-found.no-permission.already-requested.file")
:content [(tr "not-found.no-permission.already-requested.or-others.file")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
[:div {:class (stl/css :dashboard)}
[:div {:class (stl/css :dashboard-sidebar)}
[:> sidebar*
{:team nil
:projects []
:project (:default-project-id profile)
:profile profile
:section :dashboard-projects
:search-term ""}]]])
(:already-requested requested)
[:& request-dialog {:title (tr "not-found.no-permission.already-requested.project")
:content [(tr "not-found.no-permission.already-requested.or-others.project")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(when @show-dialog
(cond
(nil? profile)
[:& login-dialog {:show-dialog show-dialog}]
(:sent requested)
[:& request-dialog {:title (tr "not-found.no-permission.done.success")
:content [(tr "not-found.no-permission.done.remember")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
is-default
[:& request-dialog {:title (tr "not-found.no-permission.project")
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(some? file-id)
[:& request-dialog {:title (tr "not-found.no-permission.file")
:content [(tr "not-found.no-permission.you-can-ask.file")
(tr "not-found.no-permission.if-approves")]
:button-text (tr "not-found.no-permission.ask")
:on-button-click on-request-access
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(and (some? file-id) (:already-requested requested))
[:& request-dialog {:title (tr "not-found.no-permission.already-requested.file")
:content [(tr "not-found.no-permission.already-requested.or-others.file")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(:already-requested requested)
[:& request-dialog {:title (tr "not-found.no-permission.already-requested.project")
:content [(tr "not-found.no-permission.already-requested.or-others.project")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(:sent requested)
[:& request-dialog {:title (tr "not-found.no-permission.done.success")
:content [(tr "not-found.no-permission.done.remember")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(some? file-id)
[:& request-dialog {:title (tr "not-found.no-permission.file")
:content [(tr "not-found.no-permission.you-can-ask.file")
(tr "not-found.no-permission.if-approves")]
:button-text (tr "not-found.no-permission.ask")
:on-button-click on-request-access
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(some? team-id)
[:& request-dialog {:title (tr "not-found.no-permission.project")
:content [(tr "not-found.no-permission.you-can-ask.project")
(tr "not-found.no-permission.if-approves")]
:button-text (tr "not-found.no-permission.ask")
:on-button-click on-request-access
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]))]))
(some? team-id)
[:& request-dialog {:title (tr "not-found.no-permission.project")
:content [(tr "not-found.no-permission.you-can-ask.project")
(tr "not-found.no-permission.if-approves")]
:button-text (tr "not-found.no-permission.ask")
:on-button-click on-request-access
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}])))
(mf/defc not-found*
[]
@ -484,29 +436,77 @@
[:> internal-error* props])))
(mf/defc context-wrapper*
[{:keys [is-workspace is-dashboard is-viewer profile children]}]
[:*
(cond
is-workspace
[:div {:class (stl/css :workspace)}
[:div {:class (stl/css :workspace-left)}
i/logo-icon
[:div
[:div {:class (stl/css :project-name)} (tr "not-found.no-permission.project-name")]
[:div {:class (stl/css :file-name)} (tr "not-found.no-permission.penpot-file")]]]
[:div {:class (stl/css :workspace-right)}]]
is-viewer
[:div {:class (stl/css :viewer)}
;; FIXME: the viewer header was never designed to be reused
;; from other parts of the application, and this code looks
;; like a fast workaround reusing it as-is without a proper
;; component adaptation for be able to use it easily it on
;; viewer context or static error page context
[:& viewer.header/header {:project
{:name (tr "not-found.no-permission.project-name")}
:index 0
:file {:name (tr "not-found.no-permission.penpot-file")}
:page nil
:frame nil
:permissions {:is-logged true}
:zoom 1
:section :interactions
:shown-thumbnails false
:interactions-mode nil}]]
is-dashboard
[:div {:class (stl/css :dashboard)}
[:div {:class (stl/css :dashboard-sidebar)}
[:> sidebar*
{:team nil
:projects []
:project (:default-project-id profile)
:profile profile
:section :dashboard-projects
:search-term ""}]]])
children])
(mf/defc exception-page*
{::mf/props :obj}
[{:keys [data route] :as props}]
(let [type (:type data)
path (:path route)
(let [type (:type data)
path (:path route)
params (:query-params route)
params (:query-params route)
workspace? (str/includes? path "workspace")
dashboard? (str/includes? path "dashboard")
view? (str/includes? path "view")
workspace? (str/includes? path "workspace")
dashboard? (str/includes? path "dashboard")
view? (str/includes? path "view")
;; We store the request access info int this state
info* (mf/use-state nil)
info (deref info*)
info* (mf/use-state nil)
info (deref info*)
loaded? (get info :loaded false)
loaded? (get info :loaded false)
profile (mf/deref refs/profile)
auth-error?
(= type :authentication)
request-access?
(and
(or (= type :not-found)
(= type :authentication))
(or (= type :not-found) auth-error?)
(or workspace? dashboard? view?)
(or (:file-id info)
(:team-id info)))]
@ -517,11 +517,25 @@
(rx/subs! (partial reset! info*)
(partial reset! info* {:loaded true})))))
(when loaded?
(if request-access?
[:> request-access* {:file-id (:file-id info)
:team-id (:team-id info)
:is-default (:team-default info)
:is-workspace workspace?}]
[:> exception-section* props]))))
(if auth-error?
[:> context-wrapper*
{:is-workspace workspace?
:is-dashboard dashboard?
:is-viewer view?
:profile profile}
[:> login-dialog* {}]]
(when loaded?
(if request-access?
[:> context-wrapper* {:is-workspace workspace?
:is-dashboard dashboard?
:is-viewer view?
:profile profile}
[:> request-access* {:file-id (:file-id info)
:team-id (:team-id info)
:is-default (:team-default info)
:is-workspace workspace?}]]
[:> exception-section* props])))))