Files
logseq/src/main/frontend/handler/user.cljs
Charlie a0a19a91fa feat: new authenticate UI (#12172)
* enhance(ui): login form

* enhance(ui): add localization support with translate and locale management

* enhance(ui): WIP implement new authentication forms with context management

* enhance(ui): add password visibility toggle to input row

* enhance(ui): adjust padding for password visibility toggle

* enhance(i18n): implement internationalization support for authentication UI

* enhance(ui): implement sign in and sign up functionality with loading state

* enhance(ui): add session management and error handling in login form

* enhance(ui): add confirm code form and enhance authentication flow

* enhance(ui): improve sign-in flow and confirm code handling

* enhance(ui): add warning variant to alerts and improve error handling

* enhance(ui): implement countdown timer for code resend functionality

* enhance(ui): implement countdown timer for password reset and enhance login flow

* enhance(ui): export authentication and enhance UI components

* enhance(ui): integrate new login component and refresh token handling

* chore: clear amplify related codes

* enhance(i18n): normalize language codes and update locale handling

* enhance(auth): add multilingual support for signup and password reset flows

* enhance(ui): update login styles to inherit text color

* enhance(ui): adjust input color variables for improved accessibility

* enhance(auth): add password policy validation and tips in multiple languages

* enhance(ui): improve localization handling and update alert styles

* enhance(mobile): enhance login modal styling and accessibility

* fix(ui): update password validation regex for special characters

* enhance(ui): add padding to card header in login dialog

---------

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
2025-10-28 16:55:43 +08:00

361 lines
12 KiB
Clojure

(ns frontend.handler.user
"Provides user related handler fns like login and logout"
(:require-macros [frontend.handler.user])
(:require [cljs-http.client :as http]
[cljs-time.coerce :as tc]
[cljs-time.core :as t]
[cljs.core.async :as async :refer [<! go]]
[clojure.set :as set]
[clojure.string :as string]
[frontend.common.missionary :as c.m]
[frontend.config :as config]
[frontend.debug :as debug]
[frontend.flows :as flows]
[frontend.handler.config :as config-handler]
[frontend.handler.notification :as notification]
[frontend.state :as state]
[goog.crypt :as crypt]
[goog.crypt.Hmac]
[goog.crypt.Sha256]
[missionary.core :as m]))
(defn set-preferred-format!
[format]
(when format
(config-handler/set-config! :preferred-format format)
(state/set-preferred-format! format)))
(defn set-preferred-workflow!
[workflow]
(when workflow
(config-handler/set-config! :preferred-workflow workflow)
(state/set-preferred-workflow! workflow)))
;;; userinfo, token, login/logout, ...
(defn- decode-username
[username]
(let [arr (new js/Uint8Array (count username))]
(doseq [i (range (count username))]
(aset arr i (.charCodeAt username i)))
(.decode (new js/TextDecoder "utf-8") arr)))
(defn parse-jwt [jwt]
(some-> jwt
(string/split ".")
second
(#(.decodeString ^js crypt/base64 % true))
js/JSON.parse
(js->clj :keywordize-keys true)
(update :cognito:username decode-username)))
(defn- expired? [parsed-jwt]
(some->
(* 1000 (:exp parsed-jwt))
tc/from-long
(t/before? (t/now))))
(defn- almost-expired?
"return true when jwt will expire after 1h"
[parsed-jwt]
(some->
(* 1000 (:exp parsed-jwt))
tc/from-long
(t/before? (-> 1 t/hours t/from-now))))
(defn- almost-expired-or-expired?
[parsed-jwt]
(or (almost-expired? parsed-jwt)
(expired? parsed-jwt)))
(defn email []
(some->
(state/get-auth-id-token)
parse-jwt
:email))
(defn username []
(some->
(state/get-auth-id-token)
parse-jwt
:cognito:username))
(defn user-uuid []
(some->
(state/get-auth-id-token)
parse-jwt
:sub))
(defn logged-in? []
(some? (state/get-auth-refresh-token)))
(defn- set-token-to-localstorage!
([id-token access-token]
(prn :debug "set-token-to-localstorage!")
(js/localStorage.setItem "id-token" id-token)
(js/localStorage.setItem "access-token" access-token))
([id-token access-token refresh-token]
(prn :debug "set-token-to-localstorage!")
(js/localStorage.setItem "id-token" id-token)
(js/localStorage.setItem "access-token" access-token)
(js/localStorage.setItem "refresh-token" refresh-token)))
(defn- clear-cognito-tokens!
"Clear tokens for cognito's localstorage, prefix is 'CognitoIdentityServiceProvider'"
[]
(let [prefix "CognitoIdentityServiceProvider."]
(doseq [key (js/Object.keys js/localStorage)]
(when (string/starts-with? key prefix)
(js/localStorage.removeItem key)))))
(defn auto-fill-refresh-token-from-cognito!
[]
(let [prefix "CognitoIdentityServiceProvider."
refresh-token-key (some #(when (string/starts-with? % prefix)
(when (string/ends-with? % "refreshToken")
%))
(js/Object.keys js/localStorage))]
(when refresh-token-key
(let [refresh-token (js/localStorage.getItem refresh-token-key)]
(when (and refresh-token (not= refresh-token "undefined"))
(state/set-auth-refresh-token refresh-token)
(js/localStorage.setItem "refresh-token" refresh-token)))))
)
(defn- clear-tokens
([]
(state/set-auth-id-token nil)
(state/set-auth-access-token nil)
(state/set-auth-refresh-token nil)
(set-token-to-localstorage! "" "" "")
(clear-cognito-tokens!))
([except-refresh-token?]
(state/set-auth-id-token nil)
(state/set-auth-access-token nil)
(when-not except-refresh-token?
(state/set-auth-refresh-token nil))
(if except-refresh-token?
(set-token-to-localstorage! "" "")
(set-token-to-localstorage! "" "" ""))))
(defn- set-tokens!
([id-token access-token]
(state/set-auth-id-token id-token)
(state/set-auth-access-token access-token)
(set-token-to-localstorage! id-token access-token)
(some->> (parse-jwt (state/get-auth-id-token))
(reset! flows/*current-login-user)))
([id-token access-token refresh-token]
(state/set-auth-id-token id-token)
(state/set-auth-access-token access-token)
(state/set-auth-refresh-token refresh-token)
(set-token-to-localstorage! id-token access-token refresh-token)
(some->> (parse-jwt (state/get-auth-id-token))
(reset! flows/*current-login-user))))
(defn- <refresh-tokens
"return refreshed id-token, access-token"
[refresh-token]
(http/post (str "https://" config/OAUTH-DOMAIN "/oauth2/token")
{:form-params {:grant_type "refresh_token"
:client_id config/COGNITO-CLIENT-ID
:refresh_token refresh-token}}))
(defn <refresh-id-token&access-token
"Refresh id-token and access-token"
[]
(go
(when-let [refresh-token (state/get-auth-refresh-token)]
(let [resp (<! (<refresh-tokens refresh-token))]
(cond
(and (<= 400 (:status resp))
(> 500 (:status resp)))
;; invalid refresh-token
(let [invalid-grant? (and (= 400 (:status resp))
(= (:error (:body resp)) "invalid_grant"))]
(prn :debug :refresh-token-failed
:status (:status resp))
(when invalid-grant?
(clear-tokens)))
;; e.g. api return 500, server internal error
;; we shouldn't clear tokens if they aren't expired yet
;; the `refresh-tokens-loop` will retry soon
(and (not (http/unexceptional-status? (:status resp)))
(not (-> (state/get-auth-id-token) parse-jwt expired?)))
(do
(prn :debug :refresh-token-failed
:status (:status resp)
:body (:body resp)
:error-code (:error-code resp)
:error-text (:error-text resp))
nil) ; do nothing
(not (http/unexceptional-status? (:status resp)))
(notification/show! "exceptional status when refresh-token" :warning true)
:else ; ok
(when (and (:id_token (:body resp)) (:access_token (:body resp)))
(set-tokens! (:id_token (:body resp)) (:access_token (:body resp)))))))))
(defn restore-tokens-from-localstorage
"Refresh id-token&access-token, pull latest repos, returns nil when tokens are not available."
[]
(println "restore-tokens-from-localstorage")
(let [refresh-token (js/localStorage.getItem "refresh-token")]
(when refresh-token
(go
(<! (<refresh-id-token&access-token))
;; refresh remote graph list by pub login event
(when (user-uuid) (state/pub-event! [:user/fetch-info-and-graphs]))))))
(defn has-refresh-token?
"Has refresh-token"
[]
(boolean (js/localStorage.getItem "refresh-token")))
(defn login-callback
[session]
(set-tokens!
(:jwtToken (:idToken session))
(:jwtToken (:accessToken session))
(:token (:refreshToken session)))
(auto-fill-refresh-token-from-cognito!)
(state/pub-event! [:user/fetch-info-and-graphs]))
(defn ^:export login-with-username-password-e2e
[username' password client-id client-secret]
(let [text-encoder (new js/TextEncoder)
key (.encode text-encoder client-secret)
hasher (new crypt/Sha256)
hmacer (new crypt/Hmac hasher key)
secret-hash (.encodeByteArray ^js crypt/base64 (.getHmac hmacer (str username' client-id)))
payload {"AuthParameters" {"USERNAME" username',
"PASSWORD" password,
"SECRET_HASH" secret-hash}
"AuthFlow" "USER_PASSWORD_AUTH",
"ClientId" client-id}
headers {"X-Amz-Target" "AWSCognitoIdentityProviderService.InitiateAuth",
"Content-Type" "application/x-amz-json-1.1"}]
(go
(let [resp (<! (http/post config/COGNITO-IDP {:headers headers
:body (js/JSON.stringify (clj->js payload))}))]
(assert (= 200 (:status resp)))
(let [body (js->clj (js/JSON.parse (:body resp)))
access-token (get-in body ["AuthenticationResult" "AccessToken"])
id-token (get-in body ["AuthenticationResult" "IdToken"])
refresh-token (get-in body ["AuthenticationResult" "RefreshToken"])]
(set-tokens! id-token access-token refresh-token)
(state/pub-event! [:user/fetch-info-and-graphs])
{:id-token id-token :access-token access-token :refresh-token refresh-token})))))
(defn logout []
(clear-tokens)
(state/clear-user-info!)
(state/pub-event! [:user/logout])
(reset! flows/*current-login-user :logout))
(defn upgrade []
(let [base-upgrade-url "https://logseqdemo.lemonsqueezy.com/checkout/buy/13e194b5-c927-41a8-af58-ed1a36d6000d"
user-uuid' (user-uuid)
url (cond-> base-upgrade-url
user-uuid' (str "?checkout[custom][user_uuid]=" (name user-uuid')))]
(println " ~~~ LEMON: " url " ~~~ ")
(js/window.open url)))
; (js/window.open
; "https://logseqdemo.lemonsqueezy.com/checkout/buy/13e194b5-c927-41a8-af58-ed1a36d6000d"))
(defn <ensure-id&access-token
[]
(let [id-token (state/get-auth-id-token)]
(go
(when (or (nil? id-token)
(-> id-token parse-jwt almost-expired-or-expired?))
(debug/pprint (str "refresh tokens... " (tc/to-string (t/now))))
(<! (<refresh-id-token&access-token))
(when (or (nil? (state/get-auth-id-token))
(-> (state/get-auth-id-token) parse-jwt expired?))
(ex-info "empty or expired token and refresh failed" {:anom :expired-token}))))))
(def task--ensure-id&access-token
(m/sp
(let [id-token (state/get-auth-id-token)]
(when (or (nil? id-token)
(-> id-token parse-jwt almost-expired-or-expired?))
(prn (str "refresh tokens... " (tc/to-string (t/now))))
(c.m/<? (<refresh-id-token&access-token))
(when (or (nil? (state/get-auth-id-token))
(-> (state/get-auth-id-token) parse-jwt expired?))
(throw (ex-info "empty or expired token and refresh failed" {:type :expired-token})))))))
(defn <user-uuid
[]
(go
(if-some [exp (<! (<ensure-id&access-token))]
exp
(user-uuid))))
;;; user groups
(defn rtc-group?
[]
(boolean (seq (set/intersection (state/user-groups) #{"team" "rtc_2025_07_10"}))))
(defn alpha-user?
[]
(or config/dev?
(contains? (state/user-groups) "alpha-tester")))
(defn beta-user?
[]
(or config/dev?
(contains? (state/user-groups) "beta-tester")))
(defn alpha-or-beta-user?
[]
(or (alpha-user?) (beta-user?)))
(defn get-user-type
[repo]
(-> (some #(when (= repo (:url %)) %) (:rtc/graphs @state/state))
:graph<->user-user-type))
(defn manager?
[repo]
(= (get-user-type repo) "manager"))
;; TODO: Remove if still unused
#_(defn member?
[repo]
(= (get-user-type repo) "member"))
(defn new-task--upload-user-avatar
[avatar-str]
(m/sp
(when-let [token (state/get-auth-id-token)]
(let [{:keys [status body] :as resp}
(c.m/<?
(http/post
(str "https://" config/API-DOMAIN "/logseq/get_presigned_user_avatar_put_url")
{:oauth-token token
:with-credentials? false}))]
(when-not (http/unexceptional-status? status)
(throw (ex-info "failed to get presigned url" {:resp resp})))
(let [presigned-url (:presigned-url body)
{:keys [status]} (c.m/<? (http/put presigned-url {:body avatar-str :with-credentials? false}))]
(when-not (http/unexceptional-status? status)
(throw (ex-info "failed to upload avatar" {:resp resp}))))))))
(comment
;; We probably need this for some new features later
(defonce feature-matrix {:file-sync :beta})
(defn feature-available?
[feature]
(or config/dev?
(when (logged-in?)
(case (feature feature-matrix)
:beta (alpha-or-beta-user?)
:alpha (alpha-user?)
false)))))