mirror of
https://github.com/penpot/penpot.git
synced 2026-06-02 13:40:30 +00:00
* ✨ Improve error handling and exception formatting - Enhance exception formatting with visual separators and cause chaining - Add new handler for :internal error type - Refine error types: change assertion-related errors to :assertion type - Improve error messages and hints consistency - Clean up error handling in zip utilities and HTTP modules * 🐛 Properly handle AbortError on fetch request unsubscription When a fetch request in-flight is cancelled due to RxJS unsubscription (e.g. navigating away from the workspace while thumbnail loads are pending), the AbortController.abort() call triggers a catch handler that previously relied solely on a @unsubscribed? flag to suppress the error. This was unreliable: nested observables spawned inside rx/mapcat (such as datauri->blob-uri conversions within get-file-object-thumbnails) could abort independently, with their own AbortController instances, meaning the outer unsubscribed? flag was never set and the AbortError propagated as an unhandled exception. Add an explicit AbortError name check as a disjunctive condition so that abort errors originating from any observable in the chain are suppressed at the source, regardless of subscription state. Signed-off-by: Andrey Antukh <niwi@niwi.nz>
350 lines
12 KiB
Clojure
350 lines
12 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.main.data.auth
|
|
"Auth related data events"
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.schema :as sm]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.main.data.common :as dcm]
|
|
[app.main.data.event :as ev]
|
|
[app.main.data.notifications :as ntf]
|
|
[app.main.data.profile :as dp]
|
|
[app.main.data.team :as dtm]
|
|
[app.main.data.websocket :as ws]
|
|
[app.main.repo :as rp]
|
|
[app.main.router :as rt]
|
|
[app.util.i18n :as i18n :refer [tr]]
|
|
[app.util.storage :as storage]
|
|
[beicon.v2.core :as rx]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
;; --- HELPERS
|
|
|
|
(defn is-authenticated?
|
|
[{:keys [id]}]
|
|
(and (uuid? id) (not= id uuid/zero)))
|
|
|
|
;; --- EVENT: login
|
|
|
|
(defn- logged-in
|
|
"This is the main event that is executed once we have logged in
|
|
profile. The profile can proceed from standard login or from
|
|
accepting invitation, or third party auth signup or singin."
|
|
[{:keys [props] :as profile}]
|
|
(letfn [(get-redirect-events [teams]
|
|
(if-let [token (:invitation-token profile)]
|
|
(rx/of (rt/nav :auth-verify-token {:token token}))
|
|
(if-let [redirect-href (:login-redirect storage/session)]
|
|
(binding [storage/*sync* true]
|
|
(swap! storage/session dissoc :login-redirect)
|
|
(if (= redirect-href (rt/get-current-href))
|
|
(rx/of (rt/reload true))
|
|
(rx/of (rt/nav-raw :href redirect-href))))
|
|
(if-let [file-id (get props :welcome-file-id)]
|
|
(rx/of (dcm/go-to-workspace
|
|
:file-id file-id
|
|
:team-id (:default-team-id profile))
|
|
(dp/update-profile-props {:welcome-file-id nil}))
|
|
|
|
(let [teams (into #{} (map :id) teams)
|
|
team-id (dtm/get-last-team-id)
|
|
team-id (if (and team-id (contains? teams team-id))
|
|
team-id
|
|
(:default-team-id profile))]
|
|
(rx/of (dcm/go-to-dashboard-recent {:team-id team-id})))))))]
|
|
|
|
(ptk/reify ::logged-in
|
|
ev/Event
|
|
(-data [_]
|
|
{::ev/name "signin"
|
|
::ev/type "identify"
|
|
:email (:email profile)
|
|
:auth-backend (:auth-backend profile)
|
|
:fullname (:fullname profile)
|
|
:is-muted (:is-muted profile)
|
|
:default-team-id (:default-team-id profile)
|
|
:default-project-id (:default-project-id profile)})
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ _ stream]
|
|
(cf/initialize-external-context-info)
|
|
|
|
(->> (rx/merge
|
|
(rx/of (dp/set-profile profile)
|
|
(ws/initialize)
|
|
(dtm/fetch-teams))
|
|
|
|
(->> stream
|
|
(rx/filter (ptk/type? ::dtm/teams-fetched))
|
|
(rx/take 1)
|
|
(rx/map deref)
|
|
(rx/mapcat get-redirect-events)))
|
|
|
|
(rx/observe-on :async))))))
|
|
|
|
(declare login-from-register)
|
|
|
|
(defn login
|
|
[{:keys [email password invitation-token] :as data}]
|
|
(ptk/reify ::login
|
|
ptk/WatchEvent
|
|
(watch [_ _ stream]
|
|
(let [{:keys [on-error on-success]
|
|
:or {on-error rx/throw
|
|
on-success identity}} (meta data)
|
|
|
|
params {:email email
|
|
:password password
|
|
:invitation-token invitation-token}]
|
|
|
|
;; NOTE: We can't take the profile value from login because
|
|
;; there are cases when login is successful but the cookie is
|
|
;; not set properly (because of possible misconfiguration).
|
|
;; So, we proceed to make an additional call to fetch the
|
|
;; profile, and ensure that cookie is set correctly. If
|
|
;; profile fetch is successful, we mark the user logged in, if
|
|
;; the returned profile is an NOT authenticated profile, we
|
|
;; proceed to logout and show an error message.
|
|
|
|
(->> (rp/cmd! :login-with-password (d/without-nils params))
|
|
(rx/merge-map (fn [data]
|
|
(rx/merge
|
|
(rx/of (dp/fetch-profile))
|
|
(->> stream
|
|
(rx/filter dp/profile-fetched?)
|
|
(rx/take 1)
|
|
(rx/map deref)
|
|
(rx/filter (complement is-authenticated?))
|
|
(rx/tap on-error)
|
|
(rx/map #(ex/raise :type :authentication))
|
|
(rx/observe-on :async))
|
|
|
|
(->> stream
|
|
(rx/filter dp/profile-fetched?)
|
|
(rx/take 1)
|
|
(rx/map deref)
|
|
(rx/filter is-authenticated?)
|
|
(rx/map (fn [profile]
|
|
(with-meta (merge data profile)
|
|
{::ev/source "login"})))
|
|
(rx/tap on-success)
|
|
(rx/map logged-in)
|
|
(rx/observe-on :async)))))
|
|
(rx/catch on-error))))))
|
|
|
|
(def ^:private schema:login-with-ldap
|
|
[:map {:title "login-with-ldap"}
|
|
[:email ::sm/email]
|
|
[:password :string]])
|
|
|
|
(defn login-with-ldap
|
|
[params]
|
|
|
|
(assert (sm/check schema:login-with-ldap params))
|
|
|
|
(ptk/reify ::login-with-ldap
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [{:keys [on-error on-success]
|
|
:or {on-error rx/throw
|
|
on-success identity}} (meta params)]
|
|
(->> (rp/cmd! :login-with-ldap params)
|
|
(rx/tap on-success)
|
|
(rx/map (fn [profile]
|
|
(-> profile
|
|
(with-meta {::ev/source "login-with-ldap"})
|
|
(logged-in))))
|
|
(rx/catch on-error))))))
|
|
|
|
(def ^:private schema:login-with-sso
|
|
[:map {:title "login-with-sso"}
|
|
[:provider [:or :string ::sm/uuid]]])
|
|
|
|
(defn login-with-sso
|
|
"Start the SSO flow"
|
|
[params]
|
|
(assert (sm/check schema:login-with-sso params))
|
|
(ptk/reify ::login-with-sso
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(->> (rp/cmd! :login-with-oidc params)
|
|
(rx/map (fn [{:keys [redirect-uri] :as rsp}]
|
|
(if redirect-uri
|
|
(rt/nav-raw :uri redirect-uri)
|
|
(ex/raise :type :assertion
|
|
:code :unexpected-response
|
|
:hint "unexpected response from OIDC method"
|
|
:resp (pr-str rsp)))))
|
|
(rx/catch (fn [cause]
|
|
(let [{:keys [type code] :as error} (ex-data cause)]
|
|
(if (and (= type :restriction)
|
|
(= code :provider-not-configured))
|
|
(rx/of (ntf/error (tr "errors.auth-provider-not-configured")))
|
|
(rx/throw cause)))))))))
|
|
|
|
(defn login-from-token
|
|
"Used mainly as flow continuation after token validation."
|
|
[{:keys [profile] :as tdata}]
|
|
(ptk/reify ::login-from-token
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(->> (dp/on-fetch-profile-success profile)
|
|
(rx/map (fn [profile]
|
|
(logged-in (with-meta profile {::ev/source "login-with-token"}))))
|
|
;; NOTE: we need this to be asynchronous because the effect
|
|
;; should be called before proceed with the login process
|
|
(rx/observe-on :async)))))
|
|
|
|
(defn login-from-register
|
|
"Event used mainly for mark current session as logged-in in after the
|
|
user successfully registered using third party auth provider (in this
|
|
case we dont need to verify the email)."
|
|
[]
|
|
(ptk/reify ::login-from-register
|
|
ptk/WatchEvent
|
|
(watch [_ _ stream]
|
|
(rx/merge
|
|
(rx/of (dp/fetch-profile))
|
|
(->> stream
|
|
(rx/filter dp/profile-fetched?)
|
|
(rx/take 1)
|
|
(rx/map deref)
|
|
(rx/filter is-authenticated?)
|
|
(rx/map (fn [profile]
|
|
(with-meta profile
|
|
{::ev/source "register"})))
|
|
(rx/map logged-in)
|
|
(rx/observe-on :async))))))
|
|
|
|
;; --- EVENT: logout
|
|
|
|
(defn logged-out
|
|
[{:keys [redirect-uri]}]
|
|
(ptk/reify ::logged-out
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(select-keys state [:route :router :session-id :history]))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(if redirect-uri
|
|
(->> (rx/of (rt/nav-raw :uri (str redirect-uri)))
|
|
(rx/observe-on :async))
|
|
|
|
(rx/merge
|
|
;; NOTE: We need the `effect` of the current event to be
|
|
;; executed before the redirect.
|
|
(->> (rx/of (rt/nav :auth-login))
|
|
(rx/observe-on :async))
|
|
(rx/of (ws/finalize)))))
|
|
|
|
ptk/EffectEvent
|
|
(effect [_ _ _]
|
|
;; We prefer to keek some stuff in the storage like the current-team-id and the profile
|
|
(swap! storage/user (constantly {})))))
|
|
|
|
(defn logout
|
|
[]
|
|
(ptk/reify ::logout
|
|
ev/Event
|
|
(-data [_] {})
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [profile-id (:profile-id state)]
|
|
(->> (rx/interval 500)
|
|
(rx/take 1)
|
|
(rx/mapcat (fn [_]
|
|
(->> (rp/cmd! :logout {:profile-id profile-id})
|
|
(rx/delay-at-least 300)
|
|
(rx/catch (constantly (rx/of nil))))))
|
|
(rx/map logged-out))))))
|
|
|
|
;; --- Update Profile
|
|
|
|
(def ^:private
|
|
schema:request-profile-recovery
|
|
[:map {:title "request-profile-recovery" :closed true}
|
|
[:email ::sm/email]])
|
|
|
|
(defn request-profile-recovery
|
|
[data]
|
|
|
|
(assert (sm/check schema:request-profile-recovery data))
|
|
|
|
(ptk/reify ::request-profile-recovery
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [{:keys [on-error on-success]
|
|
:or {on-error rx/throw
|
|
on-success identity}} (meta data)]
|
|
|
|
(->> (rp/cmd! :request-profile-recovery data)
|
|
(rx/tap on-success)
|
|
(rx/catch on-error))))))
|
|
|
|
;; --- EVENT: recover-profile (Password)
|
|
|
|
(def ^:private
|
|
schema:recover-profile
|
|
[:map {:title "recover-profile" :closed true}
|
|
[:password :string]
|
|
[:token :string]])
|
|
|
|
(defn recover-profile
|
|
[data]
|
|
(assert (sm/check schema:recover-profile data))
|
|
|
|
(ptk/reify ::recover-profile
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [{:keys [on-error on-success]
|
|
:or {on-error rx/throw
|
|
on-success identity}} (meta data)]
|
|
(->> (rp/cmd! :recover-profile data)
|
|
(rx/tap on-success)
|
|
(rx/catch on-error))))))
|
|
|
|
;; --- EVENT: crete-demo-profile
|
|
|
|
(defn create-demo-profile
|
|
[]
|
|
(ptk/reify ::create-demo-profile
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(->> (rp/cmd! :create-demo-profile {})
|
|
(rx/map login)))))
|
|
|
|
(defn show-redirect-error
|
|
"A helper event that interprets the OIDC redirect errors on the URI
|
|
and shows an appropriate error message using the notification
|
|
banners."
|
|
[error]
|
|
(ptk/reify ::show-redirect-error
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(when-let [hint (case error
|
|
"registration-disabled"
|
|
(tr "errors.registration-disabled")
|
|
"profile-blocked"
|
|
(tr "errors.profile-blocked")
|
|
"auth-provider-not-allowed"
|
|
(tr "errors.auth-provider-not-allowed")
|
|
"email-domain-not-allowed"
|
|
(tr "errors.email-domain-not-allowed")
|
|
|
|
;; We explicitly do not show any error here, it a explicit user operation.
|
|
"unable-to-auth"
|
|
nil
|
|
|
|
(tr "errors.generic"))]
|
|
|
|
(rx/of (ntf/warn hint))))))
|