Compare commits

...

2 Commits

Author SHA1 Message Date
Andrey Antukh
3f2cd30f52 ♻️ Refactor auth related components 2023-09-06 14:26:49 +02:00
Andrey Antukh
c598656f60 🎉 Add PassKeys and 2FA support 2023-09-06 14:26:47 +02:00
33 changed files with 1529 additions and 375 deletions

View File

@@ -18,6 +18,9 @@
io.lettuce/lettuce-core {:mvn/version "6.2.6.RELEASE"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
com.webauthn4j/webauthn4j-core {:mvn/version "0.21.3.RELEASE"}
dev.samstevens.totp/totp {:mvn/version "1.7.1"}
funcool/yetti
{:git/tag "v9.16"
:git/sha "7df3e08"
@@ -33,9 +36,6 @@
io.whitfin/siphash {:mvn/version "2.0.0"}
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.5.351"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.8"}
org.jsoup/jsoup {:mvn/version "1.16.1"}

View File

@@ -103,6 +103,12 @@
:else
{::yrs/status 400 ::yrs/body data})))
(defmethod handle-exception :negotiation
[err _]
(let [data (ex-data err)]
{::yrs/status 412
::yrs/body data}))
(defmethod handle-exception :assertion
[error request]
(binding [l/*context* (request->context request)]

View File

@@ -324,12 +324,14 @@
{:name "0104-mod-file-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0104-mod-file-thumbnail-table.sql")}
{:name "0105-add-profile-credential-table"
:fn (mg/resource "app/migrations/sql/0105-add-profile-credential-table.sql")}
{:name "0105-mod-file-change-table"
:fn (mg/resource "app/migrations/sql/0105-mod-file-change-table.sql")}
{:name "0105-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0105-mod-server-error-report-table.sql")}
])
(defn apply-migrations!

View File

@@ -0,0 +1,19 @@
CREATE TABLE profile_passkey (
id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY,
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
credential_id bytea NOT NULL,
attestation bytea NOT NULL,
sign_count bigint NOT NULL
);
CREATE INDEX profile__passkey__profile_id ON profile_passkey (credential_id, profile_id);
CREATE TABLE profile_challenge (
profile_id uuid PRIMARY KEY REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
data bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)

View File

@@ -225,6 +225,7 @@
'app.rpc.commands.teams
'app.rpc.commands.verify-token
'app.rpc.commands.viewer
'app.rpc.commands.webauthn
'app.rpc.commands.webhooks)
(map (partial process-method cfg))
(into {}))))

View File

@@ -22,11 +22,13 @@
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.commands.webauthn :as webauthn]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
[app.util.totp :as totp]
[cuerdas.core :as str]))
(def schema:password
@@ -37,8 +39,65 @@
;; ---- COMMAND: login with password
(defn- check-password!
[cfg profile {:keys [password]}]
(if (= (:password profile) "!")
(ex/raise :type :validation
:code :account-without-password
:hint "the current account does not have password")
(let [result (profile/verify-password cfg password (:password profile))]
(when (:update result)
(l/trace :hint "updating profile password" :id (:id profile) :email (:email profile))
(profile/update-profile-password! cfg (assoc profile :password password)))
(:valid result))))
(defn validate-profile!
[cfg {:keys [totp] :as params} {:keys [props] :as profile}]
(when-not profile
(ex/raise :type :validation
:code :wrong-credentials))
(when-not (:is-active profile)
(ex/raise :type :validation
:code :wrong-credentials))
(when (:is-blocked profile)
(ex/raise :type :restriction
:code :profile-blocked))
(when (= :totp (:2fa props))
(if (some? totp)
(when-not (totp/valid-code? (:2fa/secret props) totp)
(ex/raise :type :negotiation
:code :invalid-totp))
(ex/raise :type :negotiation
:code :totp)))
(when-not (check-password! cfg profile params)
(ex/raise :type :validation
:code :wrong-credentials))
(when (= :passkey (:2fa props))
;; NOTE: as we raise negotiation exception the current transaction
;; will be aborted; so for passkey we need another, parallel
;; transaction for persist the new challege
(let [data (db/with-atomic cfg
(webauthn/prepare-login-with-passkey cfg profile))]
(ex/raise :type :negotiation
:code :passkey
::ex/data data)))
(when-let [deleted-at (:deleted-at profile)]
(when (dt/is-after? (dt/now) deleted-at)
(ex/raise :type :validation
:code :wrong-credentials)))
profile)
(defn login-with-password
[{:keys [::db/pool] :as cfg} {:keys [email password] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [email] :as params}]
(when-not (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password))
@@ -46,61 +105,32 @@
:code :login-disabled
:hint "login is disabled in this instance"))
(letfn [(check-password [conn profile password]
(if (= (:password profile) "!")
(ex/raise :type :validation
:code :account-without-password
:hint "the current account does not have password")
(let [result (profile/verify-password cfg password (:password profile))]
(when (:update result)
(l/trace :hint "updating profile password" :id (:id profile) :email (:email profile))
(profile/update-profile-password! conn (assoc profile :password password)))
(:valid result))))
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)
profile (->> (profile/get-profile-by-email conn email)
(validate-profile! cfg params)
(profile/strip-private-attrs))
(validate-profile [conn profile]
(when-not profile
(ex/raise :type :validation
:code :wrong-credentials))
(when-not (:is-active profile)
(ex/raise :type :validation
:code :wrong-credentials))
(when (:is-blocked profile)
(ex/raise :type :restriction
:code :profile-blocked))
(when-not (check-password conn profile password)
(ex/raise :type :validation
:code :wrong-credentials))
(when-let [deleted-at (:deleted-at profile)]
(when (dt/is-after? (dt/now) deleted-at)
(ex/raise :type :validation
:code :wrong-credentials)))
invitation (when-let [token (:invitation-token params)]
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))
profile)]
(db/with-atomic [conn pool]
(let [profile (->> (profile/get-profile-by-email conn email)
(validate-profile conn)
(profile/strip-private-attrs))
invitation (when-let [token (:invitation-token params)]
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
;; invitation because invitations matches exactly; and user can't login with other email and
;; accept invitation with other email
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
{:invitation-token (:invitation-token params)}
(assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))]
(-> response
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))))
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
;; invitation because invitations matches exactly; and user can't login with other email and
;; accept invitation with other email
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
{:invitation-token (:invitation-token params)}
(assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))]
(-> response
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})))))
(def schema:login-with-password
[:map {:title "login-with-password"}
[:email ::sm/email]
[:password schema:password]
[:totp {:optional true} ::sm/word-string]
[:invitation-token {:optional true} schema:token]])
(sv/defmethod ::login-with-password
@@ -464,5 +494,3 @@
::sm/params schema:request-profile-recovery}
[cfg params]
(request-profile-recovery cfg params))

View File

@@ -7,7 +7,6 @@
(ns app.rpc.commands.profile
(:require
[app.auth :as auth]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -27,6 +26,7 @@
[app.tokens :as tokens]
[app.util.services :as sv]
[app.util.time :as dt]
[app.util.totp :as totp]
[cuerdas.core :as str]))
(declare check-profile-existence!)
@@ -36,6 +36,7 @@
(declare get-profile)
(declare strip-private-attrs)
(declare verify-password)
(declare ^:private process-props)
(def schema:profile
[:map {:title "Profile"}
@@ -83,7 +84,7 @@
(def schema:update-profile
[:map {:title "update-profile"}
[:fullname [::sm/word-string {:max 250}]]
[:fullname {:optional true} [::sm/word-string {:max 250}]]
[:lang {:optional true} [:string {:max 5}]]
[:theme {:optional true} [:string {:max 250}]]])
@@ -91,11 +92,7 @@
{::doc/added "1.0"
::sm/params schema:update-profile
::sm/result schema:profile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}]
(dm/assert!
"expected valid profile data"
(profile? params))
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme props] :as params}]
(db/with-atomic [conn pool]
;; NOTE: we need to retrieve the profile independently if we use
@@ -104,26 +101,25 @@
(let [profile (-> (db/get-by-id conn :profile profile-id ::db/for-update? true)
(decode-row))
;; Update the profile map with direct params
profile (-> profile
(assoc :fullname fullname)
(assoc :lang lang)
(assoc :theme theme))
]
props (cond-> (process-props props profile)
(and (contains? props :2fa)
(= :totp (:2fa props))
(not= :totp (dm/get-in profile [:props :2fa])))
(assoc :2fa/secret (totp/gen-secret)))
(db/update! conn :profile
{:fullname fullname
:lang lang
:theme theme
:props (db/tjson (:props profile))}
{:id profile-id})
params (cond-> {:props (db/tjson props)}
(some? fullname) (assoc :fullname fullname)
(some? lang) (assoc :lang lang)
(some? theme) (assoc :theme theme))
profile (db/update! conn :profile params {:id profile-id})
profile (decode-row profile)]
(-> profile
(update :props filter-props)
(strip-private-attrs)
(d/without-nils)
(rph/with-meta {::audit/props (audit/profile->props profile)})))))
;; --- MUTATION: Update Password
(declare validate-password!)
@@ -153,7 +149,7 @@
:code :email-as-password
:hint "you can't use your email as password"))
(update-profile-password! conn (assoc profile :password password))
(update-profile-password! cfg (assoc profile :password password))
(invalidate-profile-session! cfg profile-id session-id)
nil)))
@@ -173,7 +169,7 @@
profile))
(defn update-profile-password!
[conn {:keys [id password] :as profile}]
[{:keys [::db/conn]} {:keys [id password] :as profile}]
(when-not (db/read-only? conn)
(db/update! conn :profile
{:password (auth/derive-password password)}
@@ -315,6 +311,18 @@
;; --- MUTATION: Update Profile Props
(defn- process-props
[props profile]
(reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props))
(def schema:update-profile-props
[:map {:title "update-profile-props"}
[:props [:map-of :keyword :any]]])
@@ -325,15 +333,7 @@
[{:keys [::db/pool]} {:keys [::rpc/profile-id props]}]
(db/with-atomic [conn pool]
(let [profile (get-profile conn profile-id ::db/for-update? true)
props (reduce-kv (fn [props k v]
;; We don't accept namespaced keys
(if (simple-ident? k)
(if (nil? v)
(dissoc props k)
(assoc props k v))
props))
(:props profile)
props)]
props (process-props props profile)]
(db/update! conn :profile
{:props (db/tjson props)}
@@ -373,6 +373,19 @@
(rph/with-transform {} (session/delete-fn cfg)))))
;; --- TOTP/2FA
(sv/defmethod ::get-profile-2fa-secret
[{:keys [::db/pool]} {:keys [::rpc/profile-id]}]
(dm/with-open [conn (db/open pool)]
(let [{:keys [props] :as profile} (get-profile conn profile-id)]
(when (= :totp (:2fa props))
(let [secret (:2fa/secret props)
image (totp/get-qrcode-image secret (:email profile))]
{:secret secret
:image image})))))
;; --- HELPERS
(def sql:owned-teams

View File

@@ -0,0 +1,334 @@
;; 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.rpc.commands.webauthn
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.util.services :as sv]
[buddy.core.nonce :as bn]
[cuerdas.core :as str])
(:import
com.webauthn4j.WebAuthnManager
com.webauthn4j.authenticator.Authenticator
com.webauthn4j.authenticator.AuthenticatorImpl
com.webauthn4j.converter.AttestedCredentialDataConverter
com.webauthn4j.converter.util.ObjectConverter
com.webauthn4j.data.AuthenticationData
com.webauthn4j.data.AuthenticationParameters
com.webauthn4j.data.AuthenticationRequest
com.webauthn4j.data.RegistrationData
com.webauthn4j.data.RegistrationParameters
com.webauthn4j.data.RegistrationRequest
com.webauthn4j.data.attestation.authenticator.AttestedCredentialData
com.webauthn4j.data.client.Origin
com.webauthn4j.data.client.challenge.DefaultChallenge
com.webauthn4j.server.ServerProperty))
(declare ^:private create-challenge!)
(declare ^:private get-current-challenge)
(declare ^:private prepare-registration-data)
(declare ^:private prepare-auth-data)
(declare ^:private validate-registration-data!)
(declare ^:private validate-auth-data!)
(declare ^:private get-attestation)
(declare ^:private update-passkey!)
(declare ^:private get-sign-count)
(declare ^:private get-profile)
(declare ^:private get-credentials)
(declare ^:private get-passkey)
(declare ^:private encode-attestation)
(declare ^:private decode-attestation)
(def ^:private manager
(delay (WebAuthnManager/createNonStrictWebAuthnManager)))
;; TODO: output schema
(sv/defmethod ::prepare-profile-passkey-registration
{::doc/added "1.20"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id]}]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)
profile (get-profile cfg profile-id)
challenge (create-challenge! cfg profile-id)
uri (u/uri (cf/get :public-uri))]
{:challenge challenge
:user-id (uuid/get-bytes profile-id)
:user-email (:email profile)
:user-name (:fullname profile)
:rp-id (:host uri)
:rp-name "Penpot"})))
(def ^:private schema:create-profile-passkey
[:map {:title "create-profile-passkey"}
[:credential-id ::sm/bytes]
[:attestation ::sm/bytes]
[:client-data ::sm/bytes]])
(def ^:private schema:partial-passkey
[:map {:title "PartilProfilePasskey"}
[:id ::sm/uuid]
[:created-at ::sm/inst]
[:profile-id ::sm/uuid]])
(sv/defmethod ::create-profile-passkey
{::sm/params schema:create-profile-passkey
::sm/result schema:partial-passkey
::doc/added "1.20"}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id credential-id] :as params}]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)
challenge (get-current-challenge cfg profile-id)
regdata (prepare-registration-data params)]
(validate-registration-data! regdata challenge)
(let [attestation (get-attestation regdata)
sign-count (get-sign-count regdata)
passkey (db/insert! conn :profile-passkey
{:id (uuid/next)
:profile-id profile-id
:credential-id credential-id
:attestation attestation
:sign-count sign-count})]
(select-keys passkey [:id :created-at :profile-id])))))
;; FIXME: invitation token handling
(def ^:private schema:prepare-login-with-passkey
[:map {:title "prepare-login-with-passkey"}
[:email ::sm/email]])
(def ^:private schema:passkey-prepared-login
[:map {:title "PasskeyPreparedLogin"}
[:passkeys [:set ::sm/bytes]]
[:challenge ::sm/bytes]])
(declare prepare-login-with-passkey)
(sv/defmethod ::prepare-login-with-passkey
{::rpc/auth false
::doc/added "1.20"
::sm/params schema:prepare-login-with-passkey
::sm/result schema:passkey-prepared-login}
[{:keys [::db/pool] :as cfg} {:keys [email]}]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)
profile (get-profile cfg email)
props (:props profile)]
(when (not= :all (:passkey props :all))
(ex/raise :type :restriction
:code :passkey-disabled))
(prepare-login-with-passkey cfg profile))))
(defn prepare-login-with-passkey
[cfg {:keys [id] :as profile}]
(let [credentials (get-credentials cfg id)
challenge (create-challenge! cfg id)
uri (u/uri (cf/get :public-uri))]
{:credentials credentials
:challenge challenge
:rp-id (:host uri)}))
;; FIXME: invitation token handling
(def ^:private schema:login-with-passkey
[:map {:title "login-with-passkey"}
[:credential-id ::sm/bytes]
[:user-handle [:maybe ::sm/bytes]]
[:auth-data ::sm/bytes]
[:client-data ::sm/bytes]])
(sv/defmethod ::login-with-passkey
{::rpc/auth false
::doc/added "1.20"
::sm/params schema:login-with-passkey}
[{:keys [::db/pool] :as cfg} params]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)
passkey (get-passkey cfg params)
challenge (get-current-challenge cfg (:profile-id passkey))
authdata (prepare-auth-data params)]
(validate-auth-data! authdata passkey challenge)
(update-passkey! cfg passkey authdata)
(let [profile (->> (profile/get-profile conn (:profile-id passkey))
(profile/strip-private-attrs))]
(-> profile
(rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))))
(sv/defmethod ::get-profile-passkeys
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id]}]
(db/query pool :profile-passkey {:profile-id profile-id}
{:columns [:id :profile-id :created-at :updated-at :sign-count]}))
(def ^:private schema:delete-profile-passkey
[:map {:title "delete-profile-passkey"}
[:id ::sm/uuid]])
(sv/defmethod ::delete-profile-passkey
{::doc/added "1.20"
::sm/params schema:delete-profile-passkey}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(db/delete! pool :profile-passkey {:profile-id profile-id :id id})
nil)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMPL HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- create-challenge!
[{:keys [::db/conn]} profile-id]
(let [data (bn/random-nonce 32)
sql (dm/str "insert into profile_challenge values (?,?,now()) "
" on conflict (profile_id) "
" do update set data=?, created_at=now()")]
(db/exec-one! conn [sql profile-id data data])
data))
(defn- get-current-challenge
[{:keys [::db/conn]} profile-id]
(let [row (db/get conn :profile-challenge {:profile-id profile-id})]
(DefaultChallenge. (:data row))))
(defn get-server-property
[challenge]
(let [uri (cf/get :public-uri)
host (-> uri u/uri :host)
orig (Origin/create ^String uri)]
(ServerProperty. ^Origin orig
^String host
^bytes challenge
nil)))
(defn- get-profile
[{:keys [::db/conn]} email]
(profile/decode-row
(db/get* conn :profile
{:email (str/lower email)}
{:columns [:id :email :fullname :props]})))
(defn- get-credentials
[{:keys [::db/conn]} profile-id]
(->> (db/query conn :profile-passkey
{:profile-id profile-id}
{:columns [:credential-id]})
(into #{} (map :credential-id))))
(defn- get-passkey
[{:keys [::db/conn]} {:keys [credential-id user-handle]}]
(let [params (cond-> {:credential-id credential-id}
(some? user-handle)
(assoc :profile-id (uuid/from-bytes user-handle)))]
(db/get conn :profile-passkey params)))
(defn- update-passkey!
[{:keys [::db/conn]} passkey ^AuthenticationData authdata]
(let [credential-id (:credential-id passkey)
sign-count (.. authdata getAuthenticatorData getSignCount)]
(db/update! conn :profile-passkey
{:sign-count sign-count}
{:credential-id credential-id})))
(defn- prepare-auth-data
[{:keys [credential-id user-handle auth-data client-data signature] :as params}]
(let [request (AuthenticationRequest. ^bytes credential-id
^bytes user-handle
^bytes auth-data
^bytes client-data
nil
^bytes signature)]
(.parse ^WebAuthnManager @manager
^AuthenticationRequest request)))
(defn- prepare-registration-data
[{:keys [attestation client-data]}]
(let [request (RegistrationRequest. attestation client-data)]
(.parse ^WebAuthnManager @manager
^RegistrationRequest request)))
(defn- validate-registration-data!
[regdata challenge]
(let [property (get-server-property challenge)
params (RegistrationParameters. ^ServerProperty property false true)]
(try
(.validate ^WebAuthnManager @manager
^RegistrationData regdata
^RegistrationParameters params)
(catch Throwable cause
(ex/raise :type :validation
:code :webauthn-error
:cause cause)))))
(defn- get-authenticator
[{:keys [attestation sign-count]}]
(let [attestation (decode-attestation attestation)]
(AuthenticatorImpl. ^AttestedCredentialData attestation nil ^long sign-count)))
(defn- validate-auth-data!
[authdata passkey challenge]
(let [property (get-server-property challenge)
auth (get-authenticator passkey)
params (AuthenticationParameters. ^ServerProperty property
^Authenticator auth
nil
false
true)]
(try
(.validate ^WebAuthnManager @manager
^AuthenticationData authdata
^AuthenticationParameters params)
(catch Throwable cause
(l/err :hint "validation error on auth request" :cause cause)
(ex/raise :type :validation
:code :webauthn-error
:cause cause)))))
(defn- get-attestation
[^RegistrationData regdata]
(encode-attestation
(.. regdata
(getAttestationObject)
(getAuthenticatorData)
(getAttestedCredentialData))))
(defn- get-sign-count
[^RegistrationData regdata]
(.. regdata
(getAttestationObject)
(getAuthenticatorData)
(getSignCount)))
(defn- encode-attestation
[attestation]
(assert (instance? AttestedCredentialData attestation) "expected AttestedCredentialData instance")
(let [converter (AttestedCredentialDataConverter. (ObjectConverter.))]
(.convert converter ^AttestedCredentialData attestation)))
(defn- decode-attestation
[attestation]
(assert (bytes? attestation) "expected byte array")
(let [converter (AttestedCredentialDataConverter. (ObjectConverter.))]
(.convert converter ^bytes attestation)))

View File

@@ -0,0 +1,56 @@
;; 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.util.totp
(:import
dev.samstevens.totp.code.DefaultCodeGenerator
dev.samstevens.totp.code.DefaultCodeVerifier
dev.samstevens.totp.code.CodeVerifier
dev.samstevens.totp.code.HashingAlgorithm
dev.samstevens.totp.qr.QrData
dev.samstevens.totp.qr.QrData$Builder
dev.samstevens.totp.qr.ZxingPngQrGenerator
dev.samstevens.totp.secret.DefaultSecretGenerator
dev.samstevens.totp.time.SystemTimeProvider
dev.samstevens.totp.util.Utils))
(defn get-verifier
[]
(DefaultCodeVerifier.
(DefaultCodeGenerator. HashingAlgorithm/SHA1 6)
(SystemTimeProvider.)))
(defn valid-code?
[secret code]
(let [verifier (doto (get-verifier)
(.setTimePeriod 30)
(.setAllowedTimePeriodDiscrepancy 2))
result (.isValidCode ^CodeVerifier verifier
^String secret
^String code)]
result))
(defn gen-secret
([] (gen-secret 32))
([n]
(let [sgen (DefaultSecretGenerator. (int n))]
(.generate ^DefaultSecretGenerator sgen))))
(defn get-qrcode-image
[secret email]
(let [data (.. (QrData$Builder.)
(label ^String email)
(secret ^String secret)
(issuer "Penpot")
(digits 6)
(period 30)
(build))
imgen (ZxingPngQrGenerator.)
imgdt (.generate imgen ^QrData data)
imgmt (.getImageMimeType imgen)]
(Utils/getDataUriForImage imgdt imgmt)))

View File

@@ -18,6 +18,9 @@
selmer/selmer {:mvn/version "1.12.59"}
criterium/criterium {:mvn/version "0.4.6"}
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.5.351"}
metosin/jsonista {:mvn/version "0.3.7"}
metosin/malli {:mvn/version "0.11.0"}

View File

@@ -8,6 +8,7 @@
(:refer-clojure :exclude [deref merge parse-uuid])
#?(:cljs (:require-macros [app.common.schema :refer [ignoring]]))
(:require
#?(:clj [buddy.core.codecs :as bc])
[app.common.data.macros :as dm]
[app.common.schema.generators :as sg]
[app.common.schema.openapi :as-alias oapi]
@@ -489,6 +490,26 @@
::oapi/format "uri"
::oapi/decode (comp u/uri str/trim)}})
#?(:clj
(def! ::bytes
{:type ::bytes
:pred bytes?
:type-properties
{:title "bytes"
:description "bytes"
:error/message "expected a bytes instance"
:gen/gen (sg/word-string)
::oapi/decode (fn [v]
(if (string? v)
(-> v bc/str->bytes bc/b64->bytes)
v))
::oapi/encode (fn [v]
(if (bytes? v)
(-> v bc/bytes->b64 bc/bytes->str)
v))
::oapi/type "bytes"
::oapi/format "string"}}))
;; ---- PREDICATES
(def safe-int?

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -121,6 +121,29 @@
*:not(:last-child) {
margin-bottom: $size-4;
}
&.center {
align-items: center;
}
}
section.passkey {
display: flex;
flex-direction: column;
width: 100%;
align-items: center;
margin-top: 20px;
.btn-disabled {
filter: grayscale(100%);
background-color: unset;
}
.btn-passkey-auth {
width: 60px;
cursor: pointer;
border: 0px;
}
}
.btn-large {

View File

@@ -38,6 +38,7 @@
width: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
.form-container {
margin-top: 50px;
@@ -155,6 +156,17 @@
margin-bottom: 20px;
}
}
.auth-settings {
h2 {
color: $color-black;
}
.options-form {
margin-top: 40px;
width: 368px;
}
}
}
.dashboard-access-tokens {
@@ -301,3 +313,94 @@
color: $color-gray-40;
}
}
.dashboard-passkeys {
display: flex;
flex-direction: column;
align-items: center;
.passkeys-hero-container {
max-width: 1000px;
width: 100%;
display: flex;
flex-direction: column;
}
.passkeys-hero {
font-size: $fs14;
padding: $size-6;
background-color: $color-white;
margin-top: $size-6;
display: flex;
justify-content: space-between;
.desc {
width: 80%;
color: $color-gray-40;
h2 {
margin-bottom: $size-4;
color: $color-black;
}
p {
font-size: $fs16;
}
}
.btn-primary {
flex-shrink: 0;
}
}
.passkeys-empty {
text-align: center;
max-width: 1000px;
width: 100%;
padding: $size-6;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 1px dashed $color-gray-20;
color: $color-gray-40;
margin-top: 12px;
min-height: 136px;
}
.table-row {
background-color: $color-white;
display: grid;
grid-template-columns: 1fr 25% 40px 12px;
height: 63px;
&:not(:first-child) {
margin-top: 8px;
}
}
.table-field {
&.name {
color: $color-gray-60;
width: 150px;
}
&.create-date {
color: $color-gray-40;
font-size: $fs14;
.content {
padding: 2px 5px;
&.expired {
background-color: $color-warning-lighter;
border-radius: $br4;
color: $color-gray-40;
}
}
}
&.passkey-created {
word-break: break-all;
}
&.actions {
position: relative;
}
}
}

View File

@@ -15,9 +15,10 @@
[app.config :as cf]
[app.main.data.events :as ev]
[app.main.data.media :as di]
[app.main.data.messages :as msg]
[app.main.data.websocket :as ws]
[app.main.repo :as rp]
[app.util.i18n :as i18n]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[app.util.storage :refer [storage]]
[beicon.core :as rx]
@@ -119,6 +120,40 @@
;; --- EVENT: login
(defn- create-passkey-assertion
"A mandatory step on passkey authentication ceremony"
[{:keys [credentials challenge rp-id]}]
(let [challenge (js/Uint8Array. challenge)
credentials (->> credentials
(map (fn [credential]
#js {:id credential :type "public-key"}))
(into-array))
options #js {:challenge challenge
:rpId rp-id
:userVerification "preferred"
:allowCredentials credentials
:timeout 30000}
platform (.-credentials js/navigator)]
(.get ^js platform #js {:publicKey options})))
(defn- login-with-passkey*
[assertion data]
(js/console.log "login-with-passkey*" assertion)
(let [credential-id (unchecked-get assertion "rawId")
response (unchecked-get assertion "response")
auth-data (unchecked-get response "authenticatorData")
client-data (unchecked-get response "clientDataJSON")
user-handle (unchecked-get response "userHandle")
signature (unchecked-get response "signature")
params (-> data
(assoc :credential-id (js/Uint8Array. credential-id))
(assoc :auth-data (js/Uint8Array. auth-data))
(assoc :client-data (js/Uint8Array. client-data))
(assoc :user-handle (some-> user-handle (js/Uint8Array.)))
(assoc :signature (js/Uint8Array. signature)))]
(rp/cmd! :login-with-passkey params)))
(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
@@ -148,9 +183,10 @@
(declare login-from-register)
(defn login
[{:keys [email password invitation-token] :as data}]
(ptk/reify ::login
(defn login-with-password
[{:keys [email password invitation-token totp] :as data}]
(prn "login-with-password" data)
(ptk/reify ::login-with-password
ptk/WatchEvent
(watch [_ _ stream]
(let [{:keys [on-error on-success]
@@ -159,6 +195,7 @@
params {:email email
:password password
:totp totp
:invitation-token invitation-token}]
;; NOTE: We can't take the profile value from login because
@@ -171,6 +208,12 @@
;; proceed to logout and show an error message.
(->> (rp/cmd! :login-with-password (d/without-nils params))
(rx/catch (fn [cause]
(if (and (= :negotiation (:type cause))
(= :passkey (:code cause)))
(->> (rx/from (create-passkey-assertion cause))
(rx/mapcat #(login-with-passkey* % data)))
(rx/throw cause))))
(rx/merge-map (fn [data]
(rx/merge
(rx/of (fetch-profile))
@@ -267,27 +310,17 @@
(defn update-profile
[data]
(dm/assert! (profile? data))
(ptk/reify ::update-profile
ptk/WatchEvent
(watch [_ _ stream]
(watch [_ _ _]
(let [mdata (meta data)
on-success (:on-success mdata identity)
on-error (:on-error mdata rx/throw)]
(->> (rp/cmd! :update-profile (dissoc data :props))
(rx/mapcat
(fn [_]
(rx/merge
(->> stream
(rx/filter (ptk/type? ::profile-fetched))
(rx/take 1)
(rx/tap on-success)
(rx/ignore))
(rx/of (profile-fetched data)))))
(->> (rp/cmd! :update-profile data)
(rx/tap on-success)
(rx/map profile-fetched)
(rx/catch on-error))))))
;; --- Request Email Change
(defn request-email-change
@@ -501,7 +534,7 @@
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :create-demo-profile {})
(rx/map login)))))
(rx/map login-with-password)))))
;; --- EVENT: fetch-team-webhooks
@@ -556,3 +589,121 @@
(->> (rp/cmd! :delete-access-token params)
(rx/tap on-success)
(rx/catch on-error))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PASSKEYS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn fetch-passkeys
[]
(ptk/reify ::fetch-passkeys
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :get-profile-passkeys)
(rx/map (fn [passkeys]
(fn [state]
(assoc state :passkeys passkeys))))))))
(defn delete-passkey
[{:keys [id] :as params}]
(us/assert! ::us/uuid id)
(ptk/reify ::delete-passkey
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error rx/throw}} (meta params)]
(->> (rp/cmd! :delete-profile-passkey params)
(rx/tap on-success)
(rx/catch on-error))))))
(defn create-passkey
[]
(letfn [(create-pubkey [{:keys [challenge user-id user-name user-email rp-id rp-name]}]
(let [user #js {:id user-id
:name user-email
:displayName user-name}
auths #js {:authenticatorAttachment "cross-platform"
:residentKey "preferred"
:requireResidentKey false
:userVerification "preferred"}
options #js {:challenge challenge
:rp #js {:id rp-id :name rp-name}
:user user
:pubKeyCredParams #js [#js {:alg -7 :type "public-key"}
#js {:alg -257 :type "public-key"}]
:authenticatorSelection auths
:timeout 30000,
:attestation "direct"}
platform (. js/navigator -credentials)]
(.create ^js platform #js {:publicKey options})))
(persist-pubkey [pubkey]
(let [response (unchecked-get pubkey "response")
id (unchecked-get pubkey "rawId")
attestation (unchecked-get response "attestationObject")
client-data (unchecked-get response "clientDataJSON")
params {:credential-id (js/Uint8Array. id)
:attestation (js/Uint8Array. attestation)
:client-data (js/Uint8Array. client-data)}]
(rp/cmd! :create-profile-passkey params)))
]
(ptk/reify ::create-passkey
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :prepare-profile-passkey-registration {})
(rx/mapcat create-pubkey)
(rx/mapcat persist-pubkey)
(rx/map (fn [_] (fetch-passkeys)))
(rx/catch (fn [cause]
(if (instance? js/DOMException cause)
(rx/of (msg/show {:type :error
:tag :passkey
:timeout 5000
:content (tr "errors.passkey-rejection-or-timeout")}))
(rx/throw cause)))))))))
(defn login-with-passkey
[data]
(ptk/reify ::login-with-passkey
ptk/WatchEvent
(watch [_ _ stream]
(let [{:keys [on-error on-success]
:or {on-error rx/throw
on-success identity}} (meta data)]
(->> (rp/cmd! :prepare-login-with-passkey data)
(rx/mapcat create-passkey-assertion)
(rx/mapcat #(login-with-passkey* % data))
(rx/merge-map (fn [data]
(rx/merge
(rx/of (fetch-profile))
(->> stream
(rx/filter 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 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 (fn [cause]
(if (instance? js/DOMException cause)
(rx/of (msg/show {:type :error
:tag :passkey
:timeout 5000
:content (tr "errors.passkey-rejection-or-timeout")}))
(rx/throw cause))))
(rx/catch on-error))))))

View File

@@ -21,7 +21,7 @@
[cuerdas.core :as str]
[potok.core :as ptk]))
(defn- print-data!
(defn print-data!
[data]
(-> data
(dissoc ::sm/explain)
@@ -30,13 +30,13 @@
(dissoc ::instance)
(pp/pprint {:width 70})))
(defn- print-explain!
(defn print-explain!
[data]
(when-let [explain (::sm/explain data)]
(-> (sm/humanize-data explain)
(pp/pprint {:width 70}))))
(defn- print-trace!
(defn print-trace!
[data]
(some-> data ::trace js/console.log))

View File

@@ -58,7 +58,8 @@
:settings-password
:settings-options
:settings-feedback
:settings-access-tokens)
:settings-access-tokens
:settings-passkeys)
[:& settings/settings {:route route}]
:debug-icons-preview

View File

@@ -12,6 +12,7 @@
[app.config :as cf]
[app.main.data.messages :as dm]
[app.main.data.users :as du]
[app.main.errors :as err]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.button-link :as bl]
@@ -79,8 +80,8 @@
(s/def ::invitation-token ::us/not-empty-string)
(s/def ::login-form
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(s/keys :req-un [::email]
:opt-un [::password ::invitation-token]))
(defn handle-error-messages
[errors _data]
@@ -91,55 +92,101 @@
(assoc :message (tr "errors.email-invalid"))))))
(mf/defc login-form
[{:keys [params on-success-callback] :as props}]
[{:keys [params on-success] :as props}]
(let [initial (mf/use-memo (mf/deps params) (constantly params))
totp* (mf/use-state false)
totp? (deref totp*)
error (mf/use-state false)
form (fm/use-form :spec ::login-form
:validators [handle-error-messages]
:initial initial)
on-error
(fn [cause]
(cond
(and (= :restriction (:type cause))
(= :profile-blocked (:code cause)))
(reset! error (tr "errors.profile-blocked"))
(mf/use-fn
(fn [form cause]
(when (map? cause)
(err/print-trace! cause)
(err/print-data! cause)
(err/print-explain! cause))
(and (= :restriction (:type cause))
(= :admin-only-profile (:code cause)))
(reset! error (tr "errors.profile-blocked"))
(cond
(and (= :validation (:type cause))
(= :wrong-credentials (:code cause)))
(reset! error (tr "errors.wrong-credentials"))
(and (= :totp (:code cause))
(= :negotiation (:type cause)))
(reset! totp* true)
(and (= :validation (:type cause))
(= :account-without-password (:code cause)))
(reset! error (tr "errors.wrong-credentials"))
(and (= :invalid-totp (:code cause))
(= :negotiation (:type cause)))
(do
;; (reset! error (tr "errors.invalid-totp"))
(swap! form (fn [form]
(-> form
(update :errors assoc :totp {:message (tr "errors.invalid-totp")})
(update :touched assoc :totp true)))))
:else
(reset! error (tr "errors.generic"))))
(and (= :restriction (:type cause))
(= :passkey-disabled (:code cause)))
(reset! error (tr "errors.wrong-credentials"))
(and (= :restriction (:type cause))
(= :profile-blocked (:code cause)))
(reset! error (tr "errors.profile-blocked"))
(and (= :restriction (:type cause))
(= :admin-only-profile (:code cause)))
(reset! error (tr "errors.profile-blocked"))
(and (= :validation (:type cause))
(= :wrong-credentials (:code cause)))
(reset! error (tr "errors.wrong-credentials"))
(and (= :validation (:type cause))
(= :account-without-password (:code cause)))
(reset! error (tr "errors.wrong-credentials"))
:else
(reset! error (tr "errors.generic")))))
on-success-default
(fn [data]
(when-let [token (:invitation-token data)]
(st/emit! (rt/nav :auth-verify-token {} {:token token}))))
(mf/use-fn
(fn [data]
(when-let [token (:invitation-token data)]
(st/emit! (rt/nav :auth-verify-token {} {:token token})))))
on-success
(fn [data]
(if (nil? on-success-callback)
(on-success-default data)
(on-success-callback)))
(mf/use-fn
(mf/deps on-success)
(fn [data]
(if (fn? on-success)
(on-success data)
(on-success-default data))))
on-submit
(mf/use-callback
(fn [form _event]
(reset! error nil)
(let [params (with-meta (:clean-data @form)
{:on-error on-error
:on-success on-success})]
(st/emit! (du/login params)))))
(mf/use-fn
(fn [form event]
(let [event (dom/event->native-event event)
submitter (unchecked-get event "submitter")
submitter (dom/get-data submitter "role")
on-error (partial on-error form)
on-success (partial on-success form)]
(case submitter
"login-with-passkey"
(let [params (with-meta (:clean-data @form)
{:on-error on-error
:on-success on-success})]
(st/emit! (du/login-with-passkey params)))
"login-with-password"
(let [params (with-meta (:clean-data @form)
{:on-error on-error
:on-success on-success})]
(st/emit! (du/login-with-password params)))
nil))))
on-submit-ldap
(mf/use-callback
@@ -174,17 +221,35 @@
:help-icon i/eye
:label (tr "auth.password")}]]
(when totp?
[:div.fields-row
[:& fm/input
{:type "text"
:name :totp
:label (tr "auth.totp")}]])
[:div.buttons-stack
(when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password))
[:> fm/submit-button*
{:label (tr "auth.login-submit")
:data-role "login-with-password"
:data-test "login-submit"}])
(when (contains? cf/flags :login-with-ldap)
[:> fm/submit-button*
{:label (tr "auth.login-with-ldap-submit")
:on-click on-submit-ldap}])]]]))
:data-role "login-with-ldap"
:on-click on-submit-ldap}])]
[:section.passkey
[:> fm/submit-button*
{:data-role "login-with-passkey"
:class "btn-passkey-auth"}
[:img {:src "/images/passkey.png"}]]]
]]))
(mf/defc login-buttons
[{:keys [params] :as props}]
@@ -230,7 +295,7 @@
(tr "auth.login-with-oidc-submit")]]))
(mf/defc login-methods
[{:keys [params on-success-callback] :as props}]
[{:keys [params on-success] :as props}]
[:*
(when show-alt-login-buttons?
[:*
@@ -252,35 +317,40 @@
(when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password)
(contains? cf/flags :login-with-ldap))
[:& login-form {:params params :on-success-callback on-success-callback}])])
[:& login-form {:params params :on-success on-success}])])
(mf/defc login-page
[{:keys [params] :as props}]
[:div.generic-form.login-form
[:div.form-container
[:h1 {:data-test "login-title"} (tr "auth.login-title")]
{::mf/wrap-props false}
[{:keys [params]}]
(let [nav-to-recovery (mf/use-fn #(st/emit! (rt/nav :auth-recovery-request)))
nav-to-register (mf/use-fn (mf/deps params) #(st/emit! (rt/nav :auth-register {} params)))
create-demo (mf/use-fn #(st/emit! (du/create-demo-profile)))]
[:& login-methods {:params params}]
[:div.generic-form.login-form
[:div.form-container
[:h1 {:data-test "login-title"} (tr "auth.login-title")]
[:div.links
(when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password))
[:div.link-entry
[:& lk/link {:action #(st/emit! (rt/nav :auth-recovery-request))
:data-test "forgot-password"}
(tr "auth.forgot-password")]])
[:& login-methods {:params params}]
(when (contains? cf/flags :registration)
[:div.link-entry
[:span (tr "auth.register") " "]
[:& lk/link {:action #(st/emit! (rt/nav :auth-register {} params))
:data-test "register-submit"}
(tr "auth.register-submit")]])]
[:div.links
(when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password))
[:div.link-entry
[:& lk/link {:on-click nav-to-recovery
:data-test "forgot-password"}
(tr "auth.forgot-password")]])
(when (contains? cf/flags :demo-users)
[:div.links.demo
[:div.link-entry
[:span (tr "auth.create-demo-profile") " "]
[:& lk/link {:action #(st/emit! (du/create-demo-profile))
:data-test "demo-account-link"}
(tr "auth.create-demo-account")]]])]])
(when (contains? cf/flags :registration)
[:div.link-entry
[:span (tr "auth.register") " "]
[:& lk/link {:on-click nav-to-register
:data-test "register-submit"}
(tr "auth.register-submit")]])]
(when (contains? cf/flags :demo-users)
[:div.links.demo
[:div.link-entry
[:span (tr "auth.create-demo-profile") " "]
[:& lk/link {:on-click create-demo
:data-test "demo-account-link"}
(tr "auth.create-demo-account")]]])]]))

View File

@@ -21,7 +21,9 @@
[rumext.v2 :as mf]))
(s/def ::email ::us/email)
(s/def ::recovery-request-form (s/keys :req-un [::email]))
(s/def ::recovery-request-form
(s/keys :req-un [::email]))
(defn handle-error-messages
[errors _data]
(d/update-when errors :email
@@ -31,24 +33,27 @@
(assoc :message (tr "errors.email-invalid"))))))
(mf/defc recovery-form
[{:keys [on-success-callback] :as props}]
{::mf/wrap-props false}
[{:keys [on-success]}]
(let [form (fm/use-form :spec ::recovery-request-form
:validators [handle-error-messages]
:initial {})
submitted (mf/use-state false)
default-success-finish #(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent")))
default-on-success
(mf/use-fn #(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent"))))
on-success
(mf/use-callback
(mf/use-fn
(mf/deps default-on-success on-success)
(fn [cdata _]
(reset! submitted false)
(if (nil? on-success-callback)
(default-success-finish)
(on-success-callback (:email cdata)))))
(if (fn? on-success)
(on-success (:email cdata))
(default-on-success))))
on-error
(mf/use-callback
(mf/use-fn
(fn [data {:keys [code] :as error}]
(reset! submitted false)
(case code
@@ -64,7 +69,7 @@
(rx/throw error))))
on-submit
(mf/use-callback
(mf/use-fn
(fn []
(reset! submitted true)
(let [cdata (:clean-data @form)
@@ -74,32 +79,34 @@
(reset! form nil)
(st/emit! (du/request-profile-recovery params)))))]
[:& fm/form {:on-submit on-submit
:form form}
[:& fm/form {:on-submit on-submit :form form}
[:div.fields-row
[:& fm/input {:name :email
:label (tr "auth.email")
:help-icon i/at
:type "text"}]]
[:& fm/input
{:name :email
:label (tr "auth.email")
:help-icon i/at
:type "text"}]]
[:> fm/submit-button*
{:label (tr "auth.recovery-request-submit")
:data-test "recovery-resquest-submit"}]]))
;; --- Recovery Request Page
(mf/defc recovery-request-page
[{:keys [params on-success-callback go-back-callback] :as props}]
(let [default-go-back #(st/emit! (rt/nav :auth-login))
go-back (or go-back-callback default-go-back)]
{::mf/wrap-props false}
[{:keys [params on-success on-go-back]}]
(let [default-go-back (mf/use-fn #(st/emit! (rt/nav :auth-login)))
on-go-back (or on-go-back default-go-back)]
[:section.generic-form
[:div.form-container
[:h1 (tr "auth.recovery-request-title")]
[:div.subtitle (tr "auth.recovery-request-subtitle")]
[:& recovery-form {:params params :on-success-callback on-success-callback}]
[:& recovery-form
{:params params
:on-success on-success}]
[:div.links
[:div.link-entry
[:& lk/link {:action go-back
[:& lk/link {:on-click on-go-back
:data-test "go-back-link"}
(tr "labels.go-back")]]]]]))

View File

@@ -16,6 +16,7 @@
[app.main.ui.auth.login :as login]
[app.main.ui.components.forms :as fm]
[app.main.ui.components.link :as lk]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.util.i18n :refer [tr]]
@@ -55,63 +56,64 @@
(s/keys :req-un [::password ::email]
:opt-un [::invitation-token]))
(defn- handle-prepare-register-error
[form {:keys [type code] :as cause}]
(condp = [type code]
[:restriction :registration-disabled]
(st/emit! (dm/error (tr "errors.registration-disabled")))
[:restriction :profile-blocked]
(st/emit! (dm/error (tr "errors.profile-blocked")))
[:validation :email-has-permanent-bounces]
(let [email (get @form [:data :email])]
(st/emit! (dm/error (tr "errors.email-has-permanent-bounces" email))))
[:validation :email-already-exists]
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
[:validation :email-as-password]
(swap! form assoc-in [:errors :password]
{:message "errors.email-as-password"})
(st/emit! (dm/error (tr "errors.generic")))))
(defn- handle-prepare-register-success
[params]
(st/emit! (rt/nav :auth-register-validate {} params)))
(mf/defc register-form
[{:keys [params on-success-callback] :as props}]
(let [initial (mf/use-memo (mf/deps params) (constantly params))
[{:keys [params on-success]}]
(let [initial (hooks/use-equal-memo params)
form (fm/use-form :spec ::register-form
:validators [validate
(fm/validate-not-empty :password (tr "auth.password-not-empty"))]
:initial initial)
submitted? (mf/use-state false)
on-success (fn [p]
(if (nil? on-success-callback)
(handle-prepare-register-success p)
(on-success-callback p)))
on-success-default
(mf/use-fn #(st/emit! (rt/nav :auth-register-validate {} %)))
on-success
(mf/use-fn
(mf/deps on-success on-success-default)
(fn [_ data]
(if (fn? on-success)
(on-success data)
(on-success-default data))))
on-error
(mf/use-fn
(fn [form {:keys [type code] :as cause}]
(condp = [type code]
[:restriction :registration-disabled]
(st/emit! (dm/error (tr "errors.registration-disabled")))
[:restriction :profile-blocked]
(st/emit! (dm/error (tr "errors.profile-blocked")))
[:validation :email-has-permanent-bounces]
(let [email (get @form [:data :email])]
(st/emit! (dm/error (tr "errors.email-has-permanent-bounces" email))))
[:validation :email-already-exists]
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
[:validation :email-as-password]
(swap! form assoc-in [:errors :password]
{:message "errors.email-as-password"})
(st/emit! (dm/error (tr "errors.generic"))))))
on-submit
(mf/use-callback
(mf/use-fn
(mf/deps on-success on-error)
(fn [form _event]
(reset! submitted? true)
(let [cdata (:clean-data @form)]
(let [cdata (:clean-data @form)
on-error (partial on-error form)
on-success (partial on-success form)]
(->> (rp/cmd! :prepare-register-profile cdata)
(rx/map #(merge % params))
(rx/finalize #(reset! submitted? false))
(rx/subs
on-success
(partial handle-prepare-register-error form))))))]
(rx/subs on-success on-error)))))]
[:& fm/form {:on-submit on-submit
:form form}
[:& fm/form {:on-submit on-submit :form form}
[:div.fields-row
[:& fm/input {:type "email"
:name :email
@@ -131,7 +133,7 @@
(mf/defc register-methods
[{:keys [params on-success-callback] :as props}]
[{:keys [params on-success]}]
[:*
(when login/show-alt-login-buttons?
[:*
@@ -149,62 +151,39 @@
[:span.text (tr "labels.or")]
[:span.line]])])
[:& register-form {:params params :on-success-callback on-success-callback}]])
[:& register-form {:params params :on-success on-success}]])
(mf/defc register-page
[{:keys [params] :as props}]
[:div.form-container
[{:keys [params]}]
(let [nav-to-login (mf/use-fn (mf/deps params) #(st/emit! (rt/nav :auth-login {} params)))
create-demo (mf/use-fn #(st/emit! (du/create-demo-profile)))]
[:h1 {:data-test "registration-title"} (tr "auth.register-title")]
[:div.subtitle (tr "auth.register-subtitle")]
[:div.form-container
(when (contains? cf/flags :demo-warning)
[:& demo-warning])
[:h1 {:data-test "registration-title"} (tr "auth.register-title")]
[:div.subtitle (tr "auth.register-subtitle")]
[:& register-methods {:params params}]
(when (contains? cf/flags :demo-warning)
[:& demo-warning])
[:div.links
[:div.link-entry
[:span (tr "auth.already-have-account") " "]
[:& register-methods {:params params}]
[:& lk/link {:action #(st/emit! (rt/nav :auth-login {} params))
:data-test "login-here-link"}
(tr "auth.login-here")]]
(when (contains? cf/flags :demo-users)
[:div.links
[:div.link-entry
[:span (tr "auth.create-demo-profile") " "]
[:& lk/link {:action #(st/emit! (du/create-demo-profile))}
(tr "auth.create-demo-account")]])]])
[:span (tr "auth.already-have-account") " "]
[:& lk/link {:on-click nav-to-login
:data-test "login-here-link"}
(tr "auth.login-here")]]
(when (contains? cf/flags :demo-users)
[:div.link-entry
[:span (tr "auth.create-demo-profile") " "]
[:& lk/link {:on-click create-demo}
(tr "auth.create-demo-account")]])]]))
;; --- PAGE: register validation
(defn- handle-register-error
[form error]
(case (:code error)
:email-already-exists
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
(do
(println (:explain error))
(st/emit! (dm/error (tr "errors.generic"))))))
(defn- handle-register-success
[data]
(cond
(some? (:invitation-token data))
(let [token (:invitation-token data)]
(st/emit! (rt/nav :auth-verify-token {} {:token token})))
;; The :is-active flag is true, when insecure-register is enabled
;; or the user used external auth provider.
(:is-active data)
(st/emit! (du/login-from-register))
:else
(st/emit! (rt/nav :auth-register-success {} {:email (:email data)}))))
(s/def ::accept-terms-and-privacy (s/and ::us/boolean true?))
(s/def ::accept-newsletter-subscription ::us/boolean)
@@ -218,30 +197,63 @@
::accept-newsletter-subscription])))
(mf/defc register-validate-form
[{:keys [params on-success-callback] :as props}]
(let [form (fm/use-form :spec ::register-validate-form
[{:keys [params on-success]}]
(let [params (hooks/use-equal-memo params)
form (fm/use-form :spec ::register-validate-form
:validators [(fm/validate-not-empty :fullname (tr "auth.name.not-all-space"))
(fm/validate-length :fullname fm/max-length-allowed (tr "auth.name.too-long"))]
:initial params)
submitted? (mf/use-state false)
on-success (fn [p]
(if (nil? on-success-callback)
(handle-register-success p)
(on-success-callback (:email p))))
on-error
(mf/use-fn
(fn [form error]
(case (:code error)
:email-already-exists
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
(do
(println (:explain error))
(st/emit! (dm/error (tr "errors.generic")))))))
on-success-default
(mf/use-fn
(fn [data]
(cond
(some? (:invitation-token data))
(let [token (:invitation-token data)]
(st/emit! (rt/nav :auth-verify-token {} {:token token})))
;; The :is-active flag is true, when insecure-register is enabled
;; or the user used external auth provider.
(:is-active data)
(st/emit! (du/login-from-register))
:else
(st/emit! (rt/nav :auth-register-success {} {:email (:email data)})))))
on-success
(mf/use-fn
(mf/deps on-success on-success-default)
(fn [_ data]
(if (fn? on-success)
(on-success data)
(on-success-default data))))
on-submit
(mf/use-callback
(mf/use-fn
(fn [form _event]
(reset! submitted? true)
(let [params (:clean-data @form)]
(let [params (:clean-data @form)
on-success (partial on-success form)
on-error (partial on-error form)]
(->> (rp/cmd! :register-profile params)
(rx/finalize #(reset! submitted? false))
(rx/subs on-success
(partial handle-register-error form))))))]
(rx/subs on-success on-error)))))]
[:& fm/form {:on-submit on-submit
:form form}
[:& fm/form {:on-submit on-submit :form form}
[:div.fields-row
[:& fm/input {:name :fullname
:label (tr "auth.fullname")
@@ -265,20 +277,20 @@
(mf/defc register-validate-page
[{:keys [params] :as props}]
[:div.form-container
[:h1 {:data-test "register-title"} (tr "auth.register-title")]
[:div.subtitle (tr "auth.register-subtitle")]
[{:keys [params]}]
(let [nav-to-register (mf/use-fn #(st/emit! (rt/nav :auth-register {} {})))]
[:div.form-container
[:h1 {:data-test "register-title"} (tr "auth.register-title")]
[:div.subtitle (tr "auth.register-subtitle")]
[:& register-validate-form {:params params}]
[:& register-validate-form {:params params}]
[:div.links
[:div.link-entry
[:& lk/link {:action #(st/emit! (rt/nav :auth-register {} {}))}
(tr "labels.go-back")]]]])
[:div.links
[:div.link-entry
[:& lk/link {:on-click nav-to-register} (tr "labels.go-back")]]]]))
(mf/defc register-success-page
[{:keys [params] :as props}]
[{:keys [params]}]
[:div.form-container
[:div.notification-icon i/icon-verify]
[:div.notification-text (tr "auth.verification-email-sent")]

View File

@@ -9,13 +9,19 @@
[app.util.keyboard :as kbd]
[rumext.v2 :as mf]))
(mf/defc link [{:keys [action klass data-test keyboard-action children]}]
(let [keyboard-action (or keyboard-action action)]
[:a {:on-click action
:class klass
:on-key-down (fn [event]
(when (kbd/enter? event)
(keyboard-action event)))
(mf/defc link
{::mf/wrap-props false}
[{:keys [on-click class data-test on-key-enter children]}]
(let [on-key-enter (or on-key-enter on-click)
on-key-down
(mf/use-fn
(mf/deps on-key-enter)
(fn [event]
(when (and (kbd/enter? event) (fn? on-key-enter))
(on-key-enter event))))]
[:a {:on-click on-click
:on-key-down on-key-down
:class class
:tab-index "0"
:data-test data-test}
[:* children]]))
children]))

View File

@@ -629,19 +629,19 @@
[:ul.sidebar-nav.no-overflow
[:li.recent-projects
{:class-name (when projects? "current")}
[:& link {:action go-projects
:keyboard-action go-projects-with-key}
[:& link {:on-click go-projects
:on-key-enter go-projects-with-key}
[:span.element-title (tr "labels.projects")]]]
[:li {:class-name (when drafts? "current")}
[:& link {:action go-drafts
:keyboard-action go-drafts-with-key}
[:& link {:on-click go-drafts
:on-key-enter go-drafts-with-key}
[:span.element-title (tr "labels.drafts")]]]
[:li {:class-name (when libs? "current")}
[:& link {:action go-libs
:keyboard-action go-libs-with-key}
[:& link {:on-click go-libs
:on-key-enter go-libs-with-key}
[:span.element-title (tr "labels.shared-libraries")]]]]]
[:hr]
@@ -650,8 +650,8 @@
[:ul.sidebar-nav.no-overflow
[:li {:class-name (when fonts? "current")}
[:& link {:action go-fonts
:keyboard-action go-fonts-with-key
[:& link {:on-click go-fonts
:on-key-enter go-fonts-with-key
:data-test "fonts"}
[:span.element-title (tr "labels.fonts")]]]]]

View File

@@ -5,7 +5,7 @@
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.icons
(:refer-clojure :exclude [import mask])
(:refer-clojure :exclude [import mask key])
(:require-macros [app.main.ui.icons :refer [icon-xref]])
(:require [rumext.v2 :as mf]))
@@ -150,7 +150,7 @@
(def justify-content-row-center (icon-xref :justify-content-row-center))
(def justify-content-row-end (icon-xref :justify-content-row-end))
(def justify-content-row-start (icon-xref :justify-content-row-start))
(def icon-key (icon-xref :icon-key))
(def key (icon-xref :key))
(def layers (icon-xref :layers))
(def layout-columns (icon-xref :layout-columns))
(def layout-rows (icon-xref :layout-rows))

View File

@@ -48,7 +48,8 @@
["/password" :settings-password]
["/feedback" :settings-feedback]
["/options" :settings-options]
["/access-tokens" :settings-access-tokens]]
["/access-tokens" :settings-access-tokens]
["/passkeys" :settings-passkeys]]
["/view/:file-id"
{:name :viewer

View File

@@ -13,6 +13,7 @@
[app.main.ui.settings.delete-account]
[app.main.ui.settings.feedback :refer [feedback-page]]
[app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.passkeys :refer [passkeys-page]]
[app.main.ui.settings.password :refer [password-page]]
[app.main.ui.settings.profile :refer [profile-page]]
[app.main.ui.settings.sidebar :refer [sidebar]]
@@ -59,5 +60,8 @@
[:& options-page {:locale locale}]
:settings-access-tokens
[:& access-tokens-page])]]]))
[:& access-tokens-page]
:settings-passkeys
[:& passkeys-page])]]]))

View File

@@ -85,7 +85,7 @@
(mf/use-callback
(mf/deps profile)
(partial on-submit profile))
on-email-change
(mf/use-callback
(fn [_ _]

View File

@@ -6,57 +6,152 @@
(ns app.main.ui.settings.options
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.spec :as us]
[app.main.data.messages :as dm]
[app.main.data.messages :as msg]
[app.main.data.modal :as modal]
[app.main.data.users :as du]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[rumext.v2 :as mf]))
(s/def ::lang (s/nilable ::us/string))
(s/def ::theme (s/nilable ::us/not-empty-string))
(s/def ::2fa ::us/keyword)
(s/def ::passkey ::us/keyword)
(s/def ::options-form
(s/keys :opt-un [::lang ::theme]))
(s/keys :opt-un [::lang ::theme ::2fa ::passkey]))
(defn- on-success
[_]
(st/emit! (dm/success (tr "notifications.profile-saved"))))
(st/emit! (msg/success (tr "notifications.profile-saved"))))
(defn- on-submit
[form _event]
(let [data (:clean-data @form)
(let [fdata (:clean-data @form)
data (d/without-nils
{:theme (:theme fdata)
:lang (:lang fdata)
:props {:passkey (:passkey fdata)
:2fa (:2fa fdata)}})
mdata {:on-success (partial on-success form)}]
(st/emit! (du/update-profile (with-meta data mdata)))))
(mf/defc options-form
(mf/defc settings
{::mf/wrap-props false}
[]
[_props]
(let [profile (mf/deref refs/profile)
initial (mf/with-memo [profile]
(update profile :lang #(or % "")))
form (fm/use-form :spec ::options-form
:initial initial)
new-css-system (features/use-feature :new-css-system)]
(let [props (:props profile)]
(d/without-nils
{:lang (d/nilv (:lang profile) "")
:theme (:theme profile)
:passkey (:passkey props :all)
:2fa (:2fa props :none)})))
[:& fm/form {:class "options-form"
:on-submit on-submit
:form form}
form (fm/use-form :spec ::options-form :initial initial)
totp? (= :totp (dm/get-in profile [:props :2fa]))
new-css-system (features/use-feature :new-css-system)
[:h2 (tr "labels.language")]
on-show-totp-secret
(mf/use-fn #(st/emit! (modal/show! :two-factor-qrcode {})))]
[:div.fields-row
[:& fm/select {:options (into [{:label "Auto (browser)" :value ""}]
i18n/supported-locales)
:label (tr "dashboard.select-ui-language")
:default ""
:name :lang
:data-test "setting-lang"}]]
[:div.form-container
{:data-test "settings-form"}
[:& fm/form {:class "options-form"
:on-submit on-submit
:form form}
[:h2 (tr "labels.language")]
[:div.fields-row
[:& fm/select
{:options (into [{:label "Auto (browser)" :value ""}] i18n/supported-locales)
:label (tr "dashboard.select-ui-language")
:default ""
:name :lang
:data-test "setting-lang"}]]
(when new-css-system
[:*
[:h2 (tr "dashboard.theme-change")]
[:div.fields-row
[:& fm/select
{:label (tr "dashboard.select-ui-theme")
:name :theme
:default "default"
:options [{:label "Penpot Dark (default)" :value "default"}
{:label "Penpot Light" :value "light"}]
:data-test "setting-theme"}]]])
[:h2 "PassKey"]
[:div.fields-row
[:& fm/radio-buttons
{:name :passkey
:encode-fn d/name
:decode-fn keyword
:options [{:label "Auth & 2FA" :value :all}
{:label "Only 2FA" :value :2fa}]}]]
[:h2 "2FA"]
[:div.fields-row
[:& fm/radio-buttons
{:name :2fa
:encode-fn d/name
:decode-fn keyword
:options [{:label "NONE" :value :none}
{:label "TOTP" :value :totp}
{:label "PASSKEY" :value :passkey}]}]
(when ^boolean totp?
[:a {:on-click on-show-totp-secret} "(show secret)"])]
[:> fm/submit-button*
{:label (tr "dashboard.update-settings")
:data-test "submit-lang-change"}]]]))
(mf/defc two-factor-qrcode-modal
{::mf/register modal/components
::mf/register-as :two-factor-qrcode}
[]
(let [on-close (mf/use-fn #(st/emit! (modal/hide)))
image* (mf/use-state nil)
secret* (mf/use-state nil)]
(mf/with-effect []
(->> (rp/cmd! :get-profile-2fa-secret)
(rx/subs (fn [{:keys [secret image] :as result}]
(prn "result" result)
(reset! image* image)
(reset! secret* secret)))))
[:div.modal-overlay
[:div.modal-container.change-email-modal
[:div.modal-header
[:div.modal-header-title
[:h2 (tr "modals.two-factor-qrcode.title")]]
[:div.modal-close-button
{:on-click on-close}
i/close]]
(when-let [uri @image*]
[:div.modal-content
[:img {:width "300"
:height "300"
:src uri}]])
[:div.modal-footer]]]))
(when new-css-system
[:h2 (tr "dashboard.theme-change")]
@@ -79,6 +174,4 @@
#(dom/set-html-title (tr "title.settings.options")))
[:div.dashboard-settings
[:div.form-container
{:data-test "settings-form"}
[:& options-form {}]]])
[:& settings]])

View File

@@ -0,0 +1,144 @@
;; 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.ui.settings.passkeys
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.main.data.modal :as modal]
[app.main.data.users :as du]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.time :as dt]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ref:passkeys
(l/derived :passkeys st/state))
(mf/defc passkeys-hero
[]
(let [on-click (mf/use-fn #(st/emit! (du/create-passkey)))]
[:div.passkeys-hero-container
[:div.passkeys-hero
[:div.desc
[:h2 (tr "dashboard.passkeys.title")]
[:p (tr "dashboard.passkeys.description")]]
[:button.btn-primary
{:on-click on-click}
[:span (tr "dashboard.passkeys.create")]]]]))
(mf/defc passkey-actions
[{:keys [on-delete]}]
(let [show* (mf/use-state false)
show? (deref show*)
menu-ref (mf/use-ref)
menu-options
(mf/with-memo [on-delete]
[{:option-name (tr "labels.delete")
:id "passkey-delete"
:option-handler on-delete}])
on-menu-close
(mf/use-fn #(reset! show* false))
on-menu-click
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(reset! show* true)))
on-key-down
(mf/use-fn
(mf/deps on-menu-click)
(fn [event]
(when (kbd/enter? event)
(dom/stop-propagation event)
(on-menu-click event))))]
[:div.icon
{:tab-index "0"
:ref menu-ref
:on-click on-menu-click
:on-key-down on-key-down}
i/actions
[:& context-menu-a11y
{:on-close on-menu-close
:show show?
:fixed? true
:min-width? true
:top "auto"
:left "auto"
:options menu-options}]]))
(mf/defc passkey-item
{::mf/wrap [mf/memo]
::mf/wrap-props false}
[{:keys [passkey]}]
(let [locale (mf/deref i18n/locale)
created-at (dt/format-date-locale (:created-at passkey) {:locale locale})
passkey-id (:id passkey)
sign-count (:sign-count passkey)
on-delete-accept
(mf/use-fn
(mf/deps passkey-id)
(fn []
(let [params {:id passkey-id}
mdata {:on-success #(st/emit! (du/fetch-passkeys))}]
(st/emit! (du/delete-passkey (with-meta params mdata))))))
on-delete
(mf/use-fn
(mf/deps on-delete-accept)
(fn []
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-passkey.title")
:message (tr "modals.delete-passkey.message")
:accept-label (tr "modals.delete-passkey.accept")
:on-accept on-delete-accept}))))]
[:div.table-row
[:div.table-field.name
(uuid/uuid->short-id passkey-id)]
[:div.table-field.create-date
[:span.content created-at]]
[:div.table-field.sign-count
[:span.content sign-count]]
[:div.table-field.actions
[:& passkey-actions
{:on-delete on-delete}]]]))
(mf/defc passkeys-page
[]
(let [passkeys (mf/deref ref:passkeys)]
(mf/with-effect []
(dom/set-html-title (tr "dashboard.password.page-title"))
(st/emit! (du/fetch-passkeys)))
[:div.dashboard-passkeys
[:div
[:& passkeys-hero]
(if (empty? passkeys)
[:div.passkeys-empty
[:div (tr "dashboard.passkeys.empty.no-passkeys")]
[:div (tr "dashboard.passkeys.empty.add-one")]]
[:div.dashboard-table
[:div.table-rows
(for [{:keys [id] :as item} passkeys]
[:& passkey-item {:passkey item :key (dm/str id)}])]])]]))

View File

@@ -8,7 +8,7 @@
(:require
[app.common.spec :as us]
[app.config :as cf]
[app.main.data.messages :as dm]
[app.main.data.messages :as msg]
[app.main.data.modal :as modal]
[app.main.data.users :as du]
[app.main.refs :as refs]
@@ -29,7 +29,7 @@
(defn- on-success
[_]
(st/emit! (dm/success (tr "notifications.profile-saved"))))
(st/emit! (msg/success (tr "notifications.profile-saved"))))
(defn- on-submit
[form _event]
@@ -101,6 +101,7 @@
:on-selected on-file-selected
:data-test "profile-image-input"}]]]))
;; --- Profile Page
(mf/defc profile-page []
@@ -110,4 +111,3 @@
[:div.form-container.two-columns
[:& profile-photo-form]
[:& profile-form]]])

View File

@@ -26,39 +26,33 @@
options? (= section :settings-options)
feedback? (= section :settings-feedback)
access-tokens? (= section :settings-access-tokens)
passkeys? (= section :settings-passkeys)
go-dashboard
(mf/use-callback
(mf/use-fn
(mf/deps profile)
#(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)})))
go-settings-profile
(mf/use-callback
(mf/deps profile)
#(st/emit! (rt/nav :settings-profile)))
(mf/use-fn #(st/emit! (rt/nav :settings-profile)))
go-settings-feedback
(mf/use-callback
(mf/deps profile)
#(st/emit! (rt/nav :settings-feedback)))
(mf/use-fn #(st/emit! (rt/nav :settings-feedback)))
go-settings-password
(mf/use-callback
(mf/deps profile)
#(st/emit! (rt/nav :settings-password)))
(mf/use-fn #(st/emit! (rt/nav :settings-password)))
go-settings-options
(mf/use-callback
(mf/deps profile)
#(st/emit! (rt/nav :settings-options)))
(mf/use-fn #(st/emit! (rt/nav :settings-options)))
go-settings-access-tokens
(mf/use-callback
(mf/deps profile)
#(st/emit! (rt/nav :settings-access-tokens)))
(mf/use-fn #(st/emit! (rt/nav :settings-access-tokens)))
go-settings-passkeys
(mf/use-fn #(st/emit! (rt/nav :settings-passkeys)))
show-release-notes
(mf/use-callback
(mf/use-fn
(fn [event]
(let [version (:main cf/version)]
(st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version}))
@@ -95,9 +89,17 @@
[:li {:class (when access-tokens? "current")
:on-click go-settings-access-tokens
:data-test "settings-access-tokens"}
i/icon-key
i/key
[:span.element-title (tr "labels.access-tokens")]])
(when (contains? cf/flags :passkeys)
[:li {:class (when passkeys? "current")
:on-click go-settings-passkeys
:data-test "settings-passkeys"}
i/key
[:span.element-title (tr "dashboard.passkeys.sidebar-label")]])
[:hr]
[:li {:on-click show-release-notes :data-test "release-notes"}

View File

@@ -38,17 +38,21 @@
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide)))
success-email-sent
(fn [email]
(fn [{:keys [email]}]
(reset! user-email email)
(set-current-section :email-sent))
success-login
(fn []
(fn [_]
(.reload js/window.location true))
success-register
(fn [data]
(reset! register-token (:token data))
(set-current-section :register-validate))]
(mf/with-effect []
(swap! storage assoc :redirect-url uri))
@@ -66,7 +70,7 @@
:login
[:div.generic-form.login-form
[:div.form-container
[:& login-methods {:on-success-callback success-login}]
[:& login-methods {:on-success success-login}]
[:div.links
[:div.link-entry
[:a {:on-click #(set-current-section :recovery-request)}
@@ -78,7 +82,7 @@
:register
[:div.form-container
[:& register-methods {:on-success-callback success-register}]
[:& register-methods {:on-success success-register}]
[:div.links
[:div.link-entry
[:span (tr "auth.already-have-account") " "]
@@ -88,15 +92,15 @@
:register-validate
[:div.form-container
[:& register-validate-form {:params {:token @register-token}
:on-success-callback success-email-sent}]
:on-success success-email-sent}]
[:div.links
[:div.link-entry
[:a {:on-click #(set-current-section :register)}
(tr "labels.go-back")]]]]
:recovery-request
[:& recovery-request-page {:go-back-callback #(set-current-section :login)
:on-success-callback success-email-sent}]
[:& recovery-request-page {:on-go-back #(set-current-section :login)
:on-success success-email-sent}]
:email-sent
[:div.form-container
[:& register-success-page {:params {:email @user-email}}]])]

View File

@@ -391,6 +391,48 @@ msgstr "The token will expire on %s"
msgid "dashboard.access-tokens.token-will-not-expire"
msgstr "The token has no expiration date"
#: src/app/main/ui/settings/access-tokens.cljs
msgid "dashboard.password.page-title"
msgstr "Profile - PassKeys"
#: src/app/main/ui/settings/passkeys.cljs
msgid "dashboard.passkeys.title"
msgstr "PassKeys"
#: src/app/main/ui/settings/passkeys.cljs
msgid "dashboard.passkeys.description"
msgstr "Passkeys are a safer and easier alternative to passwords. With passkeys, users can sign in to apps and websites with a biometric sensor (such as a fingerprint or facial recognition), PIN, or pattern, freeing them from having to remember and manage passwords."
#: src/app/main/ui/settings/passkeys.cljs
msgid "dashboard.passkeys.create"
msgstr "Add Passkey"
#: src/app/main/ui/settings/sidebar.cljs
msgid "dashboard.passkeys.sidebar-label"
msgstr "PassKeys"
#: src/app/main/ui/settings/passkeys.cljs
msgid "dashboard.passkeys.empty.no-passkeys"
msgstr "You have no passkeys so far."
#: src/app/main/ui/settings/passkeys.cljs
msgid "dashboard.passkeys.empty.add-one"
msgstr "Press the button \"Add PassKey\" to create one."
#: src/app/main/ui/settings/passkeys.cljs
msgid "modals.delete-passkey.title"
msgstr "Delete PassKey"
#: src/app/main/ui/settings/passkeys.cljs
msgid "modals.delete-passkey.message"
msgstr "Are you sure you want to delete this PassKey?"
#: src/app/main/ui/settings/passkeys.cljs
msgid "modals.delete-passkey.accept"
msgstr "Delete PassKey"
#: src/app/main/data/dashboard.cljs, src/app/main/data/dashboard.cljs
msgid "dashboard.copy-suffix"
msgstr "(copy)"
@@ -864,6 +906,14 @@ msgstr "Are you sure?"
msgid "errors.auth-provider-not-configured"
msgstr "Authentication provider not configured."
#: src/app/main/ui/auth/login.cljs
msgid "errors.invalid-totp"
msgstr "The 2FA token looks invalid"
#: src/app/main/ui/auth/login.cljs
msgid "auth.totp"
msgstr "2FA Token"
msgid "errors.auth.unable-to-login"
msgstr "Looks like you are not authenticated or session expired."