mirror of
https://github.com/penpot/penpot.git
synced 2025-03-28 15:41:25 -05:00
🎉 Add PassKeys and 2FA support
This commit is contained in:
parent
2706d1ffd3
commit
c598656f60
28 changed files with 1279 additions and 170 deletions
|
@ -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"}
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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()
|
||||
)
|
|
@ -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 {}))))
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
334
backend/src/app/rpc/commands/webauthn.clj
Normal file
334
backend/src/app/rpc/commands/webauthn.clj
Normal 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)))
|
56
backend/src/app/util/totp.clj
Normal file
56
backend/src/app/util/totp.clj
Normal 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)))
|
||||
|
|
@ -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"}
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
BIN
frontend/resources/images/passkey.png
Normal file
BIN
frontend/resources/images/passkey.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))))))
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
@ -94,6 +95,9 @@
|
|||
[{:keys [params on-success-callback] :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]
|
||||
|
@ -101,7 +105,27 @@
|
|||
|
||||
on-error
|
||||
(fn [cause]
|
||||
(when (map? cause)
|
||||
(err/print-trace! cause)
|
||||
(err/print-data! cause)
|
||||
(err/print-explain! cause))
|
||||
|
||||
(cond
|
||||
(and (= :totp (:code cause))
|
||||
(= :negotiation (:type cause)))
|
||||
(do
|
||||
(reset! totp* true)
|
||||
(reset! error (tr "errors.missing-totp"))
|
||||
(swap! form (fn [form]
|
||||
(-> form
|
||||
(update :errors assoc :totp {:message (tr "errors.missing-totp")})
|
||||
(update :touched assoc :totp true)))))
|
||||
|
||||
|
||||
(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"))
|
||||
|
@ -133,13 +157,25 @@
|
|||
(on-success-callback)))
|
||||
|
||||
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")]
|
||||
|
||||
(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 +210,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}]
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])]]]))
|
||||
|
||||
|
|
|
@ -85,7 +85,7 @@
|
|||
(mf/use-callback
|
||||
(mf/deps profile)
|
||||
(partial on-submit profile))
|
||||
|
||||
|
||||
on-email-change
|
||||
(mf/use-callback
|
||||
(fn [_ _]
|
||||
|
|
|
@ -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]])
|
||||
|
|
144
frontend/src/app/main/ui/settings/passkeys.cljs
Normal file
144
frontend/src/app/main/ui/settings/passkeys.cljs
Normal 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)}])]])]]))
|
||||
|
||||
|
|
@ -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]]])
|
||||
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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."
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue